Compare commits

..

24 Commits

Author SHA1 Message Date
1baf03a84e feat(record-mode): unify mouse tool and enhance visuals
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 1m6s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-15 18:25:52 +01:00
483dfabe10 feat: refactor clicks to generic mouse interactions with click/hover subtypes 2026-02-15 18:17:10 +01:00
65f8b2c485 style: sharpen Studio hover previews by removing blur and diffuse shadows 2026-02-15 18:14:13 +01:00
90cdd7e713 feat: enhance Recording Studio with reorderable events, origin options, and hover previews 2026-02-15 18:13:25 +01:00
40fa2a7721 fix: industrial accuracy for record mode events via cross-window sync 2026-02-15 18:10:59 +01:00
a136e7b4a7 feat: optimize event capturing and playback accuracy 2026-02-15 18:06:50 +01:00
e615d88fd8 chore: remove temporary test file contact.html
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m38s
Build & Deploy / 🏗️ Build (push) Successful in 6m16s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Failing after 56s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-13 01:38:37 +01:00
3d498f3df8 fix(og): enable automatic OG image discovery and refine Traefik whitelist
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
- Removed manual 'images' metadata overrides.
- This allows Next.js to use built-in automatic discovery.
- Ensures metadata uses the dynamic metadataBase from the environment.
- Refined Traefik public router regex for sub-routes.
- Restored and verified imports in modified page.tsx files.
2026-02-13 01:38:26 +01:00
d9a7cf6a77 fix(cms): update env schema and cms-apply script to fix email and auth issues
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 37s
Build & Deploy / 🧪 QA (push) Successful in 1m36s
Build & Deploy / 🏗️ Build (push) Successful in 4m0s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Smoke Test (push) Successful in 42s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-13 01:13:47 +01:00
cd7be080d7 fix(middleware): correctly include infrastructure routes in matcher for bypass
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 2m13s
Build & Deploy / 🏗️ Build (push) Successful in 3m54s
Build & Deploy / 🚀 Deploy (push) Successful in 29s
Build & Deploy / 🔔 Notify (push) Successful in 1s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m19s
2026-02-13 00:24:43 +01:00
4e602da15d fix(infra): definitive fix for Traefik Host rule and Gatekeeper bypass
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 1m38s
Build & Deploy / 🏗️ Build (push) Successful in 3m59s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Failing after 53s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Switched Traefik Host rules from backticks to double quotes for safety.
- Used printf in deploy.yml to guarantee literal writing of environment variables.
- Verified that Host rules now correctly match without shell-side side-effects.
- Maintained WOFF fonts for Satori compatibility.
2026-02-12 23:34:33 +01:00
e47982d394 fix(og): final verified robust fix for OG images and CI
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m41s
Build & Deploy / 🏗️ Build (push) Successful in 3m42s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Failing after 56s
Build & Deploy / 🔔 Notify (push) Successful in 1s
- Fixed font corruption: Replaced WOFF2/HTML stubs with valid binary WOFF (v1) fonts.
- Verified local rendering: check:og script passes on production-like build.
- Secure CI Env: Prevented backtick execution in deploy.yml using safe echo blocks.
- Guaranteed Traefik Bypass: Priority 2000 and explicit PathPrefix whitelists in docker-compose.yml.
- Middleware Bypass: Ensured OG routes are ignored by next-intl.
2026-02-12 22:32:56 +01:00
877108020b fix(og): verified font and infrastructure fix
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m18s
Build & Deploy / 🏗️ Build (push) Successful in 3m55s
Build & Deploy / 🚀 Deploy (push) Successful in 46s
Build & Deploy / 🧪 Smoke Test (push) Failing after 40s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Fixed font corruption: Replaced WOFF2/HTML stubs with valid binary WOFF fonts.
- Verified local rendering: check:og script now passes on local production build.
- Robust infrastructure: Guaranteed Traefik bypass with Host match and priority 2000.
- Middleware bypass: Ensured OG routes are never intercepted by next-intl.
2026-02-12 22:23:21 +01:00
0fff5ae52a fix(infra): guaranteed Traefik bypass for OG images and sitemaps
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 1m20s
Build & Deploy / 🏗️ Build (push) Has started running
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
- Added explicit Host match and PathPrefixes to public router in docker-compose.yml.
- Increased priority of public router to 2000.
- Updated middleware.ts to bypass next-intl for OG images and API routes.
- Verified local rendering of OG images.
2026-02-12 22:18:21 +01:00
459716d09c fix(og): robust infrastructure fix for OG image check
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m39s
Build & Deploy / 🏗️ Build (push) Successful in 2m59s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Smoke Test (push) Failing after 52s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Added exhaustive PathRegexp whitelists in docker-compose.yml to bypass Gatekeeper.
- Fixed TRAEFIK_HOST_RULE interpolation in deploy.yml.
- Enhanced scripts/check-og-images.ts with header and body diagnostics.
- Added server-side font loading logs in lib/og-helper.tsx.
2026-02-12 21:59:13 +01:00
a0d4023f89 fix(og): diagnostic fix for CI OG image check
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 17s
Build & Deploy / 🧪 QA (push) Successful in 1m44s
Build & Deploy / 🏗️ Build (push) Successful in 33s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🧪 Smoke Test (push) Failing after 52s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Updated scripts/check-og-images.ts to log response body on failure.
- Refined Traefik public router rule in docker-compose.yml for better path matching.
- Fixed TRAEFIK_HOST_RULE assignment in deploy.yml (removed literal single quotes).
2026-02-12 21:35:45 +01:00
9746416146 fix(infra): whitelist OG images in Traefik to bypass Gatekeeper
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 1m38s
Build & Deploy / 🏗️ Build (push) Successful in 3m47s
Build & Deploy / 🚀 Deploy (push) Successful in 32s
Build & Deploy / 🧪 Smoke Test (push) Failing after 49s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Added public router labels to ensure OG images, sitemaps, and health checks
are accessible on testing/staging environments for crawlers and CI tests.
2026-02-12 21:25:04 +01:00
fc9746335d fix(ci): use native fetch in OG image check script
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 2m5s
Build & Deploy / 🏗️ Build (push) Successful in 2m43s
Build & Deploy / 🚀 Deploy (push) Successful in 32s
Build & Deploy / 🧪 Smoke Test (push) Failing after 54s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Removed node-fetch dependency to fix ERR_MODULE_NOT_FOUND in CI.
2026-02-12 21:16:00 +01:00
4058abab13 fix(og): resolve font corruption and Next.js 15+ params compatibility
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m20s
Build & Deploy / 🏗️ Build (push) Successful in 7m10s
Build & Deploy / 🚀 Deploy (push) Successful in 38s
Build & Deploy / 🧪 Smoke Test (push) Failing after 42s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Replaced corrupted HTML font files with binary WOFF2 versions.
- Updated all opengraph-image.tsx files to await params, as required by Next.js 15+.
- Improved OG image reliability by using SITE_URL for absolute image paths.
- Added scripts/check-og-images.ts for automated production verification.
- Integrated smoke_test job into deployment pipeline.
2026-02-12 19:14:14 +01:00
6074747b34 fix(middleware): bypass internationalization for stats and errors
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 2m6s
Build & Deploy / 🏗️ Build (push) Failing after 23m58s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-02-12 18:18:51 +01:00
319b2b3e0c fix(analytics): restore missing UMAMI_API_ENDPOINT in environment schema
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m35s
Build & Deploy / 🏗️ Build (push) Successful in 36s
Build & Deploy / 🚀 Deploy (push) Successful in 28s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 17:59:03 +01:00
d7f5504149 fix(analytics): restore Smart Proxy mechanism and remove conflicting rewrites
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m36s
Build & Deploy / 🏗️ Build (push) Successful in 6m37s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 17:33:42 +01:00
0f705b474b fix(analytics): ensure Umami Website ID is visible to client bundle
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m18s
Build & Deploy / 🏗️ Build (push) Successful in 3m46s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 17:19:01 +01:00
67046b9301 feat: align analytics and error naming standards and fix Umami proxy
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 2m24s
Build & Deploy / 🏗️ Build (push) Successful in 6m58s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 16:55:20 +01:00
60 changed files with 3787 additions and 3229 deletions

5
.env
View File

@@ -22,8 +22,8 @@ DIRECTUS_ADMIN_EMAIL=marc@mintel.me
DIRECTUS_ADMIN_PASSWORD=Tim300493. DIRECTUS_ADMIN_PASSWORD=Tim300493.
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
DIRECTUS_DB_NAME=directus DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus DIRECTUS_DB_USER=klz_db_user
DIRECTUS_DB_PASSWORD=directus DIRECTUS_DB_PASSWORD=klz_db_pass
# Local Development # Local Development
PROJECT_NAME=klz-cables PROJECT_NAME=klz-cables
GATEKEEPER_BYPASS_ENABLED=true GATEKEEPER_BYPASS_ENABLED=true
@@ -33,3 +33,4 @@ GATEKEEPER_PASSWORD=klz2026
COOKIE_DOMAIN=localhost COOKIE_DOMAIN=localhost
INFRA_DIRECTUS_URL=http://localhost:8059 INFRA_DIRECTUS_URL=http://localhost:8059
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
GATEKEEPER_ORIGIN=http://klz.localhost

View File

@@ -85,10 +85,10 @@ jobs:
# Standardize Traefik Rule # Standardize Traefik Rule
if [[ "$TRAEFIK_HOST" == *","* ]]; then 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') PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
else else
TRAEFIK_RULE="Host(\`$TRAEFIK_HOST\`)" TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
PRIMARY_HOST="$TRAEFIK_HOST" PRIMARY_HOST="$TRAEFIK_HOST"
fi fi
@@ -198,6 +198,8 @@ jobs:
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }} NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
NPM_TOKEN=${{ secrets.REGISTRY_PASS }} NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }} tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache
@@ -246,6 +248,10 @@ jobs:
# Gatekeeper # Gatekeeper
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
# Analytics
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -275,51 +281,59 @@ jobs:
# Gatekeeper Origin # Gatekeeper Origin
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper" GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
cat > .env.deploy << EOF {
# Generated by CI - $TARGET echo "# Generated by CI - $TARGET"
IMAGE_TAG=$IMAGE_TAG echo "IMAGE_TAG=$IMAGE_TAG"
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL"
GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
SENTRY_DSN=$SENTRY_DSN echo "SENTRY_DSN=$SENTRY_DSN"
LOG_LEVEL=$LOG_LEVEL echo "LOG_LEVEL=$LOG_LEVEL"
MAIL_HOST=$MAIL_HOST echo "MAIL_HOST=$MAIL_HOST"
MAIL_PORT=$MAIL_PORT echo "MAIL_PORT=$MAIL_PORT"
MAIL_USERNAME=$MAIL_USERNAME echo "MAIL_USERNAME=$MAIL_USERNAME"
MAIL_PASSWORD=$MAIL_PASSWORD echo "MAIL_PASSWORD=$MAIL_PASSWORD"
MAIL_FROM=$MAIL_FROM echo "MAIL_FROM=$MAIL_FROM"
MAIL_RECIPIENTS=$MAIL_RECIPIENTS 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 "ENV_FILE=$ENV_FILE"
echo "COMPOSE_PROFILES=$COMPOSE_PROFILES"
echo "AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE"
echo "AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED"
} > .env.deploy
# Directus echo "--- Generated .env.deploy ---"
DIRECTUS_URL=$DIRECTUS_URL cat .env.deploy
DIRECTUS_HOST=$DIRECTUS_HOST echo "----------------------------"
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
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
- name: 🚀 SSH Deploy - name: 🚀 SSH Deploy
shell: bash shell: bash
@@ -353,11 +367,43 @@ jobs:
run: docker builder prune -f --filter "until=1h" 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: notifications:
name: 🔔 Notify name: 🔔 Notify
needs: [prepare, deploy] needs: [prepare, deploy, smoke_test]
if: always() if: always()
runs-on: docker runs-on: docker
container: container:

View File

@@ -6,12 +6,16 @@ WORKDIR /app
ARG NEXT_PUBLIC_BASE_URL ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_TARGET ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL ARG DIRECTUS_URL
ARG UMAMI_WEBSITE_ID
ARG UMAMI_API_ENDPOINT
ARG NPM_TOKEN ARG NPM_TOKEN
# Environment variables for Next.js build # Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL ENV DIRECTUS_URL=$DIRECTUS_URL
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
ENV SKIP_RUNTIME_ENV_VALIDATION=true ENV SKIP_RUNTIME_ENV_VALIDATION=true
ENV CI=true ENV CI=true
@@ -30,10 +34,15 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
# Copy source code # Copy source code
COPY . . COPY . .
# Build application # Stage 2: Development (Hot-Reloading)
RUN pnpm build FROM builder AS development
ENV NODE_ENV=development
CMD ["pnpm", "dev:local"]
# Stage 2: Runner # Build application
# RUN pnpm build
# Stage 3: Runner
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
WORKDIR /app WORKDIR /app

View File

@@ -5,7 +5,12 @@ import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs'; 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); const pageData = await getPageBySlug(slug, locale);
if (!pageData) { if (!pageData) {
@@ -15,17 +20,14 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
const fonts = await getOgFonts(); const fonts = await getOgFonts();
return new ImageResponse( return new ImageResponse(
( <OGImageTemplate
<OGImageTemplate title={pageData.frontmatter.title}
title={pageData.frontmatter.title} description={pageData.frontmatter.excerpt}
description={pageData.frontmatter.excerpt} label="Information"
label="Information" />,
/>
),
{ {
...OG_IMAGE_SIZE, ...OG_IMAGE_SIZE,
fonts, fonts,
} },
); );
} }

View File

@@ -5,7 +5,6 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getPageBySlug, getAllPages } from '@/lib/pages'; import { getPageBySlug, getAllPages } from '@/lib/pages';
import { mdxComponents } from '@/components/blog/MDXComponents'; import { mdxComponents } from '@/components/blog/MDXComponents';
import { getOGImageMetadata } from '@/lib/metadata';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
interface PageProps { interface PageProps {
@@ -50,7 +49,6 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
title: `${pageData.frontmatter.title} | KLZ Cables`, title: `${pageData.frontmatter.title} | KLZ Cables`,
description: pageData.frontmatter.excerpt || '', description: pageData.frontmatter.excerpt || '',
url: `${SITE_URL}/${locale}/${slug}`, url: `${SITE_URL}/${locale}/${slug}`,
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',

View File

@@ -5,13 +5,15 @@ import { OGImageTemplate } from '@/components/OGImageTemplate';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper'; import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
import { SITE_URL } from '@/lib/schema';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ locale: string }> }, { params }: { params: Promise<{ locale: string }> },
) { ) {
const { searchParams, origin } = new URL(request.url); const { searchParams } = new URL(request.url);
const slug = searchParams.get('slug'); const slug = searchParams.get('slug');
const { locale } = await params; const { locale } = await params;
@@ -58,7 +60,7 @@ export async function GET(
const featuredImage = product.frontmatter.images?.[0] const featuredImage = product.frontmatter.images?.[0]
? product.frontmatter.images[0].startsWith('http') ? product.frontmatter.images[0].startsWith('http')
? product.frontmatter.images[0] ? product.frontmatter.images[0]
: `${origin}${product.frontmatter.images[0]}` : `${SITE_URL}${product.frontmatter.images[0]}`
: undefined; : undefined;
return new ImageResponse( return new ImageResponse(

View File

@@ -7,10 +7,11 @@ import { SITE_URL } from '@/lib/schema';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export default async function Image({ 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); const post = await getPostBySlug(slug, locale);
if (!post) { if (!post) {

View File

@@ -10,7 +10,6 @@ import PowerCTA from '@/components/blog/PowerCTA';
import TableOfContents from '@/components/blog/TableOfContents'; import TableOfContents from '@/components/blog/TableOfContents';
import { mdxComponents } from '@/components/blog/MDXComponents'; import { mdxComponents } from '@/components/blog/MDXComponents';
import { Heading } from '@/components/ui'; import { Heading } from '@/components/ui';
import { getOGImageMetadata } from '@/lib/metadata';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
interface BlogPostProps { interface BlogPostProps {
@@ -45,7 +44,6 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
publishedTime: post.frontmatter.date, publishedTime: post.frontmatter.date,
authors: ['KLZ Cables'], authors: ['KLZ Cables'],
url: `${SITE_URL}/${locale}/blog/${slug}`, url: `${SITE_URL}/${locale}/blog/${slug}`,
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',

View File

@@ -5,21 +5,16 @@ import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs'; 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 t = await getTranslations({ locale, namespace: 'Blog.meta' });
const fonts = await getOgFonts(); const fonts = await getOgFonts();
return new ImageResponse( return new ImageResponse(
( <OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
<OGImageTemplate
title={t('title')}
description={t('description')}
label="Blog"
/>
),
{ {
...OG_IMAGE_SIZE, ...OG_IMAGE_SIZE,
fonts, fonts,
} },
); );
} }

View File

@@ -3,8 +3,8 @@ import Image from 'next/image';
import { getAllPosts } from '@/lib/blog'; import { getAllPosts } from '@/lib/blog';
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui'; import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
import Reveal from '@/components/Reveal'; import Reveal from '@/components/Reveal';
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { getOGImageMetadata } from '@/lib/metadata';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
interface BlogIndexProps { interface BlogIndexProps {
@@ -31,7 +31,6 @@ export async function generateMetadata({ params }: BlogIndexProps) {
title: `${t('title')} | KLZ Cables`, title: `${t('title')} | KLZ Cables`,
description: t('description'), description: t('description'),
url: `${SITE_URL}/${locale}/blog`, url: `${SITE_URL}/${locale}/blog`,
images: getOGImageMetadata('blog', t('title'), locale),
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',

View File

@@ -5,7 +5,8 @@ import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs'; 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 t = await getTranslations({ locale, namespace: 'Contact' });
const fonts = await getOgFonts(); const fonts = await getOgFonts();
@@ -13,16 +14,10 @@ export default async function Image({ params: { locale } }: { params: { locale:
const description = t('meta.description') || t('subtitle'); const description = t('meta.description') || t('subtitle');
return new ImageResponse( return new ImageResponse(
( <OGImageTemplate title={title} description={description} label="Contact" />,
<OGImageTemplate
title={title}
description={description}
label="Contact"
/>
),
{ {
...OG_IMAGE_SIZE, ...OG_IMAGE_SIZE,
fonts, fonts,
} },
); );
} }

View File

@@ -35,7 +35,6 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
description, description,
url: `${SITE_URL}/${locale}/contact`, url: `${SITE_URL}/${locale}/contact`,
siteName: 'KLZ Cables', siteName: 'KLZ Cables',
images: getOGImageMetadata('contact', title, locale),
locale: `${locale.toUpperCase()}_DE`, locale: `${locale.toUpperCase()}_DE`,
type: 'website', type: 'website',
}, },
@@ -43,7 +42,6 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
card: 'summary_large_image', card: 'summary_large_image',
title: `${title} | KLZ Cables`, title: `${title} | KLZ Cables`,
description, description,
images: [`${SITE_URL}/${locale}/contact/opengraph-image`],
}, },
robots: { robots: {
index: true, index: true,

View File

@@ -3,7 +3,9 @@ import Header from '@/components/Header';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider'; import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice'; 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 { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
@@ -96,18 +98,50 @@ export default async function LocaleLayout({
return ( return (
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}> <html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
<head>
<style dangerouslySetInnerHTML={{
__html: `
/* Effectively Invisible Scrollbar */
::-webkit-scrollbar {
width: 2px;
height: 2px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(130, 237, 32, 0.4);
}
* {
scrollbar-width: none;
}
*:hover {
scrollbar-width: thin;
scrollbar-color: rgba(130, 237, 32, 0.2) transparent;
}
`}} />
</head>
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden"> <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}> <NextIntlClientProvider messages={messages} locale={safeLocale}>
<JsonLd /> <RecordModeProvider>
<Header /> <RecordModeVisuals>
<main className="flex-grow animate-fade-in overflow-visible">{children}</main> <JsonLd />
<Footer /> <Header />
<CMSConnectivityNotice /> <main className="flex-grow animate-fade-in overflow-visible">{children}</main>
<Footer />
</RecordModeVisuals>
<Suspense fallback={null}> <CMSConnectivityNotice />
<AnalyticsProvider />
</Suspense> <Suspense fallback={null}>
{config.feedbackEnabled && <FeedbackOverlay />} <AnalyticsProvider />
</Suspense>
<ToolCoordinator />
</RecordModeProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@@ -5,22 +5,20 @@ import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs'; 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: 'Index.meta' }); const t = await getTranslations({ locale, namespace: 'Index.meta' });
const fonts = await getOgFonts(); const fonts = await getOgFonts();
return new ImageResponse( return new ImageResponse(
( <OGImageTemplate
<OGImageTemplate title={t('title')}
title={t('title')} description={t('description')}
description={t('description')} label="Reliable Energy Infrastructure"
label="Reliable Energy Infrastructure" />,
/>
),
{ {
...OG_IMAGE_SIZE, ...OG_IMAGE_SIZE,
fonts, fonts,
} },
); );
} }

View File

@@ -212,8 +212,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
<Container className="relative z-10"> <Container className="relative z-10">
<div className="max-w-4xl animate-slide-up"> <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"> <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"> <Link href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`} className="hover:text-accent transition-colors">
{t('title')} {t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
</Link> </Link>
<span className="mx-3 opacity-30">/</span> <span className="mx-3 opacity-30">/</span>
<span className="text-white/90">{categoryTitle}</span> <span className="text-white/90">{categoryTitle}</span>
@@ -361,8 +361,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
<Container className="relative z-10"> <Container className="relative z-10">
<div className="max-w-4xl animate-slide-up"> <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]"> <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"> <Link href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`} className="hover:text-accent transition-colors">
{t('title')} {t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
</Link> </Link>
<span className="mx-4 opacity-20">/</span> <span className="mx-4 opacity-20">/</span>
<Link <Link

View File

@@ -5,25 +5,19 @@ import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs'; 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 t = await getTranslations({ locale, namespace: 'Products' });
const fonts = await getOgFonts(); 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'); const description = t('meta.description') || t('subtitle');
return new ImageResponse( return new ImageResponse(
( <OGImageTemplate title={title} description={description} label="Products" />,
<OGImageTemplate
title={title}
description={description}
label="Products"
/>
),
{ {
...OG_IMAGE_SIZE, ...OG_IMAGE_SIZE,
fonts, fonts,
} },
); );
} }

View File

@@ -6,7 +6,6 @@ import { Metadata } from 'next';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { mapFileSlugToTranslated } from '@/lib/slugs'; import { mapFileSlugToTranslated } from '@/lib/slugs';
import { getOGImageMetadata } from '@/lib/metadata';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
interface ProductsPageProps { interface ProductsPageProps {
@@ -18,24 +17,23 @@ interface ProductsPageProps {
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> { export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Products' }); 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'); const description = t('meta.description') || t('subtitle');
return { return {
title, title,
description, description,
alternates: { alternates: {
canonical: `/${locale}/products`, canonical: `/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
languages: { languages: {
de: '/de/products', de: `/de/${await mapFileSlugToTranslated('products', 'de')}`,
en: '/en/products', en: `/en/${await mapFileSlugToTranslated('products', 'en')}`,
'x-default': '/en/products', 'x-default': `/en/${await mapFileSlugToTranslated('products', 'en')}`,
}, },
}, },
openGraph: { openGraph: {
title: `${title} | KLZ Cables`, title: `${title} | KLZ Cables`,
description, description,
url: `${SITE_URL}/${locale}/products`, url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
images: getOGImageMetadata('products', title, locale),
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
@@ -56,34 +54,36 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale); const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale); const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
const productsSlug = await mapFileSlugToTranslated('products', locale);
const categories = [ const categories = [
{ {
title: t('categories.lowVoltage.title'), title: t('categories.lowVoltage.title'),
desc: t('categories.lowVoltage.description'), desc: t('categories.lowVoltage.description'),
img: '/uploads/2024/11/low-voltage-category.webp', img: '/uploads/2024/11/low-voltage-category.webp',
icon: '/uploads/2024/11/Low-Voltage.svg', icon: '/uploads/2024/11/Low-Voltage.svg',
href: `/${locale}/products/${lowVoltageSlug}`, href: `/${locale}/${productsSlug}/${lowVoltageSlug}`,
}, },
{ {
title: t('categories.mediumVoltage.title'), title: t('categories.mediumVoltage.title'),
desc: t('categories.mediumVoltage.description'), desc: t('categories.mediumVoltage.description'),
img: '/uploads/2024/11/medium-voltage-category.webp', img: '/uploads/2024/11/medium-voltage-category.webp',
icon: '/uploads/2024/11/Medium-Voltage.svg', icon: '/uploads/2024/11/Medium-Voltage.svg',
href: `/${locale}/products/${mediumVoltageSlug}`, href: `/${locale}/${productsSlug}/${mediumVoltageSlug}`,
}, },
{ {
title: t('categories.highVoltage.title'), title: t('categories.highVoltage.title'),
desc: t('categories.highVoltage.description'), desc: t('categories.highVoltage.description'),
img: '/uploads/2024/11/high-voltage-category.webp', img: '/uploads/2024/11/high-voltage-category.webp',
icon: '/uploads/2024/11/High-Voltage.svg', icon: '/uploads/2024/11/High-Voltage.svg',
href: `/${locale}/products/${highVoltageSlug}`, href: `/${locale}/${productsSlug}/${highVoltageSlug}`,
}, },
{ {
title: t('categories.solar.title'), title: t('categories.solar.title'),
desc: t('categories.solar.description'), desc: t('categories.solar.description'),
img: '/uploads/2024/11/solar-category.webp', img: '/uploads/2024/11/solar-category.webp',
icon: '/uploads/2024/11/Solar.svg', icon: '/uploads/2024/11/Solar.svg',
href: `/${locale}/products/${solarSlug}`, href: `/${locale}/${productsSlug}/${solarSlug}`,
}, },
]; ];

View File

@@ -5,7 +5,8 @@ import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs'; 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 t = await getTranslations({ locale, namespace: 'Team' });
const fonts = await getOgFonts(); const fonts = await getOgFonts();
@@ -13,17 +14,10 @@ export default async function Image({ params: { locale } }: { params: { locale:
const description = t('meta.description') || t('hero.title'); const description = t('meta.description') || t('hero.title');
return new ImageResponse( return new ImageResponse(
( <OGImageTemplate title={title} description={description} label="Our Team" />,
<OGImageTemplate
title={title}
description={description}
label="Our Team"
/>
),
{ {
...OG_IMAGE_SIZE, ...OG_IMAGE_SIZE,
fonts, fonts,
} },
); );
} }

View File

@@ -3,7 +3,6 @@ import { Metadata } from 'next';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema'; import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
import { Section, Container, Heading, Badge, Button } from '@/components/ui'; import { Section, Container, Heading, Badge, Button } from '@/components/ui';
import { getOGImageMetadata } from '@/lib/metadata';
import Image from 'next/image'; import Image from 'next/image';
import Reveal from '@/components/Reveal'; import Reveal from '@/components/Reveal';
import Gallery from '@/components/team/Gallery'; import Gallery from '@/components/team/Gallery';
@@ -34,7 +33,6 @@ export async function generateMetadata({ params }: TeamPageProps): Promise<Metad
title: `${title} | KLZ Cables`, title: `${title} | KLZ Cables`,
description, description,
url: `${SITE_URL}/${locale}/team`, url: `${SITE_URL}/${locale}/team`,
images: getOGImageMetadata('team', title, locale),
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',

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

View File

@@ -92,7 +92,7 @@ export default function Footer() {
</li> </li>
<li> <li>
<Link <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" className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
> >
{navT('products')} {navT('products')}

View File

@@ -46,10 +46,22 @@ export default function Header() {
return segments.join('/'); return segments.join('/');
}; };
const [productsSlug, setProductsSlug] = useState('products');
useEffect(() => {
// We can't use mapFileSlugToTranslated directly in client components easily without an API or similar
// For now, let's just check the locale
if (currentLocale === 'de') {
setProductsSlug('produkte');
} else {
setProductsSlug('products');
}
}, [currentLocale]);
const menuItems = [ const menuItems = [
{ label: t('home'), href: '/' }, { label: t('home'), href: '/' },
{ label: t('team'), href: '/team' }, { label: t('team'), href: '/team' },
{ label: t('products'), href: '/products' }, { label: t('products'), href: `/${productsSlug}` },
{ label: t('blog'), href: '/blog' }, { label: t('blog'), href: '/blog' },
]; ];

View File

@@ -13,11 +13,11 @@ interface RelatedProductsProps {
export default async function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) { export default async function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) {
const allProducts = await getAllProducts(locale); const allProducts = await getAllProducts(locale);
const t = await getTranslations('Products'); const t = await getTranslations('Products');
// Filter products: same category, not current product // Filter products: same category, not current product
const related = allProducts const related = allProducts
.filter(p => .filter(p =>
p.slug !== currentSlug && p.slug !== currentSlug &&
p.frontmatter.categories.some(cat => categories.includes(cat)) p.frontmatter.categories.some(cat => categories.includes(cat))
) )
.slice(0, 3); // Limit to 3 for better spacing .slice(0, 3); // Limit to 3 for better spacing
@@ -42,17 +42,19 @@ export default async function RelatedProducts({ currentSlug, categories, locale
const catSlug = categorySlugs.find(slug => { const catSlug = categorySlugs.find(slug => {
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase()); const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const title = t(`categories.${key}.title`); const title = t(`categories.${key}.title`);
return product.frontmatter.categories.some(cat => return product.frontmatter.categories.some(cat =>
cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title
); );
}) || 'low-voltage-cables'; }) || 'low-voltage-cables';
const translatedProductSlug = await mapFileSlugToTranslated(product.slug, locale); const translatedProductSlug = await mapFileSlugToTranslated(product.slug, locale);
const translatedCategorySlug = await mapFileSlugToTranslated(catSlug, locale);
const productsBase = await mapFileSlugToTranslated('products', locale);
return ( return (
<Link <Link
key={product.slug} key={product.slug}
href={`/${locale}/products/${catSlug}/${translatedProductSlug}`} href={`/${locale}/${productsBase}/${translatedCategorySlug}/${translatedProductSlug}`}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5" 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"> <div className="aspect-[16/10] relative bg-neutral-light/30 p-8 overflow-hidden">

View File

@@ -3,12 +3,13 @@
import Scribble from '@/components/Scribble'; import Scribble from '@/components/Scribble';
import { Button, Container, Heading, Section } from '@/components/ui'; import { Button, Container, Heading, Section } from '@/components/ui';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false }); const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
export default function Hero() { export default function Hero() {
const t = useTranslations('Home.hero'); const t = useTranslations('Home.hero');
const locale = useLocale();
return ( 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"> <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">
@@ -66,7 +67,7 @@ export default function Hero() {
</motion.div> </motion.div>
<motion.div variants={buttonVariants}> <motion.div variants={buttonVariants}>
<Button <Button
href="/products" href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
variant="white" variant="white"
size="lg" 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" 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"

View File

@@ -8,34 +8,36 @@ export default function ProductCategories() {
const t = useTranslations('Products'); const t = useTranslations('Products');
const locale = useLocale(); const locale = useLocale();
const productsBase = locale === 'de' ? 'produkte' : 'products';
const categories = [ const categories = [
{ {
title: t('categories.lowVoltage.title'), title: t('categories.lowVoltage.title'),
desc: t('categories.lowVoltage.description'), desc: t('categories.lowVoltage.description'),
img: '/uploads/2024/11/low-voltage-category.webp', img: '/uploads/2024/11/low-voltage-category.webp',
icon: '/uploads/2024/11/Low-Voltage.svg', 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'), title: t('categories.mediumVoltage.title'),
desc: t('categories.mediumVoltage.description'), desc: t('categories.mediumVoltage.description'),
img: '/uploads/2024/11/medium-voltage-category.webp', img: '/uploads/2024/11/medium-voltage-category.webp',
icon: '/uploads/2024/11/Medium-Voltage.svg', 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'), title: t('categories.highVoltage.title'),
desc: t('categories.highVoltage.description'), desc: t('categories.highVoltage.description'),
img: '/uploads/2024/11/high-voltage-category.webp', img: '/uploads/2024/11/high-voltage-category.webp',
icon: '/uploads/2024/11/High-Voltage.svg', 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'), title: t('categories.solar.title'),
desc: t('categories.solar.description'), desc: t('categories.solar.description'),
img: '/uploads/2024/11/solar-category.webp', img: '/uploads/2024/11/solar-category.webp',
icon: '/uploads/2024/11/Solar.svg', icon: '/uploads/2024/11/Solar.svg',
href: `/${locale}/products/solar-cables`, href: `/${locale}/${productsBase}/${locale === 'de' ? 'solarkabel' : 'solar-cables'}`,
}, },
]; ];

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

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

View File

@@ -0,0 +1,369 @@
'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;
}
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,
};
}
return context;
}
export function RecordModeProvider({ children }: { children: React.ReactNode }) {
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(() => {
const embedded =
typeof window !== 'undefined' &&
(window.location.search.includes('embedded=true') ||
window.name === 'record-mode-iframe' ||
window.self !== window.top);
setIsEmbedded(embedded);
}, []);
const setIsActive = (active: boolean) => {
setIsActiveState(active);
if (active) setIsFeedbackActiveState(false);
};
const setIsFeedbackActive = (active: boolean) => {
setIsFeedbackActiveState(active);
if (active) setIsActiveState(false);
};
const isPlayingRef = useRef(false);
const isLoadedRef = useRef(false);
useEffect(() => {
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;
}, []);
useEffect(() => {
if (!isLoadedRef.current) return;
localStorage.setItem('klz-record-events', JSON.stringify(events));
}, [events]);
useEffect(() => {
localStorage.setItem('klz-record-active', JSON.stringify(isActive));
}, [isActive]);
useEffect(() => {
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]);
useEffect(() => {
if (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]);
const addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
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>) => {
setEvents((prev) =>
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event)),
);
};
const reorderEvents = (startIndex: number, endIndex: number) => {
const result = Array.from(events);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
setEvents(result);
};
const removeEvent = (id: string) => {
setEvents((prev) => prev.filter((event) => event.id !== id));
};
const clearEvents = () => {
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) => {
console.log('Saving session:', name, events);
};
const playEvents = async () => {
if (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 {
// Self-execution logic for guest
const el = document.querySelector(event.selector) as HTMLElement;
if (el) {
if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' });
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,
}}
>
{children}
</RecordModeContext.Provider>
);
}

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

View File

@@ -0,0 +1,189 @@
'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 jsx global>{`
@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; }
`}</style>
</div>
</>
);
}

View File

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

View 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: []

View File

@@ -6,51 +6,474 @@ collections:
meta: meta:
accountability: all accountability: all
archive_app_filter: true archive_app_filter: true
archive_field: null
archive_value: null
collapse: open collapse: open
collection: contact_submissions collection: contact_submissions
color: '#002b49' color: '#002b49'
display_template: '{{first_name}} {{last_name}} | {{subject}}' display_template: '{{name}} | {{email}}'
group: null
hidden: false hidden: false
icon: contact_mail icon: contact_mail
item_duplication_fields: null
note: null
preview_url: null
singleton: false singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema: schema:
name: contact_submissions name: contact_submissions
- collection: product_requests - collection: product_requests
meta: meta:
accountability: all accountability: all
archive_app_filter: true archive_app_filter: true
archive_field: null
archive_value: null
collapse: open collapse: open
collection: product_requests collection: product_requests
color: '#002b49' color: '#002b49'
display_template: null display_template: '{{product_name}} | {{email}}'
group: null
hidden: false hidden: false
icon: inventory icon: inventory
item_duplication_fields: null
note: null
preview_url: null
singleton: false singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema: schema:
name: product_requests 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
- collection: visual_feedback
meta:
accountability: all
archive_app_filter: true
collapse: open
collection: visual_feedback
color: '#002b49'
display_template: '{{user_name}} | {{type}}: {{text}}'
hidden: false
icon: feedback
singleton: false
versioning: false
schema:
name: visual_feedback
- collection: visual_feedback_comments
meta:
accountability: all
archive_app_filter: true
collapse: open
collection: visual_feedback_comments
color: '#002b49'
display_template: '{{user_name}}: {{text}}'
hidden: false
icon: comment
singleton: false
versioning: false
schema:
name: visual_feedback_comments
fields:
# contact_submissions
- collection: contact_submissions
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
# visual_feedback (from current snapshot)
- collection: visual_feedback
field: id
type: uuid
meta:
collection: visual_feedback
field: id
hidden: true
sort: 1
schema:
name: id
table: visual_feedback
data_type: uuid
is_nullable: false
is_primary_key: true
- collection: visual_feedback
field: status
type: string
meta:
collection: visual_feedback
display: labels
interface: select-dropdown
sort: 2
schema:
name: status
table: visual_feedback
data_type: character varying
default_value: open
is_nullable: true
- collection: visual_feedback
field: type
type: string
meta:
collection: visual_feedback
display: labels
interface: select-dropdown
sort: 3
schema:
name: type
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: text
type: text
meta:
collection: visual_feedback
interface: input-multiline
sort: 4
schema:
name: text
table: visual_feedback
data_type: text
is_nullable: true
- collection: visual_feedback
field: url
type: string
meta:
collection: visual_feedback
interface: input
readonly: true
sort: 5
schema:
name: url
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: user_info_group
type: alias
meta:
collection: visual_feedback
field: user_info_group
interface: group-detail
sort: 6
special:
- alias
- no-data
- group
- collection: visual_feedback
field: user_name
type: string
meta:
collection: visual_feedback
field: user_name
group: user_info_group
interface: input
sort: 1
schema:
name: user_name
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: user_identity
type: string
meta:
collection: visual_feedback
field: user_identity
group: user_info_group
interface: input
readonly: true
sort: 2
schema:
name: user_identity
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: technical_details_group
type: alias
meta:
collection: visual_feedback
field: technical_details_group
interface: group-detail
sort: 7
special:
- alias
- no-data
- group
- collection: visual_feedback
field: selector
type: string
meta:
collection: visual_feedback
field: selector
group: technical_details_group
interface: input
readonly: true
sort: 1
schema:
name: selector
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: x
type: float
meta:
collection: visual_feedback
field: x
group: technical_details_group
interface: input
sort: 2
schema:
name: x
table: visual_feedback
data_type: real
is_nullable: true
- collection: visual_feedback
field: 'y'
type: float
meta:
collection: visual_feedback
field: 'y'
group: technical_details_group
interface: input
sort: 3
schema:
name: 'y'
table: visual_feedback
data_type: real
is_nullable: true
- collection: visual_feedback
field: date_created
type: timestamp
meta:
collection: visual_feedback
interface: datetime
readonly: true
sort: 8
schema:
name: date_created
table: visual_feedback
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
is_nullable: true
- collection: visual_feedback_comments
field: id
type: uuid
meta:
collection: visual_feedback_comments
field: id
hidden: true
schema:
name: id
table: visual_feedback_comments
data_type: uuid
is_primary_key: true
- collection: visual_feedback_comments
field: feedback_id
type: uuid
meta:
collection: visual_feedback_comments
field: feedback_id
interface: select-relational
sort: 2
schema:
name: feedback_id
table: visual_feedback_comments
data_type: uuid
- collection: visual_feedback_comments
field: user_name
type: string
meta:
collection: visual_feedback_comments
field: user_name
interface: input
sort: 3
schema:
name: user_name
table: visual_feedback_comments
data_type: character varying
- collection: visual_feedback_comments
field: text
type: text
meta:
collection: visual_feedback_comments
field: text
interface: input-multiline
sort: 4
schema:
name: text
table: visual_feedback_comments
data_type: text
- collection: visual_feedback_comments
field: date_created
type: timestamp
meta:
collection: visual_feedback_comments
interface: datetime
readonly: true
sort: 5
schema:
name: date_created
table: visual_feedback_comments
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
systemFields: systemFields:
- collection: directus_activity - collection: directus_activity
field: timestamp field: timestamp
@@ -64,4 +487,18 @@ systemFields:
field: parent field: parent
schema: schema:
is_indexed: true is_indexed: true
relations: []
relations:
- collection: visual_feedback_comments
field: feedback_id
related_collection: visual_feedback
schema:
column: feedback_id
foreign_key_column: id
foreign_key_table: visual_feedback
table: visual_feedback_comments
meta:
many_collection: visual_feedback_comments
many_field: feedback_id
one_collection: visual_feedback
one_field: null

View File

@@ -0,0 +1,43 @@
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"
directus-cms:
container_name: klz-cms-dev
restart: "no"
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"
gatekeeper:
restart: "no"

View File

@@ -1,7 +1,13 @@
services: services:
klz-app: 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} image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
restart: always restart: unless-stopped
networks: networks:
- default - default
- infra - infra
@@ -10,24 +16,25 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# HTTP ⇒ HTTPS redirect # 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.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
# HTTPS router (Standard) # 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}.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=websecure" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=true" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}.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-cables}.middlewares=${AUTH_MIDDLEWARE:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
# HTTPS router (Unprotected - for Analytics & Errors) # Public Router (Whitelist for OG Images, Sitemaps, Health)
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)} && PathPrefix(`/stats`, `/errors`)" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`, `/sitemap.xml`, `/robots.txt`, `/manifest.webmanifest`, `/api/og`) || PathRegexp(`.*opengraph-image.*`) || PathRegexp(`.*sitemap.*`))"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.entrypoints=websecure" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls.certresolver=le" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls=true" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.service=${PROJECT_NAME:-klz-cables}" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.service=${PROJECT_NAME:-klz-cables}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.priority=2000"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=3000" - "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=3000"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http" - "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
@@ -46,20 +53,20 @@ services:
- "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.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-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
# Middleware Definitions # Rate Limit Middleware
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100" - "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50" - "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
healthcheck: healthcheck:
test: [ "CMD", "curl", "-f", "http://127.0.0.1:3000/" ] test: [ "CMD", "curl", "-f", "http://127.0.0.1:3000/health" ]
interval: 10s interval: 15s
timeout: 5s timeout: 10s
retries: 5 retries: 3
start_period: 30s start_period: 45s
gatekeeper: gatekeeper:
profiles: [ "gatekeeper" ] profiles: [ "gatekeeper" ]
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12 image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
restart: always restart: unless-stopped
networks: networks:
infra: infra:
aliases: aliases:
@@ -78,21 +85,17 @@ services:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=infra" - "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.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.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=true" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper" - "traefik.http.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.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
- "traefik.docker.network=infra" - "traefik.docker.network=infra"
directus: directus-cms:
image: directus/directus:11 image: registry.infra.mintel.me/mintel/directus:latest
restart: always restart: unless-stopped
networks: command: [ "node", "cli.js", "start" ]
default:
infra:
aliases:
- ${PROJECT_NAME:-klz-cables}-directus
env_file: env_file:
- ${ENV_FILE:-.env} - ${ENV_FILE:-.env}
environment: environment:
@@ -101,37 +104,35 @@ services:
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL} ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD} ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
DB_CLIENT: 'pg' DB_CLIENT: 'pg'
DB_HOST: 'directus-db' DB_HOST: 'klz-db'
DB_PORT: '5432' DB_PORT: '5432'
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus} DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus} DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus} DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
WEBSOCKETS_ENABLED: 'true' WEBSOCKETS_ENABLED: 'true'
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com} PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
# Error Tracking HOST: '0.0.0.0'
SENTRY_DSN: ${SENTRY_DSN} networks:
SENTRY_ENVIRONMENT: ${TARGET:-development} - infra
LOGGER_LEVEL: ${LOG_LEVEL:-info}
volumes: volumes:
- ./directus/uploads:/directus/uploads - ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions - ./directus/extensions:/directus/extensions
- ./directus/schema:/directus/schema - ./directus/schema:/directus/schema
- ./directus/migrations:/directus/migrations - ./directus/migrations:/directus/migrations
healthcheck:
disable: true
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(`${DIRECTUS_HOST}`)" - "traefik.http.routers.klz-production-cms.rule=Host(`cms.klz.localhost`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.entrypoints=websecure" - "traefik.http.routers.klz-production-cms.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls.certresolver=le" - "traefik.http.routers.klz-production-cms.priority=5000"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls=true" - "traefik.http.routers.klz-production-cms.tls=false"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.middlewares=${PROJECT_NAME:-klz-cables}-forward,compress" - "traefik.http.routers.klz-production-cms.service=klz-production-cms-svc"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-directus.loadbalancer.server.port=8055" - "traefik.http.services.klz-production-cms-svc.loadbalancer.server.port=8055"
- "traefik.docker.network=infra" - "traefik.docker.network=infra"
klz-db:
directus-db:
image: postgres:15-alpine image: postgres:15-alpine
restart: always restart: unless-stopped
networks:
- default
env_file: env_file:
- ${ENV_FILE:-.env} - ${ENV_FILE:-.env}
environment: environment:
@@ -140,6 +141,8 @@ services:
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus} POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
volumes: volumes:
- directus-db-data:/var/lib/postgresql/data - directus-db-data:/var/lib/postgresql/data
networks:
- infra
networks: networks:
default: default:

View File

@@ -50,7 +50,7 @@ function createConfig() {
}, },
logging: { logging: {
level: env.LOG_LEVEL, level: env.LOG_LEVEL || 'info',
}, },
mail: { mail: {

View File

@@ -22,6 +22,24 @@ const envExtension = {
INFRA_DIRECTUS_URL: z.string().url().optional(), INFRA_DIRECTUS_URL: z.string().url().optional(),
INFRA_DIRECTUS_TOKEN: z.string().optional(), INFRA_DIRECTUS_TOKEN: z.string().optional(),
// 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(),
}; };
/** /**

View File

@@ -6,37 +6,41 @@ import { join } from 'path';
* Since we are using runtime = 'nodejs', we can read them from the filesystem. * Since we are using runtime = 'nodejs', we can read them from the filesystem.
*/ */
export async function getOgFonts() { export async function getOgFonts() {
const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.ttf'); const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.woff');
const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.ttf'); const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.woff');
try { try {
const boldFont = readFileSync(boldFontPath); console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
const regularFont = readFileSync(regularFontPath); const boldFont = readFileSync(boldFontPath);
const regularFont = readFileSync(regularFontPath);
console.log(
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
);
return [ return [
{ {
name: 'Inter', name: 'Inter',
data: boldFont, data: boldFont,
weight: 700 as const, weight: 700 as const,
style: 'normal' as const, style: 'normal' as const,
}, },
{ {
name: 'Inter', name: 'Inter',
data: regularFont, data: regularFont,
weight: 400 as const, weight: 400 as const,
style: 'normal' as const, style: 'normal' as const,
}, },
]; ];
} catch (error) { } catch (error) {
console.error('Failed to load OG fonts from filesystem, falling back to system fonts:', error); console.error(`[OG] Failed to load fonts from ${process.cwd()}:`, error);
return []; return [];
} }
} }
/** /**
* Common configuration for OG images * Common configuration for OG images
*/ */
export const OG_IMAGE_SIZE = { export const OG_IMAGE_SIZE = {
width: 1200, width: 1200,
height: 630, height: 630,
}; };

View File

@@ -207,6 +207,7 @@
"description": "Entdecken Sie unser umfassendes Sortiment an zertifizierten Kabeln: von Niederspannung über Mittel- und Hochspannung bis hin zu spezialisierten Solarkabeln." "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>", "title": "Unsere <green>Produkte</green>",
"breadcrumb": "Produkte",
"subtitle": "Entdecken Sie unser umfassendes Sortiment an hochwertigen Kabeln für jede Anwendung.", "subtitle": "Entdecken Sie unser umfassendes Sortiment an hochwertigen Kabeln für jede Anwendung.",
"heroSubtitle": "Produktportfolio", "heroSubtitle": "Produktportfolio",
"categoryLabel": "Kategorie", "categoryLabel": "Kategorie",
@@ -393,4 +394,4 @@
"cta": "Zurück zur Sicherheit" "cta": "Zurück zur Sicherheit"
} }
} }
} }

View File

@@ -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." "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>", "title": "Our <green>Products</green>",
"breadcrumb": "Products",
"subtitle": "Explore our comprehensive range of high-quality cables designed for every application.", "subtitle": "Explore our comprehensive range of high-quality cables designed for every application.",
"heroSubtitle": "Product Portfolio", "heroSubtitle": "Product Portfolio",
"categoryLabel": "Category", "categoryLabel": "Category",
@@ -393,4 +394,4 @@
"cta": "Back to Safety" "cta": "Back to Safety"
} }
} }
} }

View File

@@ -12,6 +12,18 @@ const intlMiddleware = createMiddleware({
export default function middleware(request: NextRequest) { export default function middleware(request: NextRequest) {
const { method, url, headers } = request; 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.startsWith('/api/og') ||
pathname.includes('opengraph-image')
) {
return;
}
// Build header object for logging // Build header object for logging
const headerObj: Record<string, string> = {}; const headerObj: Record<string, string> = {};
@@ -62,5 +74,5 @@ export default function middleware(request: NextRequest) {
export const config = { export const config = {
// Match only internationalized pathnames // Match only internationalized pathnames
matcher: ['/((?!api|_next|_vercel|stats|errors|health|.*\\..*).*)', '/', '/(de|en)/:path*'], matcher: ['/((?!api|_next|_vercel|.*\\..*).*)', '/', '/(de|en)/:path*'],
}; };

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <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 // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -322,6 +322,15 @@ const nextConfig = {
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
}, },
async rewrites() { async rewrites() {
const umamiUrl =
process.env.UMAMI_API_ENDPOINT ||
process.env.UMAMI_SCRIPT_URL ||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ||
'https://analytics.infra.mintel.me';
const glitchtipUrl = process.env.SENTRY_DSN
? new URL(process.env.SENTRY_DSN).origin
: 'https://errors.infra.mintel.me';
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com'; const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
return [ return [

View File

@@ -4,9 +4,10 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@directus/sdk": "^21.0.0", "@directus/sdk": "^21.0.0",
"@mintel/mail": "1.7.12", "@medv/finder": "^4.0.2",
"@mintel/next-config": "1.7.12", "@mintel/mail": "1.8.3",
"@mintel/next-feedback": "1.7.12", "@mintel/next-config": "1.8.3",
"@mintel/next-feedback": "1.8.10",
"@mintel/next-utils": "^1.7.15", "@mintel/next-utils": "^1.7.15",
"@react-email/components": "^1.0.7", "@react-email/components": "^1.0.7",
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
@@ -19,7 +20,6 @@
"import-in-the-middle": "^1.11.0", "import-in-the-middle": "^1.11.0",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.562.0",
"next": "16.1.6", "next": "16.1.6",
"next-i18next": "^15.4.3", "next-i18next": "^15.4.3",
"next-intl": "^4.8.2", "next-intl": "^4.8.2",
@@ -45,8 +45,12 @@
"@commitlint/cli": "^20.4.0", "@commitlint/cli": "^20.4.0",
"@commitlint/config-conventional": "^20.4.0", "@commitlint/config-conventional": "^20.4.0",
"@lhci/cli": "^0.15.1", "@lhci/cli": "^0.15.1",
"@mintel/eslint-config": "1.7.12", "@mintel/eslint-config": "1.8.3",
"@mintel/tsconfig": "1.7.12", "@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/cli": "^4.1.18",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
@@ -63,8 +67,10 @@
"happy-dom": "^20.6.1", "happy-dom": "^20.6.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"lucide-react": "^0.563.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"remotion": "^4.0.421",
"sass": "^1.97.1", "sass": "^1.97.1",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tsx": "^4.21.0", "tsx": "^4.21.0",
@@ -72,7 +78,8 @@
"vitest": "^4.0.16" "vitest": "^4.0.16"
}, },
"scripts": { "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 directus-cms klz-db gatekeeper",
"dev:infra": "docker network create infra 2>/dev/null || true && docker-compose up -d directus-cms klz-db gatekeeper",
"dev:local": "next dev", "dev:local": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
@@ -80,6 +87,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"test:og": "vitest run tests/og-image.test.ts", "test:og": "vitest run tests/og-image.test.ts",
"check:og": "tsx scripts/check-og-images.ts",
"cms:branding:local": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts", "cms:branding:local": "DIRECTUS_URL=http://localhost:8055 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: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:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
@@ -100,6 +108,8 @@
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production", "cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts", "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')))\"", "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", "prepare": "husky",
"preinstall": "npx only-allow pnpm" "preinstall": "npx only-allow pnpm"
}, },
@@ -108,5 +118,13 @@
"overrides": { "overrides": {
"next": "16.1.6" "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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

32
remotion/Root.tsx Normal file
View 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'
}}
/>
</>
);
};

107
remotion/WebsiteVideo.tsx Normal file
View File

@@ -0,0 +1,107 @@
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();
if (!session || !session.events.length) {
return (
<AbsoluteFill style={{ backgroundColor: 'black', color: 'white', justifyContent: 'center', alignItems: 'center' }}>
No session data found.
</AbsoluteFill>
);
}
const sortedEvents = useMemo(() => {
return [...session.events].sort((a, b) => a.timestamp - b.timestamp);
}, [session]);
const elapsedTimeMs = (frame / fps) * 1000;
// --- Interpolation Logic ---
// 1. Find the current window (between which two events are we?)
const nextEventIndex = sortedEvents.findIndex(e => e.timestamp > elapsedTimeMs);
let currentEventIndex;
if (nextEventIndex === -1) {
// We are past the last event, stay at the end
currentEventIndex = sortedEvents.length - 1;
} else {
currentEventIndex = Math.max(0, nextEventIndex - 1);
}
const currentEvent = sortedEvents[currentEventIndex];
// If there is no next event, we just stay at current (next=current)
const nextEvent = (nextEventIndex !== -1) ? sortedEvents[nextEventIndex] : currentEvent;
// 2. Calculate Progress between events
const gap = nextEvent.timestamp - currentEvent.timestamp;
const progress = gap > 0 ? (elapsedTimeMs - currentEvent.timestamp) / gap : 1;
const easedProgress = Easing.cubic(Math.min(Math.max(progress, 0), 1));
// 3. Calculate Cursor Position from Rects
const getCenter = (event: RecordEvent) => {
if (event.rect) {
return {
x: event.rect.x + event.rect.width / 2,
y: event.rect.y + event.rect.height / 2
};
}
return { x: width / 2, y: height / 2 };
};
const p1 = getCenter(currentEvent);
const p2 = getCenter(nextEvent);
const cursorX = interpolate(easedProgress, [0, 1], [p1.x, p2.x]);
const cursorY = interpolate(easedProgress, [0, 1], [p1.y, p2.y]);
// 4. Zoom & Blur
const zoom = interpolate(easedProgress, [0, 1], [currentEvent.zoom || 1, nextEvent.zoom || 1]);
const isBlurry = currentEvent.motionBlur || nextEvent.motionBlur;
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
View File

@@ -0,0 +1,4 @@
import { registerRoot } from 'remotion';
import { RemotionRoot } from './Root';
registerRoot(RemotionRoot);

35
remotion/session.json Normal file
View 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
}
}
]
}

View File

@@ -0,0 +1,74 @@
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}...`);
if (response.status !== 200) {
throw new Error(`Status: ${response.status}`);
}
const contentType = response.headers.get('content-type');
if (!contentType?.includes('image/png')) {
const body = await response.text();
console.log(` Headers: ${JSON.stringify(Object.fromEntries(response.headers))}`);
throw new Error(
`Content-Type: ${contentType}. Body preview: ${body.substring(0, 500).replace(/\n/g, ' ')}...`,
);
}
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();

View File

@@ -9,7 +9,7 @@ if [ -z "$ENV" ]; then
exit 1 exit 1
fi 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 case $ENV in
local) local)
@@ -25,7 +25,10 @@ case $ENV in
case $ENV in case $ENV in
testing) PROJECT_NAME="${PRJ_ID}-testing" ;; testing) PROJECT_NAME="${PRJ_ID}-testing" ;;
staging) PROJECT_NAME="${PRJ_ID}-staging" ;; 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 esac
echo "📤 Uploading snapshot to $ENV..." echo "📤 Uploading snapshot to $ENV..."
@@ -34,8 +37,16 @@ case $ENV in
echo "🔍 Detecting remote container..." echo "🔍 Detecting remote container..."
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus") 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 if [ -z "$REMOTE_CONTAINER" ]; then
echo "❌ Remote container for $ENV not found." echo "❌ Remote container for $ENV not found (checked $PROJECT_NAME)."
exit 1 exit 1
fi fi

View File

@@ -5,7 +5,7 @@ REMOTE_HOST="root@alpha.mintel.me"
REMOTE_DIR="/home/deploy/sites/klz-cables.com" REMOTE_DIR="/home/deploy/sites/klz-cables.com"
# DB Details (matching docker-compose defaults) # DB Details (matching docker-compose defaults)
DB_USER="directus" DB_USER="klz_db_user"
DB_NAME="directus" DB_NAME="directus"
ACTION=$1 ACTION=$1
@@ -49,9 +49,9 @@ esac
# Detect local container # Detect local container
echo "🔍 Detecting local database..." echo "🔍 Detecting local database..."
# Use a more robust way to find the container if multiple projects exist locally # 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-directus-db)
if [ -z "$LOCAL_DB_CONTAINER" ]; then 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 exit 1
fi fi

View File

@@ -7,16 +7,19 @@
--font-heading: 'Inter', system-ui, sans-serif; --font-heading: 'Inter', system-ui, sans-serif;
--font-body: '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-dark: #000d26;
--color-primary-light: #e6ebf5; --color-primary-light: #e6ebf5;
--color-saturated: #011dff; /* Saturated Blue Accent */ --color-saturated: #011dff;
/* Saturated Blue Accent */
--color-secondary: #003d82; --color-secondary: #003d82;
--color-secondary-light: #0056b3; --color-secondary-light: #0056b3;
--color-accent: #82ed20; /* Sustainability Green */ --color-accent: #82ed20;
/* Sustainability Green */
--color-accent-dark: #6bc41a; --color-accent-dark: #6bc41a;
--color-accent-light: #f0f9e6; --color-accent-light: #f0f9e6;
@@ -40,76 +43,107 @@
--animate-slide-up: slide-up 0.6s ease-out; --animate-slide-up: slide-up 0.6s ease-out;
--animate-slow-zoom: slow-zoom 20s linear infinite; --animate-slow-zoom: slow-zoom 20s linear infinite;
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; --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 --animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-gradient-x: gradient-x 15s ease infinite; --animate-gradient-x: gradient-x 15s ease infinite;
@keyframes gradient-x { @keyframes gradient-x {
0%, 0%,
100% { 100% {
background-position: 0% 50%; background-position: 0% 50%;
} }
50% { 50% {
background-position: 100% 50%; background-position: 100% 50%;
} }
} }
@keyframes fade-in { @keyframes fade-in {
from { from {
opacity: 0; opacity: 0;
} }
to { to {
opacity: 1; opacity: 1;
} }
} }
@keyframes slide-up { @keyframes slide-up {
from { from {
opacity: 0; opacity: 0;
transform: translateY(20px); transform: translateY(20px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
@keyframes slow-zoom { @keyframes slow-zoom {
from { from {
transform: scale(1); transform: scale(1);
} }
to { to {
transform: scale(1.1); transform: scale(1.1);
} }
} }
@keyframes reveal { @keyframes reveal {
from { from {
opacity: 0; opacity: 0;
transform: translateY(20px); transform: translateY(20px);
filter: blur(8px); filter: blur(8px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
filter: blur(0); filter: blur(0);
} }
} }
@keyframes slight-fade-in-from-bottom { @keyframes slight-fade-in-from-bottom {
from { from {
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(10px);
filter: blur(4px); filter: blur(4px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
filter: blur(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 { @layer base {
.bg-primary a, .bg-primary a,
.bg-primary-dark a { .bg-primary-dark a {
@apply text-white/90 hover:text-white transition-colors; @apply text-white/90 hover:text-white transition-colors;
} }
body { body {
@apply text-base md:text-lg antialiased; @apply text-base md:text-lg antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@@ -132,18 +166,23 @@
h1 { h1 {
@apply text-3xl md:text-5xl lg:text-6xl leading-[1.1]; @apply text-3xl md:text-5xl lg:text-6xl leading-[1.1];
} }
h2 { h2 {
@apply text-2xl md:text-4xl lg:text-5xl leading-[1.2]; @apply text-2xl md:text-4xl lg:text-5xl leading-[1.2];
} }
h3 { h3 {
@apply text-xl md:text-2xl lg:text-3xl leading-[1.3]; @apply text-xl md:text-2xl lg:text-3xl leading-[1.3];
} }
h4 { h4 {
@apply text-lg md:text-xl lg:text-2xl leading-[1.4]; @apply text-lg md:text-xl lg:text-2xl leading-[1.4];
} }
h5 { h5 {
@apply text-base md:text-lg leading-[1.5]; @apply text-base md:text-lg leading-[1.5];
} }
h6 { h6 {
@apply text-sm md:text-base leading-[1.6]; @apply text-sm md:text-base leading-[1.6];
} }
@@ -202,18 +241,23 @@
.glass-panel { .glass-panel {
@apply bg-white/80 backdrop-blur-md border border-white/20 shadow-lg; @apply bg-white/80 backdrop-blur-md border border-white/20 shadow-lg;
} }
.image-overlay-gradient { .image-overlay-gradient {
@apply absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent; @apply absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent;
} }
.premium-card { .premium-card {
@apply bg-white rounded-3xl shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1; @apply bg-white rounded-3xl shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1;
} }
.sticky-narrative-container { .sticky-narrative-container {
@apply grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20; @apply grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20;
} }
.sticky-narrative-sidebar { .sticky-narrative-sidebar {
@apply lg:col-span-4 lg:sticky lg:top-32 h-fit; @apply lg:col-span-4 lg:sticky lg:top-32 h-fit;
} }
.sticky-narrative-content { .sticky-narrative-content {
@apply lg:col-span-8; @apply lg:col-span-8;
} }
@@ -221,7 +265,8 @@
/* Custom Utilities */ /* Custom Utilities */
@utility touch-target { @utility touch-target {
min-height: 48px; /* Increased for better touch-first feel */ min-height: 48px;
/* Increased for better touch-first feel */
min-width: 48px; min-width: 48px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -276,4 +321,4 @@
@utility content-visibility-auto { @utility content-visibility-auto {
content-visibility: auto; content-visibility: auto;
contain-intrinsic-size: 1px 1000px; contain-intrinsic-size: 1px 1000px;
} }

0
traefik_dump.json Normal file
View File

View File

@@ -3,10 +3,18 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./*"], "@/*": [
"lib/*": ["./lib/*"], "./*"
"components/*": ["./components/*"], ],
"data/*": ["./data/*"] "lib/*": [
"./lib/*"
],
"components/*": [
"./components/*"
],
"data/*": [
"./data/*"
]
} }
}, },
"include": [ "include": [
@@ -17,5 +25,11 @@
"tests/**/*.test.ts", "tests/**/*.test.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": ["node_modules", "scripts", "reference", "data"] "exclude": [
} "node_modules",
"scripts",
"reference",
"data",
"remotion"
]
}

21
types/record-mode.ts Normal file
View 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;
}