Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| cd7be080d7 | |||
| 4e602da15d | |||
| e47982d394 | |||
| 877108020b | |||
| 0fff5ae52a | |||
| 459716d09c | |||
| a0d4023f89 | |||
| 9746416146 | |||
| fc9746335d | |||
| 4058abab13 | |||
| 6074747b34 |
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)
|
||||
|
||||
@@ -33,4 +33,4 @@ 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
|
||||
|
||||
@@ -85,10 +85,10 @@ jobs:
|
||||
|
||||
# Standardize Traefik Rule
|
||||
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(`%s`)%s", $i, (i==NF?"":" || ")}')
|
||||
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\"%s\")%s", $i, (i==NF?"":" || ")}')
|
||||
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||
else
|
||||
TRAEFIK_RULE="Host(\`$TRAEFIK_HOST\`)"
|
||||
TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
|
||||
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||
fi
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -281,55 +285,62 @@ jobs:
|
||||
# Gatekeeper Origin
|
||||
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
||||
|
||||
cat > .env.deploy << EOF
|
||||
# Generated by CI - $TARGET
|
||||
IMAGE_TAG=$IMAGE_TAG
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN
|
||||
SENTRY_DSN=$SENTRY_DSN
|
||||
LOG_LEVEL=$LOG_LEVEL
|
||||
MAIL_HOST=$MAIL_HOST
|
||||
MAIL_PORT=$MAIL_PORT
|
||||
MAIL_USERNAME=$MAIL_USERNAME
|
||||
MAIL_PASSWORD=$MAIL_PASSWORD
|
||||
MAIL_FROM=$MAIL_FROM
|
||||
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
||||
{
|
||||
echo "# Generated by CI - $TARGET"
|
||||
echo "IMAGE_TAG=$IMAGE_TAG"
|
||||
echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL"
|
||||
echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
|
||||
echo "SENTRY_DSN=$SENTRY_DSN"
|
||||
echo "LOG_LEVEL=$LOG_LEVEL"
|
||||
echo "MAIL_HOST=$MAIL_HOST"
|
||||
echo "MAIL_PORT=$MAIL_PORT"
|
||||
echo "MAIL_USERNAME=$MAIL_USERNAME"
|
||||
echo "MAIL_PASSWORD=$MAIL_PASSWORD"
|
||||
echo "MAIL_FROM=$MAIL_FROM"
|
||||
echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS"
|
||||
echo ""
|
||||
echo "# Directus"
|
||||
echo "DIRECTUS_URL=$DIRECTUS_URL"
|
||||
echo "DIRECTUS_HOST=$DIRECTUS_HOST"
|
||||
echo "DIRECTUS_KEY=$DIRECTUS_KEY"
|
||||
echo "DIRECTUS_SECRET=$DIRECTUS_SECRET"
|
||||
echo "DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL"
|
||||
echo "DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD"
|
||||
echo "DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME"
|
||||
echo "DIRECTUS_DB_USER=$DIRECTUS_DB_USER"
|
||||
echo "DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD"
|
||||
echo "DIRECTUS_DB_CLIENT=pg"
|
||||
echo "DIRECTUS_DB_HOST=directus-db"
|
||||
echo "DIRECTUS_DB_PORT=5432"
|
||||
echo "DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN"
|
||||
echo "INTERNAL_DIRECTUS_URL=http://directus:8055"
|
||||
echo ""
|
||||
echo "# Gatekeeper"
|
||||
echo "GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD"
|
||||
echo "AUTH_COOKIE_NAME=klz_gatekeeper_session"
|
||||
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
|
||||
echo ""
|
||||
echo "# Analytics"
|
||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||
echo ""
|
||||
echo "TARGET=$TARGET"
|
||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||
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"
|
||||
echo "AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED"
|
||||
} > .env.deploy
|
||||
|
||||
# Directus
|
||||
DIRECTUS_URL=$DIRECTUS_URL
|
||||
DIRECTUS_HOST=$DIRECTUS_HOST
|
||||
DIRECTUS_KEY=$DIRECTUS_KEY
|
||||
DIRECTUS_SECRET=$DIRECTUS_SECRET
|
||||
DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL
|
||||
DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD
|
||||
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
|
||||
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
|
||||
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
|
||||
DIRECTUS_DB_CLIENT=pg
|
||||
DIRECTUS_DB_HOST=directus-db
|
||||
DIRECTUS_DB_PORT=5432
|
||||
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
|
||||
INTERNAL_DIRECTUS_URL=http://directus:8055
|
||||
|
||||
# Gatekeeper
|
||||
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
||||
AUTH_COOKIE_NAME=klz_gatekeeper_session
|
||||
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
||||
|
||||
# Analytics
|
||||
UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
|
||||
TARGET=$TARGET
|
||||
SENTRY_ENVIRONMENT=$TARGET
|
||||
PROJECT_NAME=$PROJECT_NAME
|
||||
TRAEFIK_HOST_RULE='$TRAEFIK_RULE'
|
||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||
ENV_FILE=$ENV_FILE
|
||||
COMPOSE_PROFILES=$COMPOSE_PROFILES
|
||||
AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE
|
||||
AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED
|
||||
EOF
|
||||
echo "--- Generated .env.deploy ---"
|
||||
cat .env.deploy
|
||||
echo "----------------------------"
|
||||
|
||||
- name: 🚀 SSH Deploy
|
||||
shell: bash
|
||||
@@ -363,11 +374,43 @@ jobs:
|
||||
run: docker builder prune -f --filter "until=1h"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 5: Notifications
|
||||
# JOB 5: Smoke Test (OG Images)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
smoke_test:
|
||||
name: 🧪 Smoke Test
|
||||
needs: [prepare, deploy]
|
||||
if: needs.deploy.result == 'success'
|
||||
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: 🚀 Run OG Image Check
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: pnpm run check:og
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, deploy]
|
||||
needs: [prepare, deploy, smoke_test]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
|
||||
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
|
||||
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,9 +3,16 @@ 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({ params: { locale, slug } }: { params: { locale: string, slug: string } }) {
|
||||
export default async function Image({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}) {
|
||||
const { locale, slug } = await params;
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
|
||||
if (!pageData) {
|
||||
@@ -15,17 +22,14 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={pageData.frontmatter.title}
|
||||
description={pageData.frontmatter.excerpt}
|
||||
label="Information"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate
|
||||
title={pageData.frontmatter.title}
|
||||
description={pageData.frontmatter.excerpt}
|
||||
label="Information"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,13 +5,15 @@ import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ locale: string }> },
|
||||
) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const { searchParams } = new URL(request.url);
|
||||
const slug = searchParams.get('slug');
|
||||
const { locale } = await params;
|
||||
|
||||
@@ -58,7 +60,7 @@ export async function GET(
|
||||
const featuredImage = product.frontmatter.images?.[0]
|
||||
? product.frontmatter.images[0].startsWith('http')
|
||||
? product.frontmatter.images[0]
|
||||
: `${origin}${product.frontmatter.images[0]}`
|
||||
: `${SITE_URL}${product.frontmatter.images[0]}`
|
||||
: undefined;
|
||||
|
||||
return new ImageResponse(
|
||||
|
||||
@@ -4,13 +4,16 @@ 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({
|
||||
params: { locale, slug },
|
||||
params,
|
||||
}: {
|
||||
params: { locale: string; slug: string };
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}) {
|
||||
const { locale, slug } = await params;
|
||||
const post = await getPostBySlug(slug, locale);
|
||||
|
||||
if (!post) {
|
||||
|
||||
@@ -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,11 +31,11 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
||||
title: post.frontmatter.title,
|
||||
description: description,
|
||||
alternates: {
|
||||
canonical: `/${locale}/blog/${slug}`,
|
||||
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
languages: {
|
||||
de: `/de/blog/${slug}`,
|
||||
en: `/en/blog/${slug}`,
|
||||
'x-default': `/en/blog/${slug}`,
|
||||
de: `${SITE_URL}/de/blog/${slug}`,
|
||||
en: `${SITE_URL}/en/blog/${slug}`,
|
||||
'x-default': `${SITE_URL}/en/blog/${slug}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
@@ -45,7 +45,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 +68,12 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
|
||||
return (
|
||||
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
||||
<BlogEngagementTracker
|
||||
title={post.frontmatter.title}
|
||||
slug={slug}
|
||||
category={post.frontmatter.category}
|
||||
readingTime={getReadingTime(post.content)}
|
||||
/>
|
||||
{/* Featured Image Header */}
|
||||
{post.frontmatter.featuredImage ? (
|
||||
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
||||
|
||||
@@ -3,23 +3,20 @@ 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: { locale } }: { params: { locale: string } }) {
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Blog"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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: { locale } }: { params: { locale: string } }) {
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
@@ -13,16 +16,10 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={title}
|
||||
description={description}
|
||||
label="Contact"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate title={title} description={description} label="Contact" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,8 +2,11 @@ import Footer from '@/components/Footer';
|
||||
import Header from '@/components/Header';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||
import ScrollDepthTracker from '@/components/analytics/ScrollDepthTracker';
|
||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||
import { 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 { Metadata, Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
@@ -40,16 +43,13 @@ export const viewport: Viewport = {
|
||||
themeColor: '#001a4d',
|
||||
};
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
// Ensure locale is a valid string, fallback to 'en'
|
||||
const params = await props.params;
|
||||
const { locale } = params;
|
||||
const { children } = props;
|
||||
const supportedLocales = ['en', 'de'];
|
||||
const localeStr = (typeof locale === 'string' ? locale : '').trim();
|
||||
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
|
||||
@@ -64,12 +64,9 @@ export default async function LocaleLayout({
|
||||
messages = {};
|
||||
}
|
||||
|
||||
// Track pageview on the server with high-fidelity header context
|
||||
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
||||
const serverServices = getServerAppServices();
|
||||
|
||||
// We wrap this in a try-catch to allow static rendering during build
|
||||
// headers() and cookies() force dynamic rendering in Next.js
|
||||
try {
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
@@ -83,10 +80,8 @@ export default async function LocaleLayout({
|
||||
});
|
||||
}
|
||||
|
||||
// Track initial server-side pageview
|
||||
serverServices.analytics.trackPageview();
|
||||
} catch {
|
||||
// Falls back to noop or client-side only during static generation
|
||||
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||
console.warn(
|
||||
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
|
||||
@@ -94,20 +89,31 @@ 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>
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
||||
<Footer />
|
||||
</RecordModeVisuals>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<AnalyticsProvider />
|
||||
</Suspense>
|
||||
{config.feedbackEnabled && <FeedbackOverlay />}
|
||||
<CMSConnectivityNotice />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<AnalyticsProvider />
|
||||
<ScrollDepthTracker />
|
||||
</Suspense>
|
||||
<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,24 +3,25 @@ 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: { locale } }: { params: { locale: string } }) {
|
||||
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();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Reliable Energy Infrastructure"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Reliable Energy Infrastructure"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -85,11 +85,11 @@ export async function generateMetadata({
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `/${locale}`,
|
||||
canonical: `${SITE_URL}/${locale}`,
|
||||
languages: {
|
||||
de: '/de',
|
||||
en: '/en',
|
||||
'x-default': '/en',
|
||||
de: `${SITE_URL}/de`,
|
||||
en: `${SITE_URL}/en`,
|
||||
'x-default': `${SITE_URL}/en`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
|
||||
@@ -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>
|
||||
@@ -353,6 +357,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-white relative">
|
||||
{/* Product Hero */}
|
||||
<ProductEngagementTracker
|
||||
productName={product.frontmatter.title}
|
||||
productSlug={productSlug}
|
||||
categories={product.frontmatter.categories}
|
||||
sku={product.frontmatter.sku}
|
||||
/>
|
||||
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
|
||||
{/* Background Decorative Elements */}
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
||||
@@ -361,8 +371,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,27 +3,23 @@ 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: { locale } }: { params: { locale: string } }) {
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
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(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={title}
|
||||
description={description}
|
||||
label="Products"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate title={title} description={description} label="Products" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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: { locale } }: { params: { locale: string } }) {
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
@@ -13,17 +16,10 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
||||
const description = t('meta.description') || t('hero.title');
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={title}
|
||||
description={description}
|
||||
label="Our Team"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate title={title} description={description} label="Our Team" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -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">
|
||||
@@ -243,15 +247,20 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
|
||||
{t('klaus.description')}
|
||||
</p>
|
||||
<Button
|
||||
<TrackedButton
|
||||
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
||||
variant="saturated"
|
||||
size="lg"
|
||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||
eventProperties={{
|
||||
type: 'social_linkedin',
|
||||
person: 'Klaus Mintel',
|
||||
location: 'team_page',
|
||||
}}
|
||||
>
|
||||
{t('klaus.linkedin')}
|
||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||
</Button>
|
||||
</TrackedButton>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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,10 +48,18 @@ export default function ContactForm() {
|
||||
(e.target as HTMLFormElement).reset();
|
||||
} else {
|
||||
console.error('Contact form submission failed:', { email, error: result.error });
|
||||
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||
form_id: 'contact_form',
|
||||
error: result.error || 'submission_failed',
|
||||
});
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Contact form submission error:', { email, error });
|
||||
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||
form_id: 'contact_form',
|
||||
error: (error as Error).message || 'unexpected_error',
|
||||
});
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
@@ -112,7 +139,7 @@ export default function ContactForm() {
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.namePlaceholder')}
|
||||
onFocus={() => handleFocus('name')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -126,6 +153,7 @@ export default function ContactForm() {
|
||||
inputMode="email"
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.emailPlaceholder')}
|
||||
onFocus={() => handleFocus('email')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -137,6 +165,7 @@ export default function ContactForm() {
|
||||
rows={4}
|
||||
enterKeyHint="send"
|
||||
placeholder={t('form.messagePlaceholder')}
|
||||
onFocus={() => handleFocus('message')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface DatasheetDownloadProps {
|
||||
datasheetPath: string;
|
||||
@@ -10,34 +12,42 @@ interface DatasheetDownloadProps {
|
||||
|
||||
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
|
||||
const t = useTranslations('Products');
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
return (
|
||||
<div className={cn("mt-8 animate-slight-fade-in-from-bottom", className)}>
|
||||
<a
|
||||
href={datasheetPath}
|
||||
<div className={cn('mt-8 animate-slight-fade-in-from-bottom', className)}>
|
||||
<a
|
||||
href={datasheetPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: datasheetPath.split('/').pop(),
|
||||
file_path: datasheetPath,
|
||||
location: 'product_page',
|
||||
})
|
||||
}
|
||||
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||
>
|
||||
{/* Animated Background Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<svg
|
||||
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
<svg
|
||||
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -45,7 +55,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
{/* Text Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">PDF Datasheet</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
||||
PDF Datasheet
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
{t('downloadDatasheet')}
|
||||
@@ -58,7 +70,12 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { Container } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations('Footer');
|
||||
const navT = useTranslations('Navigation');
|
||||
const { trackEvent } = useAnalytics();
|
||||
const locale = useLocale();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
@@ -17,7 +22,16 @@ export default function Footer() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
||||
{/* Brand Column */}
|
||||
<div className="lg:col-span-4 space-y-8">
|
||||
<Link href={`/${locale}`} className="inline-block group">
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="inline-block group"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
target: 'home_logo',
|
||||
location: 'footer',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src="/logo-white.svg"
|
||||
alt={t('products')}
|
||||
@@ -34,6 +48,13 @@ export default function Footer() {
|
||||
href="https://www.linkedin.com/company/klz-vertriebs-gmbh/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
type: 'social',
|
||||
target: 'linkedin',
|
||||
location: 'footer',
|
||||
})
|
||||
}
|
||||
className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center text-white hover:bg-accent hover:text-primary-dark transition-all duration-300 border border-white/10"
|
||||
>
|
||||
<span className="sr-only">LinkedIn</span>
|
||||
@@ -54,6 +75,13 @@ export default function Footer() {
|
||||
<Link
|
||||
href={`/${locale}/${t('legalNoticeSlug')}`}
|
||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: t('legalNotice'),
|
||||
href: t('legalNoticeSlug'),
|
||||
location: 'footer_legal',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('legalNotice')}
|
||||
</Link>
|
||||
@@ -62,6 +90,13 @@ export default function Footer() {
|
||||
<Link
|
||||
href={`/${locale}/${t('privacyPolicySlug')}`}
|
||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: t('privacyPolicy'),
|
||||
href: t('privacyPolicySlug'),
|
||||
location: 'footer_legal',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('privacyPolicy')}
|
||||
</Link>
|
||||
@@ -70,6 +105,13 @@ export default function Footer() {
|
||||
<Link
|
||||
href={`/${locale}/${t('termsSlug')}`}
|
||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: t('terms'),
|
||||
href: t('termsSlug'),
|
||||
location: 'footer_legal',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('terms')}
|
||||
</Link>
|
||||
@@ -86,14 +128,28 @@ export default function Footer() {
|
||||
<Link
|
||||
href={`/${locale}/team`}
|
||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: navT('team'),
|
||||
href: '/team',
|
||||
location: 'footer_company',
|
||||
})
|
||||
}
|
||||
>
|
||||
{navT('team')}
|
||||
</Link>
|
||||
</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>
|
||||
@@ -146,7 +216,17 @@ export default function Footer() {
|
||||
},
|
||||
].map((post, i) => (
|
||||
<li key={i}>
|
||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block text-white/80">
|
||||
<Link
|
||||
href={`/${locale}/blog/${post.slug}`}
|
||||
className="group block text-white/80"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BLOG_POST_VIEW, {
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
location: 'footer_recent',
|
||||
})
|
||||
}
|
||||
>
|
||||
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
|
||||
{post.title}
|
||||
</p>
|
||||
@@ -163,10 +243,34 @@ export default function Footer() {
|
||||
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/40 text-xs md:text-sm font-medium">
|
||||
<p>{t('copyright', { year: currentYear })}</p>
|
||||
<div className="flex gap-8">
|
||||
<Link href="/en" locale="en" className="hover:text-white transition-colors">
|
||||
<Link
|
||||
href="/en"
|
||||
locale="en"
|
||||
className="hover:text-white transition-colors"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||
type: 'language',
|
||||
from: locale,
|
||||
to: 'en',
|
||||
location: 'footer',
|
||||
})
|
||||
}
|
||||
>
|
||||
English
|
||||
</Link>
|
||||
<Link href="/de" locale="de" className="hover:text-white transition-colors">
|
||||
<Link
|
||||
href="/de"
|
||||
locale="de"
|
||||
className="hover:text-white transition-colors"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||
type: 'language',
|
||||
from: locale,
|
||||
to: 'de',
|
||||
location: 'footer',
|
||||
})
|
||||
}
|
||||
>
|
||||
Deutsch
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,13 @@ import { usePathname } from 'next/navigation';
|
||||
import { Button } from './ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
export default function Header() {
|
||||
const t = useTranslations('Navigation');
|
||||
const pathname = usePathname();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
@@ -49,7 +52,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 +82,15 @@ export default function Header() {
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
||||
>
|
||||
<Link href={`/${currentLocale}`}>
|
||||
<Link
|
||||
href={`/${currentLocale}`}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
target: 'home_logo',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={t('home')}
|
||||
@@ -109,7 +120,14 @@ export default function Header() {
|
||||
<motion.div key={item.href} variants={navLinkVariants}>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
location: 'header_nav',
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
textColorClass,
|
||||
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||
@@ -139,6 +157,14 @@ export default function Header() {
|
||||
>
|
||||
<Link
|
||||
href={getPathForLocale('en')}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||
type: 'language',
|
||||
from: currentLocale,
|
||||
to: 'en',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||
>
|
||||
EN
|
||||
@@ -157,6 +183,14 @@ export default function Header() {
|
||||
>
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||
type: 'language',
|
||||
from: currentLocale,
|
||||
to: 'de',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||
>
|
||||
DE
|
||||
@@ -174,6 +208,12 @@ export default function Header() {
|
||||
variant="white"
|
||||
size="md"
|
||||
className="px-8 shadow-xl"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: t('contact'),
|
||||
location: 'header_cta',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
@@ -196,7 +236,14 @@ export default function Header() {
|
||||
damping: 20,
|
||||
delay: 0.5,
|
||||
}}
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
onClick={() => {
|
||||
const newState = !isMobileMenuOpen;
|
||||
setIsMobileMenuOpen(newState);
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
type: 'mobile_menu',
|
||||
action: newState ? 'open' : 'close',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<motion.svg
|
||||
className="w-7 h-7"
|
||||
@@ -274,7 +321,14 @@ export default function Header() {
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
location: 'mobile_menu',
|
||||
});
|
||||
}}
|
||||
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
||||
>
|
||||
{item.label}
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
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,22 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-0.5">
|
||||
{t('details')}
|
||||
</span>
|
||||
<svg className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<svg
|
||||
className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</RelatedProductLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { Input, Textarea, Button } from '@/components/ui';
|
||||
import { sendContactFormAction } from '@/app/actions/contact';
|
||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||
|
||||
interface RequestQuoteFormProps {
|
||||
productName: string;
|
||||
@@ -16,6 +17,26 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
const [email, setEmail] = useState('');
|
||||
const [request, setRequest] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
|
||||
const handleFocus = (fieldId: string) => {
|
||||
// Initial form start
|
||||
if (!hasStarted) {
|
||||
setHasStarted(true);
|
||||
trackEvent(AnalyticsEvents.FORM_START, {
|
||||
form_id: 'quote_request_form',
|
||||
form_name: 'Product Quote Inquiry',
|
||||
product_name: productName,
|
||||
});
|
||||
}
|
||||
|
||||
// Field-level transparency
|
||||
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
|
||||
form_id: 'quote_request_form',
|
||||
field_id: fieldId,
|
||||
product_name: productName,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -39,10 +60,20 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
setEmail('');
|
||||
setRequest('');
|
||||
} else {
|
||||
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||
form_id: 'quote_request_form',
|
||||
product_name: productName,
|
||||
error: result.error || 'submission_failed',
|
||||
});
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||
form_id: 'quote_request_form',
|
||||
product_name: productName,
|
||||
error: (error as Error).message || 'unexpected_error',
|
||||
});
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
@@ -131,6 +162,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onFocus={() => handleFocus('email')}
|
||||
placeholder={t('email')}
|
||||
className="h-9 text-xs !mt-0"
|
||||
/>
|
||||
@@ -143,6 +175,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
rows={3}
|
||||
value={request}
|
||||
onChange={(e) => setRequest(e.target.value)}
|
||||
onFocus={() => handleFocus('request')}
|
||||
placeholder={t('message')}
|
||||
className="text-xs !mt-0"
|
||||
/>
|
||||
|
||||
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',
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,34 +8,36 @@ 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'}`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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,5 @@ 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,61 +1,80 @@
|
||||
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}
|
||||
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}"
|
||||
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=3000"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
|
||||
# Public Router (Whitelist for OG Images, Sitemaps, Health)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathRegexp(`^/([a-z]{2}/)?api/og`) || PathRegexp(`^/([a-z]{2}/)?opengraph-image$`) || PathRegexp(`^/([a-z]{2}/)?blog/opengraph-image$`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`))"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.service=${PROJECT_NAME:-klz}-app-svc"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-klz-ratelimit,klz-forward,klz-compress}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.priority=2000"
|
||||
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-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:
|
||||
@@ -69,22 +88,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:
|
||||
@@ -93,45 +108,48 @@ 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:
|
||||
- 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:
|
||||
- infra
|
||||
|
||||
networks:
|
||||
default:
|
||||
|
||||
@@ -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,
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,37 +6,41 @@ import { join } from 'path';
|
||||
* Since we are using runtime = 'nodejs', we can read them from the filesystem.
|
||||
*/
|
||||
export async function getOgFonts() {
|
||||
const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.ttf');
|
||||
const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.ttf');
|
||||
const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.woff');
|
||||
const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.woff');
|
||||
|
||||
try {
|
||||
const boldFont = readFileSync(boldFontPath);
|
||||
const regularFont = readFileSync(regularFontPath);
|
||||
try {
|
||||
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
|
||||
const boldFont = readFileSync(boldFontPath);
|
||||
const regularFont = readFileSync(regularFontPath);
|
||||
console.log(
|
||||
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: boldFont,
|
||||
weight: 700 as const,
|
||||
style: 'normal' as const,
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: regularFont,
|
||||
weight: 400 as const,
|
||||
style: 'normal' as const,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Failed to load OG fonts from filesystem, falling back to system fonts:', error);
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: boldFont,
|
||||
weight: 700 as const,
|
||||
style: 'normal' as const,
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: regularFont,
|
||||
weight: 400 as const,
|
||||
style: 'normal' as const,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
console.error(`[OG] Failed to load fonts from ${process.cwd()}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common configuration for OG images
|
||||
*/
|
||||
export const OG_IMAGE_SIZE = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
};
|
||||
|
||||
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.
|
||||
|
||||
@@ -207,6 +207,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",
|
||||
@@ -393,4 +394,4 @@
|
||||
"cta": "Zurück zur Sicherheit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,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",
|
||||
@@ -393,4 +394,4 @@
|
||||
"cta": "Back to Safety"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,18 @@ const intlMiddleware = createMiddleware({
|
||||
|
||||
export default function middleware(request: NextRequest) {
|
||||
const { method, url, headers } = request;
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Explicit bypass for infrastructure routes to avoid locale redirects/interception
|
||||
if (
|
||||
pathname.startsWith('/stats') ||
|
||||
pathname.startsWith('/errors') ||
|
||||
pathname.startsWith('/health') ||
|
||||
pathname.includes('/api/og') ||
|
||||
pathname.includes('opengraph-image')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build header object for logging
|
||||
const headerObj: Record<string, string> = {};
|
||||
@@ -30,11 +42,8 @@ export default function middleware(request: NextRequest) {
|
||||
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
|
||||
const hostHeader =
|
||||
headers.get('x-forwarded-host') || headers.get('host') || 'testing.klz-cables.com';
|
||||
hostHeader.split(':');
|
||||
|
||||
urlObj.protocol = proto;
|
||||
// urlObj.hostname = publicHostname; // Don't rewrite hostname yet as it breaks internal fetches in dev
|
||||
// urlObj.port = ''; // DON'T clear internal port (3000) anymore
|
||||
|
||||
effectiveRequest = new NextRequest(urlObj, {
|
||||
headers: request.headers,
|
||||
@@ -43,13 +52,35 @@ export default function middleware(request: NextRequest) {
|
||||
});
|
||||
|
||||
console.log(
|
||||
`🛡️ Middleware: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
|
||||
`🛡️ Proxy: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Apply internationalization middleware
|
||||
const response = intlMiddleware(effectiveRequest);
|
||||
|
||||
// Upgrade 307 (Temporary Redirect) to 308 (Permanent Redirect)
|
||||
// This improves compatibility with scanners (Website Carbon, PageSpeed) and SEO.
|
||||
if (response.status === 307) {
|
||||
const location = response.headers.get('Location');
|
||||
if (location) {
|
||||
const url = new URL(location, request.url);
|
||||
return Response.redirect(url, 308);
|
||||
}
|
||||
}
|
||||
|
||||
// Allow iframe embedding from recorder domains
|
||||
const referer = headers.get('referer') || '';
|
||||
const recorderDomains = ['recorder.localhost', 'recorder.mintel.me'];
|
||||
const isRecorderRequest = recorderDomains.some((domain) => referer.includes(domain));
|
||||
|
||||
if (isRecorderRequest) {
|
||||
response.headers.delete('x-frame-options');
|
||||
response.headers.delete('content-security-policy');
|
||||
response.headers.set('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -61,6 +92,7 @@ export default function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Match only internationalized pathnames
|
||||
matcher: ['/((?!api|_next|_vercel|stats|errors|health|.*\\..*).*)', '/', '/(de|en)/:path*'],
|
||||
matcher: [
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf)$).*)',
|
||||
],
|
||||
};
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
import withMintelConfig from '@mintel/next-config';
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
console.log('🚀 NEXT CONFIG LOADED FROM:', process.cwd());
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
onDemandEntries: {
|
||||
// Make sure entries are not disposed too quickly
|
||||
maxInactiveAge: 60 * 1000,
|
||||
},
|
||||
logging: {
|
||||
fetches: {
|
||||
fullUrl: true,
|
||||
},
|
||||
},
|
||||
output: 'standalone',
|
||||
async redirects() {
|
||||
return [
|
||||
@@ -342,6 +350,4 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const nextIntlConfig = withNextIntl(nextConfig);
|
||||
|
||||
export default withMintelConfig(nextIntlConfig);
|
||||
export default withMintelConfig(nextConfig);
|
||||
|
||||
35
package.json
35
package.json
@@ -4,9 +4,10 @@
|
||||
"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",
|
||||
@@ -19,7 +20,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",
|
||||
@@ -45,8 +45,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",
|
||||
@@ -63,8 +67,10 @@
|
||||
"happy-dom": "^20.6.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"lucide-react": "^0.563.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"remotion": "^4.0.421",
|
||||
"sass": "^1.97.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "^4.21.0",
|
||||
@@ -72,7 +78,8 @@
|
||||
"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",
|
||||
@@ -80,7 +87,9 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:og": "vitest run tests/og-image.test.ts",
|
||||
"cms:branding:local": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||
"check:og": "tsx scripts/check-og-images.ts",
|
||||
"check:mdx": "node scripts/validate-mdx.mjs",
|
||||
"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,6 +109,8 @@
|
||||
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
|
||||
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
||||
"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"
|
||||
},
|
||||
@@ -108,5 +119,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"
|
||||
}
|
||||
}
|
||||
|
||||
1029
pnpm-lock.yaml
generated
1029
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
BIN
public/fonts/Inter-Bold.woff
Normal file
BIN
public/fonts/Inter-Bold.woff
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
public/fonts/Inter-Regular.woff
Normal file
BIN
public/fonts/Inter-Regular.woff
Normal file
Binary file not shown.
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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
78
scripts/check-og-images.ts
Normal file
78
scripts/check-og-images.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { SITE_URL } from '../lib/schema.js';
|
||||
|
||||
const BASE_URL = process.env.TEST_URL || SITE_URL;
|
||||
|
||||
console.log(`\n🚀 Starting OG Image Verification for ${BASE_URL}\n`);
|
||||
|
||||
const routes = [
|
||||
'/de/opengraph-image',
|
||||
'/en/opengraph-image',
|
||||
'/de/blog/opengraph-image',
|
||||
'/de/api/og/product?slug=nay2y',
|
||||
'/en/api/og/product?slug=medium-voltage-cables',
|
||||
];
|
||||
|
||||
async function verifyImage(path: string): Promise<boolean> {
|
||||
const url = `${BASE_URL}${path}`;
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
console.log(`Checking ${url}...`);
|
||||
|
||||
const body = await response.clone().text();
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
if (response.status !== 200) {
|
||||
console.log(` Headers: ${JSON.stringify(Object.fromEntries(response.headers))}`);
|
||||
console.log(` Status Text: ${response.statusText}`);
|
||||
throw new Error(
|
||||
`Status: ${response.status}. Body preview: ${body.substring(0, 1000).replace(/\n/g, ' ')}...`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!contentType?.includes('image/png')) {
|
||||
throw new Error(
|
||||
`Content-Type: ${contentType}. Body preview: ${body.substring(0, 1000).replace(/\n/g, ' ')}...`,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
// PNG Signature: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) {
|
||||
throw new Error('Invalid PNG signature');
|
||||
}
|
||||
|
||||
if (bytes.length < 10000) {
|
||||
throw new Error(`Image too small (${bytes.length} bytes), likely blank`);
|
||||
}
|
||||
|
||||
console.log(` ✅ OK (${bytes.length} bytes, ${duration}ms)`);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
console.error(` ❌ FAILED:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let allOk = true;
|
||||
for (const route of routes) {
|
||||
const ok = await verifyImage(route);
|
||||
if (!ok) allOk = false;
|
||||
}
|
||||
|
||||
if (allOk) {
|
||||
console.log('\n✨ All OG images verified successfully!\n');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('\n⚠️ Some OG images failed verification.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -9,11 +9,11 @@ if [ -z "$ENV" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//')
|
||||
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//' | sed 's/-nextjs$//' | sed 's/-cables$//')
|
||||
|
||||
case $ENV in
|
||||
local)
|
||||
CONTAINER=$(docker compose ps -q directus)
|
||||
CONTAINER=$(docker compose ps -q klz-cms)
|
||||
if [ -z "$CONTAINER" ]; then
|
||||
echo "❌ Local directus container not found."
|
||||
exit 1
|
||||
@@ -25,7 +25,10 @@ case $ENV in
|
||||
case $ENV in
|
||||
testing) PROJECT_NAME="${PRJ_ID}-testing" ;;
|
||||
staging) PROJECT_NAME="${PRJ_ID}-staging" ;;
|
||||
production) PROJECT_NAME="${PRJ_ID}-prod" ;;
|
||||
production)
|
||||
PROJECT_NAME="${PRJ_ID}-production"
|
||||
OLD_PROJECT_NAME="${PRJ_ID}-prod" # Fallback for previous convention
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "📤 Uploading snapshot to $ENV..."
|
||||
@@ -34,8 +37,16 @@ case $ENV in
|
||||
echo "🔍 Detecting remote container..."
|
||||
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus")
|
||||
|
||||
if [ -z "$REMOTE_CONTAINER" ] && [ -n "$OLD_PROJECT_NAME" ]; then
|
||||
echo "⚠️ $PROJECT_NAME not found, trying fallback $OLD_PROJECT_NAME..."
|
||||
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $OLD_PROJECT_NAME ps -q directus")
|
||||
if [ -n "$REMOTE_CONTAINER" ]; then
|
||||
PROJECT_NAME=$OLD_PROJECT_NAME
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$REMOTE_CONTAINER" ]; then
|
||||
echo "❌ Remote container for $ENV not found."
|
||||
echo "❌ Remote container for $ENV not found (checked $PROJECT_NAME)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
59
scripts/fix-directus-token.ts
Normal file
59
scripts/fix-directus-token.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import client, { ensureAuthenticated } from '../lib/directus';
|
||||
import { readUsers, updateUser } from '@directus/sdk';
|
||||
import { config } from '../lib/config';
|
||||
|
||||
async function fixToken() {
|
||||
console.log('🔑 Ensuring Directus Admin Token is set...');
|
||||
|
||||
try {
|
||||
// 1. Authenticate with credentials
|
||||
await ensureAuthenticated();
|
||||
|
||||
// 2. Find admin user
|
||||
const users = await client.request(
|
||||
readUsers({
|
||||
filter: {
|
||||
email: { _eq: config.directus.adminEmail },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
console.error(`❌ Could not find user with email ${config.directus.adminEmail}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const admin = users[0];
|
||||
const targetToken = config.directus.token;
|
||||
|
||||
if (!targetToken) {
|
||||
console.error('❌ No DIRECTUS_API_TOKEN configured in environment.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (admin.token === targetToken) {
|
||||
console.log('✅ Token is already correctly set.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Update token
|
||||
console.log(`📡 Updating token for ${config.directus.adminEmail}...`);
|
||||
await client.request(
|
||||
updateUser(admin.id, {
|
||||
token: targetToken,
|
||||
}),
|
||||
);
|
||||
|
||||
console.log('✨ Token successfully updated!');
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error fixing token:');
|
||||
if (error.errors) {
|
||||
console.error(JSON.stringify(error.errors, null, 2));
|
||||
} else {
|
||||
console.error(error.message || error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fixToken();
|
||||
68
scripts/manual-schema-fix.ts
Normal file
68
scripts/manual-schema-fix.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import client, { ensureAuthenticated } from '../lib/directus';
|
||||
import { createCollection, createField } from '@directus/sdk';
|
||||
|
||||
async function setupSchema() {
|
||||
console.log('🏗️ Manually creating contact_submissions collection...');
|
||||
|
||||
try {
|
||||
// 1. Authenticate (using token from config)
|
||||
await ensureAuthenticated();
|
||||
|
||||
// 2. Create collection
|
||||
console.log('📡 Creating "contact_submissions" collection...');
|
||||
await client.request(
|
||||
createCollection({
|
||||
collection: 'contact_submissions',
|
||||
meta: {
|
||||
icon: 'contact_mail',
|
||||
color: '#002b49',
|
||||
display_template: '{{name}} | {{email}}',
|
||||
},
|
||||
schema: {
|
||||
name: 'contact_submissions',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 3. Create fields
|
||||
console.log('📡 Creating fields for "contact_submissions"...');
|
||||
|
||||
// name
|
||||
await client.request(
|
||||
createField('contact_submissions', {
|
||||
field: 'name',
|
||||
type: 'string',
|
||||
meta: { interface: 'input' },
|
||||
}),
|
||||
);
|
||||
|
||||
// email
|
||||
await client.request(
|
||||
createField('contact_submissions', {
|
||||
field: 'email',
|
||||
type: 'string',
|
||||
meta: { interface: 'input' },
|
||||
}),
|
||||
);
|
||||
|
||||
// message
|
||||
await client.request(
|
||||
createField('contact_submissions', {
|
||||
field: 'message',
|
||||
type: 'text',
|
||||
meta: { interface: 'textarea' },
|
||||
}),
|
||||
);
|
||||
|
||||
console.log('✨ Collection and fields created successfully!');
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error creating schema:');
|
||||
if (error.errors) {
|
||||
console.error(JSON.stringify(error.errors, null, 2));
|
||||
} else {
|
||||
console.error(error.message || error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupSchema();
|
||||
205
scripts/merge-umami-data.ts
Normal file
205
scripts/merge-umami-data.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const CSV_PATHS = [
|
||||
'/Users/marcmintel/Downloads/pages.csv',
|
||||
'/Users/marcmintel/Downloads/pages(1).csv',
|
||||
'/Users/marcmintel/Downloads/pages(2).csv',
|
||||
];
|
||||
const JSON_OUTPUT_PATH = path.join(__dirname, '../data/umami-import-merged.json');
|
||||
const SQL_OUTPUT_PATH = path.join(__dirname, '../data/umami-import-new.sql');
|
||||
const WEBSITE_ID = '59a7db94-0100-4c7e-98ef-99f45b17f9c3';
|
||||
const HOSTNAME = 'klz-cables.com';
|
||||
|
||||
function parseCSV(content: string) {
|
||||
const lines = content.split('\n');
|
||||
if (lines.length === 0) return [];
|
||||
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
|
||||
const data = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (!lines[i].trim()) continue;
|
||||
|
||||
// Simple CSV parser that handles quotes
|
||||
const values: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (let j = 0; j < lines[i].length; j++) {
|
||||
const char = lines[i][j];
|
||||
if (char === '"') inQuotes = !inQuotes;
|
||||
else if (char === ',' && !inQuotes) {
|
||||
values.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
values.push(current.trim());
|
||||
|
||||
const row: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index]?.replace(/^"|"$/g, '');
|
||||
});
|
||||
data.push(row);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function normalizeURL(url: string) {
|
||||
if (!url) return '/';
|
||||
if (url.startsWith('http')) {
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
return url.startsWith('/') ? url : `/${url}`;
|
||||
}
|
||||
|
||||
async function mergeData() {
|
||||
console.log('Reading CSVs...');
|
||||
const aggregatedData: Record<string, { views: number; visitors: number; title: string }> = {};
|
||||
|
||||
for (const csvPath of CSV_PATHS) {
|
||||
if (!fs.existsSync(csvPath)) {
|
||||
console.warn(`File not found: ${csvPath}`);
|
||||
continue;
|
||||
}
|
||||
const csvContent = fs.readFileSync(csvPath, 'utf-8');
|
||||
const csvData = parseCSV(csvContent);
|
||||
|
||||
for (const row of csvData) {
|
||||
const url = normalizeURL(row.URL);
|
||||
const views = parseInt(row.Views) || 0;
|
||||
const visitors = parseInt(row.Visitors) || 0;
|
||||
const title = row.Title || '';
|
||||
|
||||
if (!aggregatedData[url]) {
|
||||
aggregatedData[url] = { views, visitors, title };
|
||||
} else {
|
||||
aggregatedData[url].views = Math.max(aggregatedData[url].views, views);
|
||||
aggregatedData[url].visitors = Math.max(aggregatedData[url].visitors, visitors);
|
||||
if (!aggregatedData[url].title && title) {
|
||||
aggregatedData[url].title = title;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jsonEvents = [];
|
||||
const sqlStatements = [];
|
||||
|
||||
// Spread data across the whole period since early 2025 launch
|
||||
const START_DATE = new Date('2025-01-01T08:00:00Z');
|
||||
const END_DATE = new Date('2026-02-13T20:00:00Z');
|
||||
const startTs = START_DATE.getTime();
|
||||
const endTs = END_DATE.getTime();
|
||||
const totalDays = Math.ceil((endTs - startTs) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Cleanup for the target period
|
||||
sqlStatements.push(`-- Cleanup previous artificial imports (Full Year 2025 and 2026 until now)
|
||||
DELETE FROM website_event WHERE website_id = '${WEBSITE_ID}' AND created_at >= '2025-01-01 00:00:00' AND created_at <= '2026-02-13 23:59:59' AND hostname = '${HOSTNAME}';
|
||||
DELETE FROM session WHERE website_id = '${WEBSITE_ID}' AND created_at >= '2025-01-01 00:00:00' AND created_at <= '2026-02-13 23:59:59';
|
||||
`);
|
||||
|
||||
// Helper for weighted random date selection
|
||||
function getRandomWeightedDate() {
|
||||
while (true) {
|
||||
const randomDays = Math.random() * totalDays;
|
||||
const date = new Date(startTs + randomDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
// 1. Growth Factor (0.2 at start to 1.0 at end)
|
||||
const growthWeight = 0.2 + (randomDays / totalDays) * 0.8;
|
||||
|
||||
// 2. Weekend Factor (30% traffic on weekends)
|
||||
const dayOfWeek = date.getDay();
|
||||
const weekendWeight = dayOfWeek === 0 || dayOfWeek === 6 ? 0.3 : 1.0;
|
||||
|
||||
// 3. Seasonality (simple sine wave)
|
||||
const month = date.getMonth();
|
||||
const seasonWeight = 0.8 + Math.sin((month / 12) * Math.PI * 2) * 0.2;
|
||||
|
||||
// Combined weight
|
||||
const combinedWeight = growthWeight * weekendWeight * seasonWeight;
|
||||
|
||||
// Pick based on weight
|
||||
if (Math.random() < combinedWeight) {
|
||||
// Return timestamp with random hour/minute
|
||||
date.setHours(Math.floor(Math.random() * 12) + 8); // Business hours mostly
|
||||
date.setMinutes(Math.floor(Math.random() * 60));
|
||||
return date;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const urls = Object.keys(aggregatedData);
|
||||
console.log(`Processing ${urls.length} aggregated URLs...`);
|
||||
|
||||
for (const url of urls) {
|
||||
const { views, visitors, title } = aggregatedData[url];
|
||||
if (views === 0) continue;
|
||||
|
||||
// We distribute views across visitors
|
||||
const sessionData = [];
|
||||
for (let v = 0; v < (visitors || 1); v++) {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const visitId = crypto.randomUUID();
|
||||
|
||||
const sessionDate = getRandomWeightedDate();
|
||||
const dateStr = sessionDate.toISOString().replace('T', ' ').split('.')[0];
|
||||
|
||||
sessionData.push({ sessionId, visitId, date: sessionDate });
|
||||
|
||||
sqlStatements.push(`INSERT INTO session (session_id, website_id, browser, os, device, screen, language, country, created_at)
|
||||
VALUES ('${sessionId}', '${WEBSITE_ID}', 'Chrome', 'Windows', 'desktop', '1920x1080', 'en', 'DE', '${dateStr}')
|
||||
ON CONFLICT (session_id) DO NOTHING;`);
|
||||
}
|
||||
|
||||
// Distribute views across these sessions
|
||||
for (let i = 0; i < views; i++) {
|
||||
const sIdx = i % sessionData.length;
|
||||
const session = sessionData[sIdx];
|
||||
const sessionId = session.sessionId;
|
||||
const visitId = session.visitId;
|
||||
const eventId = crypto.randomUUID();
|
||||
|
||||
// Event date should be close to session date
|
||||
const eventDate = new Date(session.date.getTime() + Math.random() * 1000 * 60 * 30); // within 30 mins
|
||||
const timestamp = eventDate.toISOString();
|
||||
const dateStr = timestamp.replace('T', ' ').split('.')[0];
|
||||
|
||||
// JSON Format
|
||||
jsonEvents.push({
|
||||
website_id: WEBSITE_ID,
|
||||
hostname: HOSTNAME,
|
||||
path: url,
|
||||
referrer: '',
|
||||
event_name: null,
|
||||
pageview: true,
|
||||
session: true,
|
||||
duration: Math.floor(Math.random() * 120) + 10,
|
||||
created_at: timestamp,
|
||||
});
|
||||
|
||||
// SQL Format
|
||||
sqlStatements.push(`INSERT INTO website_event (event_id, website_id, session_id, created_at, url_path, url_query, referrer_path, referrer_query, referrer_domain, page_title, event_type, event_name, visit_id, hostname)
|
||||
VALUES ('${eventId}', '${WEBSITE_ID}', '${sessionId}', '${dateStr}', '${url}', '', '', '', '', '${title.replace(/'/g, "''")}', 1, NULL, '${visitId}', '${HOSTNAME}');`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Writing ${jsonEvents.length} events to ${JSON_OUTPUT_PATH}...`);
|
||||
fs.writeFileSync(JSON_OUTPUT_PATH, JSON.stringify(jsonEvents, null, 2));
|
||||
|
||||
console.log(`Writing SQL statements to ${SQL_OUTPUT_PATH}...`);
|
||||
fs.writeFileSync(SQL_OUTPUT_PATH, sqlStatements.join('\n'));
|
||||
|
||||
console.log('✅ Refined Restoration Script complete!');
|
||||
}
|
||||
|
||||
mergeData().catch(console.error);
|
||||
@@ -5,7 +5,7 @@ REMOTE_HOST="root@alpha.mintel.me"
|
||||
REMOTE_DIR="/home/deploy/sites/klz-cables.com"
|
||||
|
||||
# DB Details (matching docker-compose defaults)
|
||||
DB_USER="directus"
|
||||
DB_USER="klz_db_user"
|
||||
DB_NAME="directus"
|
||||
|
||||
ACTION=$1
|
||||
@@ -49,9 +49,9 @@ esac
|
||||
# Detect local container
|
||||
echo "🔍 Detecting local database..."
|
||||
# Use a more robust way to find the container if multiple projects exist locally
|
||||
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
|
||||
LOCAL_DB_CONTAINER=$(docker compose ps -q klz-db)
|
||||
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
||||
echo "❌ Local directus-db container not found. Is it running? (npm run dev)"
|
||||
echo "❌ Local klz-directus-db container not found. Is it running? (npm run dev)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
55
scripts/validate-mdx.mjs
Normal file
55
scripts/validate-mdx.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import { compile } from '@mdx-js/mdx';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import matter from 'gray-matter';
|
||||
|
||||
const TARGET_DIRS = ['./data/blog', './data/products', './data/pages'];
|
||||
|
||||
function getAllFiles(dirPath, arrayOfFiles) {
|
||||
if (!fs.existsSync(dirPath)) return arrayOfFiles || [];
|
||||
|
||||
const files = fs.readdirSync(dirPath);
|
||||
|
||||
arrayOfFiles = arrayOfFiles || [];
|
||||
|
||||
files.forEach(function (file) {
|
||||
const fullPath = path.join(dirPath, file);
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
arrayOfFiles = getAllFiles(fullPath, arrayOfFiles);
|
||||
} else {
|
||||
arrayOfFiles.push(fullPath);
|
||||
}
|
||||
});
|
||||
|
||||
return arrayOfFiles;
|
||||
}
|
||||
|
||||
const allMdxFiles = TARGET_DIRS.flatMap(dir => getAllFiles(dir)).filter(file => file.endsWith('.mdx'));
|
||||
|
||||
console.log(`Found ${allMdxFiles.length} MDX files to validate...`);
|
||||
|
||||
let errorCount = 0;
|
||||
|
||||
for (const file of allMdxFiles) {
|
||||
const fileContent = fs.readFileSync(file, 'utf8');
|
||||
const { content } = matter(fileContent);
|
||||
|
||||
try {
|
||||
// Attempt to compile MDX content
|
||||
await compile(content);
|
||||
} catch (err) {
|
||||
console.error(`\x1b[31mError in ${file}:\x1b[0m`);
|
||||
console.error(err.message);
|
||||
if (err.line && err.column) {
|
||||
console.error(`At line ${err.line}, column ${err.column}`);
|
||||
}
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
console.error(`\n\x1b[31mValidation failed: ${errorCount} errors found.\x1b[0m`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n\x1b[32mAll MDX files are valid.\x1b[0m');
|
||||
}
|
||||
@@ -7,16 +7,19 @@
|
||||
--font-heading: 'Inter', system-ui, sans-serif;
|
||||
--font-body: 'Inter', system-ui, sans-serif;
|
||||
|
||||
--color-primary: #001a4d; /* Deep Blue */
|
||||
--color-primary: #001a4d;
|
||||
/* Deep Blue */
|
||||
--color-primary-dark: #000d26;
|
||||
--color-primary-light: #e6ebf5;
|
||||
|
||||
--color-saturated: #011dff; /* Saturated Blue Accent */
|
||||
--color-saturated: #011dff;
|
||||
/* Saturated Blue Accent */
|
||||
|
||||
--color-secondary: #003d82;
|
||||
--color-secondary-light: #0056b3;
|
||||
|
||||
--color-accent: #82ed20; /* Sustainability Green */
|
||||
--color-accent: #82ed20;
|
||||
/* Sustainability Green */
|
||||
--color-accent-dark: #6bc41a;
|
||||
--color-accent-light: #f0f9e6;
|
||||
|
||||
@@ -40,76 +43,107 @@
|
||||
--animate-slide-up: slide-up 0.6s ease-out;
|
||||
--animate-slow-zoom: slow-zoom 20s linear infinite;
|
||||
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
--animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s
|
||||
cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
--animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
--animate-gradient-x: gradient-x 15s ease infinite;
|
||||
|
||||
@keyframes gradient-x {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slow-zoom {
|
||||
from {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slight-fade-in-from-bottom {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
33% {
|
||||
transform: translate(2%, 4%) scale(1.1);
|
||||
}
|
||||
|
||||
66% {
|
||||
transform: translate(-3%, 2%) scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
.bg-primary a,
|
||||
.bg-primary-dark a {
|
||||
@apply text-white/90 hover:text-white transition-colors;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-base md:text-lg antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -132,18 +166,23 @@
|
||||
h1 {
|
||||
@apply text-3xl md:text-5xl lg:text-6xl leading-[1.1];
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl md:text-4xl lg:text-5xl leading-[1.2];
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl md:text-2xl lg:text-3xl leading-[1.3];
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-lg md:text-xl lg:text-2xl leading-[1.4];
|
||||
}
|
||||
|
||||
h5 {
|
||||
@apply text-base md:text-lg leading-[1.5];
|
||||
}
|
||||
|
||||
h6 {
|
||||
@apply text-sm md:text-base leading-[1.6];
|
||||
}
|
||||
@@ -202,18 +241,23 @@
|
||||
.glass-panel {
|
||||
@apply bg-white/80 backdrop-blur-md border border-white/20 shadow-lg;
|
||||
}
|
||||
|
||||
.image-overlay-gradient {
|
||||
@apply absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent;
|
||||
}
|
||||
|
||||
.premium-card {
|
||||
@apply bg-white rounded-3xl shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1;
|
||||
}
|
||||
|
||||
.sticky-narrative-container {
|
||||
@apply grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20;
|
||||
}
|
||||
|
||||
.sticky-narrative-sidebar {
|
||||
@apply lg:col-span-4 lg:sticky lg:top-32 h-fit;
|
||||
}
|
||||
|
||||
.sticky-narrative-content {
|
||||
@apply lg:col-span-8;
|
||||
}
|
||||
@@ -221,7 +265,8 @@
|
||||
|
||||
/* Custom Utilities */
|
||||
@utility touch-target {
|
||||
min-height: 48px; /* Increased for better touch-first feel */
|
||||
min-height: 48px;
|
||||
/* Increased for better touch-first feel */
|
||||
min-width: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -276,4 +321,4 @@
|
||||
@utility content-visibility-auto {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 1px 1000px;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,18 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"lib/*": ["./lib/*"],
|
||||
"components/*": ["./components/*"],
|
||||
"data/*": ["./data/*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
],
|
||||
"lib/*": [
|
||||
"./lib/*"
|
||||
],
|
||||
"components/*": [
|
||||
"./components/*"
|
||||
],
|
||||
"data/*": [
|
||||
"./data/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -17,5 +25,11 @@
|
||||
"tests/**/*.test.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "scripts", "reference", "data"]
|
||||
}
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"scripts",
|
||||
"reference",
|
||||
"data",
|
||||
"remotion"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +0,0 @@
|
||||
|
||||
> klz-cables-nextjs@1.0.0 typecheck /Users/marcmintel/Projects/klz-2026
|
||||
> tsc --noEmit
|
||||
|
||||
21
types/record-mode.ts
Normal file
21
types/record-mode.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface RecordEvent {
|
||||
id: string;
|
||||
type: 'mouse' | 'scroll' | 'wait';
|
||||
interactionType?: 'click' | 'hover';
|
||||
selector?: string; // CSS selector
|
||||
timestamp: number; // Time in ms since start of recording
|
||||
duration: number; // Duration allocated for this action in playback
|
||||
zoom?: number; // Zoom level during event
|
||||
description?: string; // Optional label
|
||||
motionBlur?: boolean; // Enable motion blur effect
|
||||
rect?: { x: number; y: number; width: number; height: number }; // Element position for rendering
|
||||
clickOrigin?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
realClick?: boolean; // Trigger real browser action (navigation)
|
||||
}
|
||||
|
||||
export interface RecordingSession {
|
||||
id: string;
|
||||
name: string;
|
||||
events: RecordEvent[];
|
||||
createdAt: string;
|
||||
}
|
||||
Reference in New Issue
Block a user