Compare commits
56 Commits
v1.0.7
...
v1.1.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 837abd4921 | |||
| 75c6d363c0 | |||
| a2b7f28b9f | |||
| 52ecd1b052 | |||
| f0672600e4 | |||
| 61daeaf03f | |||
| 9d935ce03b | |||
| 9fab9a4536 | |||
| 291f6aa34f | |||
| a111851176 | |||
| 64c6873735 | |||
| 0d39beef70 | |||
| 95d0d094e1 | |||
| 38cf6a8d75 | |||
| ea55580e18 | |||
| df2dd23206 | |||
| 374fcc9689 | |||
| 02bd1dcd7f | |||
| 4b0433394f | |||
| d9bddae20e | |||
| e7c482dabf | |||
| 8974d89b33 | |||
| f99ca4d35d | |||
| d10f15abe3 | |||
| 9bdbcc2803 | |||
| b08f07494c | |||
| 1f758758e3 | |||
| fb8d9574b6 | |||
| 6856b7835c | |||
| 1d074ba6d2 | |||
| 0e972983bc | |||
| c979582193 | |||
| e47ba31763 | |||
| 28072908f7 | |||
| 7e6b4a3ed7 | |||
| d7e5a57344 | |||
| c859d5e677 | |||
| e036dea089 | |||
| 39088ca868 | |||
| 18f9104623 | |||
| 76f745cc87 | |||
| 848d58010f | |||
| c0f5799667 | |||
| 0e089f9471 | |||
| 52b17423dd | |||
| bfd3c8164b | |||
| b091175b89 | |||
| 1baf03a84e | |||
| 483dfabe10 | |||
| 65f8b2c485 | |||
| 90cdd7e713 | |||
| 40fa2a7721 | |||
| a136e7b4a7 | |||
| e615d88fd8 | |||
| 3d498f3df8 | |||
| d9a7cf6a77 |
11
.env
11
.env
@@ -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,15 +17,15 @@ 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
|
||||
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
||||
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||
DIRECTUS_DB_NAME=directus
|
||||
DIRECTUS_DB_USER=directus
|
||||
DIRECTUS_DB_PASSWORD=directus
|
||||
DIRECTUS_DB_USER=klz_db_user
|
||||
DIRECTUS_DB_PASSWORD=klz_db_pass
|
||||
# Local Development
|
||||
PROJECT_NAME=klz-cables
|
||||
GATEKEEPER_BYPASS_ENABLED=true
|
||||
@@ -33,3 +35,4 @@ GATEKEEPER_PASSWORD=klz2026
|
||||
COOKIE_DOMAIN=localhost
|
||||
INFRA_DIRECTUS_URL=http://localhost:8059
|
||||
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||
GATEKEEPER_ORIGIN=http://klz.localhost
|
||||
@@ -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)
|
||||
@@ -56,6 +57,9 @@ SENTRY_DSN=
|
||||
IMAGE_TAG=latest
|
||||
TRAEFIK_HOST=klz-cables.com
|
||||
ENV_FILE=.env
|
||||
# IMGPROXY_URL: The backend URL of the imgproxy instance (e.g. img.infra.mintel.me)
|
||||
# Next.js will proxy requests from /_img to this URL.
|
||||
IMGPROXY_URL=https://img.infra.mintel.me
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Varnish Configuration
|
||||
|
||||
@@ -33,4 +33,10 @@ jobs:
|
||||
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
||||
|
||||
- name: 🧪 QA Checks
|
||||
run: pnpm lint && pnpm typecheck && pnpm test
|
||||
run: pnpm check:mdx && pnpm lint && pnpm typecheck && pnpm test
|
||||
|
||||
- name: 🏗️ Build
|
||||
run: pnpm build
|
||||
|
||||
- name: ♿ Accessibility Check
|
||||
run: pnpm check:a11y
|
||||
|
||||
@@ -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"
|
||||
@@ -399,11 +406,79 @@ jobs:
|
||||
run: pnpm run check:og
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Notifications
|
||||
# JOB 6: Lighthouse (Performance & Accessibility)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
lighthouse:
|
||||
name: ⚡ Lighthouse
|
||||
needs: [prepare, deploy]
|
||||
if: success() && needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: 🔍 Install Chromium (Native & ARM64)
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y gnupg wget ca-certificates
|
||||
|
||||
# Detect OS
|
||||
OS_ID=$(. /etc/os-release && echo $ID)
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
|
||||
if [ "$OS_ID" = "debian" ]; then
|
||||
echo "🎯 Debian detected - installing native chromium"
|
||||
apt-get install -y chromium
|
||||
else
|
||||
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
||||
mkdir -p /etc/apt/keyrings
|
||||
KEY_ID="82BB6851C64F6880"
|
||||
|
||||
# Fetch PPA key
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
|
||||
# Add PPA repository
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
|
||||
# PRIORITY PINNING: Force PPA over Snap-dummy
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
|
||||
apt-get update
|
||||
apt-get install -y --allow-downgrades chromium
|
||||
fi
|
||||
|
||||
# Standardize binary paths
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||
- name: ⚡ Run Lighthouse CI
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
CHROME_PATH: /usr/bin/chromium
|
||||
PAGESPEED_LIMIT: 8
|
||||
run: pnpm run pagespeed:test
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 7: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, deploy, smoke_test]
|
||||
needs: [prepare, deploy, smoke_test, lighthouse]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,6 +2,11 @@ node_modules
|
||||
.next
|
||||
.DS_Store
|
||||
|
||||
# Lighthouse CI
|
||||
.lighthouseci/
|
||||
lighthouserc.cjs
|
||||
.lighthouserc.json
|
||||
|
||||
# Directus
|
||||
directus/uploads
|
||||
!directus/extensions/
|
||||
|
||||
32
.husky/pre-push
Executable file
32
.husky/pre-push
Executable 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
|
||||
26
.pa11yci.json
Normal file
26
.pa11yci.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"defaults": {
|
||||
"standard": "WCAG2AA",
|
||||
"runners": ["axe", "htmlcs"],
|
||||
"ignore": [],
|
||||
"timeout": 50000,
|
||||
"wait": 1000,
|
||||
"chromeLaunchConfig": {
|
||||
"args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
|
||||
},
|
||||
"threshold": 25
|
||||
},
|
||||
"urls": [
|
||||
"http://localhost:3000/en",
|
||||
"http://localhost:3000/en/blog",
|
||||
"http://localhost:3000/en/blog/which-cables-for-wind-power-differences-from-low-to-extra-high-voltage-explained-2",
|
||||
"http://localhost:3000/en/contact",
|
||||
"http://localhost:3000/en/team",
|
||||
"http://localhost:3000/en/products",
|
||||
"http://localhost:3000/en/products/medium-voltage-cables",
|
||||
"http://localhost:3000/en/products/low-voltage-cables",
|
||||
"http://localhost:3000/en/products/medium-voltage-cables/n2xs2y",
|
||||
"http://localhost:3000/en/legal-notice",
|
||||
"http://localhost:3000/en/privacy-policy"
|
||||
]
|
||||
}
|
||||
11
Dockerfile
11
Dockerfile
@@ -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
|
||||
@@ -34,10 +34,17 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Stage 2: Development (Hot-Reloading)
|
||||
FROM base AS development
|
||||
ENV NODE_ENV=development
|
||||
CMD ["pnpm", "dev:local"]
|
||||
|
||||
# Build application
|
||||
# Stage 3: Builder (Production)
|
||||
FROM base AS builder
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: Runner
|
||||
# Stage 3: Runner
|
||||
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -5,8 +5,8 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
@@ -39,18 +39,17 @@ 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: {
|
||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
url: `${SITE_URL}/${locale}/${slug}`,
|
||||
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -112,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">
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
</TrackedLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -10,8 +10,8 @@ import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import TableOfContents from '@/components/blog/TableOfContents';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { Heading } from '@/components/ui';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||
|
||||
interface BlogPostProps {
|
||||
params: Promise<{
|
||||
@@ -31,12 +31,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
||||
title: post.frontmatter.title,
|
||||
description: description,
|
||||
alternates: {
|
||||
canonical: `/${locale}/blog/${slug}`,
|
||||
languages: {
|
||||
de: `/de/blog/${slug}`,
|
||||
en: `/en/blog/${slug}`,
|
||||
'x-default': `/en/blog/${slug}`,
|
||||
},
|
||||
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
},
|
||||
openGraph: {
|
||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||
@@ -45,7 +40,6 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
||||
publishedTime: post.frontmatter.date,
|
||||
authors: ['KLZ Cables'],
|
||||
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -69,6 +63,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">
|
||||
|
||||
@@ -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 }> }) {
|
||||
|
||||
@@ -3,8 +3,8 @@ import Image from 'next/image';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
interface BlogIndexProps {
|
||||
@@ -20,18 +20,17 @@ 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: {
|
||||
title: `${t('title')} | KLZ Cables`,
|
||||
description: t('description'),
|
||||
url: `${SITE_URL}/${locale}/blog`,
|
||||
images: getOGImageMetadata('blog', t('title'), locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -43,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);
|
||||
|
||||
@@ -58,7 +58,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
<div className="bg-neutral-light min-h-screen">
|
||||
{/* Hero Section - Immersive Magazine Feel */}
|
||||
<Reveal>
|
||||
<section className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||
<article className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||
<>
|
||||
<Image
|
||||
@@ -101,7 +101,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
</article>
|
||||
</Reveal>
|
||||
|
||||
<Section className="bg-neutral-light py-12 md:py-28">
|
||||
@@ -146,7 +146,10 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
{remainingPosts.map((post, idx) => (
|
||||
<Reveal key={post.slug} delay={idx * 100}>
|
||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden">
|
||||
<Card
|
||||
tag="article"
|
||||
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden"
|
||||
>
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
||||
<Image
|
||||
|
||||
@@ -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 }> }) {
|
||||
|
||||
@@ -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: {
|
||||
@@ -35,7 +36,6 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/contact`,
|
||||
siteName: 'KLZ Cables',
|
||||
images: getOGImageMetadata('contact', title, locale),
|
||||
locale: `${locale.toUpperCase()}_DE`,
|
||||
type: 'website',
|
||||
},
|
||||
@@ -43,7 +43,6 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
||||
card: 'summary_large_image',
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
images: [`${SITE_URL}/${locale}/contact/opengraph-image`],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
@@ -137,7 +136,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
||||
{t('info.howToReachUs')}
|
||||
</Heading>
|
||||
<div className="space-y-4 md:space-y-8">
|
||||
<address className="space-y-4 md:space-y-8 not-italic">
|
||||
<div className="flex items-start gap-4 md:gap-6 group">
|
||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||
<svg
|
||||
@@ -198,7 +197,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</address>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import Footer from '@/components/Footer';
|
||||
import Header from '@/components/Header';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||
import SkipLink from '@/components/SkipLink';
|
||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||
import { FeedbackOverlay } from '@mintel/next-feedback';
|
||||
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
|
||||
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
|
||||
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
|
||||
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Suspense } from 'react';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
@@ -20,36 +24,48 @@ const inter = Inter({
|
||||
variable: '--font-inter',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: 'any' },
|
||||
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
|
||||
],
|
||||
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
||||
},
|
||||
};
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const { locale } = params;
|
||||
|
||||
return {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
manifest: '/manifest.webmanifest',
|
||||
alternates: {
|
||||
canonical: locale === 'en' ? '/' : `/${locale}`,
|
||||
languages: {
|
||||
de: '/de',
|
||||
en: '/en',
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: 'any' },
|
||||
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
|
||||
],
|
||||
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
maximumScale: 5,
|
||||
userScalable: true,
|
||||
viewportFit: 'cover',
|
||||
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';
|
||||
@@ -64,12 +80,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();
|
||||
@@ -83,10 +96,9 @@ export default async function LocaleLayout({
|
||||
});
|
||||
}
|
||||
|
||||
// Track initial server-side pageview
|
||||
serverServices.analytics.trackPageview();
|
||||
// Server-side analytics tracking removed to prevent duplicate/empty events.
|
||||
// Client-side AnalyticsProvider handles all pageviews.
|
||||
} 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',
|
||||
@@ -94,20 +106,35 @@ 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></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}>
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
||||
<Footer />
|
||||
<CMSConnectivityNotice />
|
||||
<RecordModeProvider isEnabled={recordModeEnabled}>
|
||||
<RecordModeVisuals>
|
||||
<SkipLink />
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-grow animate-fade-in overflow-visible"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</RecordModeVisuals>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<AnalyticsProvider />
|
||||
</Suspense>
|
||||
{config.feedbackEnabled && <FeedbackOverlay />}
|
||||
<CMSConnectivityNotice />
|
||||
|
||||
<AnalyticsShell />
|
||||
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
|
||||
</RecordModeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -79,17 +79,19 @@ export async function generateMetadata({
|
||||
}
|
||||
|
||||
const title = t('title') || 'KLZ Cables';
|
||||
const description = t('description') || '';
|
||||
const description =
|
||||
t('description') ||
|
||||
'Ihr Experte für hochwertige Stromkabel, Mittelspannungslösungen und Solarkabel. Zuverlässige Infrastruktur für eine grüne Energiezukunft.';
|
||||
|
||||
return {
|
||||
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: {
|
||||
|
||||
@@ -5,7 +5,7 @@ import ProductTabs from '@/components/ProductTabs';
|
||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||
import RelatedProducts from '@/components/RelatedProducts';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import { Badge, Container, Heading, Section } from '@/components/ui';
|
||||
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getDatasheetPath } from '@/lib/datasheets';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||
@@ -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,8 +213,11 @@ 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}/products`} className="hover:text-accent transition-colors">
|
||||
{t('title')}
|
||||
<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>
|
||||
<span className="text-white/90">{categoryTitle}</span>
|
||||
@@ -235,57 +239,59 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
href={`/${locale}/products/${productSlug}/${product.translatedSlug}`}
|
||||
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-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
||||
{product.frontmatter.images?.[0] && (
|
||||
<>
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
{/* Subtle reflection/shadow effect */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-8 md:p-10">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{product.frontmatter.categories.map((cat, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||
>
|
||||
{cat}
|
||||
<Card tag="article" className="premium-card-reset">
|
||||
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
||||
{product.frontmatter.images?.[0] && (
|
||||
<>
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
{/* Subtle reflection/shadow effect */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-8 md:p-10">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{product.frontmatter.categories.map((cat, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
|
||||
{product.frontmatter.title}
|
||||
</h2>
|
||||
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
|
||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||
{t('details')}
|
||||
</span>
|
||||
))}
|
||||
<svg
|
||||
className="w-5 h-5 ml-3 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>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
|
||||
{product.frontmatter.title}
|
||||
</h2>
|
||||
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
|
||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||
{t('details')}
|
||||
</span>
|
||||
<svg
|
||||
className="w-5 h-5 ml-3 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>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -353,6 +359,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,8 +373,11 @@ 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}/products`} className="hover:text-accent transition-colors">
|
||||
{t('title')}
|
||||
<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>
|
||||
<Link
|
||||
|
||||
@@ -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 }> }) {
|
||||
@@ -10,7 +12,7 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
const title = t('meta.title') || t('title');
|
||||
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
|
||||
@@ -4,10 +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 { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
|
||||
interface ProductsPageProps {
|
||||
params: Promise<{
|
||||
@@ -18,24 +17,23 @@ interface ProductsPageProps {
|
||||
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const title = t('meta.title') || t('title');
|
||||
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `/${locale}/products`,
|
||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||
languages: {
|
||||
de: '/de/products',
|
||||
en: '/en/products',
|
||||
'x-default': '/en/products',
|
||||
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: {
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/products`,
|
||||
images: getOGImageMetadata('products', title, locale),
|
||||
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -56,34 +54,36 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
|
||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
||||
|
||||
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||
|
||||
const categories = [
|
||||
{
|
||||
title: t('categories.lowVoltage.title'),
|
||||
desc: t('categories.lowVoltage.description'),
|
||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||
href: `/${locale}/products/${lowVoltageSlug}`,
|
||||
href: `/${locale}/${productsSlug}/${lowVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.mediumVoltage.title'),
|
||||
desc: t('categories.mediumVoltage.description'),
|
||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||
href: `/${locale}/products/${mediumVoltageSlug}`,
|
||||
href: `/${locale}/${productsSlug}/${mediumVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.highVoltage.title'),
|
||||
desc: t('categories.highVoltage.description'),
|
||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||
href: `/${locale}/products/${highVoltageSlug}`,
|
||||
href: `/${locale}/${productsSlug}/${highVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.solar.title'),
|
||||
desc: t('categories.solar.description'),
|
||||
img: '/uploads/2024/11/solar-category.webp',
|
||||
icon: '/uploads/2024/11/Solar.svg',
|
||||
href: `/${locale}/products/${solarSlug}`,
|
||||
href: `/${locale}/${productsSlug}/${solarSlug}`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }> }) {
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Metadata } from 'next';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
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<{
|
||||
@@ -23,18 +23,17 @@ 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: {
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/team`,
|
||||
images: getOGImageMetadata('team', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -115,7 +114,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
</Reveal>
|
||||
|
||||
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
|
||||
<section className="relative bg-white overflow-hidden">
|
||||
<article className="relative bg-white overflow-hidden">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
|
||||
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
||||
@@ -135,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">→</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">
|
||||
@@ -157,7 +161,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
{/* Legacy Section - Immersive Background */}
|
||||
<Reveal>
|
||||
@@ -213,7 +217,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
</Reveal>
|
||||
|
||||
{/* Klaus Mintel Section - Reversed Split Layout */}
|
||||
<section className="relative bg-white overflow-hidden">
|
||||
<article className="relative bg-white overflow-hidden">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
|
||||
<Image
|
||||
@@ -243,19 +247,24 @@ 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">→</span>
|
||||
</Button>
|
||||
</TrackedButton>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
{/* Manifesto Section - Modern Grid */}
|
||||
<Section className="bg-white text-primary py-16 md:py-28">
|
||||
@@ -283,9 +292,9 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10">
|
||||
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
|
||||
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
||||
<div
|
||||
<li
|
||||
key={idx}
|
||||
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
|
||||
>
|
||||
@@ -300,9 +309,9 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
||||
{t(`manifesto.items.${idx}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
32
app/api/save-session/route.ts
Normal file
32
app/api/save-session/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Only allow in development
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return NextResponse.json({ error: 'This route is disabled in production.' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
// Ensure we are in the project root by using process.cwd()
|
||||
// Path: <project-root>/remotion/session.json
|
||||
const remotionDir = path.join(process.cwd(), 'remotion');
|
||||
const filePath = path.join(remotionDir, 'session.json');
|
||||
|
||||
// Create remotion directory if it doesn't exist
|
||||
if (!fs.existsSync(remotionDir)) {
|
||||
fs.mkdirSync(remotionDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write the JSON file
|
||||
fs.writeFileSync(filePath, JSON.stringify(body, null, 2), 'utf-8');
|
||||
|
||||
return NextResponse.json({ success: true, path: filePath });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save session:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -65,9 +65,28 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to proxy analytics request', {
|
||||
error: (error as Error).message,
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
// Console error to ensure it appears in logs even if logger fails
|
||||
console.error('CRITICAL PROXY ERROR:', {
|
||||
message: errorMessage,
|
||||
stack: errorStack,
|
||||
endpoint: config.analytics.umami.apiEndpoint,
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
|
||||
logger.error('Failed to proxy analytics request', {
|
||||
error: errorMessage,
|
||||
stack: errorStack,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
details: errorMessage, // Expose error for debugging
|
||||
endpoint: config.analytics.umami.apiEndpoint ? 'configured' : 'missing',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,17 +48,29 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
|
||||
<Card
|
||||
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
||||
<svg
|
||||
className="w-10 h-10 text-primary-dark"
|
||||
@@ -66,7 +97,11 @@ export default function ContactForm() {
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up">
|
||||
<Card
|
||||
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
|
||||
<svg
|
||||
className="w-10 h-10 text-destructive-foreground"
|
||||
@@ -105,38 +140,43 @@ export default function ContactForm() {
|
||||
</Heading>
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="name">{t('form.name')}</Label>
|
||||
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
id="contact-name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.namePlaceholder')}
|
||||
onFocus={() => handleFocus('contact-name')}
|
||||
aria-label={t('form.name')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="email">{t('form.email')}</Label>
|
||||
<Label htmlFor="contact-email">{t('form.email')}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
id="contact-email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.emailPlaceholder')}
|
||||
onFocus={() => handleFocus('contact-email')}
|
||||
aria-label={t('form.email')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
||||
<Label htmlFor="message">{t('form.message')}</Label>
|
||||
<Label htmlFor="contact-message">{t('form.message')}</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
id="contact-message"
|
||||
name="message"
|
||||
rows={4}
|
||||
enterKeyHint="send"
|
||||
placeholder={t('form.messagePlaceholder')}
|
||||
onFocus={() => handleFocus('contact-message')}
|
||||
aria-label={t('form.message')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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,43 @@ 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"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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 +56,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')}
|
||||
@@ -57,8 +70,19 @@ 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" />
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,10 +22,19 @@ 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')}
|
||||
alt="KLZ Vertriebs GmbH"
|
||||
width={150}
|
||||
height={40}
|
||||
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
||||
@@ -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>
|
||||
@@ -46,14 +67,21 @@ export default function Footer() {
|
||||
|
||||
{/* Links Columns */}
|
||||
<div className="lg:col-span-2">
|
||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{t('legal')}
|
||||
</h4>
|
||||
</h3>
|
||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||
<li>
|
||||
<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>
|
||||
@@ -78,22 +120,36 @@ export default function Footer() {
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{t('company')}
|
||||
</h4>
|
||||
</h3>
|
||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||
<li>
|
||||
<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>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={`/${locale}/products`}
|
||||
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>
|
||||
@@ -119,9 +189,9 @@ export default function Footer() {
|
||||
|
||||
{/* Recent Posts Column */}
|
||||
<div className="lg:col-span-4">
|
||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{t('recentPosts')}
|
||||
</h4>
|
||||
</h3>
|
||||
<ul className="space-y-6 list-none m-0 p-0">
|
||||
{[
|
||||
{
|
||||
@@ -146,11 +216,21 @@ 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>
|
||||
<span className="text-xs text-white/40 uppercase tracking-widest">
|
||||
<span className="text-xs text-white/70 uppercase tracking-widest">
|
||||
{t('readArticle')} →
|
||||
</span>
|
||||
</Link>
|
||||
@@ -160,13 +240,37 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/70 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>
|
||||
|
||||
@@ -6,14 +6,19 @@ import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Button } from './ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } 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);
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Extract locale from pathname
|
||||
const currentLocale = pathname.split('/')[1] || 'en';
|
||||
@@ -31,9 +36,52 @@ export default function Header() {
|
||||
}, []);
|
||||
|
||||
// Prevent scroll when mobile menu is open
|
||||
// Prevent scroll when mobile menu is open and handle focus trap
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Focus trap logic
|
||||
const focusableElements = mobileMenuRef.current?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
|
||||
if (focusableElements && focusableElements.length > 0) {
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
const handleTabKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab') {
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleTabKey);
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
|
||||
// Focus the first element when menu opens
|
||||
setTimeout(() => firstElement.focus(), 100);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleTabKey);
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
}
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
@@ -49,7 +97,7 @@ export default function Header() {
|
||||
const menuItems = [
|
||||
{ label: t('home'), href: '/' },
|
||||
{ label: t('team'), href: '/team' },
|
||||
{ label: t('products'), href: '/products' },
|
||||
{ label: t('products'), href: currentLocale === 'de' ? '/produkte' : '/products' },
|
||||
{ label: t('blog'), href: '/blog' },
|
||||
];
|
||||
|
||||
@@ -79,7 +127,15 @@ export default function Header() {
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
||||
>
|
||||
<Link href={`/${currentLocale}`}>
|
||||
<Link
|
||||
href={`/${currentLocale}`}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
target: 'home_logo',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={t('home')}
|
||||
@@ -109,7 +165,14 @@ export default function Header() {
|
||||
<motion.div key={item.href} variants={navLinkVariants}>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
location: 'header_nav',
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
textColorClass,
|
||||
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||
@@ -139,6 +202,14 @@ export default function Header() {
|
||||
>
|
||||
<Link
|
||||
href={getPathForLocale('en')}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||
type: 'language',
|
||||
from: currentLocale,
|
||||
to: 'en',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||
>
|
||||
EN
|
||||
@@ -157,6 +228,14 @@ export default function Header() {
|
||||
>
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||
type: 'language',
|
||||
from: currentLocale,
|
||||
to: 'de',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||
>
|
||||
DE
|
||||
@@ -174,6 +253,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>
|
||||
@@ -187,6 +272,8 @@ export default function Header() {
|
||||
textColorClass,
|
||||
)}
|
||||
aria-label={t('toggleMenu')}
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||
transition={{
|
||||
@@ -196,7 +283,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"
|
||||
@@ -241,8 +335,13 @@ export default function Header() {
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||
)}
|
||||
id="mobile-menu"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('menu')}
|
||||
ref={mobileMenuRef}
|
||||
>
|
||||
<motion.div
|
||||
<motion.nav
|
||||
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
||||
initial="closed"
|
||||
animate={isMobileMenuOpen ? 'open' : 'closed'}
|
||||
@@ -274,7 +373,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}
|
||||
@@ -357,7 +463,7 @@ export default function Header() {
|
||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.nav>
|
||||
</div>
|
||||
</motion.header>
|
||||
</>
|
||||
|
||||
@@ -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='© <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:
|
||||
'© <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" />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
@@ -19,6 +19,8 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
const pathname = usePathname();
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
@@ -76,12 +78,50 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
}, [isOpen, currentIndex, updateUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!isOpen) {
|
||||
if (previousFocusRef.current) {
|
||||
previousFocusRef.current.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture previous focus
|
||||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||
|
||||
// Focus close button on open
|
||||
setTimeout(() => closeButtonRef.current?.focus(), 100);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
if (e.key === 'ArrowLeft') prevImage();
|
||||
if (e.key === 'ArrowRight') nextImage();
|
||||
|
||||
// Focus Trap
|
||||
if (e.key === 'Tab') {
|
||||
const focusableElements = document.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
const modalElements = Array.from(focusableElements).filter((el) =>
|
||||
document.querySelector('[role="dialog"]')?.contains(el),
|
||||
);
|
||||
|
||||
if (modalElements.length === 0) return;
|
||||
|
||||
const firstElement = modalElements[0] as HTMLElement;
|
||||
const lastElement = modalElements[modalElements.length - 1] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
lastElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
firstElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Lock scroll
|
||||
@@ -101,7 +141,11 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[99999] flex items-center justify-center">
|
||||
<div
|
||||
className="fixed inset-0 z-[99999] flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -116,6 +160,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={{ delay: 0.1, duration: 0.4 }}
|
||||
ref={closeButtonRef}
|
||||
onClick={handleClose}
|
||||
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
|
||||
aria-label="Close lightbox"
|
||||
|
||||
@@ -14,25 +14,30 @@ interface ProductSidebarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ProductSidebar({ productName, productImage, datasheetPath, className }: ProductSidebarProps) {
|
||||
export default function ProductSidebar({
|
||||
productName,
|
||||
productImage,
|
||||
datasheetPath,
|
||||
className,
|
||||
}: ProductSidebarProps) {
|
||||
const t = useTranslations('Products');
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-4 animate-slight-fade-in-from-bottom", className)}>
|
||||
<aside className={cn('flex flex-col gap-4 animate-slight-fade-in-from-bottom', className)}>
|
||||
{/* Request Quote Form Card */}
|
||||
<div className="bg-white rounded-3xl border border-neutral-medium shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1 overflow-hidden group/card">
|
||||
<div className="bg-primary p-6 text-white relative overflow-hidden">
|
||||
{/* Background Accent - Saturated Blue Glow */}
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-saturated/30 rounded-full -translate-y-1/2 translate-x-1/2 blur-[80px] pointer-events-none" />
|
||||
|
||||
|
||||
{/* Product Thumbnail with Reflection */}
|
||||
{productImage && (
|
||||
<div className="relative w-full aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group">
|
||||
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
|
||||
<Image
|
||||
src={productImage}
|
||||
alt={productName}
|
||||
fill
|
||||
<Image
|
||||
src={productImage}
|
||||
alt={productName}
|
||||
fill
|
||||
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"
|
||||
/>
|
||||
{/* Subtle Reflection Overlay */}
|
||||
@@ -46,9 +51,9 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
|
||||
<h3 className="text-lg md:text-xl font-heading font-black m-0 tracking-tighter uppercase leading-none">
|
||||
{t('requestQuote')}
|
||||
</h3>
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="w-full h-3 -bottom-3 left-0 text-accent/80"
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="w-full h-3 -bottom-3 left-0 text-accent/80"
|
||||
color="var(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
@@ -57,16 +62,14 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-6 bg-neutral-light/50">
|
||||
<RequestQuoteForm productName={productName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datasheet Download */}
|
||||
{datasheetPath && (
|
||||
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
|
||||
)}
|
||||
</div>
|
||||
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
const { technicalItems = [], voltageTables = [] } = data;
|
||||
|
||||
const toggleTable = (idx: number) => {
|
||||
setExpandedTables(prev => ({
|
||||
setExpandedTables((prev) => ({
|
||||
...prev,
|
||||
[idx]: !prev[idx]
|
||||
[idx]: !prev[idx],
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -48,9 +48,16 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
|
||||
{technicalItems.map((item, idx) => (
|
||||
<div key={idx} className="flex flex-col group">
|
||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">{item.label}</dt>
|
||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-text-primary">
|
||||
{item.value} {item.unit && <span className="text-sm font-normal text-text-secondary ml-1">{item.unit}</span>}
|
||||
{item.value}{' '}
|
||||
{item.unit && (
|
||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
@@ -61,29 +68,38 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
{voltageTables.map((table, idx) => {
|
||||
const isExpanded = expandedTables[idx];
|
||||
const hasManyRows = table.rows.length > 10;
|
||||
|
||||
|
||||
return (
|
||||
<div key={idx} className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden">
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
{table.voltageLabel !== 'Voltage unknown' && table.voltageLabel !== 'Spannung unbekannt'
|
||||
? table.voltageLabel
|
||||
{table.voltageLabel !== 'Voltage unknown' &&
|
||||
table.voltageLabel !== 'Spannung unbekannt'
|
||||
? table.voltageLabel
|
||||
: 'Technical Specifications'}
|
||||
</h3>
|
||||
|
||||
|
||||
{table.metaItems.length > 0 && (
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mb-12 bg-neutral-light/50 p-8 rounded-2xl border border-neutral-dark/5">
|
||||
{table.metaItems.map((item, mIdx) => (
|
||||
<div key={mIdx}>
|
||||
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">{item.label}</dt>
|
||||
<dd className="font-bold text-primary">{item.value} {item.unit}</dd>
|
||||
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="font-bold text-primary">
|
||||
{item.value} {item.unit}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
<div
|
||||
id={`voltage-table-${idx}`}
|
||||
className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
|
||||
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||
}`}
|
||||
@@ -91,11 +107,18 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<table className="min-w-full border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] sticky left-0 bg-white z-10 border-b border-neutral-dark/10">
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] sticky left-0 bg-white z-10 border-b border-neutral-dark/10"
|
||||
>
|
||||
Config.
|
||||
</th>
|
||||
{table.columns.map((col, cIdx) => (
|
||||
<th key={cIdx} scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] whitespace-nowrap border-b border-neutral-dark/10">
|
||||
<th
|
||||
key={cIdx}
|
||||
scope="col"
|
||||
className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] whitespace-nowrap border-b border-neutral-dark/10"
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
@@ -108,7 +131,10 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
{row.configuration}
|
||||
</td>
|
||||
{row.cells.map((cell, cellIdx) => (
|
||||
<td key={cellIdx} className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap">
|
||||
<td
|
||||
key={cellIdx}
|
||||
className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap"
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
@@ -127,6 +153,8 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<div className="mt-8 flex justify-center">
|
||||
<button
|
||||
onClick={() => toggleTable(idx)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={`voltage-table-${idx}`}
|
||||
className="px-8 py-3 rounded-full bg-primary text-white text-sm font-bold uppercase tracking-widest hover:bg-accent hover:text-primary transition-all duration-300 shadow-lg hover:shadow-accent/20"
|
||||
>
|
||||
{isExpanded ? t('showLess') : t('showMore')}
|
||||
|
||||
39
components/RelatedProductLink.tsx
Normal file
39
components/RelatedProductLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,23 +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 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
|
||||
key={product.slug}
|
||||
href={`/${locale}/products/${catSlug}/${translatedProductSlug}`}
|
||||
<RelatedProductLink
|
||||
key={product.slug}
|
||||
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">
|
||||
@@ -74,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>
|
||||
))}
|
||||
@@ -87,12 +101,23 @@ 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"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</RelatedProductLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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,17 +60,31 @@ 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');
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||
<div
|
||||
className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
|
||||
<svg
|
||||
@@ -87,7 +122,11 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||
<div
|
||||
className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
||||
<svg
|
||||
@@ -127,23 +166,27 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
<div className="space-y-1 !mt-0">
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
id="quote-email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onFocus={() => handleFocus('quote-email')}
|
||||
placeholder={t('email')}
|
||||
aria-label={t('email')}
|
||||
className="h-9 text-xs !mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 !mt-0">
|
||||
<Textarea
|
||||
id="request"
|
||||
id="quote-request"
|
||||
required
|
||||
rows={3}
|
||||
value={request}
|
||||
onChange={(e) => setRequest(e.target.value)}
|
||||
onFocus={() => handleFocus('quote-request')}
|
||||
placeholder={t('message')}
|
||||
aria-label={t('message')}
|
||||
className="text-xs !mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,31 +18,31 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 1.8,
|
||||
ease: "easeInOut",
|
||||
}
|
||||
}
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (variant === 'circle') {
|
||||
return (
|
||||
<svg
|
||||
className={cn("absolute pointer-events-none", className)}
|
||||
role="presentation"
|
||||
viewBox="0 0 800 350"
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 800 350"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<motion.path
|
||||
<motion.path
|
||||
variants={pathVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||
strokeLinejoin="miter"
|
||||
fillOpacity="0"
|
||||
strokeMiterlimit="4"
|
||||
stroke={color}
|
||||
strokeOpacity="1"
|
||||
strokeWidth="20"
|
||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||
strokeLinejoin="miter"
|
||||
fillOpacity="0"
|
||||
strokeMiterlimit="4"
|
||||
stroke={color}
|
||||
strokeOpacity="1"
|
||||
strokeWidth="20"
|
||||
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
|
||||
/>
|
||||
</svg>
|
||||
@@ -51,20 +51,20 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
||||
|
||||
if (variant === 'underline') {
|
||||
return (
|
||||
<svg
|
||||
className={cn("absolute pointer-events-none", className)}
|
||||
role="presentation"
|
||||
viewBox="-400 -55 730 60"
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="-400 -55 730 60"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<motion.path
|
||||
<motion.path
|
||||
variants={pathVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
||||
stroke={color}
|
||||
strokeWidth="20"
|
||||
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
||||
stroke={color}
|
||||
strokeWidth="20"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
16
components/SkipLink.tsx
Normal file
16
components/SkipLink.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function SkipLink() {
|
||||
const t = useTranslations('Navigation');
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-6 focus:py-3 focus:bg-white focus:text-primary-dark focus:font-bold focus:rounded-lg focus:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent transition-all"
|
||||
>
|
||||
{t('skipToContent')}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
20
components/analytics/AnalyticsShell.tsx
Normal file
20
components/analytics/AnalyticsShell.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
||||
ssr: false,
|
||||
});
|
||||
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function AnalyticsShell() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DynamicAnalyticsProvider />
|
||||
<DynamicScrollDepthTracker />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
53
components/analytics/BlogEngagementTracker.tsx
Normal file
53
components/analytics/BlogEngagementTracker.tsx
Normal 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;
|
||||
}
|
||||
50
components/analytics/ProductEngagementTracker.tsx
Normal file
50
components/analytics/ProductEngagementTracker.tsx
Normal 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;
|
||||
}
|
||||
62
components/analytics/ScrollDepthTracker.tsx
Normal file
62
components/analytics/ScrollDepthTracker.tsx
Normal 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;
|
||||
}
|
||||
34
components/analytics/TrackedButton.tsx
Normal file
34
components/analytics/TrackedButton.tsx
Normal 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} />;
|
||||
}
|
||||
44
components/analytics/TrackedLink.tsx
Normal file
44
components/analytics/TrackedLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -14,57 +14,84 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
|
||||
<div className="absolute inset-0 opacity-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(#3b82f6_1px,transparent_1px)] [background-size:40px_40px]" />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Decorative accent */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/20 rounded-full blur-3xl -mr-32 -mt-32 transition-transform group-hover:scale-110 duration-1000" />
|
||||
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="inline-block px-4 py-1 bg-accent/20 text-accent text-xs font-bold uppercase tracking-[0.2em] rounded-full mb-8">
|
||||
{isDe ? 'Lösungen' : 'Solutions'}
|
||||
</div>
|
||||
|
||||
|
||||
<h3 className="text-2xl md:text-4xl font-bold text-white mb-8 leading-tight">
|
||||
{isDe ? 'Bereit für die' : 'Ready for the'}
|
||||
{isDe ? 'Bereit für die' : 'Ready for the'}
|
||||
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
|
||||
</h3>
|
||||
|
||||
|
||||
<p className="text-xl text-white/70 mb-10 leading-relaxed max-w-2xl">
|
||||
{isDe
|
||||
{isDe
|
||||
? 'Von der Planung von Wind- und Solarparks bis zur Lieferung hochwertiger Energiekabel erwecken wir Ihre Projekte zum Leben.'
|
||||
: 'From wind and solar park planning to delivering high-quality energy cables, we bring your projects to life.'
|
||||
}
|
||||
: 'From wind and solar park planning to delivering high-quality energy cables, we bring your projects to life.'}
|
||||
</p>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12">
|
||||
{[
|
||||
isDe ? 'Strategischer Hub für schnelle Lieferung' : 'Strategic hub for fast delivery',
|
||||
isDe ? 'Nachhaltige Kabelinfrastruktur' : 'Sustainable cable infrastructure',
|
||||
isDe ? 'Expertenberatung für Großprojekte' : 'Expert consulting for large-scale projects',
|
||||
isDe ? 'Zertifizierte Qualität nach EU-Standards' : 'Certified quality according to EU standards'
|
||||
isDe
|
||||
? 'Expertenberatung für Großprojekte'
|
||||
: 'Expert consulting for large-scale projects',
|
||||
isDe
|
||||
? 'Zertifizierte Qualität nach EU-Standards'
|
||||
: 'Certified quality according to EU standards',
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-4 text-white/80">
|
||||
<div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-3 h-3 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
className="w-3 h-3 text-accent"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pt-8 border-t border-white/10">
|
||||
<Link
|
||||
href={`/${locale}/contact`}
|
||||
className="inline-flex items-center gap-3 px-8 py-4 bg-primary text-white font-bold rounded-full hover:bg-primary/90 transition-all shadow-xl hover:shadow-primary/20 transform hover:-translate-y-1 group/btn"
|
||||
>
|
||||
{isDe ? 'Projekt anfragen' : 'Inquire Project'}
|
||||
<svg className="w-5 h-5 transition-transform group-hover/btn: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-5 h-5 transition-transform group-hover/btn:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<p className="text-white/50 text-sm font-medium">
|
||||
{isDe ? 'Kostenlose Erstberatung für Ihr Vorhaben.' : 'Free initial consultation for your project.'}
|
||||
{isDe
|
||||
? 'Kostenlose Erstberatung für Ihr Vorhaben.'
|
||||
: 'Free initial consultation for your project.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -32,24 +32,24 @@ export default function Experience() {
|
||||
<p className="pl-9">{t('p2')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<div className="animate-fade-in">
|
||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||
{t('certifiedQuality')}
|
||||
</div>
|
||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||
</dt>
|
||||
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||
{t('vdeApproved')}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
|
||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||
{t('fullSpectrum')}
|
||||
</div>
|
||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||
</dt>
|
||||
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||
{t('solutionsRange')}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
@@ -33,6 +33,8 @@ export default function GallerySection() {
|
||||
{images.map((src, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
aria-label={`${t('alt')} ${idx + 1}`}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('photo', idx.toString());
|
||||
@@ -47,6 +49,7 @@ export default function GallerySection() {
|
||||
fill
|
||||
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary-dark/20 group-hover:bg-transparent transition-all duration-500" />
|
||||
<div className="absolute inset-0 border-0 group-hover:border-[16px] border-white/10 transition-all duration-500 pointer-events-none" />
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
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">
|
||||
@@ -59,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">→</span>
|
||||
@@ -66,10 +76,16 @@ export default function Hero() {
|
||||
</motion.div>
|
||||
<motion.div variants={buttonVariants}>
|
||||
<Button
|
||||
href="/products"
|
||||
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||
variant="white"
|
||||
size="lg"
|
||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: t('exploreProducts'),
|
||||
location: 'home_hero_secondary',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('exploreProducts')}
|
||||
</Button>
|
||||
@@ -114,19 +130,19 @@ const containerVariants = {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.12,
|
||||
delayChildren: 0.4,
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const headingVariants = {
|
||||
hidden: { opacity: 0, y: 60, scale: 0.85 },
|
||||
hidden: { opacity: 1, y: 30, scale: 0.95 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ export default function HeroIllustration() {
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="energy-pulse" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
|
||||
@@ -8,39 +8,42 @@ export default function ProductCategories() {
|
||||
const t = useTranslations('Products');
|
||||
const locale = useLocale();
|
||||
|
||||
const productsBase = locale === 'de' ? 'produkte' : 'products';
|
||||
|
||||
const categories = [
|
||||
{
|
||||
title: t('categories.lowVoltage.title'),
|
||||
desc: t('categories.lowVoltage.description'),
|
||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||
href: `/${locale}/products/low-voltage-cables`,
|
||||
href: `/${locale}/${productsBase}/${locale === 'de' ? 'niederspannungskabel' : 'low-voltage-cables'}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.mediumVoltage.title'),
|
||||
desc: t('categories.mediumVoltage.description'),
|
||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||
href: `/${locale}/products/medium-voltage-cables`,
|
||||
href: `/${locale}/${productsBase}/${locale === 'de' ? 'mittelspannungskabel' : 'medium-voltage-cables'}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.highVoltage.title'),
|
||||
desc: t('categories.highVoltage.description'),
|
||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||
href: `/${locale}/products/high-voltage-cables`,
|
||||
href: `/${locale}/${productsBase}/${locale === 'de' ? 'hochspannungskabel' : 'high-voltage-cables'}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.solar.title'),
|
||||
desc: t('categories.solar.description'),
|
||||
img: '/uploads/2024/11/solar-category.webp',
|
||||
icon: '/uploads/2024/11/Solar.svg',
|
||||
href: `/${locale}/products/solar-cables`,
|
||||
href: `/${locale}/${productsBase}/${locale === 'de' ? 'solarkabel' : 'solar-cables'}`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
||||
<h2 className="sr-only">{t('title')}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||
{categories.map((category, idx) => (
|
||||
<Link
|
||||
|
||||
@@ -32,60 +32,69 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10">
|
||||
<ul className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10 list-none p-0 m-0">
|
||||
{recentPosts.map((post) => (
|
||||
<Link key={post.slug} href={`/${locale}/blog/${post.slug}`} className="group block">
|
||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl">
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-64 overflow-hidden">
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{post.frontmatter.category && (
|
||||
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
|
||||
{post.frontmatter.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 md:p-8 flex flex-col flex-grow">
|
||||
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
|
||||
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
|
||||
{post.frontmatter.title}
|
||||
</h3>
|
||||
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
||||
{t('readMore')}
|
||||
<svg
|
||||
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
<li key={post.slug}>
|
||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
|
||||
<Card
|
||||
tag="article"
|
||||
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
|
||||
>
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-64 overflow-hidden">
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
loading="lazy"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{post.frontmatter.category && (
|
||||
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
|
||||
{post.frontmatter.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 md:p-8 flex flex-col flex-grow">
|
||||
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
|
||||
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
|
||||
{post.frontmatter.title}
|
||||
</h3>
|
||||
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
||||
{t('readMore')}
|
||||
<svg
|
||||
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</Card>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -17,32 +17,54 @@ export default function WhyChooseUs() {
|
||||
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="mt-12 space-y-6">
|
||||
|
||||
<ul className="mt-12 space-y-6 list-none p-0">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<li key={i} className="flex items-center gap-4">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-accent flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
className="w-4 h-4 text-primary-dark"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-primary-dark text-base md:text-base">{t(`features.${i}`)}</span>
|
||||
</div>
|
||||
<span className="font-bold text-primary-dark text-base md:text-base">
|
||||
{t(`features.${i}`)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1">
|
||||
<ul className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1 list-none p-0 m-0">
|
||||
{[0, 1, 2, 3].map((idx) => (
|
||||
<div key={idx} className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group">
|
||||
<li
|
||||
key={idx}
|
||||
className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group"
|
||||
>
|
||||
<div className="w-14 h-14 bg-saturated/10 rounded-2xl flex items-center justify-center mb-8 group-hover:bg-accent transition-colors duration-500">
|
||||
<span className="text-white font-bold text-lg group-hover:text-primary-dark">0{idx + 1}</span>
|
||||
<span className="text-white font-bold text-lg group-hover:text-primary-dark">
|
||||
0{idx + 1}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 text-primary-dark">{t(`items.${idx}.title`)}</h3>
|
||||
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 text-primary-dark">
|
||||
{t(`items.${idx}.title`)}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-base md:text-base leading-relaxed">
|
||||
{t(`items.${idx}.description`)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
118
components/record-mode/PickingHelper.tsx
Normal file
118
components/record-mode/PickingHelper.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { finder } from '@medv/finder';
|
||||
|
||||
export function PickingHelper() {
|
||||
const [pickingMode, setPickingMode] = useState<'click' | 'scroll' | null>(null);
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'START_PICKING') {
|
||||
setPickingMode(e.data.mode);
|
||||
} else if (e.data.type === 'STOP_PICKING') {
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
} else if (e.data.type === 'SET_HOVER_SELECTOR') {
|
||||
const selector = e.data.selector;
|
||||
if (selector) {
|
||||
const el = document.querySelector(selector) as HTMLElement;
|
||||
setHoveredElement(el || null);
|
||||
} else {
|
||||
setHoveredElement(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickingMode) return;
|
||||
|
||||
const handleMouseOver = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.record-mode-ignore') || target.closest('.feedback-ui-ignore')) return;
|
||||
setHoveredElement(target);
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (hoveredElement) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const selector = finder(hoveredElement, {
|
||||
root: document.body,
|
||||
seedMinLength: 3,
|
||||
optimizedMinLength: 2,
|
||||
className: (name) =>
|
||||
!name.startsWith('record-mode-') &&
|
||||
!name.startsWith('feedback-') &&
|
||||
!name.includes('[') &&
|
||||
!name.includes('/') &&
|
||||
!name.match(/^[a-z]-[0-9]/) &&
|
||||
!name.match(/[0-9]{4,}/), // Avoid dynamic IDs in classnames
|
||||
idName: (name) => !name.startsWith('__next') && !name.includes(':') && !name.match(/[0-9]{5,}/),
|
||||
});
|
||||
const rect = hoveredElement.getBoundingClientRect();
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'ELEMENT_SELECTED',
|
||||
selector,
|
||||
rect: {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
},
|
||||
tagName: hoveredElement.tagName.toLowerCase()
|
||||
}, '*');
|
||||
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
window.parent.postMessage({ type: 'PICKING_CANCELLED' }, '*');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mouseover', handleMouseOver);
|
||||
window.addEventListener('click', handleClick, true);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mouseover', handleMouseOver);
|
||||
window.removeEventListener('click', handleClick, true);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [pickingMode, hoveredElement]);
|
||||
|
||||
if (!hoveredElement) return null;
|
||||
// Don't show highlight if we are in picking mode but NOT hovering anything (handled by logic above)
|
||||
// but DO show if we have a hoveredElement (from message or mouseover)
|
||||
|
||||
const rect = hoveredElement.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed pointer-events-none border-2 border-[#82ed20] bg-[#82ed20]/15 transition-all z-[9999] shadow-[0_0_20px_rgba(130,237,32,0.3)] rounded-sm"
|
||||
style={{
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-0 right-0 bg-[#82ed20] text-black text-[10px] font-black px-1.5 py-1 transform -translate-y-full uppercase tracking-tighter shadow-xl">
|
||||
{hoveredElement.tagName.toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
components/record-mode/PlaybackCursor.tsx
Normal file
90
components/record-mode/PlaybackCursor.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
|
||||
export function PlaybackCursor() {
|
||||
const { isPlaying, cursorPosition, isClicking } = useRecordMode();
|
||||
const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Track scroll so cursor stays locked to the correct element
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollOffset({ x: window.scrollX, y: window.scrollY });
|
||||
};
|
||||
|
||||
handleScroll(); // Init
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [isPlaying]);
|
||||
|
||||
if (!isPlaying) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed z-[10000] pointer-events-none"
|
||||
animate={{
|
||||
x: cursorPosition.x,
|
||||
y: cursorPosition.y,
|
||||
scale: isClicking ? 0.8 : 1,
|
||||
rotateX: isClicking ? 15 : 0,
|
||||
rotateY: isClicking ? -15 : 0,
|
||||
}}
|
||||
transition={{
|
||||
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||
scale: { type: 'spring', damping: 15, stiffness: 400 },
|
||||
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
|
||||
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
|
||||
}}
|
||||
style={{ perspective: '1000px' }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isClicking && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 2.5, opacity: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Outer Pulse Ring */}
|
||||
<div
|
||||
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
|
||||
/>
|
||||
|
||||
{/* Visual Cursor */}
|
||||
<div className="relative">
|
||||
{/* Soft Glow */}
|
||||
<div
|
||||
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
|
||||
/>
|
||||
|
||||
{/* Pointer Arrow */}
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`drop-shadow-[0_2px_8px_rgba(130,237,32,0.5)] transition-transform ${isClicking ? 'translate-y-0.5' : ''}`}
|
||||
>
|
||||
<path
|
||||
d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z"
|
||||
fill={isClicking ? '#82ed20' : 'white'}
|
||||
stroke="black"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
className="transition-colors duration-150"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
392
components/record-mode/RecordModeContext.tsx
Normal file
392
components/record-mode/RecordModeContext.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import { RecordEvent, RecordingSession } from '@/types/record-mode';
|
||||
|
||||
interface RecordModeContextType {
|
||||
isActive: boolean;
|
||||
setIsActive: (active: boolean) => void;
|
||||
events: RecordEvent[];
|
||||
addEvent: (event: Omit<RecordEvent, 'id' | 'timestamp'>) => void;
|
||||
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
|
||||
removeEvent: (id: string) => void;
|
||||
clearEvents: () => void;
|
||||
setEvents: (events: RecordEvent[]) => void;
|
||||
isPlaying: boolean;
|
||||
playEvents: () => void;
|
||||
stopPlayback: () => void;
|
||||
cursorPosition: { x: number; y: number };
|
||||
zoomLevel: number;
|
||||
isBlurry: boolean;
|
||||
currentSession: RecordingSession | null;
|
||||
saveSession: (name: string) => void;
|
||||
isFeedbackActive: boolean;
|
||||
setIsFeedbackActive: (active: boolean) => void;
|
||||
reorderEvents: (startIndex: number, endIndex: number) => void;
|
||||
hoveredEventId: string | null;
|
||||
setHoveredEventId: (id: string | null) => void;
|
||||
isClicking: boolean;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
const RecordModeContext = createContext<RecordModeContextType | null>(null);
|
||||
|
||||
export function useRecordMode(): RecordModeContextType {
|
||||
const context = useContext(RecordModeContext);
|
||||
if (!context) {
|
||||
return {
|
||||
isActive: false,
|
||||
setIsActive: () => {},
|
||||
events: [],
|
||||
addEvent: () => {},
|
||||
updateEvent: () => {},
|
||||
removeEvent: () => {},
|
||||
clearEvents: () => {},
|
||||
isPlaying: false,
|
||||
playEvents: () => {},
|
||||
stopPlayback: () => {},
|
||||
cursorPosition: { x: 0, y: 0 },
|
||||
zoomLevel: 1,
|
||||
isBlurry: false,
|
||||
currentSession: null,
|
||||
isFeedbackActive: false,
|
||||
setIsFeedbackActive: () => {},
|
||||
saveSession: () => {},
|
||||
reorderEvents: () => {},
|
||||
hoveredEventId: null,
|
||||
setHoveredEventId: () => {},
|
||||
setEvents: () => {},
|
||||
isClicking: false,
|
||||
isEnabled: false,
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
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);
|
||||
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [isBlurry, setIsBlurry] = useState(false);
|
||||
const [isFeedbackActive, setIsFeedbackActiveState] = useState(false);
|
||||
const [hoveredEventId, setHoveredEventId] = useState<string | null>(null);
|
||||
const [isClicking, setIsClicking] = useState(false);
|
||||
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 && 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 (!isEnabled || !isLoadedRef.current) return;
|
||||
localStorage.setItem('klz-record-events', JSON.stringify(events));
|
||||
}, [events, isEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
localStorage.setItem('klz-record-active', JSON.stringify(isActive));
|
||||
}, [isActive, isEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
if (isEmbedded) {
|
||||
const handlePlaybackMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'PLAY_EVENT') {
|
||||
const { event } = e.data;
|
||||
const el = event.selector
|
||||
? (document.querySelector(event.selector) as HTMLElement)
|
||||
: null;
|
||||
if (el) {
|
||||
if (event.type === 'scroll') {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else if (event.type === 'mouse') {
|
||||
const currentRect = el.getBoundingClientRect();
|
||||
let targetX = currentRect.left + currentRect.width / 2;
|
||||
let targetY = currentRect.top + currentRect.height / 2;
|
||||
|
||||
if (event.clickOrigin === 'top-left') {
|
||||
targetX = currentRect.left + 5;
|
||||
targetY = currentRect.top + 5;
|
||||
} else if (event.clickOrigin === 'top-right') {
|
||||
targetX = currentRect.right - 5;
|
||||
targetY = currentRect.top + 5;
|
||||
} else if (event.clickOrigin === 'bottom-left') {
|
||||
targetX = currentRect.left + 5;
|
||||
targetY = currentRect.bottom - 5;
|
||||
} else if (event.clickOrigin === 'bottom-right') {
|
||||
targetX = currentRect.right - 5;
|
||||
targetY = currentRect.bottom - 5;
|
||||
}
|
||||
|
||||
const eventCoords = { clientX: targetX, clientY: targetY };
|
||||
const dispatchMouse = (type: string) => {
|
||||
el.dispatchEvent(
|
||||
new MouseEvent(type, {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...eventCoords,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (event.interactionType === 'click') {
|
||||
setIsClicking(true);
|
||||
dispatchMouse('mousedown');
|
||||
setTimeout(() => {
|
||||
dispatchMouse('mouseup');
|
||||
if (event.realClick) {
|
||||
dispatchMouse('click');
|
||||
el.click();
|
||||
}
|
||||
setIsClicking(false);
|
||||
}, 150);
|
||||
} else {
|
||||
dispatchMouse('mousemove');
|
||||
dispatchMouse('mouseover');
|
||||
dispatchMouse('mouseenter');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handlePlaybackMessage);
|
||||
return () => window.removeEventListener('message', handlePlaybackMessage);
|
||||
}
|
||||
}, [isEmbedded, isEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: 'SET_HOVER_SELECTOR', selector: event?.selector || null },
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}, [hoveredEventId, events, isActive, isEmbedded, isEnabled]);
|
||||
|
||||
const addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
|
||||
if (!isEnabled) return;
|
||||
const newEvent: RecordEvent = {
|
||||
realClick: false,
|
||||
...event,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setEvents((prev) => [...prev, newEvent]);
|
||||
};
|
||||
|
||||
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);
|
||||
setEvents(result);
|
||||
};
|
||||
|
||||
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([]);
|
||||
};
|
||||
|
||||
const currentSession: RecordingSession | null =
|
||||
events.length > 0
|
||||
? {
|
||||
id: 'draft',
|
||||
name: 'Draft Session',
|
||||
events,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
: null;
|
||||
|
||||
const saveSession = (name: string) => {
|
||||
if (!isEnabled) return;
|
||||
console.log('Saving session:', name, events);
|
||||
};
|
||||
|
||||
const playEvents = async () => {
|
||||
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));
|
||||
|
||||
for (const event of sortedEvents) {
|
||||
if (!isPlayingRef.current) break;
|
||||
if (event.rect && !isEmbedded) {
|
||||
const iframe = document.querySelector(
|
||||
'iframe[name="record-mode-iframe"]',
|
||||
) as HTMLIFrameElement;
|
||||
const iframeRect = iframe?.getBoundingClientRect();
|
||||
setCursorPosition({
|
||||
x: (iframeRect?.left || 0) + event.rect.x + event.rect.width / 2,
|
||||
y: (iframeRect?.top || 0) + event.rect.y + event.rect.height / 2,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.selector) {
|
||||
if (!isEmbedded) {
|
||||
const iframe = document.querySelector(
|
||||
'iframe[name="record-mode-iframe"]',
|
||||
) as HTMLIFrameElement;
|
||||
if (iframe?.contentWindow)
|
||||
iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*');
|
||||
} else {
|
||||
const el = document.querySelector(event.selector) as HTMLElement;
|
||||
if (el) {
|
||||
if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
else if (event.type === 'mouse') {
|
||||
const currentRect = el.getBoundingClientRect();
|
||||
let targetX = currentRect.left + currentRect.width / 2;
|
||||
let targetY = currentRect.top + currentRect.height / 2;
|
||||
|
||||
if (event.clickOrigin === 'top-left') {
|
||||
targetX = currentRect.left + 5;
|
||||
targetY = currentRect.top + 5;
|
||||
} else if (event.clickOrigin === 'top-right') {
|
||||
targetX = currentRect.right - 5;
|
||||
targetY = currentRect.top + 5;
|
||||
} else if (event.clickOrigin === 'bottom-left') {
|
||||
targetX = currentRect.left + 5;
|
||||
targetY = currentRect.bottom - 5;
|
||||
} else if (event.clickOrigin === 'bottom-right') {
|
||||
targetX = currentRect.right - 5;
|
||||
targetY = currentRect.bottom - 5;
|
||||
}
|
||||
|
||||
const eventCoords = { clientX: targetX, clientY: targetY };
|
||||
const dispatchMouse = (type: string) => {
|
||||
el.dispatchEvent(
|
||||
new MouseEvent(type, {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...eventCoords,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (event.interactionType === 'click') {
|
||||
setIsClicking(true);
|
||||
dispatchMouse('mousedown');
|
||||
setTimeout(() => {
|
||||
dispatchMouse('mouseup');
|
||||
if (event.realClick) {
|
||||
dispatchMouse('click');
|
||||
el.click();
|
||||
}
|
||||
setIsClicking(false);
|
||||
}, 150);
|
||||
} else {
|
||||
dispatchMouse('mousemove');
|
||||
dispatchMouse('mouseover');
|
||||
dispatchMouse('mouseenter');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.zoom) setZoomLevel(event.zoom);
|
||||
if (event.motionBlur) setIsBlurry(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, event.duration || 1000));
|
||||
setIsBlurry(false);
|
||||
}
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
setZoomLevel(1);
|
||||
};
|
||||
|
||||
const stopPlayback = () => {
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
setZoomLevel(1);
|
||||
setIsBlurry(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<RecordModeContext.Provider
|
||||
value={{
|
||||
isActive,
|
||||
setIsActive,
|
||||
events,
|
||||
addEvent,
|
||||
updateEvent,
|
||||
removeEvent,
|
||||
clearEvents,
|
||||
setEvents,
|
||||
isPlaying,
|
||||
playEvents,
|
||||
stopPlayback,
|
||||
cursorPosition,
|
||||
zoomLevel,
|
||||
isBlurry,
|
||||
currentSession,
|
||||
saveSession,
|
||||
isFeedbackActive,
|
||||
setIsFeedbackActive,
|
||||
reorderEvents,
|
||||
hoveredEventId,
|
||||
setHoveredEventId,
|
||||
isClicking,
|
||||
isEnabled,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RecordModeContext.Provider>
|
||||
);
|
||||
}
|
||||
583
components/record-mode/RecordModeOverlay.tsx
Normal file
583
components/record-mode/RecordModeOverlay.tsx
Normal file
@@ -0,0 +1,583 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
import { Reorder, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
MousePointer2,
|
||||
Scroll,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Eye,
|
||||
Edit2,
|
||||
X,
|
||||
Check,
|
||||
Download,
|
||||
Settings2,
|
||||
GripVertical,
|
||||
Clock,
|
||||
Maximize2,
|
||||
Box,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { RecordEvent } from '@/types/record-mode';
|
||||
import { PlaybackCursor } from './PlaybackCursor';
|
||||
|
||||
export function RecordModeOverlay() {
|
||||
const {
|
||||
isActive,
|
||||
setIsActive,
|
||||
events,
|
||||
addEvent,
|
||||
updateEvent,
|
||||
removeEvent,
|
||||
isPlaying,
|
||||
playEvents,
|
||||
saveSession,
|
||||
clearEvents,
|
||||
reorderEvents,
|
||||
setHoveredEventId,
|
||||
setEvents, // Added setEvents here
|
||||
} = useRecordMode();
|
||||
|
||||
const [pickingMode, setPickingMode] = useState<'mouse' | 'scroll' | null>(null);
|
||||
const [lastInteractionType, setLastInteractionType] = useState<'click' | 'hover'>('click');
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||
const [editingEventId, setEditingEventId] = useState<string | null>(null);
|
||||
|
||||
// Edit form state
|
||||
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || !isActive) return;
|
||||
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'ELEMENT_SELECTED') {
|
||||
const { selector, rect, tagName } = e.data;
|
||||
|
||||
if (pickingMode === 'mouse') {
|
||||
addEvent({
|
||||
type: 'mouse',
|
||||
interactionType: lastInteractionType,
|
||||
selector,
|
||||
duration: lastInteractionType === 'click' ? 1000 : 1500,
|
||||
zoom: 1,
|
||||
description: `Mouse ${lastInteractionType === 'click' ? '(Click)' : '(Hover)'} on ${tagName}`,
|
||||
motionBlur: false,
|
||||
realClick: false,
|
||||
rect,
|
||||
});
|
||||
} else if (pickingMode === 'scroll') {
|
||||
addEvent({
|
||||
type: 'scroll',
|
||||
selector,
|
||||
duration: 1500,
|
||||
zoom: 1,
|
||||
description: `Scroll to ${tagName}`,
|
||||
motionBlur: false,
|
||||
rect,
|
||||
});
|
||||
}
|
||||
setPickingMode(null);
|
||||
} else if (e.data.type === 'PICKING_CANCELLED') {
|
||||
setPickingMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
if (pickingMode) {
|
||||
// Find the iframe and signal start picking
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: 'START_PICKING', mode: pickingMode }, '*');
|
||||
}
|
||||
} else {
|
||||
// Signal stop picking
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: 'STOP_PICKING' }, '*');
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [isActive, pickingMode, addEvent, mounted]);
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingEventId) {
|
||||
updateEvent(editingEventId, editForm);
|
||||
setEditingEventId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const [showEvents, setShowEvents] = useState(true);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
if (!isActive) {
|
||||
// Failsafe: Never render host toggle in embedded mode
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
(window.self !== window.top ||
|
||||
window.name === 'record-mode-iframe' ||
|
||||
window.location.search.includes('embedded=true'))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsActive(true)}
|
||||
className="fixed bottom-6 left-6 z-[9999] bg-[#82ed20]/20 hover:bg-[#82ed20]/30 text-[#82ed20] p-4 rounded-full shadow-2xl transition-all hover:scale-110 record-mode-ignore border border-[#82ed20]/30 backdrop-blur-md animate-pulse"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-[4px] border-2 border-[#82ed20]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
||||
{/* 1. Global Toolbar - Slim Industrial Bar */}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||
<div className="bg-black/80 backdrop-blur-2xl border border-white/10 p-2 rounded-[24px] shadow-[0_32px_80px_rgba(0,0,0,0.8)] flex items-center gap-2">
|
||||
{/* Identity Tag */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
|
||||
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
|
||||
Event Builder
|
||||
</span>
|
||||
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
|
||||
Manual Mode
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
{/* Action Tools */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setPickingMode('mouse');
|
||||
setLastInteractionType('click');
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<MousePointer2 size={16} />
|
||||
<span>Mouse</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setPickingMode('scroll')}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<Scroll size={16} />
|
||||
<span>Scroll</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
addEvent({
|
||||
type: 'wait',
|
||||
duration: 2000,
|
||||
zoom: 1,
|
||||
description: 'Wait for 2s',
|
||||
motionBlur: false,
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span>Wait</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
{/* Sequence Controls */}
|
||||
<div className="flex items-center gap-1 p-0.5">
|
||||
<button
|
||||
onClick={playEvents}
|
||||
disabled={isPlaying || events.length === 0}
|
||||
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
|
||||
title="Preview Sequence"
|
||||
>
|
||||
<Play size={18} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowEvents(!showEvents)}
|
||||
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
{events.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
|
||||
{events.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
const session = { events, name: 'Recording', createdAt: new Date().toISOString() };
|
||||
try {
|
||||
const res = await fetch('/api/save-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(session),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Visual feedback could be improved, but alert is fine for dev tool
|
||||
alert('Session saved to remotion/session.json');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Failed to save: ${err.error}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Error saving session');
|
||||
}
|
||||
}}
|
||||
disabled={events.length === 0}
|
||||
className="p-3 bg-white/5 hover:bg-green-500/20 rounded-2xl disabled:opacity-30 transition-all text-green-400"
|
||||
title="Save to Project (Dev)"
|
||||
>
|
||||
<Save size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const data = JSON.stringify(
|
||||
{ events, name: 'Recording', createdAt: new Date().toISOString() },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'remotion-session.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
disabled={events.length === 0}
|
||||
className="p-3 bg-white/5 hover:bg-blue-500/20 rounded-2xl disabled:opacity-30 transition-all text-blue-400"
|
||||
title="Download JSON"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={() => setIsActive(false)}
|
||||
className="p-2.5 text-red-500 hover:bg-red-500/10 rounded-[14px] transition-all mx-1"
|
||||
title="Exit Studio"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Event Timeline Popover */}
|
||||
{showEvents && (
|
||||
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
|
||||
<div className="bg-black/90 backdrop-blur-3xl border border-white/10 rounded-[32px] p-6 shadow-[0_40px_100px_rgba(0,0,0,0.9)] max-h-[500px] overflow-hidden flex flex-col scale-in">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h4 className="text-white font-bold text-lg leading-none">Recording Track</h4>
|
||||
<p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">
|
||||
{events.length} Actions Recorded
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearEvents}
|
||||
disabled={events.length === 0}
|
||||
className="text-red-400/40 hover:text-red-400 transition-colors p-2 hover:bg-red-500/10 rounded-xl disabled:opacity-10"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={events}
|
||||
onReorder={setEvents}
|
||||
className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide"
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-white/10">
|
||||
<Plus size={40} strokeWidth={1} />
|
||||
<p className="text-xs mt-4">Timeline is empty</p>
|
||||
</div>
|
||||
) : (
|
||||
events.map((event, index) => (
|
||||
<Reorder.Item
|
||||
key={event.id}
|
||||
value={event}
|
||||
className="group flex items-center gap-3 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
|
||||
onMouseEnter={() => setHoveredEventId(event.id)}
|
||||
onMouseLeave={() => setHoveredEventId(null)}
|
||||
>
|
||||
<div className="cursor-grab active:cursor-grabbing text-white/10 hover:text-white/30 transition-colors">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white text-[10px] font-black uppercase tracking-widest">
|
||||
{event.type === 'mouse' ? `Mouse (${event.interactionType})` : event.type}
|
||||
</span>
|
||||
{event.clickOrigin &&
|
||||
event.clickOrigin !== 'center' &&
|
||||
event.interactionType === 'click' && (
|
||||
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
|
||||
{event.clickOrigin}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
|
||||
{event.duration}ms
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
|
||||
{event.selector || 'system:wait'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingEventId(event.id);
|
||||
setEditForm(event);
|
||||
}}
|
||||
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeEvent(event.id)}
|
||||
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
))
|
||||
)}
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
|
||||
|
||||
{/* Picking Tooltip */}
|
||||
{pickingMode && (
|
||||
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
|
||||
<span className="font-black uppercase tracking-widest text-xs">
|
||||
Assigning {pickingMode}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-primary-dark/20" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
}}
|
||||
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
|
||||
>
|
||||
ESC to Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlaybackCursor />
|
||||
|
||||
{/* 3. Event Options Panel (Sidebar-like) */}
|
||||
<AnimatePresence>
|
||||
{editingEventId && (
|
||||
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h3 className="text-white font-black uppercase tracking-tighter text-xl">
|
||||
Event Options
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setEditingEventId(null)}
|
||||
className="p-2 text-white/40 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
|
||||
{/* Type Display */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||
Interaction Type
|
||||
</label>
|
||||
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'click' }))
|
||||
}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<MousePointer2 size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Click</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'hover' }))
|
||||
}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<Eye size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Hover</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<Scroll size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Scroll</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<Clock size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Wait</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Precise Click Origin */}
|
||||
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||
Click Origin
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
|
||||
{[
|
||||
{ id: 'top-left', label: 'TL' },
|
||||
{ id: 'top-right', label: 'TR' },
|
||||
{ id: 'center', label: 'CTR' },
|
||||
{ id: 'bottom-left', label: 'BL' },
|
||||
{ id: 'bottom-right', label: 'BR' },
|
||||
].map((origin) => (
|
||||
<button
|
||||
key={origin.id}
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
|
||||
}
|
||||
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
|
||||
>
|
||||
{origin.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
|
||||
<span>Timeline Allocation</span>
|
||||
<span className="text-accent">{editForm.duration}ms</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="5000"
|
||||
step="100"
|
||||
value={editForm.duration || 1000}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
|
||||
}
|
||||
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom & Effects */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
|
||||
<div className="flex items-center gap-3">
|
||||
<Maximize2 size={18} className="text-white/40" />
|
||||
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
||||
Zoom Shift
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="1"
|
||||
max="3"
|
||||
value={editForm.zoom || 1}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
|
||||
}
|
||||
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))}
|
||||
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Box size={18} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Motion Blur</span>
|
||||
</div>
|
||||
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||
</button>
|
||||
|
||||
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||
<button
|
||||
onClick={() => setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))}
|
||||
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.realClick ? 'bg-orange-500/10 border-orange-500/30 text-orange-400' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ExternalLink size={18} />
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-xs font-bold uppercase tracking-wider">
|
||||
Trigger Navigation
|
||||
</span>
|
||||
<span className="text-[8px] opacity-60">
|
||||
Allows URL transitions in Studio
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
|
||||
>
|
||||
Commit Changes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
261
components/record-mode/RecordModeVisuals.tsx
Normal file
261
components/record-mode/RecordModeVisuals.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
'use client';
|
||||
|
||||
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);
|
||||
|
||||
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]);
|
||||
|
||||
// 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: `
|
||||
/* Harder Isolation: Hide ALL potentially duplicate overlays and DEV TOOLS */
|
||||
#nextjs-portal,
|
||||
#nextjs-portal-root,
|
||||
[data-nextjs-toast-wrapper],
|
||||
.nextjs-static-indicator,
|
||||
[data-nextjs-indicator],
|
||||
[class*="nextjs-"],
|
||||
[id*="nextjs-"],
|
||||
nextjs-portal,
|
||||
#feedback-overlay,
|
||||
.feedback-ui-root,
|
||||
.feedback-ui-ignore,
|
||||
[class*="z-[9999]"],
|
||||
[class*="z-[10000]"],
|
||||
[style*="z-index: 9999"],
|
||||
[style*="z-index: 10000"],
|
||||
.fixed.bottom-6.left-6,
|
||||
.fixed.bottom-6.left-1/2,
|
||||
.feedback-ui-overlay,
|
||||
[id^="feedback-"],
|
||||
[class^="feedback-"] {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
z-index: -10000 !important;
|
||||
}
|
||||
|
||||
/* Nuclear Option 2.0: Kill ALL scrollbars on ALL elements */
|
||||
* {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
border-radius: 3rem;
|
||||
background: #050505 !important;
|
||||
color: white !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Global Style for Body Lock */}
|
||||
{isActive && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
html, body {
|
||||
overflow: hidden !important;
|
||||
height: 100vh !important;
|
||||
position: fixed !important;
|
||||
width: 100vw !important;
|
||||
}
|
||||
/* Kill Next.js Dev tools on host while Studio is active */
|
||||
#nextjs-portal,
|
||||
[data-nextjs-toast-wrapper],
|
||||
.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={
|
||||
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>
|
||||
</div>
|
||||
|
||||
<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); } }
|
||||
@keyframes mesh-float-4 { 0%, 100% { transform: translate(0, 0) scale(1); } 50% { transform: translate(-15%, 25%) scale(1.1); } }
|
||||
@keyframes pulse-ring { 0%, 100% { opacity: 0.15; transform: scale(1); } 50% { opacity: 0.4; transform: scale(1.005); } }
|
||||
@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; }
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
components/record-mode/ToolCoordinator.tsx
Normal file
66
components/record-mode/ToolCoordinator.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
import { FeedbackOverlay } from '@mintel/next-feedback/FeedbackOverlay';
|
||||
import { RecordModeOverlay } from './RecordModeOverlay';
|
||||
import { PickingHelper } from './PickingHelper';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import React from 'react';
|
||||
import { cn } from './utils';
|
||||
|
||||
export function Card({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
interface CardProps extends React.HTMLAttributes<HTMLElement> {
|
||||
tag?: 'div' | 'article' | 'section' | 'aside' | 'header' | 'footer' | 'nav' | 'main';
|
||||
}
|
||||
|
||||
export function Card({ className, children, tag: Tag = 'div', ...props }: CardProps) {
|
||||
return (
|
||||
<div className={cn('premium-card overflow-hidden', className)} {...props}>
|
||||
<Tag className={cn('premium-card overflow-hidden', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
52
config/lighthouserc.json
Normal file
52
config/lighthouserc.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"numberOfRuns": 1,
|
||||
"settings": {
|
||||
"preset": "desktop",
|
||||
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
|
||||
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
|
||||
}
|
||||
},
|
||||
"assert": {
|
||||
"assertions": {
|
||||
"categories:performance": [
|
||||
"error",
|
||||
{
|
||||
"minScore": 0.8
|
||||
}
|
||||
],
|
||||
"categories:accessibility": [
|
||||
"error",
|
||||
{
|
||||
"minScore": 0.9
|
||||
}
|
||||
],
|
||||
"categories:best-practices": [
|
||||
"error",
|
||||
{
|
||||
"minScore": 0.9
|
||||
}
|
||||
],
|
||||
"categories:seo": [
|
||||
"error",
|
||||
{
|
||||
"minScore": 0.9
|
||||
}
|
||||
],
|
||||
"first-contentful-paint": [
|
||||
"warn",
|
||||
{
|
||||
"maxNumericValue": 2000
|
||||
}
|
||||
],
|
||||
"interactive": [
|
||||
"warn",
|
||||
{
|
||||
"maxNumericValue": 3500
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ date: '2025-03-31T12:00:34'
|
||||
featuredImage: /uploads/2025/02/image_fx_-6.webp
|
||||
locale: de
|
||||
category: Kabel Technologie
|
||||
excerpt: Die Energiewende braucht leistungsfähige Netze. Erfahren Sie, warum Investitionen in die Kabelinfrastruktur der Schlüssel zu 100 % erneuerbarer Energie sind.
|
||||
---
|
||||
# 100 % erneuerbare Energie? Nur mit der richtigen Kabelinfrastruktur!
|
||||
Die Vision ist klar: Ein Europa, das seinen Strom zu 100 % aus erneuerbaren Energien gewinnt. Doch während Solar- und Windparks boomen, hinkt der Ausbau der Stromnetze hinterher. Die Ursache? Eine Infrastruktur, die für fossile Kraftwerke gebaut wurde und mit den neuen Anforderungen nicht Schritt hält.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -24,35 +24,21 @@ There is an often overlooked problem with solar power systems: energy loss due t
|
||||
But why does this happen? Every cable has a resistance that slows down the flow of electricity. The poorer the quality of the cable, the more energy is lost in the form of heat. This means that less of the electricity produced by the solar system actually reaches you and can be used. And that is obviously a problem, especially when you consider how much is invested in the installation of a solar system.
|
||||
### Green energy is a central component of our future today … But it is not enough to simply rely on these energy sources. The infrastructure that brings this energy to us efficiently plays an equally crucial role.
|
||||
High-quality cables, on the other hand, have better conductivity and lower resistance. This ensures that the **electricity flows more efficiently and less is lost**. This leaves more of the generated energy for you to use – which is not only good for your electricity bill, but also helps to maximize the sustainability of your solar system. So it’s worth paying attention to quality when choosing cables in order to exploit the full potential of green energy.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://ratedpower.com/blog/utility-scale-pv-losses/"
|
||||
title="Ultimate guide to utility-scale PV system losses — RatedPower"
|
||||
summary="What are solar PV system losses and how can you avoid them to maximize the electrical output from your utility-scale plant project?"
|
||||
image="https://assets.ratedpower.com/1694509274-aerial-view-solar-panels-top-building-eco-building-factory-solar-photovoltaic-cell.jpg?auto=format&fit=crop&h=630&w=1200"
|
||||
/>
|
||||
"
|
||||
title="Ultimate guide to utility-scale PV system losses — RatedPower"
|
||||
summary="What are solar PV system losses and how can you avoid them to maximize the electrical output from your utility-scale plant project?"
|
||||
image="https://assets.ratedpower.com/1694509274-aerial-view-solar-panels-top-building-eco-building-factory-solar-photovoltaic-cell.jpg?auto=format&fit=crop&h=630&w=1200"
|
||||
/>
|
||||
<h4>Fact 2: Wind farms without energy storage are not that efficient</h4>
|
||||
Wind farms have a similar problem to solar plants: energy losses due to fluctuating power generation. Imagine a wind farm produces electricity, but the wind does not blow constantly. This means that at certain times the wind turbines generate more electricity than is actually needed, while at other times, when the wind drops, they can supply almost no electricity at all. In both cases, a lot of energy is lost or not used. Without a way to **store surplus energy**, there is a gap between the energy generated and the actual use, which significantly reduces the efficiency of the entire system.The solution to this problem lies in** energy storage systems** such as batteries or pumped storage power plants. These technologies make it possible to store surplus energy when the wind is blowing strongly and therefore more electricity is produced than is required at the moment. This stored energy can then be used on demand when the wind dies down or demand is particularly high. This ensures that all the electricity generated is used efficiently instead of being lost unused. Without these storage technologies, the full potential of wind energy remains untapped and the efficiency of wind farms remains far below their actual value.
|
||||
However, despite their importance, energy storage systems are associated with challenges. High costs and limited capacity continue to make the development and installation of these storage technologies a difficult endeavor. But technological progress has not stood still: New innovations in storage technologies and increasingly improved scalability are making it more and more realistic to equip wind farms with **effective and cost-efficient storage systems**. This is crucial for the future of wind energy, because only by overcoming these challenges can wind energy fully contribute to ensuring a stable and sustainable energy supply.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.solarenergie.de/stromspeicher/arten/stromspeicher-windkraft"
|
||||
title="Speicher für Windenergie: Welche Möglichkeiten gibt es?"
|
||||
summary="Speicher für Windenergie: Welche Möglichkeiten gibt es? Windkraftanlagen mit Speicher im privaten und im öffentlichen Bereich ✓ Wie kann man Windenergie speichern? – Lernen Sie hier bereits existente und sich derzeit in der Forschung befindende Verfahren der Zukunft kennen!"
|
||||
image="https://assets.solarwatt.de/Resources/Persistent/e084aa09af5f0cdef386088bc558a52d81509cc0/Regenerative%20Energie-1200x628.jpg"
|
||||
/>
|
||||
"
|
||||
title="Speicher für Windenergie: Welche Möglichkeiten gibt es?"
|
||||
summary="Speicher für Windenergie: Welche Möglichkeiten gibt es? Windkraftanlagen mit Speicher im privaten und im öffentlichen Bereich ✓ Wie kann man Windenergie speichern? – Lernen Sie hier bereits existente und sich derzeit in der Forschung befindende Verfahren der Zukunft kennen!"
|
||||
image="https://assets.solarwatt.de/Resources/Persistent/e084aa09af5f0cdef386088bc558a52d81509cc0/Regenerative%20Energie-1200x628.jpg"
|
||||
/>
|
||||
<h4>Fact 3: Power lines can be used as habitats for biodiversity</h4>
|
||||
Did you know that power lines – the high-voltage lines that transport electricity from power plants to our homes and businesses – can also be used as** habitats for animals and plants**? These areas, which often need to be kept clear to make room for the power lines, provide a valuable opportunity to actively promote biodiversity and contribute to environmental stewardship at the same time.Traditionally, the areas along power lines have often been regarded as “wasteland” with no particular significance. However, innovative approaches to green infrastructure are increasingly creating valuable habitats here. Today, wildflower meadows, bee pastures and shrubs are planted along power lines, providing habitats for many endangered species. These meadows are not only a source of food for bees, butterflies and other pollinators, but also a refuge for birds and small animals that are finding less and less habitat in other parts of the landscape.
|
||||
In Germany, this is a growing concept known as “**ecological route maintenance**”. Here, care is taken to ensure that the areas along the power lines are designed in a near-natural way so that biodiversity is promoted. This creates flowering meadows and habitats for numerous insect species, which are finding less and less space due to the intensive use of agriculture and urbanization. There are also similar projects in Switzerland in which nature is specifically promoted along power lines.
|
||||
|
||||
@@ -31,19 +31,12 @@ Integrating wind farms into the power grid requires a systemic approach. Sound p
|
||||
Professional planning not only ensures security of supply, but also reduces operating costs in the long term and enables flexible responses to grid requirements.
|
||||
You can find more information here on how wind energy basically works:
|
||||
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.e-werk-mittelbaden.de/wie-funktioniert-windenergie"
|
||||
title="Wie funktioniert Windenergie? - Einfach erklärt | E-Werk Mittelbaden"
|
||||
summary="Erfahren Sie, wie Windenergie funktioniert und wie sie zur nachhaltigen Energieversorgung beiträgt. Jetzt informieren!"
|
||||
image="https://www.e-werk-mittelbaden.de/sites/default/files/media_image/2024-12/DJI_20231105012629_0029_D-HDR.jpg"
|
||||
/>
|
||||
"
|
||||
title="Wie funktioniert Windenergie?"
|
||||
summary="- Einfach erklärt | E-Werk MittelbadenErfahren Sie, wie Windenergie funktioniert und wie sie zur nachhaltigen Energieversorgung beiträgt. Jetzt informieren!"
|
||||
image="https://www.e-werk-mittelbaden.de/sites/default/files/media_image/2024-12/DJI_20231105012629_0029_D-HDR.jpg"
|
||||
/>
|
||||
<blockquote>
|
||||
**Those who want to transport climate-friendly energy must also build in a climate-conscious way.**
|
||||
</blockquote>
|
||||
@@ -60,19 +53,12 @@ A well-considered dismantling does not begin with removal, but with a **forward-
|
||||
This is not only about **ecological aspects** – a planned dismantling also makes sense **economically**. Projects that are **systematically designed for dismantling** avoid high **disposal costs** and meet future **regulatory requirements** much more easily.
|
||||
Overall, it becomes clear: **Sustainability does not end at the grid connection.** It covers the **entire life cycle** – right up to the **last recycled cable**. Those who think about **infrastructure holistically** think it through **to the end**.
|
||||
In the following article, you can find out how, for example, wind turbines are recycled:
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.enbw.com/unternehmen/themen/windkraft/windrad-recycling.html"
|
||||
title="Recycling von Windrädern | EnBW"
|
||||
summary="Wie funktioniert das Recycling von Windrädern? Erfahren Sie mehr über Herausforderungen und die neuesten Methoden."
|
||||
image="https://www.enbw.com/media/image-proxy/1600x914,q70,focus60x67,zoom1.45/https://www.enbw.com/media/presse/images/newsroom/windenergie/rueckbau-windpark-hemme-3_1743678993586.jpg"
|
||||
/>
|
||||
"
|
||||
title="Recycling von Windrädern | EnBW"
|
||||
summary="Wie funktioniert das Recycling von Windrädern? Erfahren Sie mehr über Herausforderungen und die neuesten Methoden."
|
||||
image="https://www.enbw.com/media/image-proxy/1600x914,q70,focus60x67,zoom1.45/https://www.enbw.com/media/presse/images/newsroom/windenergie/rueckbau-windpark-hemme-3_1743678993586.jpg"
|
||||
/>
|
||||
## Reliable Grids Don’t Happen by Accident
|
||||
The **requirements** for today’s **energy grids** are constantly **increasing**. Especially for wind power projects realized in remote or structurally weak regions, a stable grid design is crucial. It is no longer enough to transmit power from A to B. The **infrastructure** must also work in unforeseen situations – during peak loads, maintenance or external disruptions.
|
||||
This **resilience** cannot be retrofitted. It must be considered right from the planning stage. **Grid architecture** that can flexibly respond to different operating situations is not a technical extra, but a fundamental part of **sustainable project development**. The ability to switch, use alternative routes, or throttle power without causing supply outages is particularly important.
|
||||
|
||||
@@ -45,16 +45,9 @@ Those planning sustainable projects don’t just need cables – they need a cab
|
||||
Cables are no side note – they are the nervous system of the energy transition. They connect **ideas with reality, sources with consumers, visions with feasibility**. And they do so discreetly, reliably, and for decades to come.
|
||||
Anyone thinking about **renewable energy** today should also consider what keeps that energy moving: **the cable industry.** It doesn’t just deliver copper and insulation – it provides solutions that make our **green future** possible in the first place.
|
||||
Find out how you can contribute to a sustainable energy supply in the following article.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://money-for-future.com/nachhaltige-energieversorgung-erneuerbare-energie"
|
||||
title="Nachhaltige Energieversorgung und erneuerbare Energie erklärt"
|
||||
summary="Nachhaltige Energieversorgung. Was kann ich tun, um die Energiewende voranzubringen? 7 Schritte zu einer nachhaltigen Lebensweise."
|
||||
image="https://money-for-future.com/wp-content/uploads/2022/01/Image-153-1.jpg"
|
||||
/>
|
||||
"
|
||||
title="Nachhaltige Energieversorgung und erneuerbare Energie erklärt"
|
||||
summary="Nachhaltige Energieversorgung. Was kann ich tun, um die Energiewende voranzubringen? 7 Schritte zu einer nachhaltigen Lebensweise."
|
||||
image="https://money-for-future.com/wp-content/uploads/2022/01/Image-153-1.jpg"
|
||||
/>
|
||||
|
||||
@@ -9,26 +9,12 @@ category: Kabel Technologie
|
||||
### What’s the Directory of Wind Energy?
|
||||
The <em>Directory of Wind Energy 2025</em> is the ultimate reference guide for the wind energy industry. With over 200 pages of insights, company listings, and industry contacts, it’s the resource planners, developers, and decision-makers use to connect with trusted suppliers and service providers. Covering everything from turbine manufacturers to certification companies, it’s a compact treasure trove of knowledge, both in print and online.
|
||||
Now, KLZ is part of this trusted network, making it even easier for industry professionals to find us.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
<VisualLinkPreview
|
||||
url="https://www.erneuerbareenergien.de/"
|
||||
title="Erneuerbare Energien - Das Magazin für die Energiewende mit Wind-, Solar- und Bioenergie"
|
||||
summary="Heft 01-2025"
|
||||
image="https://www.erneuerbareenergien.de/sites/default/files/styles/teaser_standard__xs/public/aurora/2024/12/414535.jpeg?itok=WJmtgX-q"
|
||||
/>
|
||||
"
|
||||
title="Erneuerbare Energien - Das Magazin für die Energiewende mit Wind-, Solar- und Bioenergie"
|
||||
summary="Heft 01-2025"
|
||||
image="
|
||||
<VisualLinkPreview
|
||||
url="https://www.erneuerbareenergien.de/"
|
||||
title="Erneuerbare Energien - Das Magazin für die Energiewende mit Wind-, Solar- und Bioenergie"
|
||||
summary="Heft 01-2025"
|
||||
image="https://www.erneuerbareenergien.de/sites/default/files/styles/teaser_standard__xs/public/aurora/2024/12/414535.jpeg?itok=WJmtgX-q"
|
||||
/>
|
||||
sites/default/files/styles/teaser_standard__xs/public/aurora/2024/12/414535.jpeg?itok=WJmtgX-q"
|
||||
/>
|
||||
### Why we’re included
|
||||
Our medium voltage cables, like the **NA2XS(F)2Y**, have become essential in wind parks throughout Germany and the Netherlands. These cables play a critical role in transmitting electricity from wind turbines to substations, ensuring safe and reliable energy flow under the most demanding conditions.
|
||||
What sets us apart is more than the cables:
|
||||
|
||||
@@ -7,18 +7,11 @@ category: Kabel Technologie
|
||||
---
|
||||
# Securing the future with H1Z2Z2-K: Our solar cable for Intersolar 2025
|
||||
Around [Intersolar Europe](https://www.intersolar.de/start), the topic of photovoltaics is once again moving into the spotlight. A great reason to take a closer look at a special solar cable developed specifically for use in PV systems – robust, weather-resistant, and compliant with current standards.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://youtu.be/YbtdyvQFoVM"
|
||||
title="Intersolar Europe 2025 | Save The Date | May 7–9, 2025"
|
||||
summary="As the world’s leading exhibition for the solar industry, Intersolar Europe demonstrates the enormous vitality of the solar market. For more than 30 years, i…"
|
||||
image="https://i.ytimg.com/vi/YbtdyvQFoVM/maxresdefault.jpg?sqp=-oaymwEmCIAKENAF8quKqQMa8AEB-AH-CYAC0AWKAgwIABABGEQgSyhyMA8=&rs=AOn4CLBx90qdBxgYcyMttgdOGs3-m0udZQ"
|
||||
/>
|
||||
"
|
||||
title="Intersolar Europe 2025 | Save The Date | May 7–9, 2025"
|
||||
summary="As the world’s leading exhibition for the solar industry, Intersolar Europe demonstrates the enormous vitality of the solar market. For more than 30 years, i…"
|
||||
image="https://i.ytimg.com/vi/YbtdyvQFoVM/maxresdefault.jpg?sqp=-oaymwEmCIAKENAF8quKqQMa8AEB-AH-CYAC0AWKAgwIABABGEQgSyhyMA8=&rs=AOn4CLBx90qdBxgYcyMttgdOGs3-m0udZQ"
|
||||
image="https://i.ytimg.com/vi/YbtdyvQFoVM/maxresdefault.jpg?sqp=-oaymwEmCIAKENAF8quKqQMa8AEB-AH-CYAC0AWKAgwIABABGEQgSyhyMA8=&rs=AOn4CLBx90qdBxgYcyMttgdOGs3-m0udZQ"
|
||||
/>
|
||||
What lies behind the design, which selection criteria matter for solar cables, and why every detail counts in photovoltaic projects – that’s exactly what this article is about.
|
||||
## What is the H1Z2Z2-K 6mm² solar cable?
|
||||
@@ -113,16 +106,9 @@ The H1Z2Z2-K 6mm² stands for technical maturity and a consistent focus on profe
|
||||
What really stands out is its versatility: whether on rooftops, in underground installations, or in large-scale PV plants – the H1Z2Z2-K delivers reliability and an impressive service life. It makes a direct contribution to the economic efficiency and sustainability of solar systems.
|
||||
Further information, technical details, and ordering options can be found on the product page: 👉 [To the H1Z2Z2-K at KLZ](/products/solar-cables/h1z2z2-k/)
|
||||
All the key details about Intersolar Europe can be found here:
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.intersolar.de/messe-kompakt?ref=m5f53a666f3a2cb2fee160554-s65eec4739108db093b003a02-t1746004197-cf3c592e7"
|
||||
title="Intersolar Europe at a Glance"
|
||||
summary="Intersolar Europe | Exhibition Quick Facts | Date, Venue, Opening Hours, Exhibitors"
|
||||
image="https://www.intersolar.de/media/image/6311c9ee98bbc414b66305e2/750"
|
||||
/>
|
||||
"
|
||||
title="Intersolar Europe at a Glance"
|
||||
summary="Intersolar Europe | Exhibition Quick Facts | Date, Venue, Opening Hours, Exhibitors"
|
||||
image="https://www.intersolar.de/media/image/6311c9ee98bbc414b66305e2/750"
|
||||
/>
|
||||
|
||||
@@ -42,19 +42,12 @@ Choosing the right cable is a long-term investment that pays off in safety, cost
|
||||
<h4>The relevance of high-quality cables for a sustainable future</h4>
|
||||
In a world that is increasingly moving towards a carbon-neutral energy supply, first-class cables are helping to achieve these goals. Sustainable cables are made from recyclable materials that minimize environmental impact. They also support the integration of renewable energies into the power grid by ensuring that the electricity generated is transported efficiently and without losses.
|
||||
The right choice of cable is therefore not just a technical decision – it is a contribution to a more sustainable future. By using high-quality cables, the carbon footprint of infrastructure projects can be significantly reduced. This is an important step towards an environmentally friendly and energy-efficient society.A first-class cable is therefore more than just a technical component – it is a key to a more stable, greener and more efficient energy supply.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.konnworld.com/why-cable-quality-matters-the-impact-on-energy-efficiency-and-longevity"
|
||||
title="Why Cable Quality Matters: The Impact on Energy Efficiency and Longevity"
|
||||
summary="In the electrical systems that we have today, there’s no denying that cable quality is important in ensuring optimal performance and safety."
|
||||
image="https://www.konnworld.com/wp-content/uploads/2018/08/konn-b-logo.png"
|
||||
/>
|
||||
"
|
||||
title="Why Cable Quality Matters: The Impact on Energy Efficiency and Longevity"
|
||||
summary="In the electrical systems that we have today, there’s no denying that cable quality is important in ensuring optimal performance and safety."
|
||||
image="https://www.konnworld.com/wp-content/uploads/2018/08/konn-b-logo.png"
|
||||
/>
|
||||
<h4>Materials: What makes a cable durable and efficient?</h4>
|
||||
Choosing the right materials is crucial to making cables both durable and efficient. Two of the most common and important materials used in cables are copper and aluminum. They play a central role in the electrical conductivity and durability of cables.
|
||||
**The role of copper and aluminum**
|
||||
@@ -67,37 +60,23 @@ The demand for environmentally friendly materials is growing as more and more co
|
||||
- Aluminum recycling: Aluminum is also a highly recyclable material. The aluminum recycling process requires only about 5% of the energy needed to produce new aluminum. Many manufacturers are turning to recycled aluminum to improve their environmental footprint while increasing the efficiency of their cable products.
|
||||
- Biodegradable insulation: Another trend is the development of biodegradable or more environmentally friendly insulation materials. These materials not only reduce the use of toxic substances, but also help to minimize waste after the cable’s lifetime.In summary, choosing the right materials for cables is not only a factor in their longevity and efficiency, but also crucial for a sustainable future. Copper and aluminum offer excellent performance, but the focus on recycling and the search for more environmentally friendly alternatives is making the cable industry increasingly greener and more resource-efficient. So not only can a cable work efficiently today, it can also leave a smaller environmental footprint in the future.
|
||||
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://insights.regencysupply.com/pros-and-cons-of-copper-and-aluminum-wire"
|
||||
title="Pros and Cons of Copper and Aluminum Wire"
|
||||
summary="Copper wire and aluminum wire — which option is better? We weight the pros and cons."
|
||||
image="https://insights.regencysupply.com/hubfs/copper%20wire.jpg"
|
||||
/>
|
||||
"
|
||||
title="Pros and Cons of Copper and Aluminum Wire"
|
||||
summary="Copper wire and aluminum wire — which option is better? We weight the pros and cons."
|
||||
image="https://insights.regencysupply.com/hubfs/copper%20wire.jpg"
|
||||
/>
|
||||
<h4>Technology: Advanced designs for optimal performance</h4>
|
||||
Advanced cable technologies are critical to maximize the performance and safety of electrical systems, especially with regard to renewable energy sources. Two key technologies are insulation and sheathing, and specialized cables for renewable energy.
|
||||
**Insulation and sheathing: quality meets safety**<br />
|
||||
The insulation of a cable protects against short circuits and external influences such as moisture or mechanical damage. Materials such as PVC, PE and XLPE offer excellent protection and guarantee safe power transmission. The protective sheath protects the cable from UV radiation and extreme temperatures, which significantly extends its service life and increases safety.**Cables for renewable energy sources**<br />
|
||||
Solar and wind energy require specialized cables that can withstand extreme weather conditions and high loads. Solar cables must be UV resistant and suitable for DC systems, while wind power cables must be flexible and corrosion resistant to withstand the constant movement of rotor blades. These advanced cables enable the efficient and safe transportation of energy, which is crucial for the sustainability and economic viability of renewable energy.
|
||||
Overall, these technologies help maximize the efficiency and safety of cables and support a sustainable energy future.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.cables-unlimited.com/renewable-energy-cable-assemblies-weve-got-you-covered/"
|
||||
title="Renewable Energy Cable Assemblies — We’ve Got You Covered - Cables Unlimited Inc."
|
||||
summary="Cable assemblies used in renewable energy installations, what they are, their benefits and cable systems for renewable energy design factors."
|
||||
image="http://www.cables-unlimited.com/wp-content/uploads/2023/02/Cables-Unlimited_Featured-Renewable-Energy-Cable-Assemblies-%E2%80%94-Weve-Got-You-Covered.jpg"
|
||||
/>
|
||||
"
|
||||
title="Renewable Energy Cable Assemblies — We’ve Got You Covered - Cables Unlimited Inc."
|
||||
summary="Cable assemblies used in renewable energy installations, what they are, their benefits and cable systems for renewable energy design factors."
|
||||
image="http://www.cables-unlimited.com/wp-content/uploads/2023/02/Cables-Unlimited_Featured-Renewable-Energy-Cable-Assemblies-%E2%80%94-Weve-Got-You-Covered.jpg"
|
||||
image="http://www.cables-unlimited.com/wp-content/uploads/2023/02/Cables-Unlimited_Featured-Renewable-Energy-Cable-Assemblies-%E2%80%94-Weve-Got You-Covered.jpg"
|
||||
/>
|
||||
<h4>Certifications and standards: a guarantee of quality</h4>
|
||||
The quality of cables is not only determined by their materials and technologies, but also by compliance with certifications and standards. These guarantee that cables are safe, efficient and durable. They play a decisive role in ensuring product quality and are an important criterion when selecting cables for various applications, particularly with regard to sustainability and environmental protection.**Important standards and seals for first-class cables**
|
||||
@@ -129,34 +108,20 @@ In a world that is increasingly focusing on sustainability, sustainability certi
|
||||
- Environmentally friendly production: Certificates such as ISO 14001 prove that manufacturers use environmentally friendly production processes that minimize energy consumption and waste.
|
||||
|
||||
The increasing importance of sustainability certificates is not only a response to legal requirements, but also a response to the growing awareness of consumers and companies who are looking for environmentally friendly products. In an industry increasingly dominated by green technologies, such certificates provide companies with a competitive advantage and consumers with the assurance that they are choosing responsibly produced products.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.flukenetworks.com/blog/cabling-chronicles/cabling-certification"
|
||||
title="Three Reasons Cabling Certification Is More Important Than Ever"
|
||||
summary="Every time you complete the installation of a structured cabling system, you can choose whether to certify it. All links in the system should be tested in some way to make sure that they’re connected properly, but is it necessary to measure and document the performance of every link?"
|
||||
image="https://www.flukenetworks.com/sites/default/files/blog/fn-dsx-8000_14a_w.jpg"
|
||||
/>
|
||||
"
|
||||
title="Three Reasons Cabling Certification Is More Important Than Ever"
|
||||
summary="Every time you complete the installation of a structured cabling system, you can choose whether to certify it. All links in the system should be tested in some way to make sure that they’re connected properly, but is it necessary to measure and document the performance of every link?"
|
||||
image="https://www.flukenetworks.com/sites/default/files/blog/fn-dsx-8000_14a_w.jpg"
|
||||
/>
|
||||
<h4>Conclusion: What really makes a first-class cable</h4>
|
||||
A first-class cable is characterized by a perfect balance between quality, efficiency and sustainability. Choosing the right cable is not just a question of technical requirements, but also an important step towards a more sustainable future. A high-quality cable ensures reliable performance and maximum efficiency, reduces energy loss and at the same time offers a high standard of safety.**Quality and efficiency**<br />
|
||||
A good cable is designed to work efficiently over the long term. Materials such as copper and aluminum ensure excellent conductivity, while modern insulation materials and protective layers extend the cable’s service life and protect it against external influences such as moisture and corrosion. This is particularly important in applications such as power transmission and the use of renewable energy, where efficiency and reliability are top priorities.
|
||||
**Sustainability**<br />
|
||||
In a world that is increasingly focusing on sustainability, environmental protection is playing an ever greater role when choosing a cable. Recyclability, sustainable production processes and certifications such as RoHS or recycling seals are decisive factors that determine the ecological footprint of a cable. Integrating these elements into cable production helps to minimize resource consumption and reduce waste, which contributes to a more environmentally friendly and resource-efficient future.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://sustainablebrands.com/read/evolving-infrastructure-wire-cable-prioritize-sustainability"
|
||||
title="Evolving Our Infrastructure Means the Wire and Cable Industry Must Prioritize Sustainability | Sustainable Brands"
|
||||
summary="To sustainably support the tremendous global demand for connectivity, collaboration is needed across the value chain to create solutions that enable more inf…"
|
||||
image="https://sb-web-assets.s3.amazonaws.com/production/46426/conversions/keyart-fbimg.jpg"
|
||||
/>
|
||||
"
|
||||
title="Evolving Our Infrastructure Means the Wire and Cable Industry Must Prioritize Sustainability | Sustainable Brands"
|
||||
summary="To sustainably support the tremendous global demand for connectivity, collaboration is needed across the value chain to create solutions that enable more inf…"
|
||||
image="https://sb-web-assets.s3.amazonaws.com/production/46426/conversions/keyart-fbimg.jpg"
|
||||
/>
|
||||
|
||||
29977
data/umami-import.json
29977
data/umami-import.json
File diff suppressed because it is too large
Load Diff
83
directus/schema/minimal_schema.yaml
Normal file
83
directus/schema/minimal_schema.yaml
Normal file
@@ -0,0 +1,83 @@
|
||||
version: 1
|
||||
directus: 11.14.1
|
||||
vendor: postgres
|
||||
collections:
|
||||
- collection: contact_submissions
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
collapse: open
|
||||
collection: contact_submissions
|
||||
color: '#002b49'
|
||||
display_template: '{{name}} | {{email}}'
|
||||
hidden: false
|
||||
icon: contact_mail
|
||||
singleton: false
|
||||
schema:
|
||||
name: contact_submissions
|
||||
fields:
|
||||
- collection: contact_submissions
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: id
|
||||
hidden: true
|
||||
sort: 1
|
||||
schema:
|
||||
name: id
|
||||
table: contact_submissions
|
||||
data_type: uuid
|
||||
is_nullable: false
|
||||
is_primary_key: true
|
||||
- collection: contact_submissions
|
||||
field: name
|
||||
type: string
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: name
|
||||
interface: input
|
||||
sort: 2
|
||||
schema:
|
||||
name: name
|
||||
table: contact_submissions
|
||||
data_type: character varying
|
||||
- collection: contact_submissions
|
||||
field: email
|
||||
type: string
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: email
|
||||
interface: input
|
||||
sort: 3
|
||||
schema:
|
||||
name: email
|
||||
table: contact_submissions
|
||||
data_type: character varying
|
||||
- collection: contact_submissions
|
||||
field: message
|
||||
type: text
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: message
|
||||
interface: textarea
|
||||
sort: 4
|
||||
schema:
|
||||
name: message
|
||||
table: contact_submissions
|
||||
data_type: text
|
||||
- collection: contact_submissions
|
||||
field: date_created
|
||||
type: timestamp
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: date_created
|
||||
interface: datetime
|
||||
readonly: true
|
||||
sort: 5
|
||||
schema:
|
||||
name: date_created
|
||||
table: contact_submissions
|
||||
data_type: timestamp with time zone
|
||||
default_value: CURRENT_TIMESTAMP
|
||||
relations: []
|
||||
@@ -6,51 +6,210 @@ collections:
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
archive_field: null
|
||||
archive_value: null
|
||||
collapse: open
|
||||
collection: contact_submissions
|
||||
color: '#002b49'
|
||||
display_template: '{{first_name}} {{last_name}} | {{subject}}'
|
||||
group: null
|
||||
display_template: '{{name}} | {{email}}'
|
||||
hidden: false
|
||||
icon: contact_mail
|
||||
item_duplication_fields: null
|
||||
note: null
|
||||
preview_url: null
|
||||
singleton: false
|
||||
sort: null
|
||||
sort_field: null
|
||||
translations: null
|
||||
unarchive_value: null
|
||||
versioning: false
|
||||
schema:
|
||||
name: contact_submissions
|
||||
- collection: product_requests
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
archive_field: null
|
||||
archive_value: null
|
||||
collapse: open
|
||||
collection: product_requests
|
||||
color: '#002b49'
|
||||
display_template: null
|
||||
group: null
|
||||
display_template: '{{product_name}} | {{email}}'
|
||||
hidden: false
|
||||
icon: inventory
|
||||
item_duplication_fields: null
|
||||
note: null
|
||||
preview_url: null
|
||||
singleton: false
|
||||
sort: null
|
||||
sort_field: null
|
||||
translations: null
|
||||
unarchive_value: null
|
||||
versioning: false
|
||||
schema:
|
||||
name: product_requests
|
||||
fields: []
|
||||
- collection: products
|
||||
meta:
|
||||
accountability: all
|
||||
collection: products
|
||||
icon: inventory_2
|
||||
singleton: false
|
||||
schema:
|
||||
name: products
|
||||
- collection: products_translations
|
||||
meta:
|
||||
accountability: all
|
||||
collection: products_translations
|
||||
hidden: true
|
||||
schema:
|
||||
name: products_translations
|
||||
|
||||
fields:
|
||||
# contact_submissions
|
||||
- collection: contact_submissions
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: id
|
||||
hidden: true
|
||||
sort: 1
|
||||
schema:
|
||||
name: id
|
||||
table: contact_submissions
|
||||
data_type: uuid
|
||||
is_nullable: false
|
||||
is_primary_key: true
|
||||
- collection: contact_submissions
|
||||
field: name
|
||||
type: string
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: name
|
||||
interface: input
|
||||
sort: 2
|
||||
schema:
|
||||
name: name
|
||||
table: contact_submissions
|
||||
data_type: character varying
|
||||
- collection: contact_submissions
|
||||
field: email
|
||||
type: string
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: email
|
||||
interface: input
|
||||
sort: 3
|
||||
schema:
|
||||
name: email
|
||||
table: contact_submissions
|
||||
data_type: character varying
|
||||
- collection: contact_submissions
|
||||
field: message
|
||||
type: text
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: message
|
||||
interface: textarea
|
||||
sort: 4
|
||||
schema:
|
||||
name: message
|
||||
table: contact_submissions
|
||||
data_type: text
|
||||
- collection: contact_submissions
|
||||
field: date_created
|
||||
type: timestamp
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: date_created
|
||||
interface: datetime
|
||||
readonly: true
|
||||
sort: 5
|
||||
schema:
|
||||
name: date_created
|
||||
table: contact_submissions
|
||||
data_type: timestamp with time zone
|
||||
default_value: CURRENT_TIMESTAMP
|
||||
|
||||
# product_requests
|
||||
- collection: product_requests
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: product_requests
|
||||
field: id
|
||||
hidden: true
|
||||
sort: 1
|
||||
schema:
|
||||
name: id
|
||||
table: product_requests
|
||||
data_type: uuid
|
||||
is_nullable: false
|
||||
is_primary_key: true
|
||||
- collection: product_requests
|
||||
field: product_name
|
||||
type: string
|
||||
meta:
|
||||
collection: product_requests
|
||||
field: product_name
|
||||
interface: input
|
||||
sort: 2
|
||||
schema:
|
||||
name: product_name
|
||||
table: product_requests
|
||||
data_type: character varying
|
||||
- collection: product_requests
|
||||
field: email
|
||||
type: string
|
||||
meta:
|
||||
collection: product_requests
|
||||
field: email
|
||||
interface: input
|
||||
sort: 3
|
||||
schema:
|
||||
name: email
|
||||
table: product_requests
|
||||
data_type: character varying
|
||||
- collection: product_requests
|
||||
field: message
|
||||
type: text
|
||||
meta:
|
||||
collection: product_requests
|
||||
field: message
|
||||
interface: textarea
|
||||
sort: 4
|
||||
schema:
|
||||
name: message
|
||||
table: product_requests
|
||||
data_type: text
|
||||
- collection: product_requests
|
||||
field: date_created
|
||||
type: timestamp
|
||||
meta:
|
||||
collection: product_requests
|
||||
field: date_created
|
||||
interface: datetime
|
||||
readonly: true
|
||||
sort: 5
|
||||
schema:
|
||||
name: date_created
|
||||
table: product_requests
|
||||
data_type: timestamp with time zone
|
||||
default_value: CURRENT_TIMESTAMP
|
||||
|
||||
# products
|
||||
- collection: products
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: products
|
||||
field: id
|
||||
hidden: true
|
||||
sort: 1
|
||||
schema:
|
||||
name: id
|
||||
table: products
|
||||
data_type: uuid
|
||||
is_nullable: false
|
||||
is_primary_key: true
|
||||
|
||||
# products_translations
|
||||
- collection: products_translations
|
||||
field: id
|
||||
type: integer
|
||||
meta:
|
||||
collection: products_translations
|
||||
field: id
|
||||
hidden: true
|
||||
schema:
|
||||
name: id
|
||||
table: products_translations
|
||||
data_type: integer
|
||||
is_primary_key: true
|
||||
has_auto_increment: true
|
||||
|
||||
|
||||
|
||||
systemFields:
|
||||
- collection: directus_activity
|
||||
field: timestamp
|
||||
@@ -64,4 +223,7 @@ systemFields:
|
||||
field: parent
|
||||
schema:
|
||||
is_indexed: true
|
||||
|
||||
relations: []
|
||||
|
||||
|
||||
|
||||
45
docker-compose.override.yml
Normal file
45
docker-compose.override.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
services:
|
||||
klz-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: development
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- /app/.next
|
||||
environment:
|
||||
- WATCHPACK_POLLING=true # Useful for Docker volume mounting issues on some systems
|
||||
restart: "no"
|
||||
container_name: klz-app-dev
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Clear any production middlewares/headers redirect
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares="
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=Host(`${TRAEFIK_HOST:-klz.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
|
||||
# Configure main router for local HTTP without auth
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(`${TRAEFIK_HOST:-klz.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=web"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares="
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=false"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
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}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.service=${PROJECT_NAME:-klz-cables}-cms"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-cms.loadbalancer.server.port=8055"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
klz-db:
|
||||
restart: "no"
|
||||
|
||||
klz-gatekeeper:
|
||||
restart: "no"
|
||||
@@ -1,70 +1,82 @@
|
||||
services:
|
||||
klz-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}
|
||||
DIRECTUS_URL: ${DIRECTUS_URL}
|
||||
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
default:
|
||||
infra:
|
||||
aliases:
|
||||
- klz.localhost
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
IMGPROXY_URL: ${IMGPROXY_URL:-http://klz-imgproxy:8080}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP ⇒ HTTPS redirect
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=${TRAEFIK_HOST_RULE:-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(\"klz-cables.com\")}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=true"
|
||||
- "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(\"klz-cables.com\")}) && (PathPrefix(\"/health\", \"/sitemap.xml\", \"/robots.txt\", \"/manifest.webmanifest\", \"/api/og\") || PathPrefix(\"/de/opengraph-image\", \"/en/opengraph-image\", \"/de/blog/opengraph-image\", \"/en/blog/opengraph-image\", \"/de/products/opengraph-image\", \"/en/products/opengraph-image\") || PathRegexp(\"^/.*opengraph-image.*$\"))"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls=true"
|
||||
- "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`) || PathPrefix(`/_img`) || PathPrefix(`/api/og`) || PathPrefix(`/de/api/og`) || PathPrefix(`/en/api/og`) || PathPrefix(`/opengraph-image`) || PathPrefix(`/de/opengraph-image`) || PathPrefix(`/en/opengraph-image`) || PathPrefix(`/blog/opengraph-image`) || PathPrefix(`/de/blog/opengraph-image`) || PathPrefix(`/en/blog/opengraph-image`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`) || PathRegexp(`^/.*\\.(svg|png|jpg|jpeg|gif|webp|ico)$`))"
|
||||
- "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"
|
||||
|
||||
# Middleware Definitions
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
|
||||
# Rate Limit Middleware
|
||||
- "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/" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
test: [ "CMD", "curl", "-f", "http://127.0.0.1:3000/health" ]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 45s
|
||||
|
||||
gatekeeper:
|
||||
klz-gatekeeper:
|
||||
profiles: [ "gatekeeper" ]
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
|
||||
restart: always
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
infra:
|
||||
aliases:
|
||||
- ${PROJECT_NAME:-klz-cables}-gatekeeper
|
||||
- ${PROJECT_NAME:-klz}-gatekeeper
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
@@ -78,22 +90,18 @@ 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=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=true"
|
||||
- "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:
|
||||
image: directus/directus:11
|
||||
restart: always
|
||||
networks:
|
||||
default:
|
||||
infra:
|
||||
aliases:
|
||||
- ${PROJECT_NAME:-klz-cables}-directus
|
||||
klz-cms:
|
||||
image: registry.infra.mintel.me/mintel/directus:latest
|
||||
restart: unless-stopped
|
||||
command: [ "node", "cli.js", "start" ]
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
@@ -102,45 +110,83 @@ services:
|
||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||
DB_CLIENT: 'pg'
|
||||
DB_HOST: 'directus-db'
|
||||
DB_HOST: 'klz-db'
|
||||
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}
|
||||
# Error Tracking
|
||||
SENTRY_DSN: ${SENTRY_DSN}
|
||||
SENTRY_ENVIRONMENT: ${TARGET:-development}
|
||||
LOGGER_LEVEL: ${LOG_LEVEL:-info}
|
||||
HOST: '0.0.0.0'
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
volumes:
|
||||
- ./directus/uploads:/directus/uploads
|
||||
- ./directus/extensions:/directus/extensions
|
||||
- ./directus/schema:/directus/schema
|
||||
- ./directus/migrations:/directus/migrations
|
||||
healthcheck:
|
||||
disable: true
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(\"${DIRECTUS_HOST}\")"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.middlewares=${PROJECT_NAME:-klz-cables}-forward,compress"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-directus.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"
|
||||
|
||||
directus-db:
|
||||
- "caddy=http://${DIRECTUS_HOST:-cms.klz-cables.com}"
|
||||
- "caddy.reverse_proxy={{upstreams 8055}}"
|
||||
klz-db:
|
||||
image: postgres:15-alpine
|
||||
restart: always
|
||||
networks:
|
||||
- default
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
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:
|
||||
- default
|
||||
|
||||
klz-imgproxy:
|
||||
image: darthsim/imgproxy:latest
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
extra_hosts:
|
||||
- "klz.localhost:host-gateway"
|
||||
- "cms.klz.localhost:host-gateway"
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
IMGPROXY_URL_MAPPING: "${IMGPROXY_URL_MAPPING:-http://klz.localhost/:http://klz-app:3000/,http://cms.klz.localhost/:http://klz-cms:8055/}"
|
||||
IMGPROXY_USE_ETAG: "true"
|
||||
IMGPROXY_MAX_SRC_RESOLUTION: 20
|
||||
IMGPROXY_ALLOWED_NETWORKS: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
IMGPROXY_IGNORE_SSL_ERRORS: "true"
|
||||
IMGPROXY_DEBUG: "true"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP router (local dev)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.rule=Host(`img.${TRAEFIK_HOST:-klz.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.entrypoints=web"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.service=${PROJECT_NAME:-klz}-imgproxy-svc"
|
||||
# HTTPS router (staging/prod)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.rule=Host(`img.${TRAEFIK_HOST:-klz.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.service=${PROJECT_NAME:-klz}-imgproxy-svc"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-imgproxy-svc.loadbalancer.server.port=8080"
|
||||
- "traefik.docker.network=infra"
|
||||
- "caddy=http://img.${TRAEFIK_HOST:-klz.localhost}"
|
||||
- "caddy.reverse_proxy={{upstreams 8080}}"
|
||||
|
||||
networks:
|
||||
default:
|
||||
@@ -150,3 +196,5 @@ networks:
|
||||
|
||||
volumes:
|
||||
directus-db-data:
|
||||
external: true
|
||||
name: klz-cablescom_directus-db-data
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
@@ -30,7 +36,7 @@ function createConfig() {
|
||||
analytics: {
|
||||
umami: {
|
||||
websiteId: env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me',
|
||||
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||
},
|
||||
},
|
||||
@@ -50,7 +56,7 @@ function createConfig() {
|
||||
},
|
||||
|
||||
logging: {
|
||||
level: env.LOG_LEVEL,
|
||||
level: env.LOG_LEVEL || 'info',
|
||||
},
|
||||
|
||||
mail: {
|
||||
@@ -144,6 +150,9 @@ export const config = {
|
||||
get feedbackEnabled() {
|
||||
return getConfig().feedbackEnabled;
|
||||
},
|
||||
get recordModeEnabled() {
|
||||
return getConfig().recordModeEnabled;
|
||||
},
|
||||
get infraCMS() {
|
||||
return getConfig().infraCMS;
|
||||
},
|
||||
|
||||
37
lib/env.ts
37
lib/env.ts
@@ -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(),
|
||||
@@ -26,6 +33,20 @@ const envExtension = {
|
||||
// Analytics
|
||||
UMAMI_WEBSITE_ID: z.string().optional(),
|
||||
UMAMI_API_ENDPOINT: z.string().optional(),
|
||||
|
||||
// Mail Configuration
|
||||
MAIL_HOST: z.string().optional(),
|
||||
MAIL_PORT: z.coerce.number().optional(),
|
||||
MAIL_USERNAME: z.string().optional(),
|
||||
MAIL_PASSWORD: z.string().optional(),
|
||||
MAIL_FROM: z.string().optional(),
|
||||
MAIL_RECIPIENTS: z.string().optional(),
|
||||
|
||||
// Directus Authentication
|
||||
DIRECTUS_URL: z.string().url().optional(),
|
||||
DIRECTUS_ADMIN_EMAIL: z.string().email().optional(),
|
||||
DIRECTUS_ADMIN_PASSWORD: z.string().optional(),
|
||||
DIRECTUS_API_TOKEN: z.string().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
33
lib/imgproxy-loader.ts
Normal file
33
lib/imgproxy-loader.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getImgproxyUrl } from './imgproxy';
|
||||
|
||||
/**
|
||||
* Next.js Image Loader for imgproxy
|
||||
*
|
||||
* @param {Object} props - properties from Next.js Image component
|
||||
* @param {string} props.src - The source image URL
|
||||
* @param {number} props.width - The desired image width
|
||||
* @param {number} props.quality - The desired image quality (ignored for now as imgproxy handles it)
|
||||
*/
|
||||
export default function imgproxyLoader({
|
||||
src,
|
||||
width,
|
||||
_quality,
|
||||
}: {
|
||||
src: string;
|
||||
width: number;
|
||||
_quality?: number;
|
||||
}) {
|
||||
// Skip imgproxy for SVGs as they are vectors and don't benefit from resizing,
|
||||
// and often cause 404s if the source is not correctly resolvable by imgproxy.
|
||||
if (src.toLowerCase().endsWith('.svg')) {
|
||||
return src;
|
||||
}
|
||||
|
||||
// We use the width provided by Next.js for responsive images
|
||||
// Height is set to 0 to maintain aspect ratio
|
||||
return getImgproxyUrl(src, {
|
||||
width,
|
||||
resizing_type: 'fit',
|
||||
gravity: 'fv', // Use face-aware focusing (face detection)
|
||||
});
|
||||
}
|
||||
80
lib/imgproxy.ts
Normal file
80
lib/imgproxy.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Generates an imgproxy URL for a given source image and options.
|
||||
*
|
||||
* Documentation: https://docs.imgproxy.net/usage/processing
|
||||
*/
|
||||
|
||||
interface ImgproxyOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
resizing_type?: 'fit' | 'fill' | 'fill-down' | 'force' | 'auto';
|
||||
gravity?: string;
|
||||
enlarge?: boolean;
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a string to Base64 (URL-safe)
|
||||
*/
|
||||
function encodeBase64(str: string): string {
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(str)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
} else {
|
||||
// Fallback for browser environment if Buffer is not available
|
||||
return window.btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
export function getImgproxyUrl(src: string, options: ImgproxyOptions = {}): string {
|
||||
// Use local proxy path which is rewritten in next.config.mjs
|
||||
const baseUrl = '/_img';
|
||||
|
||||
// Handle local paths or relative URLs
|
||||
let absoluteSrc = src;
|
||||
if (src.startsWith('/')) {
|
||||
const baseUrlForSrc =
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : 'https://klz-cables.com');
|
||||
if (baseUrlForSrc) {
|
||||
absoluteSrc = `${baseUrlForSrc.replace(/\/$/, '')}${src}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Development mapping: Map local domains to internal Docker hostnames
|
||||
// so imgproxy can fetch images without SSL issues or external routing
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (absoluteSrc.includes('klz.localhost')) {
|
||||
absoluteSrc = absoluteSrc.replace(/^https?:\/\/klz\.localhost/, 'http://klz-app:3000');
|
||||
} else if (absoluteSrc.includes('cms.klz.localhost')) {
|
||||
absoluteSrc = absoluteSrc.replace(/^https?:\/\/cms\.klz\.localhost/, 'http://klz-cms:8055');
|
||||
}
|
||||
// Also handle direct container names if needed
|
||||
}
|
||||
|
||||
const {
|
||||
width = 0,
|
||||
height = 0,
|
||||
resizing_type = 'fit',
|
||||
gravity = 'sm', // Default to smart gravity
|
||||
enlarge = false,
|
||||
extension = '',
|
||||
} = options;
|
||||
|
||||
// Processing options
|
||||
// Format: /rs:<type>:<width>:<height>:<enlarge>/g:<gravity>
|
||||
const processingOptions = [
|
||||
`rs:${resizing_type}:${width}:${height}:${enlarge ? 1 : 0}`,
|
||||
`g:${gravity}`,
|
||||
].join('/');
|
||||
|
||||
// Using /unsafe/ for now as we don't handle signatures yet
|
||||
// Format: <base_url>/unsafe/<options>/<base64_url>
|
||||
const suffix = extension ? `@${extension}` : '';
|
||||
const encodedSrc = encodeBase64(absoluteSrc + suffix);
|
||||
|
||||
return `${baseUrl}/unsafe/${processingOptions}/${encodedSrc}`;
|
||||
}
|
||||
@@ -91,7 +91,7 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
||||
|
||||
// Add a timeout to prevent hanging requests
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000); // 2s timeout
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -17,6 +17,9 @@ export class GotifyNotificationService implements NotificationService {
|
||||
const url = new URL('message', this.config.url);
|
||||
url.searchParams.set('token', this.config.token);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -27,8 +30,11 @@ export class GotifyNotificationService implements NotificationService {
|
||||
message,
|
||||
priority,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Gotify notification failed:', {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
module.exports = {
|
||||
ci: {
|
||||
collect: {
|
||||
numberOfRuns: 1,
|
||||
settings: {
|
||||
preset: 'desktop',
|
||||
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
|
||||
},
|
||||
},
|
||||
assert: {
|
||||
assertions: {
|
||||
'categories:performance': ['warn', { minScore: 0.9 }],
|
||||
'categories:accessibility': ['warn', { minScore: 0.9 }],
|
||||
'categories:best-practices': ['warn', { minScore: 0.9 }],
|
||||
'categories:seo': ['warn', { minScore: 0.9 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
132
lint_output.txt
132
lint_output.txt
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -58,12 +58,14 @@
|
||||
}
|
||||
},
|
||||
"Navigation": {
|
||||
"home": "Start",
|
||||
"menu": "Menü",
|
||||
"home": "KLZ Cables Startseite",
|
||||
"team": "Team",
|
||||
"products": "Produkte",
|
||||
"blog": "Blog",
|
||||
"contact": "Kontakt",
|
||||
"toggleMenu": "Menü umschalten"
|
||||
"toggleMenu": "Menü umschalten",
|
||||
"skipToContent": "Zum Inhalt springen"
|
||||
},
|
||||
"Footer": {
|
||||
"legal": "Rechtliches",
|
||||
@@ -120,7 +122,7 @@
|
||||
"quote": "Manchmal braucht es nur einen klaren Kopf und das richtige Kabel, um die Welt ein Stück besser zu machen.",
|
||||
"description": "Klaus ist der Fels in der Brandung – selbst wenn das Kabelchaos überhandnimmt. Mit jahrzehntelanger Erfahrung und einem stabilen Netzwerk sorgt er dafür, dass alles glatt läuft. Er denkt nicht nur in Lösungen, sondern bringt auch Humor und den nötigen Weitblick mit, um selbst komplexe Themen locker auf den Punkt zu bringen.",
|
||||
"linkedin": "Klaus' LinkedIn",
|
||||
"role": "Gründer & Visionär"
|
||||
"role": "Geschäftsführer"
|
||||
},
|
||||
"manifesto": {
|
||||
"title": "Unser Manifest",
|
||||
@@ -207,6 +209,7 @@
|
||||
"description": "Entdecken Sie unser umfassendes Sortiment an zertifizierten Kabeln: von Niederspannung über Mittel- und Hochspannung bis hin zu spezialisierten Solarkabeln."
|
||||
},
|
||||
"title": "Unsere <green>Produkte</green>",
|
||||
"breadcrumb": "Produkte",
|
||||
"subtitle": "Entdecken Sie unser umfassendes Sortiment an hochwertigen Kabeln für jede Anwendung.",
|
||||
"heroSubtitle": "Produktportfolio",
|
||||
"categoryLabel": "Kategorie",
|
||||
|
||||
@@ -58,12 +58,14 @@
|
||||
}
|
||||
},
|
||||
"Navigation": {
|
||||
"home": "Home",
|
||||
"menu": "Menu",
|
||||
"home": "KLZ Cables Home",
|
||||
"team": "Team",
|
||||
"products": "Products",
|
||||
"blog": "Blog",
|
||||
"contact": "Contact",
|
||||
"toggleMenu": "Toggle Menu"
|
||||
"toggleMenu": "Toggle Menu",
|
||||
"skipToContent": "Skip to content"
|
||||
},
|
||||
"Footer": {
|
||||
"legal": "Legal",
|
||||
@@ -120,7 +122,7 @@
|
||||
"quote": "Sometimes all it takes is a clear head and a good cable to make the world a little better.",
|
||||
"description": "Klaus is the man with the experience, bringing perspective and calm to the table—even when cable chaos threatens to take over. With impressive industry knowledge and a network as solid as our cables, he ensures everything runs smoothly. Klaus isn’t just a problem solver; he’s a strategic thinker who knows how to get to the point with a touch of humor.",
|
||||
"linkedin": "Check Klaus' LinkedIn",
|
||||
"role": "Founder & Visionary"
|
||||
"role": "Managing Director"
|
||||
},
|
||||
"manifesto": {
|
||||
"title": "Our manifesto",
|
||||
@@ -207,6 +209,7 @@
|
||||
"description": "Explore our comprehensive range of certified cables: from low voltage to medium and high voltage, as well as specialized solar cables."
|
||||
},
|
||||
"title": "Our <green>Products</green>",
|
||||
"breadcrumb": "Products",
|
||||
"subtitle": "Explore our comprehensive range of high-quality cables designed for every application.",
|
||||
"heroSubtitle": "Product Portfolio",
|
||||
"categoryLabel": "Category",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Create the internationalization middleware
|
||||
const intlMiddleware = createMiddleware({
|
||||
@@ -19,10 +19,12 @@ export default function middleware(request: NextRequest) {
|
||||
pathname.startsWith('/stats') ||
|
||||
pathname.startsWith('/errors') ||
|
||||
pathname.startsWith('/health') ||
|
||||
pathname.startsWith('/api/og') ||
|
||||
pathname.includes('opengraph-image')
|
||||
pathname.includes('/api/og') ||
|
||||
pathname.includes('opengraph-image') ||
|
||||
pathname.endsWith('sitemap.xml') ||
|
||||
pathname.endsWith('manifest.webmanifest')
|
||||
) {
|
||||
return;
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Build header object for logging
|
||||
@@ -42,11 +44,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 +54,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 +94,9 @@ 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|_img|favicon.ico|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml)$).*)',
|
||||
'/(de|en)/:path*',
|
||||
'/(de|en)/:path*',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 [
|
||||
@@ -309,16 +317,10 @@ const nextConfig = {
|
||||
];
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'klz-cables.com',
|
||||
port: '',
|
||||
pathname: '/wp-content/uploads/**',
|
||||
},
|
||||
],
|
||||
loader: 'custom',
|
||||
loaderFile: './lib/imgproxy-loader.ts',
|
||||
dangerouslyAllowSVG: true,
|
||||
contentDispositionType: 'attachment',
|
||||
contentDispositionType: "attachment",
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
},
|
||||
async rewrites() {
|
||||
@@ -333,15 +335,22 @@ const nextConfig = {
|
||||
|
||||
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
|
||||
|
||||
let imgproxyUrl = process.env.IMGPROXY_URL || 'https://img.infra.mintel.me';
|
||||
if (!imgproxyUrl.startsWith('http')) {
|
||||
imgproxyUrl = `https://${imgproxyUrl}`;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
source: '/cms/:path*',
|
||||
destination: `${directusUrl}/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/_img/:path*',
|
||||
destination: `${imgproxyUrl}/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const nextIntlConfig = withNextIntl(nextConfig);
|
||||
|
||||
export default withMintelConfig(nextIntlConfig);
|
||||
export default withMintelConfig(nextConfig);
|
||||
|
||||
43
package.json
43
package.json
@@ -4,13 +4,15 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^21.0.0",
|
||||
"@mintel/mail": "1.7.12",
|
||||
"@mintel/next-config": "1.7.12",
|
||||
"@mintel/next-feedback": "1.7.12",
|
||||
"@medv/finder": "^4.0.2",
|
||||
"@mintel/mail": "1.8.3",
|
||||
"@mintel/next-config": "1.8.3",
|
||||
"@mintel/next-feedback": "1.8.10",
|
||||
"@mintel/next-utils": "^1.7.15",
|
||||
"@react-email/components": "^1.0.7",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@sentry/nextjs": "^10.38.0",
|
||||
"@types/recharts": "^2.0.1",
|
||||
"axios": "^1.13.5",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.0",
|
||||
@@ -19,7 +21,6 @@
|
||||
"import-in-the-middle": "^1.11.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.6",
|
||||
"next-i18next": "^15.4.3",
|
||||
"next-intl": "^4.8.2",
|
||||
@@ -32,6 +33,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-email": "^5.2.5",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^3.7.0",
|
||||
"require-in-the-middle": "^8.0.1",
|
||||
"resend": "^3.5.0",
|
||||
"schema-dts": "^1.1.5",
|
||||
@@ -45,8 +47,12 @@
|
||||
"@commitlint/cli": "^20.4.0",
|
||||
"@commitlint/config-conventional": "^20.4.0",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@mintel/eslint-config": "1.7.12",
|
||||
"@mintel/tsconfig": "1.7.12",
|
||||
"@mintel/eslint-config": "1.8.3",
|
||||
"@mintel/tsconfig": "1.8.3",
|
||||
"@remotion/cli": "^4.0.421",
|
||||
"@remotion/google-fonts": "^4.0.421",
|
||||
"@remotion/player": "^4.0.421",
|
||||
"@remotion/renderer": "^4.0.421",
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
@@ -59,20 +65,27 @@
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"cheerio": "^1.2.0",
|
||||
"eslint": "^9.18.0",
|
||||
"happy-dom": "^20.6.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"lucide-react": "^0.563.0",
|
||||
"pa11y-ci": "^4.0.1",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"puppeteer": "^24.37.3",
|
||||
"remotion": "^4.0.421",
|
||||
"sass": "^1.97.1",
|
||||
"start-server-and-test": "^2.1.3",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: 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 klz-app directus directus-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",
|
||||
@@ -81,7 +94,10 @@
|
||||
"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",
|
||||
"check:mdx": "node scripts/validate-mdx.mjs",
|
||||
"check:a11y": "start-server-and-test start http://localhost:3000 'pa11y-ci'",
|
||||
"check:wcag": "tsx ./scripts/wcag-sitemap.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",
|
||||
@@ -100,7 +116,10 @@
|
||||
"cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing",
|
||||
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
|
||||
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
||||
"pagespeed:audit": "./scripts/audit-local.sh",
|
||||
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
|
||||
"remotion:render": "remotion render WebsiteVideo remotion/index.ts out.mp4",
|
||||
"remotion:preview": "remotion preview remotion/index.ts",
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
@@ -109,5 +128,13 @@
|
||||
"overrides": {
|
||||
"next": "16.1.6"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@remotion/cli": "^4.0.421",
|
||||
"@remotion/google-fonts": "^4.0.421",
|
||||
"@remotion/player": "^4.0.421",
|
||||
"@remotion/renderer": "^4.0.421",
|
||||
"lucide-react": "^0.563.0",
|
||||
"remotion": "^4.0.421"
|
||||
}
|
||||
}
|
||||
|
||||
1959
pnpm-lock.yaml
generated
1959
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
32
remotion/Root.tsx
Normal file
32
remotion/Root.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Composition } from 'remotion';
|
||||
import { WebsiteVideo } from './WebsiteVideo';
|
||||
import sessionData from './session.json';
|
||||
import { RecordingSession } from '../types/record-mode';
|
||||
|
||||
const FPS = 60;
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
// Calculate duration based on last event + padding
|
||||
const durationMs = (sessionData as unknown as RecordingSession).events.reduce((max, e) => {
|
||||
return Math.max(max, e.timestamp + (e.duration || 1000));
|
||||
}, 0);
|
||||
const durationInFrames = Math.ceil((durationMs + 2000) / 1000 * FPS);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="WebsiteVideo"
|
||||
component={WebsiteVideo}
|
||||
durationInFrames={durationInFrames}
|
||||
fps={FPS}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{
|
||||
session: sessionData as unknown as RecordingSession,
|
||||
siteUrl: 'http://localhost:3000'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
127
remotion/WebsiteVideo.tsx
Normal file
127
remotion/WebsiteVideo.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useMemo } from 'react';
|
||||
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, siteUrl }) => {
|
||||
const { fps, width, height, durationInFrames } = useVideoConfig();
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
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: '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>
|
||||
);
|
||||
};
|
||||
4
remotion/index.ts
Normal file
4
remotion/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from 'remotion';
|
||||
import { RemotionRoot } from './Root';
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
35
remotion/session.json
Normal file
35
remotion/session.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"id": "sample-session",
|
||||
"name": "Sample Recording",
|
||||
"createdAt": "2024-03-20T10:00:00.000Z",
|
||||
"events": [
|
||||
{
|
||||
"id": "1",
|
||||
"type": "click",
|
||||
"timestamp": 1000,
|
||||
"duration": 1000,
|
||||
"zoom": 1,
|
||||
"selector": "body",
|
||||
"rect": {
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"width": 50,
|
||||
"height": 50
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "scroll",
|
||||
"timestamp": 2500,
|
||||
"duration": 1500,
|
||||
"zoom": 1,
|
||||
"selector": "footer",
|
||||
"rect": {
|
||||
"x": 500,
|
||||
"y": 800,
|
||||
"width": 100,
|
||||
"height": 50
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
51
scripts/audit-local.sh
Executable file
51
scripts/audit-local.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
|
||||
# audit-local.sh
|
||||
# Runs a high-fidelity Lighthouse audit locally using the Docker production stack.
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting High-Fidelity Local Audit..."
|
||||
|
||||
# 1. Environment and Infrastructure
|
||||
export DOCKER_HOST="unix:///Users/marcmintel/.docker/run/docker.sock"
|
||||
export IMGPROXY_URL="http://img.klz.localhost"
|
||||
export NEXT_URL="http://klz.localhost"
|
||||
|
||||
docker network create infra 2>/dev/null || true
|
||||
docker volume create klz-cablescom_directus-db-data 2>/dev/null || true
|
||||
|
||||
# 2. Start infra services (DB, CMS, Gatekeeper)
|
||||
echo "📦 Starting infrastructure services..."
|
||||
# Using --remove-orphans to ensure a clean state
|
||||
docker-compose up -d --remove-orphans klz-db klz-cms klz-gatekeeper
|
||||
|
||||
# 3. Build and Start klz-app and klz-imgproxy in Production Mode
|
||||
echo "🏗️ Building and starting klz-app (Production)..."
|
||||
# We bypass the dev override by explicitly using the base compose file
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_URL \
|
||||
docker-compose -f docker-compose.yml up -d --build klz-app klz-imgproxy
|
||||
|
||||
# 4. Wait for application to be ready
|
||||
echo "⏳ Waiting for application to be healthy..."
|
||||
MAX_RETRIES=30
|
||||
RETRY_COUNT=0
|
||||
|
||||
until $(curl -s -f -o /dev/null "$NEXT_URL/health"); do
|
||||
if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
|
||||
echo "❌ Error: App did not become healthy in time."
|
||||
exit 1
|
||||
fi
|
||||
echo " ...waiting for $NEXT_URL/health"
|
||||
sleep 2
|
||||
RETRY_COUNT=$((RETRY_COUNT+1))
|
||||
done
|
||||
|
||||
echo "✅ App is healthy at $NEXT_URL"
|
||||
|
||||
# 5. Run Lighthouse Audit
|
||||
echo "⚡ Executing Lighthouse CI..."
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_URL PAGESPEED_LIMIT=5 pnpm run pagespeed:test "$NEXT_URL"
|
||||
|
||||
echo "✨ Audit completed! Summary above."
|
||||
echo "💡 You can stop the production app with: docker-compose stop klz-app"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user