Compare commits
94 Commits
production
...
v1.0.9
| Author | SHA1 | Date | |
|---|---|---|---|
| d10f15abe3 | |||
| 9bdbcc2803 | |||
| b08f07494c | |||
| 1f758758e3 | |||
| fb8d9574b6 | |||
| 6856b7835c | |||
| 1d074ba6d2 | |||
| 0e972983bc | |||
| c979582193 | |||
| e47ba31763 | |||
| 28072908f7 | |||
| 7e6b4a3ed7 | |||
| d7e5a57344 | |||
| c859d5e677 | |||
| e036dea089 | |||
| 39088ca868 | |||
| 18f9104623 | |||
| 76f745cc87 | |||
| 848d58010f | |||
| c0f5799667 | |||
| 0e089f9471 | |||
| 52b17423dd | |||
| bfd3c8164b | |||
| b091175b89 | |||
| 1baf03a84e | |||
| 483dfabe10 | |||
| 65f8b2c485 | |||
| 90cdd7e713 | |||
| 40fa2a7721 | |||
| a136e7b4a7 | |||
| e615d88fd8 | |||
| 3d498f3df8 | |||
| d9a7cf6a77 | |||
| cd7be080d7 | |||
| 4e602da15d | |||
| e47982d394 | |||
| 877108020b | |||
| 0fff5ae52a | |||
| 459716d09c | |||
| a0d4023f89 | |||
| 9746416146 | |||
| fc9746335d | |||
| 4058abab13 | |||
| 6074747b34 | |||
| 319b2b3e0c | |||
| d7f5504149 | |||
| 0f705b474b | |||
| 67046b9301 | |||
| 0b6211cf5f | |||
| c7f2c3fdfe | |||
| f30c93ffce | |||
| 3e6bbe9a93 | |||
| c6cbb02dfa | |||
| bec1916ccc | |||
| ab17e9e758 | |||
| f257e5428f | |||
| 797411ccc3 | |||
| 94a609e438 | |||
| 409ac3fea7 | |||
| b3876666c8 | |||
| bd1a61e9cd | |||
| f2ce9ec262 | |||
| eddfa3a1f1 | |||
| 1e77914314 | |||
| 52dfbb3870 | |||
| 72e85b99ee | |||
| c7807610f6 | |||
| 81f0dd88a6 | |||
| 458e467a14 | |||
| 060118202f | |||
| 64af78a984 | |||
| f6d7584613 | |||
| 2192f37fee | |||
| 8a9cd7ef3e | |||
| 406cf22050 | |||
| 5e82d6edc9 | |||
| 85375eefb0 | |||
| fe829b0c4c | |||
| 9ed08004af | |||
| fa6f27114b | |||
| a60e8af26b | |||
| c111efae1a | |||
| a12759d507 | |||
| eefabfa3ff | |||
| 86d28796a7 | |||
| bb9424d482 | |||
| b1515155b7 | |||
| 65d54ae789 | |||
| dc21d480ab | |||
| 51043da882 | |||
| 4a31cddf11 | |||
| 1b999510db | |||
| 0d852db651 | |||
| f3ff9cd364 |
11
.env
11
.env
@@ -1,10 +1,12 @@
|
|||||||
# Application
|
# Application
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
|
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
|
||||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
NEXT_PUBLIC_FEEDBACK_ENABLED=true
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
|
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
||||||
|
|
||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
MAIL_HOST=smtp.eu.mailgun.org
|
MAIL_HOST=smtp.eu.mailgun.org
|
||||||
@@ -15,15 +17,15 @@ MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
|||||||
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
||||||
|
|
||||||
# Directus
|
# Directus
|
||||||
DIRECTUS_URL=https://cms.klz-cables.com
|
DIRECTUS_URL=http://klz-cms:8055
|
||||||
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
|
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
||||||
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
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 +35,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
|
||||||
@@ -15,6 +15,7 @@ DIRECTUS_PORT=8055
|
|||||||
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||||
TARGET=development
|
TARGET=development
|
||||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
|
NEXT_PUBLIC_RECORD_MODE_ENABLED=true
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
|
|||||||
@@ -32,9 +32,5 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
- name: 🧪 Parallel Checks
|
- name: 🧪 QA Checks
|
||||||
run: |
|
run: pnpm lint && pnpm typecheck && pnpm test
|
||||||
pnpm lint &
|
|
||||||
pnpm typecheck &
|
|
||||||
pnpm test &
|
|
||||||
wait
|
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ jobs:
|
|||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: 🧹 Maintenance (High Density Cleanup)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "Purging old build layers and dangling images..."
|
||||||
|
docker image prune -f
|
||||||
|
docker builder prune -f --filter "until=6h"
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -78,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
|
||||||
|
|
||||||
@@ -93,10 +100,47 @@ jobs:
|
|||||||
echo "traefik_rule=$TRAEFIK_RULE"
|
echo "traefik_rule=$TRAEFIK_RULE"
|
||||||
echo "next_public_url=https://$PRIMARY_HOST"
|
echo "next_public_url=https://$PRIMARY_HOST"
|
||||||
echo "directus_url=https://cms.$PRIMARY_HOST"
|
echo "directus_url=https://cms.$PRIMARY_HOST"
|
||||||
echo "project_name=$PRJ-$TARGET"
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
|
echo "project_name=klz-cablescom"
|
||||||
|
else
|
||||||
|
echo "project_name=$PRJ-$TARGET"
|
||||||
|
fi
|
||||||
echo "short_sha=$SHORT_SHA"
|
echo "short_sha=$SHORT_SHA"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||||
|
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
|
echo "🔎 Checking for @mintel dependencies in package.json..."
|
||||||
|
# Extract any @mintel/ version (they should be synced in monorepo)
|
||||||
|
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | grep -v "next-utils" | cut -d'"' -f4 | sed 's/\^//; s/\~//' | sort -V | tail -1)
|
||||||
|
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||||
|
|
||||||
|
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||||
|
# 1. Discovery (Works without token for public repositories)
|
||||||
|
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
|
||||||
|
|
||||||
|
if [[ -z "$UPSTREAM_SHA" ]]; then
|
||||||
|
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||||
|
|
||||||
|
# 2. Status Check (Requires GITEA_PAT for cross-repo API access)
|
||||||
|
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
|
||||||
|
|
||||||
|
if [[ -n "$POLL_TOKEN" ]]; then
|
||||||
|
echo "⏳ GITEA_PAT found. Checking upstream build status..."
|
||||||
|
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
|
||||||
|
chmod +x wait-for-upstream.sh
|
||||||
|
GITEA_TOKEN="$POLL_TOKEN" ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
|
||||||
|
else
|
||||||
|
echo "ℹ️ No GITEA_PAT secret found. Skipping build status wait (Actions API is restricted)."
|
||||||
|
echo " If this build fails, ensure that mmintel/at-mintel $TAG_TO_WAIT has finished its Docker build."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 2: QA (Lint, Typecheck, Test)
|
# JOB 2: QA (Lint, Typecheck, Test)
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -136,7 +180,7 @@ jobs:
|
|||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
build:
|
build:
|
||||||
name: 🏗️ Build
|
name: 🏗️ Build
|
||||||
needs: prepare
|
needs: [prepare, qa]
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -158,6 +202,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
|
||||||
@@ -181,6 +227,7 @@ jobs:
|
|||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||||
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
|
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
|
||||||
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||||
|
|
||||||
# Secrets mapping (Directus)
|
# Secrets mapping (Directus)
|
||||||
DIRECTUS_KEY: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_KEY) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY) || secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
|
DIRECTUS_KEY: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_KEY) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY) || secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
|
||||||
@@ -205,6 +252,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
|
||||||
@@ -212,50 +263,84 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
run: |
|
run: |
|
||||||
# Generate Environment File
|
# Middleware Selection Logic
|
||||||
|
# Regular app routes get auth on non-production
|
||||||
|
# Unprotected routes (/stats, /errors) never get auth
|
||||||
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||||
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||||
|
STD_MW="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-compress"
|
||||||
cat > .env.deploy << EOF
|
|
||||||
# Generated by CI - $TARGET
|
|
||||||
IMAGE_TAG=$IMAGE_TAG
|
|
||||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
|
||||||
SENTRY_DSN=$SENTRY_DSN
|
|
||||||
LOG_LEVEL=$LOG_LEVEL
|
|
||||||
MAIL_HOST=$MAIL_HOST
|
|
||||||
MAIL_PORT=$MAIL_PORT
|
|
||||||
MAIL_USERNAME=$MAIL_USERNAME
|
|
||||||
MAIL_PASSWORD=$MAIL_PASSWORD
|
|
||||||
MAIL_FROM=$MAIL_FROM
|
|
||||||
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
|
||||||
|
|
||||||
# Directus
|
|
||||||
DIRECTUS_URL=$DIRECTUS_URL
|
|
||||||
DIRECTUS_HOST=$DIRECTUS_HOST
|
|
||||||
DIRECTUS_KEY=$DIRECTUS_KEY
|
|
||||||
DIRECTUS_SECRET=$DIRECTUS_SECRET
|
|
||||||
DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL
|
|
||||||
DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD
|
|
||||||
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
|
|
||||||
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
|
|
||||||
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
|
|
||||||
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
|
|
||||||
INTERNAL_DIRECTUS_URL=http://directus:8055
|
|
||||||
|
|
||||||
# Gatekeeper
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
AUTH_MIDDLEWARE="$STD_MW"
|
||||||
AUTH_COOKIE_NAME=klz_gatekeeper_session
|
COMPOSE_PROFILES=""
|
||||||
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
else
|
||||||
|
# Order: Ratelimit -> Forward (Proto) -> Auth -> Compression
|
||||||
|
AUTH_MIDDLEWARE="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-auth,${PROJECT_NAME}-compress"
|
||||||
|
COMPOSE_PROFILES="gatekeeper"
|
||||||
|
fi
|
||||||
|
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
||||||
|
|
||||||
TARGET=$TARGET
|
# Gatekeeper Origin
|
||||||
SENTRY_ENVIRONMENT=$TARGET
|
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
||||||
PROJECT_NAME=$PROJECT_NAME
|
|
||||||
TRAEFIK_HOST_RULE='$TRAEFIK_RULE'
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# AUTH_MIDDLEWARE logic
|
{
|
||||||
printf "AUTH_MIDDLEWARE=%s\n" "$( [[ "$TARGET" == "production" ]] && echo "${PROJECT_NAME}-compress" || echo "${PROJECT_NAME}-auth,${PROJECT_NAME}-compress" )" >> .env.deploy
|
echo "# Generated by CI - $TARGET"
|
||||||
|
echo "IMAGE_TAG=$IMAGE_TAG"
|
||||||
|
echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL"
|
||||||
|
echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
|
||||||
|
echo "SENTRY_DSN=$SENTRY_DSN"
|
||||||
|
echo "LOG_LEVEL=$LOG_LEVEL"
|
||||||
|
echo "MAIL_HOST=$MAIL_HOST"
|
||||||
|
echo "MAIL_PORT=$MAIL_PORT"
|
||||||
|
echo "MAIL_USERNAME=$MAIL_USERNAME"
|
||||||
|
echo "MAIL_PASSWORD=$MAIL_PASSWORD"
|
||||||
|
echo "MAIL_FROM=$MAIL_FROM"
|
||||||
|
echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS"
|
||||||
|
echo ""
|
||||||
|
echo "# Directus"
|
||||||
|
echo "DIRECTUS_URL=$DIRECTUS_URL"
|
||||||
|
echo "DIRECTUS_HOST=$DIRECTUS_HOST"
|
||||||
|
echo "DIRECTUS_KEY=$DIRECTUS_KEY"
|
||||||
|
echo "DIRECTUS_SECRET=$DIRECTUS_SECRET"
|
||||||
|
echo "DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL"
|
||||||
|
echo "DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD"
|
||||||
|
echo "DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME"
|
||||||
|
echo "DIRECTUS_DB_USER=$DIRECTUS_DB_USER"
|
||||||
|
echo "DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD"
|
||||||
|
echo "DIRECTUS_DB_CLIENT=pg"
|
||||||
|
echo "DIRECTUS_DB_HOST=directus-db"
|
||||||
|
echo "DIRECTUS_DB_PORT=5432"
|
||||||
|
echo "DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN"
|
||||||
|
echo "INTERNAL_DIRECTUS_URL=http://directus:8055"
|
||||||
|
echo ""
|
||||||
|
echo "# Gatekeeper"
|
||||||
|
echo "GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD"
|
||||||
|
echo "AUTH_COOKIE_NAME=klz_gatekeeper_session"
|
||||||
|
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
|
||||||
|
echo ""
|
||||||
|
echo "# Analytics"
|
||||||
|
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||||
|
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||||
|
echo ""
|
||||||
|
echo "TARGET=$TARGET"
|
||||||
|
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||||
|
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||||
|
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
|
||||||
|
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
|
||||||
|
echo "TRAEFIK_ENTRYPOINT=websecure"
|
||||||
|
echo "TRAEFIK_TLS=true"
|
||||||
|
echo "TRAEFIK_CERT_RESOLVER=le"
|
||||||
|
echo "ENV_FILE=$ENV_FILE"
|
||||||
|
echo "COMPOSE_PROFILES=$COMPOSE_PROFILES"
|
||||||
|
echo "AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE"
|
||||||
|
echo "AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED"
|
||||||
|
} > .env.deploy
|
||||||
|
|
||||||
|
echo "--- Generated .env.deploy ---"
|
||||||
|
cat .env.deploy
|
||||||
|
echo "----------------------------"
|
||||||
|
|
||||||
- name: 🚀 SSH Deploy
|
- name: 🚀 SSH Deploy
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -284,12 +369,48 @@ jobs:
|
|||||||
|
|
||||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||||
|
|
||||||
|
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||||
|
if: always()
|
||||||
|
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:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const path = require('path');
|
/* eslint-disable no-undef */
|
||||||
|
const path = require('path'); // eslint-disable-line @typescript-eslint/no-require-imports
|
||||||
|
|
||||||
const buildEslintCommand = (filenames) =>
|
const buildEslintCommand = (filenames) =>
|
||||||
`eslint --fix ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`;
|
`eslint --fix ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`;
|
||||||
11
.prettierignore
Normal file
11
.prettierignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Ignore Next.js auto-generated environment file
|
||||||
|
# It often uses different quote styles than our project config
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Ignore build output
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
out
|
||||||
|
|
||||||
|
# Ignore other potentially generated files
|
||||||
|
pnpm-lock.yaml
|
||||||
37
Dockerfile
37
Dockerfile
@@ -1,45 +1,58 @@
|
|||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Clean the workspace in case the base image is dirty
|
|
||||||
RUN rm -rf ./*
|
|
||||||
|
|
||||||
# Arguments for build-time configuration
|
# Arguments for build-time configuration
|
||||||
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
|
||||||
|
|
||||||
# Enable pnpm
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
# Copy lockfile and manifest for dependency installation caching
|
# Copy lockfile and manifest for dependency installation caching
|
||||||
COPY pnpm-lock.yaml package.json .npmrc* ./
|
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||||
|
|
||||||
# Install dependencies with cache mount
|
# Configure private registry and install dependencies
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
--mount=type=secret,id=NPM_TOKEN \
|
--mount=type=secret,id=NPM_TOKEN \
|
||||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
|
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
|
||||||
pnpm install --frozen-lockfile
|
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
|
||||||
|
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||||
|
pnpm install --frozen-lockfile && \
|
||||||
|
rm .npmrc
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Stage 2: Development (Hot-Reloading)
|
||||||
|
FROM base AS development
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
CMD ["pnpm", "dev:local"]
|
||||||
|
|
||||||
# Build application
|
# Build application
|
||||||
|
# Stage 3: Builder (Production)
|
||||||
|
FROM base AS builder
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Stage 2: Runner
|
# Stage 3: Runner
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
||||||
|
USER root
|
||||||
|
RUN chown -R nextjs:nodejs /app
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -50,6 +63,4 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import { getPageBySlug } from '@/lib/pages';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export 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 +22,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,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ 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';
|
||||||
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -39,18 +39,17 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
title: pageData.frontmatter.title,
|
title: pageData.frontmatter.title,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/${slug}`,
|
canonical: `${SITE_URL}/${locale}/${slug}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `/de/${slug}`,
|
de: `${SITE_URL}/de/${slug}`,
|
||||||
en: `/en/${slug}`,
|
en: `${SITE_URL}/en/${slug}`,
|
||||||
'x-default': `/en/${slug}`,
|
'x-default': `${SITE_URL}/en/${slug}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
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',
|
||||||
@@ -112,15 +111,19 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
<div className="relative z-10 max-w-2xl">
|
<div className="relative z-10 max-w-2xl">
|
||||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||||
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
||||||
<a
|
<TrackedLink
|
||||||
href={`/${locale}/contact`}
|
href={`/${locale}/contact`}
|
||||||
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
||||||
|
eventProperties={{
|
||||||
|
location: 'generic_page_support_cta',
|
||||||
|
page_slug: slug,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('contactUs')}
|
{t('contactUs')}
|
||||||
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
|
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</TrackedLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { OGImageTemplate } from '@/components/OGImageTemplate';
|
|||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
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) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import Script from 'next/script';
|
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL, LOGO_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||||
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
|
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
@@ -11,8 +10,8 @@ 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';
|
||||||
|
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||||
|
|
||||||
interface BlogPostProps {
|
interface BlogPostProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -32,11 +31,11 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
|||||||
title: post.frontmatter.title,
|
title: post.frontmatter.title,
|
||||||
description: description,
|
description: description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/blog/${slug}`,
|
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `/de/blog/${slug}`,
|
de: `${SITE_URL}/de/blog/${slug}`,
|
||||||
en: `/en/blog/${slug}`,
|
en: `${SITE_URL}/en/blog/${slug}`,
|
||||||
'x-default': `/en/blog/${slug}`,
|
'x-default': `${SITE_URL}/en/blog/${slug}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -46,7 +45,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',
|
||||||
@@ -70,6 +68,12 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
||||||
|
<BlogEngagementTracker
|
||||||
|
title={post.frontmatter.title}
|
||||||
|
slug={slug}
|
||||||
|
category={post.frontmatter.category}
|
||||||
|
readingTime={getReadingTime(post.content)}
|
||||||
|
/>
|
||||||
{/* Featured Image Header */}
|
{/* Featured Image Header */}
|
||||||
{post.frontmatter.featuredImage ? (
|
{post.frontmatter.featuredImage ? (
|
||||||
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
||||||
|
|||||||
@@ -3,23 +3,20 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export 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,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
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 { getTranslations } from 'next-intl/server';
|
import { Metadata } from 'next';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface BlogIndexProps {
|
interface BlogIndexProps {
|
||||||
@@ -19,18 +20,17 @@ export async function generateMetadata({ params }: BlogIndexProps) {
|
|||||||
title: t('title'),
|
title: t('title'),
|
||||||
description: t('description'),
|
description: t('description'),
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/blog`,
|
canonical: `${SITE_URL}/${locale}/blog`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de/blog',
|
de: `${SITE_URL}/de/blog`,
|
||||||
en: '/en/blog',
|
en: `${SITE_URL}/en/blog`,
|
||||||
'x-default': '/en/blog',
|
'x-default': `${SITE_URL}/en/blog`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
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',
|
||||||
@@ -42,6 +42,7 @@ export async function generateMetadata({ params }: BlogIndexProps) {
|
|||||||
|
|
||||||
export default async function BlogIndex({ params }: BlogIndexProps) {
|
export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations('Blog');
|
const t = await getTranslations('Blog');
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
|
|
||||||
@@ -60,10 +61,13 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
<section className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
<section className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||||
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||||
<>
|
<>
|
||||||
<img
|
<Image
|
||||||
src={featuredPost.frontmatter.featuredImage}
|
src={featuredPost.frontmatter.featuredImage}
|
||||||
alt={featuredPost.frontmatter.title}
|
alt={featuredPost.frontmatter.title}
|
||||||
|
fill
|
||||||
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
|
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
|
||||||
|
sizes="100vw"
|
||||||
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient" />
|
<div className="absolute inset-0 image-overlay-gradient" />
|
||||||
</>
|
</>
|
||||||
@@ -145,10 +149,12 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden">
|
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden">
|
||||||
{post.frontmatter.featuredImage && (
|
{post.frontmatter.featuredImage && (
|
||||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
<div className="relative h-48 md:h-72 overflow-hidden">
|
||||||
<img
|
<Image
|
||||||
src={post.frontmatter.featuredImage}
|
src={post.frontmatter.featuredImage}
|
||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
|
fill
|
||||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export 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 +16,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,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/contact`,
|
canonical: `${SITE_URL}/${locale}/contact`,
|
||||||
languages: {
|
languages: {
|
||||||
'de-DE': '/de/contact',
|
de: `${SITE_URL}/de/contact`,
|
||||||
'en-US': '/en/contact',
|
en: `${SITE_URL}/en/contact`,
|
||||||
|
'x-default': `${SITE_URL}/en/contact`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -35,7 +36,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 +43,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,
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import Footer from '@/components/Footer';
|
|||||||
import Header from '@/components/Header';
|
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 ScrollDepthTracker from '@/components/analytics/ScrollDepthTracker';
|
||||||
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';
|
||||||
@@ -12,6 +15,13 @@ import '../../styles/globals.css';
|
|||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { config } from '@/lib/config';
|
import { config } from '@/lib/config';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
display: 'swap',
|
||||||
|
variable: '--font-inter',
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(SITE_URL),
|
metadataBase: new URL(SITE_URL),
|
||||||
@@ -33,16 +43,13 @@ export const viewport: Viewport = {
|
|||||||
themeColor: '#001a4d',
|
themeColor: '#001a4d',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function Layout(props: {
|
||||||
children,
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = await params;
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
// Ensure locale is a valid string, fallback to 'en'
|
const { children } = props;
|
||||||
const supportedLocales = ['en', 'de'];
|
const supportedLocales = ['en', 'de'];
|
||||||
const localeStr = (typeof locale === 'string' ? locale : '').trim();
|
const localeStr = (typeof locale === 'string' ? locale : '').trim();
|
||||||
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
|
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
|
||||||
@@ -57,12 +64,9 @@ export default async function LocaleLayout({
|
|||||||
messages = {};
|
messages = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track pageview on the server with high-fidelity header context
|
|
||||||
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
||||||
const serverServices = getServerAppServices();
|
const serverServices = getServerAppServices();
|
||||||
|
|
||||||
// We wrap this in a try-catch to allow static rendering during build
|
|
||||||
// headers() and cookies() force dynamic rendering in Next.js
|
|
||||||
try {
|
try {
|
||||||
const { headers } = await import('next/headers');
|
const { headers } = await import('next/headers');
|
||||||
const requestHeaders = await headers();
|
const requestHeaders = await headers();
|
||||||
@@ -76,10 +80,8 @@ export default async function LocaleLayout({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track initial server-side pageview
|
|
||||||
serverServices.analytics.trackPageview();
|
serverServices.analytics.trackPageview();
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Falls back to noop or client-side only during static generation
|
|
||||||
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
|
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
|
||||||
@@ -87,20 +89,31 @@ export default async function LocaleLayout({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read directly from process.env — bypasses all abstraction to guarantee correctness
|
||||||
|
const recordModeEnabled = process.env.NEXT_PUBLIC_RECORD_MODE_ENABLED === 'true';
|
||||||
|
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={safeLocale} className="scroll-smooth overflow-x-hidden">
|
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
|
||||||
|
<head></head>
|
||||||
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
<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 isEnabled={recordModeEnabled}>
|
||||||
<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 />
|
||||||
|
<ScrollDepthTracker />
|
||||||
|
</Suspense>
|
||||||
|
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
|
||||||
|
</RecordModeProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
|
'use client';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Container, Button, Heading } from '@/components/ui';
|
import { Container, Button, Heading } from '@/components/ui';
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const t = useTranslations('Error.notFound');
|
const t = useTranslations('Error.notFound');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent(AnalyticsEvents.ERROR, {
|
||||||
|
type: '404_not_found',
|
||||||
|
path: typeof window !== 'undefined' ? window.location.pathname : 'unknown',
|
||||||
|
});
|
||||||
|
}, [trackEvent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||||
@@ -16,19 +28,17 @@ export default function NotFound() {
|
|||||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
||||||
404
|
404
|
||||||
</Heading>
|
</Heading>
|
||||||
<Scribble
|
<Scribble
|
||||||
variant="circle"
|
variant="circle"
|
||||||
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
||||||
{t('title')}
|
{t('title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<p className="text-white/60 mb-10 max-w-md text-lg">
|
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
|
||||||
{t('description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<Button href="/" variant="accent" size="lg">
|
<Button href="/" variant="accent" size="lg">
|
||||||
|
|||||||
@@ -3,24 +3,25 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
console.log('🖼️ OG Image Handler Called');
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
const 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,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import JsonLd from '@/components/JsonLd';
|
|||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||||
import ProductCategories from '@/components/home/ProductCategories';
|
import ProductCategories from '@/components/home/ProductCategories';
|
||||||
import WhatWeDo from '@/components/home/WhatWeDo';
|
import WhatWeDo from '@/components/home/WhatWeDo';
|
||||||
import RecentPosts from '@/components/home/RecentPosts';
|
import dynamic from 'next/dynamic';
|
||||||
import Experience from '@/components/home/Experience';
|
|
||||||
import WhyChooseUs from '@/components/home/WhyChooseUs';
|
|
||||||
import MeetTheTeam from '@/components/home/MeetTheTeam';
|
|
||||||
import GallerySection from '@/components/home/GallerySection';
|
|
||||||
import VideoSection from '@/components/home/VideoSection';
|
|
||||||
import CTA from '@/components/home/CTA';
|
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
|
|
||||||
|
const RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
|
||||||
|
const Experience = dynamic(() => import('@/components/home/Experience'));
|
||||||
|
const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs'));
|
||||||
|
const MeetTheTeam = dynamic(() => import('@/components/home/MeetTheTeam'));
|
||||||
|
const GallerySection = dynamic(() => import('@/components/home/GallerySection'));
|
||||||
|
const VideoSection = dynamic(() => import('@/components/home/VideoSection'));
|
||||||
|
const CTA = dynamic(() => import('@/components/home/CTA'));
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
@@ -49,7 +51,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
|||||||
<Reveal>
|
<Reveal>
|
||||||
<VideoSection />
|
<VideoSection />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal>
|
<Reveal className="content-visibility-auto">
|
||||||
<CTA />
|
<CTA />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,12 +69,12 @@ export async function generateMetadata({
|
|||||||
let t;
|
let t;
|
||||||
try {
|
try {
|
||||||
t = await getTranslations({ locale, namespace: 'Index.meta' });
|
t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||||
} catch (err) {
|
} catch {
|
||||||
// If translations for Index.meta are not present, try generic Index namespace
|
// If translations for Index.meta are not present, try generic Index namespace
|
||||||
try {
|
try {
|
||||||
t = await getTranslations({ locale, namespace: 'Index' });
|
t = await getTranslations({ locale, namespace: 'Index' });
|
||||||
} catch (e) {
|
} catch {
|
||||||
t = (key: string) => '';
|
t = () => '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,11 +85,11 @@ export async function generateMetadata({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}`,
|
canonical: `${SITE_URL}/${locale}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de',
|
de: `${SITE_URL}/de`,
|
||||||
en: '/en',
|
en: `${SITE_URL}/en`,
|
||||||
'x-default': '/en',
|
'x-default': `${SITE_URL}/en`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Script from 'next/script';
|
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import ProductSidebar from '@/components/ProductSidebar';
|
import ProductSidebar from '@/components/ProductSidebar';
|
||||||
import ProductTabs from '@/components/ProductTabs';
|
import ProductTabs from '@/components/ProductTabs';
|
||||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||||
@@ -17,6 +16,7 @@ import { MDXRemote } from 'next-mdx-remote/rsc';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
||||||
|
|
||||||
interface ProductPageProps {
|
interface ProductPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -53,11 +53,11 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
title: categoryTitle,
|
title: categoryTitle,
|
||||||
description: categoryDesc,
|
description: categoryDesc,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products/${productSlug}`,
|
canonical: `${SITE_URL}/${locale}/products/${productSlug}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||||
en: `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
'x-default': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -81,11 +81,11 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
title: product.frontmatter.title,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products/${slug.join('/')}`,
|
canonical: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||||
en: `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
'x-default': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -213,8 +213,11 @@ 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
|
||||||
{t('title')}
|
href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
|
||||||
|
className="hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
@@ -244,6 +247,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
alt={product.frontmatter.title}
|
alt={product.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
/>
|
/>
|
||||||
{/* Subtle reflection/shadow effect */}
|
{/* Subtle reflection/shadow effect */}
|
||||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
||||||
@@ -353,6 +357,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-white relative">
|
<div className="flex flex-col min-h-screen bg-white relative">
|
||||||
{/* Product Hero */}
|
{/* Product Hero */}
|
||||||
|
<ProductEngagementTracker
|
||||||
|
productName={product.frontmatter.title}
|
||||||
|
productSlug={productSlug}
|
||||||
|
categories={product.frontmatter.categories}
|
||||||
|
sku={product.frontmatter.sku}
|
||||||
|
/>
|
||||||
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
|
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
|
||||||
{/* Background Decorative Elements */}
|
{/* Background Decorative Elements */}
|
||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
||||||
@@ -361,8 +371,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<Container className="relative z-10">
|
<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
|
||||||
{t('title')}
|
href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
|
||||||
|
className="hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
{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
|
||||||
|
|||||||
@@ -3,27 +3,23 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export 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,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import { Badge, Button, Card, Container, Heading, Section } from '@/components/u
|
|||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
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';
|
||||||
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
|
|
||||||
interface ProductsPageProps {
|
interface ProductsPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -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: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de/products',
|
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}`,
|
||||||
en: '/en/products',
|
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
|
||||||
'x-default': '/en/products',
|
'x-default': `${SITE_URL}/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}`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -135,7 +135,15 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
|
||||||
{categories.map((category, idx) => (
|
{categories.map((category, idx) => (
|
||||||
<Reveal key={idx} delay={idx * 100}>
|
<Reveal key={idx} delay={idx * 100}>
|
||||||
<Link key={idx} href={category.href} className="group block">
|
<TrackedLink
|
||||||
|
key={idx}
|
||||||
|
href={category.href}
|
||||||
|
className="group block"
|
||||||
|
eventProperties={{
|
||||||
|
category_title: category.title,
|
||||||
|
location: 'products_index',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
|
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
|
||||||
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
|
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
@@ -143,8 +151,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
alt={category.title}
|
alt={category.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||||
sizes="(max-width: 768px) 100vw, 50vw"
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
|
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
|
||||||
|
|
||||||
@@ -196,7 +203,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</TrackedLink>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export 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 +16,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,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ 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';
|
||||||
|
import TrackedButton from '@/components/analytics/TrackedButton';
|
||||||
|
|
||||||
interface TeamPageProps {
|
interface TeamPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -23,18 +23,17 @@ export async function generateMetadata({ params }: TeamPageProps): Promise<Metad
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/team`,
|
canonical: `${SITE_URL}/${locale}/team`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de/team',
|
de: `${SITE_URL}/de/team`,
|
||||||
en: '/en/team',
|
en: `${SITE_URL}/en/team`,
|
||||||
'x-default': '/en/team',
|
'x-default': `${SITE_URL}/en/team`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
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',
|
||||||
@@ -94,6 +93,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
alt="KLZ Team"
|
alt="KLZ Team"
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
|
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
|
||||||
|
sizes="100vw"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
||||||
@@ -134,15 +134,20 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
|
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
|
||||||
{t('michael.description')}
|
{t('michael.description')}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<TrackedButton
|
||||||
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||||
|
eventProperties={{
|
||||||
|
type: 'social_linkedin',
|
||||||
|
person: 'Michael Bodemer',
|
||||||
|
location: 'team_page',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('michael.linkedin')}
|
{t('michael.linkedin')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Button>
|
</TrackedButton>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
|
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
|
||||||
@@ -242,15 +247,20 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
|
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
|
||||||
{t('klaus.description')}
|
{t('klaus.description')}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<TrackedButton
|
||||||
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
||||||
variant="saturated"
|
variant="saturated"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||||
|
eventProperties={{
|
||||||
|
type: 'social_linkedin',
|
||||||
|
person: 'Klaus Mintel',
|
||||||
|
location: 'team_page',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('klaus.linkedin')}
|
{t('klaus.linkedin')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Button>
|
</TrackedButton>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
32
app/api/save-session/route.ts
Normal file
32
app/api/save-session/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
// Only allow in development
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return NextResponse.json({ error: 'This route is disabled in production.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
// Ensure we are in the project root by using process.cwd()
|
||||||
|
// Path: <project-root>/remotion/session.json
|
||||||
|
const remotionDir = path.join(process.cwd(), 'remotion');
|
||||||
|
const filePath = path.join(remotionDir, 'session.json');
|
||||||
|
|
||||||
|
// Create remotion directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(remotionDir)) {
|
||||||
|
fs.mkdirSync(remotionDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the JSON file
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(body, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, path: filePath });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save session:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Empty envelope' }, { status: 400 });
|
return NextResponse.json({ error: 'Empty envelope' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const header = JSON.parse(lines[0]);
|
JSON.parse(lines[0]);
|
||||||
const realDsn = config.errors.glitchtip.dsn;
|
const realDsn = config.errors.glitchtip.dsn;
|
||||||
|
|
||||||
if (!realDsn) {
|
if (!realDsn) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ['@commitlint/config-conventional'],
|
extends: ['@commitlint/config-conventional'],
|
||||||
rules: {
|
rules: {
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { AlertCircle, RefreshCw, Database } from 'lucide-react';
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
import { config } from '../lib/config';
|
import { config } from '../lib/config';
|
||||||
|
|
||||||
export default function CMSConnectivityNotice() {
|
export default function CMSConnectivityNotice() {
|
||||||
const [status, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
const [, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export default function CMSConnectivityNotice() {
|
|||||||
setStatus('ok');
|
setStatus('ok');
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
// If it's a connection error, only show if we are really debugging
|
// If it's a connection error, only show if we are really debugging
|
||||||
if (isDebug || isLocal) {
|
if (isDebug || isLocal) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
|
|||||||
@@ -5,11 +5,30 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
||||||
import { sendContactFormAction } from '@/app/actions/contact';
|
import { sendContactFormAction } from '@/app/actions/contact';
|
||||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
export default function ContactForm() {
|
export default function ContactForm() {
|
||||||
const t = useTranslations('Contact');
|
const t = useTranslations('Contact');
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
|
|
||||||
|
const handleFocus = (fieldId: string) => {
|
||||||
|
// Initial form start
|
||||||
|
if (!hasStarted) {
|
||||||
|
setHasStarted(true);
|
||||||
|
trackEvent(AnalyticsEvents.FORM_START, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
form_name: 'Contact',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field-level transparency
|
||||||
|
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
field_id: fieldId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -29,10 +48,18 @@ export default function ContactForm() {
|
|||||||
(e.target as HTMLFormElement).reset();
|
(e.target as HTMLFormElement).reset();
|
||||||
} else {
|
} else {
|
||||||
console.error('Contact form submission failed:', { email, error: result.error });
|
console.error('Contact form submission failed:', { email, error: result.error });
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
error: result.error || 'submission_failed',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Contact form submission error:', { email, error });
|
console.error('Contact form submission error:', { email, error });
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
error: (error as Error).message || 'unexpected_error',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,7 +139,7 @@ export default function ContactForm() {
|
|||||||
name="name"
|
name="name"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
placeholder={t('form.namePlaceholder')}
|
onFocus={() => handleFocus('name')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,6 +153,7 @@ export default function ContactForm() {
|
|||||||
inputMode="email"
|
inputMode="email"
|
||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
placeholder={t('form.emailPlaceholder')}
|
placeholder={t('form.emailPlaceholder')}
|
||||||
|
onFocus={() => handleFocus('email')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,6 +165,7 @@ export default function ContactForm() {
|
|||||||
rows={4}
|
rows={4}
|
||||||
enterKeyHint="send"
|
enterKeyHint="send"
|
||||||
placeholder={t('form.messagePlaceholder')}
|
placeholder={t('form.messagePlaceholder')}
|
||||||
|
onFocus={() => handleFocus('message')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { cn } from '@/components/ui/utils';
|
import { cn } from '@/components/ui/utils';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
interface DatasheetDownloadProps {
|
interface DatasheetDownloadProps {
|
||||||
datasheetPath: string;
|
datasheetPath: string;
|
||||||
@@ -10,34 +12,42 @@ interface DatasheetDownloadProps {
|
|||||||
|
|
||||||
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
|
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
|
||||||
const t = useTranslations('Products');
|
const t = useTranslations('Products');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("mt-8 animate-slight-fade-in-from-bottom", className)}>
|
<div className={cn('mt-8 animate-slight-fade-in-from-bottom', className)}>
|
||||||
<a
|
<a
|
||||||
href={datasheetPath}
|
href={datasheetPath}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||||
|
file_name: datasheetPath.split('/').pop(),
|
||||||
|
file_path: datasheetPath,
|
||||||
|
location: 'product_page',
|
||||||
|
})
|
||||||
|
}
|
||||||
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||||
>
|
>
|
||||||
{/* Animated Background Gradient */}
|
{/* Animated Background Gradient */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||||
|
|
||||||
{/* Inner Content */}
|
{/* Inner Content */}
|
||||||
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
|
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
|
||||||
{/* Icon Container */}
|
{/* Icon Container */}
|
||||||
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||||
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
<svg
|
<svg
|
||||||
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,7 +55,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
{/* Text Content */}
|
{/* Text Content */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">PDF Datasheet</span>
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
||||||
|
PDF Datasheet
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||||
{t('downloadDatasheet')}
|
{t('downloadDatasheet')}
|
||||||
@@ -58,7 +70,12 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
{/* Arrow Icon */}
|
{/* Arrow Icon */}
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,40 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { Container } from './ui';
|
import { Container } from './ui';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const t = useTranslations('Footer');
|
const t = useTranslations('Footer');
|
||||||
const navT = useTranslations('Navigation');
|
const navT = useTranslations('Navigation');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-primary text-white py-24 relative overflow-hidden">
|
<footer className="bg-primary text-white py-24 relative overflow-hidden content-visibility-auto">
|
||||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||||
|
|
||||||
<Container>
|
<Container>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
||||||
{/* Brand Column */}
|
{/* Brand Column */}
|
||||||
<div className="lg:col-span-4 space-y-8">
|
<div className="lg:col-span-4 space-y-8">
|
||||||
<Link href={`/${locale}`} className="inline-block group">
|
<Link
|
||||||
<Image
|
href={`/${locale}`}
|
||||||
src="/logo-white.svg"
|
className="inline-block group"
|
||||||
alt={t('products')}
|
onClick={() =>
|
||||||
width={150}
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
height={40}
|
target: 'home_logo',
|
||||||
|
location: 'footer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/logo-white.svg"
|
||||||
|
alt={t('products')}
|
||||||
|
width={150}
|
||||||
|
height={40}
|
||||||
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-white/60 text-base md:text-lg leading-relaxed max-w-sm">
|
<p className="text-white/60 text-base md:text-lg leading-relaxed max-w-sm">
|
||||||
{t('tagline')}
|
{t('tagline')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<a href="https://www.linkedin.com/company/klz-vertriebs-gmbh/" target="_blank" rel="noopener noreferrer" className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center text-white hover:bg-accent hover:text-primary-dark transition-all duration-300 border border-white/10">
|
<a
|
||||||
|
href="https://www.linkedin.com/company/klz-vertriebs-gmbh/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
type: 'social',
|
||||||
|
target: 'linkedin',
|
||||||
|
location: 'footer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center text-white hover:bg-accent hover:text-primary-dark transition-all duration-300 border border-white/10"
|
||||||
|
>
|
||||||
<span className="sr-only">LinkedIn</span>
|
<span className="sr-only">LinkedIn</span>
|
||||||
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
|
||||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
|
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,52 +67,172 @@ export default function Footer() {
|
|||||||
|
|
||||||
{/* Links Columns */}
|
{/* Links Columns */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">{t('legal')}</h4>
|
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||||
|
{t('legal')}
|
||||||
|
</h4>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
<li><Link href={`/${locale}/${t('legalNoticeSlug')}`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{t('legalNotice')}</Link></li>
|
<li>
|
||||||
<li><Link href={`/${locale}/${t('privacyPolicySlug')}`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{t('privacyPolicy')}</Link></li>
|
<Link
|
||||||
<li><Link href={`/${locale}/${t('termsSlug')}`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{t('terms')}</Link></li>
|
href={`/${locale}/${t('legalNoticeSlug')}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: t('legalNotice'),
|
||||||
|
href: t('legalNoticeSlug'),
|
||||||
|
location: 'footer_legal',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('legalNotice')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/${t('privacyPolicySlug')}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: t('privacyPolicy'),
|
||||||
|
href: t('privacyPolicySlug'),
|
||||||
|
location: 'footer_legal',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('privacyPolicy')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/${t('termsSlug')}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: t('terms'),
|
||||||
|
href: t('termsSlug'),
|
||||||
|
location: 'footer_legal',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('terms')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">{t('company')}</h4>
|
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||||
|
{t('company')}
|
||||||
|
</h4>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
<li><Link href={`/${locale}/team`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('team')}</Link></li>
|
<li>
|
||||||
<li><Link href={`/${locale}/products`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('products')}</Link></li>
|
<Link
|
||||||
<li><Link href={`/${locale}/blog`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('blog')}</Link></li>
|
href={`/${locale}/team`}
|
||||||
<li><Link href={`/${locale}/contact`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('contact')}</Link></li>
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: navT('team'),
|
||||||
|
href: '/team',
|
||||||
|
location: 'footer_company',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navT('team')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: navT('products'),
|
||||||
|
href: locale === 'de' ? '/produkte' : '/products',
|
||||||
|
location: 'footer_company',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navT('products')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/blog`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: navT('blog'),
|
||||||
|
href: '/blog',
|
||||||
|
location: 'footer_company',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navT('blog')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/contact`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: navT('contact'),
|
||||||
|
href: '/contact',
|
||||||
|
location: 'footer_company',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navT('contact')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Posts Column */}
|
{/* Recent Posts Column */}
|
||||||
<div className="lg:col-span-4">
|
<div className="lg:col-span-4">
|
||||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">{t('recentPosts')}</h4>
|
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||||
|
{t('recentPosts')}
|
||||||
|
</h4>
|
||||||
<ul className="space-y-6 list-none m-0 p-0">
|
<ul className="space-y-6 list-none m-0 p-0">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
title: locale === 'de'
|
title:
|
||||||
? "Windparkbau im Fokus: drei typische Kabelherausforderungen"
|
locale === 'de'
|
||||||
: "Focus on wind farm construction: three typical cable challenges",
|
? 'Windparkbau im Fokus: drei typische Kabelherausforderungen'
|
||||||
slug: locale === 'de'
|
: 'Focus on wind farm construction: three typical cable challenges',
|
||||||
? "windparkbau-im-fokus-drei-typische-kabelherausforderungen"
|
slug:
|
||||||
: "focus-on-wind-farm-construction-three-typical-cable-challenges"
|
locale === 'de'
|
||||||
|
? 'windparkbau-im-fokus-drei-typische-kabelherausforderungen'
|
||||||
|
: 'focus-on-wind-farm-construction-three-typical-cable-challenges',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: locale === 'de'
|
title:
|
||||||
? "Warum das N2XS(F)2Y das ideale Kabel für Ihr Energieprojekt ist"
|
locale === 'de'
|
||||||
: "Why the N2XS(F)2Y is the ideal cable for your energy project",
|
? 'Warum das N2XS(F)2Y das ideale Kabel für Ihr Energieprojekt ist'
|
||||||
slug: locale === 'de'
|
: 'Why the N2XS(F)2Y is the ideal cable for your energy project',
|
||||||
? "n2xsf2y-mittelspannungskabel-energieprojekt"
|
slug:
|
||||||
: "why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project"
|
locale === 'de'
|
||||||
}
|
? 'n2xsf2y-mittelspannungskabel-energieprojekt'
|
||||||
|
: 'why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project',
|
||||||
|
},
|
||||||
].map((post, i) => (
|
].map((post, i) => (
|
||||||
<li key={i}>
|
<li key={i}>
|
||||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block text-white/80">
|
<Link
|
||||||
|
href={`/${locale}/blog/${post.slug}`}
|
||||||
|
className="group block text-white/80"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BLOG_POST_VIEW, {
|
||||||
|
title: post.title,
|
||||||
|
slug: post.slug,
|
||||||
|
location: 'footer_recent',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
|
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
|
||||||
{post.title}
|
{post.title}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs text-white/40 uppercase tracking-widest">{t('readArticle')} →</span>
|
<span className="text-xs text-white/40 uppercase tracking-widest">
|
||||||
|
{t('readArticle')} →
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -98,8 +243,36 @@ export default function Footer() {
|
|||||||
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/40 text-xs md:text-sm font-medium">
|
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/40 text-xs md:text-sm font-medium">
|
||||||
<p>{t('copyright', { year: currentYear })}</p>
|
<p>{t('copyright', { year: currentYear })}</p>
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<Link href="/en" locale="en" className="hover:text-white transition-colors">English</Link>
|
<Link
|
||||||
<Link href="/de" locale="de" className="hover:text-white transition-colors">Deutsch</Link>
|
href="/en"
|
||||||
|
locale="en"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: locale,
|
||||||
|
to: 'en',
|
||||||
|
location: 'footer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/de"
|
||||||
|
locale="de"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: locale,
|
||||||
|
to: 'de',
|
||||||
|
location: 'footer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Deutsch
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import { usePathname } from 'next/navigation';
|
|||||||
import { Button } from './ui';
|
import { Button } from './ui';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { cn } from './ui';
|
import { cn } from './ui';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const t = useTranslations('Navigation');
|
const t = useTranslations('Navigation');
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
@@ -30,13 +33,6 @@ export default function Header() {
|
|||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Close mobile menu on route change
|
|
||||||
useEffect(() => {
|
|
||||||
if (isMobileMenuOpen) {
|
|
||||||
setIsMobileMenuOpen(false);
|
|
||||||
}
|
|
||||||
}, [pathname, isMobileMenuOpen]);
|
|
||||||
|
|
||||||
// Prevent scroll when mobile menu is open
|
// Prevent scroll when mobile menu is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobileMenuOpen) {
|
if (isMobileMenuOpen) {
|
||||||
@@ -56,7 +52,7 @@ export default function Header() {
|
|||||||
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: currentLocale === 'de' ? '/produkte' : '/products' },
|
||||||
{ label: t('blog'), href: '/blog' },
|
{ label: t('blog'), href: '/blog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -86,7 +82,15 @@ export default function Header() {
|
|||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
||||||
>
|
>
|
||||||
<Link href={`/${currentLocale}`}>
|
<Link
|
||||||
|
href={`/${currentLocale}`}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
target: 'home_logo',
|
||||||
|
location: 'header',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
src={logoSrc}
|
src={logoSrc}
|
||||||
alt={t('home')}
|
alt={t('home')}
|
||||||
@@ -94,7 +98,6 @@ export default function Header() {
|
|||||||
height={120}
|
height={120}
|
||||||
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
||||||
priority
|
priority
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -113,10 +116,18 @@ export default function Header() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
|
<motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
|
||||||
{menuItems.map((item, idx) => (
|
{menuItems.map((item, _idx) => (
|
||||||
<motion.div key={item.href} variants={navLinkVariants}>
|
<motion.div key={item.href} variants={navLinkVariants}>
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
location: 'header_nav',
|
||||||
|
});
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
textColorClass,
|
textColorClass,
|
||||||
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||||
@@ -146,6 +157,14 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={getPathForLocale('en')}
|
href={getPathForLocale('en')}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: currentLocale,
|
||||||
|
to: 'en',
|
||||||
|
location: 'header',
|
||||||
|
})
|
||||||
|
}
|
||||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||||
>
|
>
|
||||||
EN
|
EN
|
||||||
@@ -164,6 +183,14 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={getPathForLocale('de')}
|
href={getPathForLocale('de')}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: currentLocale,
|
||||||
|
to: 'de',
|
||||||
|
location: 'header',
|
||||||
|
})
|
||||||
|
}
|
||||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||||
>
|
>
|
||||||
DE
|
DE
|
||||||
@@ -181,6 +208,12 @@ export default function Header() {
|
|||||||
variant="white"
|
variant="white"
|
||||||
size="md"
|
size="md"
|
||||||
className="px-8 shadow-xl"
|
className="px-8 shadow-xl"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('contact'),
|
||||||
|
location: 'header_cta',
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t('contact')}
|
{t('contact')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -203,7 +236,14 @@ export default function Header() {
|
|||||||
damping: 20,
|
damping: 20,
|
||||||
delay: 0.5,
|
delay: 0.5,
|
||||||
}}
|
}}
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
onClick={() => {
|
||||||
|
const newState = !isMobileMenuOpen;
|
||||||
|
setIsMobileMenuOpen(newState);
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
type: 'mobile_menu',
|
||||||
|
action: newState ? 'open' : 'close',
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<motion.svg
|
<motion.svg
|
||||||
className="w-7 h-7"
|
className="w-7 h-7"
|
||||||
@@ -281,6 +321,14 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
location: 'mobile_menu',
|
||||||
|
});
|
||||||
|
}}
|
||||||
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
'use client';
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
// Fix for default marker icon in Leaflet with Next.js
|
// Fix for default marker icon in Leaflet with Next.js
|
||||||
const DefaultIcon = L.icon({
|
if (typeof window !== 'undefined') {
|
||||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
const DefaultIcon = L.icon({
|
||||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
iconSize: [25, 41],
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
iconAnchor: [12, 41],
|
iconSize: [25, 41],
|
||||||
});
|
iconAnchor: [12, 41],
|
||||||
|
});
|
||||||
|
|
||||||
L.Marker.prototype.options.icon = DefaultIcon;
|
L.Marker.prototype.options.icon = DefaultIcon;
|
||||||
|
}
|
||||||
|
|
||||||
interface LeafletMapProps {
|
interface LeafletMapProps {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -21,25 +21,46 @@ interface LeafletMapProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LeafletMap({ address, lat, lng }: LeafletMapProps) {
|
export default function LeafletMap({ address, lat, lng }: LeafletMapProps) {
|
||||||
const position: [number, number] = [lat, lng];
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<MapContainer
|
if (!mapRef.current || mapInstanceRef.current) return;
|
||||||
center={position}
|
|
||||||
zoom={15}
|
// Initialize map
|
||||||
scrollWheelZoom={false}
|
const map = L.map(mapRef.current, {
|
||||||
className="h-full w-full z-0"
|
center: [lat, lng],
|
||||||
>
|
zoom: 15,
|
||||||
<TileLayer
|
scrollWheelZoom: false,
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
});
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
/>
|
// Add tiles
|
||||||
<Marker position={position}>
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
<Popup>
|
attribution:
|
||||||
<div className="text-primary font-bold">KLZ Cables</div>
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
<div className="text-sm whitespace-pre-line">{address}</div>
|
}).addTo(map);
|
||||||
</Popup>
|
|
||||||
</Marker>
|
// Add marker
|
||||||
</MapContainer>
|
const marker = L.marker([lat, lng]).addTo(map);
|
||||||
);
|
|
||||||
|
// Create popup content
|
||||||
|
const popupContent = `
|
||||||
|
<div class="text-primary font-bold">KLZ Cables</div>
|
||||||
|
<div class="text-sm">${address.replace(/\n/g, '<br/>')}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
marker.bindPopup(popupContent);
|
||||||
|
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
if (mapInstanceRef.current) {
|
||||||
|
mapInstanceRef.current.remove();
|
||||||
|
mapInstanceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [lat, lng, address]);
|
||||||
|
|
||||||
|
return <div ref={mapRef} className="h-full w-full z-0" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
||||||
return () => setMounted(false);
|
return () => setMounted(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
if (photoParam !== null) {
|
if (photoParam !== null) {
|
||||||
const index = parseInt(photoParam, 10);
|
const index = parseInt(photoParam, 10);
|
||||||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
if (!isNaN(index) && index >= 0 && index < images.length) {
|
||||||
setCurrentIndex(index);
|
setCurrentIndex(index); // eslint-disable-line react-hooks/set-state-in-effect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [searchParams, images.length]);
|
}, [searchParams, images.length]);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function OGImageTemplate({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -182,4 +183,3 @@ export function OGImageTemplate({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
components/RelatedProductLink.tsx
Normal file
39
components/RelatedProductLink.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
|
interface RelatedProductLinkProps {
|
||||||
|
href: string;
|
||||||
|
productSlug: string;
|
||||||
|
productTitle: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RelatedProductLink({
|
||||||
|
href,
|
||||||
|
productSlug,
|
||||||
|
productTitle,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: RelatedProductLinkProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={className}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
|
||||||
|
product_id: productSlug,
|
||||||
|
product_name: productTitle,
|
||||||
|
location: 'related_products',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { getAllProducts } from '@/lib/mdx';
|
import { getAllProducts } from '@/lib/mdx';
|
||||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import { RelatedProductLink } from './RelatedProductLink';
|
||||||
|
|
||||||
interface RelatedProductsProps {
|
interface RelatedProductsProps {
|
||||||
currentSlug: string;
|
currentSlug: string;
|
||||||
@@ -10,15 +9,19 @@ interface RelatedProductsProps {
|
|||||||
locale: string;
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) {
|
export default async function RelatedProducts({
|
||||||
const allProducts = await getAllProducts(locale);
|
currentSlug,
|
||||||
|
categories,
|
||||||
|
locale,
|
||||||
|
}: RelatedProductsProps) {
|
||||||
|
const products = 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 = products
|
||||||
.filter(p =>
|
.filter(
|
||||||
p.slug !== currentSlug &&
|
(p) =>
|
||||||
p.frontmatter.categories.some(cat => categories.includes(cat))
|
p.slug !== currentSlug && 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
|
||||||
|
|
||||||
@@ -36,23 +39,31 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
{related.map(async (product) => {
|
{related.map((product) => {
|
||||||
// Find the category slug for the link
|
// Find the category slug for the link
|
||||||
const categorySlugs = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
const categorySlugs = [
|
||||||
const catSlug = categorySlugs.find(slug => {
|
'low-voltage-cables',
|
||||||
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
'medium-voltage-cables',
|
||||||
const title = t(`categories.${key}.title`);
|
'high-voltage-cables',
|
||||||
return product.frontmatter.categories.some(cat =>
|
'solar-cables',
|
||||||
cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title
|
];
|
||||||
);
|
const catSlug =
|
||||||
}) || 'low-voltage-cables';
|
categorySlugs.find((slug) => {
|
||||||
|
const key = slug
|
||||||
const translatedProductSlug = await mapFileSlugToTranslated(product.slug, locale);
|
.replace(/-cables$/, '')
|
||||||
|
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
const title = t(`categories.${key}.title`);
|
||||||
|
return product.frontmatter.categories.some(
|
||||||
|
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title,
|
||||||
|
);
|
||||||
|
}) || 'low-voltage-cables';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<RelatedProductLink
|
||||||
key={product.slug}
|
key={product.slug}
|
||||||
href={`/${locale}/products/${catSlug}/${translatedProductSlug}`}
|
href={`/${locale}/products/${catSlug}/${product.slug}`}
|
||||||
|
productSlug={product.slug}
|
||||||
|
productTitle={product.frontmatter.title}
|
||||||
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
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">
|
||||||
@@ -74,8 +85,11 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
{product.frontmatter.categories.slice(0, 1).map((cat, idx) => (
|
{product.frontmatter.categories.slice(0, 1).map((cat: any, idx: number) => (
|
||||||
<span key={idx} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||||
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -87,12 +101,22 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
|||||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-0.5">
|
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-0.5">
|
||||||
{t('details')}
|
{t('details')}
|
||||||
</span>
|
</span>
|
||||||
<svg className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</RelatedProductLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { Input, Textarea, Button } from '@/components/ui';
|
import { Input, Textarea, Button } from '@/components/ui';
|
||||||
import { sendContactFormAction } from '@/app/actions/contact';
|
import { sendContactFormAction } from '@/app/actions/contact';
|
||||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
interface RequestQuoteFormProps {
|
interface RequestQuoteFormProps {
|
||||||
productName: string;
|
productName: string;
|
||||||
@@ -16,6 +17,26 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [request, setRequest] = useState('');
|
const [request, setRequest] = useState('');
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
|
|
||||||
|
const handleFocus = (fieldId: string) => {
|
||||||
|
// Initial form start
|
||||||
|
if (!hasStarted) {
|
||||||
|
setHasStarted(true);
|
||||||
|
trackEvent(AnalyticsEvents.FORM_START, {
|
||||||
|
form_id: 'quote_request_form',
|
||||||
|
form_name: 'Product Quote Inquiry',
|
||||||
|
product_name: productName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field-level transparency
|
||||||
|
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
|
||||||
|
form_id: 'quote_request_form',
|
||||||
|
field_id: fieldId,
|
||||||
|
product_name: productName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -39,10 +60,20 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
setEmail('');
|
setEmail('');
|
||||||
setRequest('');
|
setRequest('');
|
||||||
} else {
|
} else {
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'quote_request_form',
|
||||||
|
product_name: productName,
|
||||||
|
error: result.error || 'submission_failed',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Form submission error:', error);
|
console.error('Form submission error:', error);
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'quote_request_form',
|
||||||
|
product_name: productName,
|
||||||
|
error: (error as Error).message || 'unexpected_error',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -131,6 +162,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onFocus={() => handleFocus('email')}
|
||||||
placeholder={t('email')}
|
placeholder={t('email')}
|
||||||
className="h-9 text-xs !mt-0"
|
className="h-9 text-xs !mt-0"
|
||||||
/>
|
/>
|
||||||
@@ -143,6 +175,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
rows={3}
|
rows={3}
|
||||||
value={request}
|
value={request}
|
||||||
onChange={(e) => setRequest(e.target.value)}
|
onChange={(e) => setRequest(e.target.value)}
|
||||||
|
onFocus={() => handleFocus('request')}
|
||||||
placeholder={t('message')}
|
placeholder={t('message')}
|
||||||
className="text-xs !mt-0"
|
className="text-xs !mt-0"
|
||||||
/>
|
/>
|
||||||
|
|||||||
53
components/analytics/BlogEngagementTracker.tsx
Normal file
53
components/analytics/BlogEngagementTracker.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
interface BlogEngagementTrackerProps {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
category?: string;
|
||||||
|
readingTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlogEngagementTracker
|
||||||
|
* Tracks reading time and article completion.
|
||||||
|
*/
|
||||||
|
export default function BlogEngagementTracker({
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
category,
|
||||||
|
readingTime,
|
||||||
|
}: BlogEngagementTrackerProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Article start
|
||||||
|
trackEvent(AnalyticsEvents.BLOG_POST_VIEW, {
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
category,
|
||||||
|
estimated_reading_time: readingTime,
|
||||||
|
location: 'blog_post_pdp',
|
||||||
|
});
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const dwellTime = Math.round((Date.now() - startTime) / 1000);
|
||||||
|
|
||||||
|
// We only consider it a "read" if they stay a reasonable amount of time
|
||||||
|
// or if they scroll (covered by ScrollDepthTracker)
|
||||||
|
trackEvent('blog_dwell_time', {
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
seconds: dwellTime,
|
||||||
|
reading_time_completion: Math.min(100, Math.round((dwellTime / (readingTime * 60)) * 100)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [title, slug, category, readingTime, trackEvent]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
50
components/analytics/ProductEngagementTracker.tsx
Normal file
50
components/analytics/ProductEngagementTracker.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
interface ProductEngagementTrackerProps {
|
||||||
|
productName: string;
|
||||||
|
productSlug: string;
|
||||||
|
categories: string[];
|
||||||
|
sku?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProductEngagementTracker
|
||||||
|
* Deep analytics for product pages.
|
||||||
|
* Tracks specific view events with full metadata for sales analysis.
|
||||||
|
*/
|
||||||
|
export default function ProductEngagementTracker({
|
||||||
|
productName,
|
||||||
|
productSlug,
|
||||||
|
categories,
|
||||||
|
sku,
|
||||||
|
}: ProductEngagementTrackerProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Standardized product view event for "High-Fidelity" sales insights
|
||||||
|
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
|
||||||
|
product_id: productSlug,
|
||||||
|
product_name: productName,
|
||||||
|
product_sku: sku,
|
||||||
|
product_categories: categories.join(', '),
|
||||||
|
location: 'pdp_standard',
|
||||||
|
});
|
||||||
|
|
||||||
|
// We can also track "Engagement Start" to measure dwell time later
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const dwellTime = Math.round((Date.now() - startTime) / 1000);
|
||||||
|
trackEvent('pdp_dwell_time', {
|
||||||
|
product_id: productSlug,
|
||||||
|
seconds: dwellTime,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [productName, productSlug, categories, sku, trackEvent]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
62
components/analytics/ScrollDepthTracker.tsx
Normal file
62
components/analytics/ScrollDepthTracker.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScrollDepthTracker
|
||||||
|
* Tracks user scroll progress across pages.
|
||||||
|
* Fires events at 25%, 50%, 75%, and 100% depth.
|
||||||
|
*/
|
||||||
|
export default function ScrollDepthTracker() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
const trackedDepths = useRef<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Reset tracking when path changes
|
||||||
|
useEffect(() => {
|
||||||
|
trackedDepths.current.clear();
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
const scrollY = window.scrollY;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
const documentHeight = document.documentElement.scrollHeight;
|
||||||
|
|
||||||
|
// Calculate how far the user has scrolled in percentage
|
||||||
|
// documentHeight - windowHeight is the total scrollable distance
|
||||||
|
const totalScrollable = documentHeight - windowHeight;
|
||||||
|
if (totalScrollable <= 0) return; // Not scrollable
|
||||||
|
|
||||||
|
const scrollPercentage = Math.round((scrollY / totalScrollable) * 100);
|
||||||
|
|
||||||
|
// We only care about specific milestones
|
||||||
|
const milestones = [25, 50, 75, 100];
|
||||||
|
|
||||||
|
milestones.forEach((milestone) => {
|
||||||
|
if (scrollPercentage >= milestone && !trackedDepths.current.has(milestone)) {
|
||||||
|
trackedDepths.current.add(milestone);
|
||||||
|
trackEvent(AnalyticsEvents.SCROLL_DEPTH, {
|
||||||
|
depth: milestone,
|
||||||
|
path: pathname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use passive listener for better performance
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
// Initial check (in case page is short or already scrolled)
|
||||||
|
handleScroll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, [pathname, trackEvent]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
34
components/analytics/TrackedButton.tsx
Normal file
34
components/analytics/TrackedButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button, ButtonProps } from '../ui/Button';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
interface TrackedButtonProps extends ButtonProps {
|
||||||
|
eventName?: string;
|
||||||
|
eventProperties?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper around the project's Button component that tracks click events.
|
||||||
|
* Safe to use in server components.
|
||||||
|
*/
|
||||||
|
export default function TrackedButton({
|
||||||
|
eventName = AnalyticsEvents.BUTTON_CLICK,
|
||||||
|
eventProperties = {},
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: TrackedButtonProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
trackEvent(eventName, {
|
||||||
|
...eventProperties,
|
||||||
|
label: typeof props.children === 'string' ? props.children : eventProperties.label,
|
||||||
|
});
|
||||||
|
if (onClick) onClick(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Button {...props} onClick={handleClick} />;
|
||||||
|
}
|
||||||
44
components/analytics/TrackedLink.tsx
Normal file
44
components/analytics/TrackedLink.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
interface TrackedLinkProps {
|
||||||
|
href: string;
|
||||||
|
eventName?: string;
|
||||||
|
eventProperties?: Record<string, any>;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper around next/link that tracks the click event.
|
||||||
|
* Useful for adding tracking to server components.
|
||||||
|
*/
|
||||||
|
export default function TrackedLink({
|
||||||
|
href,
|
||||||
|
eventName = AnalyticsEvents.LINK_CLICK,
|
||||||
|
eventProperties = {},
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: TrackedLinkProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
trackEvent(eventName, {
|
||||||
|
href,
|
||||||
|
...eventProperties,
|
||||||
|
});
|
||||||
|
if (onClick) onClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href} className={className} onClick={handleClick}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Analytics Events Utility
|
* Analytics Events Utility
|
||||||
*
|
*
|
||||||
* Centralized definitions for common analytics events and their properties.
|
* Centralized definitions for common analytics events and their properties.
|
||||||
* This helps maintain consistency across the application and makes it easier
|
* This helps maintain consistency across the application and makes it easier
|
||||||
* to track meaningful events.
|
* to track meaningful events.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* import { useAnalytics } from '@/components/analytics/useAnalytics';
|
* import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
* import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
* import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
*
|
*
|
||||||
* function ProductPage() {
|
* function ProductPage() {
|
||||||
* const { trackEvent } = useAnalytics();
|
* const { trackEvent } = useAnalytics();
|
||||||
*
|
*
|
||||||
* const handleAddToCart = (productId: string, productName: string) => {
|
* const handleAddToCart = (productId: string, productName: string) => {
|
||||||
* trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
|
* trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
|
||||||
* product_id: productId,
|
* product_id: productId,
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
* page: 'product-detail'
|
* page: 'product-detail'
|
||||||
* });
|
* });
|
||||||
* };
|
* };
|
||||||
*
|
*
|
||||||
* return <button onClick={() => handleAddToCart('123', 'Cable')}>Add to Cart</button>;
|
* return <button onClick={() => handleAddToCart('123', 'Cable')}>Add to Cart</button>;
|
||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
@@ -31,6 +31,7 @@ export const AnalyticsEvents = {
|
|||||||
PAGE_VIEW: 'pageview',
|
PAGE_VIEW: 'pageview',
|
||||||
PAGE_SCROLL: 'page_scroll',
|
PAGE_SCROLL: 'page_scroll',
|
||||||
PAGE_EXIT: 'page_exit',
|
PAGE_EXIT: 'page_exit',
|
||||||
|
SCROLL_DEPTH: 'scroll_depth',
|
||||||
|
|
||||||
// User Interaction Events
|
// User Interaction Events
|
||||||
BUTTON_CLICK: 'button_click',
|
BUTTON_CLICK: 'button_click',
|
||||||
@@ -38,6 +39,7 @@ export const AnalyticsEvents = {
|
|||||||
FORM_SUBMIT: 'form_submit',
|
FORM_SUBMIT: 'form_submit',
|
||||||
FORM_START: 'form_start',
|
FORM_START: 'form_start',
|
||||||
FORM_ERROR: 'form_error',
|
FORM_ERROR: 'form_error',
|
||||||
|
FORM_FIELD_FOCUS: 'form_field_focus',
|
||||||
|
|
||||||
// E-commerce Events
|
// E-commerce Events
|
||||||
PRODUCT_VIEW: 'product_view',
|
PRODUCT_VIEW: 'product_view',
|
||||||
@@ -46,6 +48,7 @@ export const AnalyticsEvents = {
|
|||||||
PRODUCT_PURCHASE: 'product_purchase',
|
PRODUCT_PURCHASE: 'product_purchase',
|
||||||
PRODUCT_WISHLIST_ADD: 'product_wishlist_add',
|
PRODUCT_WISHLIST_ADD: 'product_wishlist_add',
|
||||||
PRODUCT_WISHLIST_REMOVE: 'product_wishlist_remove',
|
PRODUCT_WISHLIST_REMOVE: 'product_wishlist_remove',
|
||||||
|
PRODUCT_TAB_SWITCH: 'product_tab_switch',
|
||||||
|
|
||||||
// Search & Filter Events
|
// Search & Filter Events
|
||||||
SEARCH: 'search',
|
SEARCH: 'search',
|
||||||
@@ -71,6 +74,7 @@ export const AnalyticsEvents = {
|
|||||||
TOGGLE_SWITCH: 'toggle_switch',
|
TOGGLE_SWITCH: 'toggle_switch',
|
||||||
ACCORDION_TOGGLE: 'accordion_toggle',
|
ACCORDION_TOGGLE: 'accordion_toggle',
|
||||||
TAB_SWITCH: 'tab_switch',
|
TAB_SWITCH: 'tab_switch',
|
||||||
|
TOC_CLICK: 'toc_click',
|
||||||
|
|
||||||
// Error & Performance Events
|
// Error & Performance Events
|
||||||
ERROR: 'error',
|
ERROR: 'error',
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { cn } from '@/components/ui/utils';
|
import { cn } from '@/components/ui/utils';
|
||||||
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
|
|
||||||
interface TocItem {
|
interface TocItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,11 +18,12 @@ interface TableOfContentsProps {
|
|||||||
|
|
||||||
export default function TableOfContents({ headings, locale }: TableOfContentsProps) {
|
export default function TableOfContents({ headings, locale }: TableOfContentsProps) {
|
||||||
const [activeId, setActiveId] = useState<string>('');
|
const [activeId, setActiveId] = useState<string>('');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observerOptions = {
|
const observerOptions = {
|
||||||
rootMargin: '-10% 0% -70% 0%',
|
rootMargin: '-10% 0% -70% 0%',
|
||||||
threshold: 0
|
threshold: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
@@ -66,15 +69,20 @@ export default function TableOfContents({ headings, locale }: TableOfContentsPro
|
|||||||
<a
|
<a
|
||||||
href={`#${heading.id}`}
|
href={`#${heading.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm md:text-base transition-all duration-300 hover:text-primary block leading-snug",
|
'text-sm md:text-base transition-all duration-300 hover:text-primary block leading-snug',
|
||||||
activeId === heading.id
|
activeId === heading.id
|
||||||
? "text-primary font-bold translate-x-1"
|
? 'text-primary font-bold translate-x-1'
|
||||||
: "text-text-secondary font-medium hover:translate-x-1"
|
: 'text-text-secondary font-medium hover:translate-x-1',
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const element = document.getElementById(heading.id);
|
const element = document.getElementById(heading.id);
|
||||||
if (element) {
|
if (element) {
|
||||||
|
trackEvent(AnalyticsEvents.TOC_CLICK, {
|
||||||
|
heading_id: heading.id,
|
||||||
|
heading_text: heading.text,
|
||||||
|
location: 'blog_sidebar',
|
||||||
|
});
|
||||||
const yOffset = -100;
|
const yOffset = -100;
|
||||||
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
|
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
|
||||||
window.scrollTo({ top: y, behavior: 'smooth' });
|
window.scrollTo({ top: y, behavior: 'smooth' });
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ export default function Experience() {
|
|||||||
return (
|
return (
|
||||||
<Section className="relative py-32 md:py-48 overflow-hidden text-white">
|
<Section className="relative py-32 md:py-48 overflow-hidden text-white">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
||||||
alt={t('subtitle')}
|
alt={t('subtitle')}
|
||||||
fill
|
fill
|
||||||
className="object-cover object-center scale-105 animate-slow-zoom"
|
className="object-cover object-center scale-105 animate-slow-zoom"
|
||||||
unoptimized
|
sizes="100vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<Heading level={2} subtitle={t('subtitle')} className="text-white">
|
<Heading level={2} subtitle={t('subtitle')} className="text-white">
|
||||||
@@ -29,19 +29,25 @@ export default function Experience() {
|
|||||||
<p className="border-l-4 border-accent pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl">
|
<p className="border-l-4 border-accent pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl">
|
||||||
{t('p1')}
|
{t('p1')}
|
||||||
</p>
|
</p>
|
||||||
<p className="pl-9">
|
<p className="pl-9">{t('p2')}</p>
|
||||||
{t('p2')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">{t('certifiedQuality')}</div>
|
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">{t('vdeApproved')}</div>
|
{t('certifiedQuality')}
|
||||||
|
</div>
|
||||||
|
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||||
|
{t('vdeApproved')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
|
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
|
||||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">{t('fullSpectrum')}</div>
|
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">{t('solutionsRange')}</div>
|
{t('fullSpectrum')}
|
||||||
|
</div>
|
||||||
|
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||||
|
{t('solutionsRange')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Section, Container, Heading } from '../../components/ui';
|
import { Section, Container, Heading } from '../../components/ui';
|
||||||
@@ -19,19 +18,9 @@ export default function GallerySection() {
|
|||||||
'/uploads/2024/12/DSC07768-Large.webp',
|
'/uploads/2024/12/DSC07768-Large.webp',
|
||||||
];
|
];
|
||||||
|
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const photoParam = searchParams.get('photo');
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
const lightboxOpen = photoParam !== null;
|
||||||
|
const lightboxIndex = photoParam ? parseInt(photoParam, 10) : 0;
|
||||||
useEffect(() => {
|
|
||||||
const photoParam = searchParams.get('photo');
|
|
||||||
if (photoParam !== null) {
|
|
||||||
const index = parseInt(photoParam, 10);
|
|
||||||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
|
||||||
if (lightboxIndex !== index) setLightboxIndex(index);
|
|
||||||
if (!lightboxOpen) setLightboxOpen(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchParams, images.length, lightboxIndex, lightboxOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-white text-white py-32">
|
<Section className="bg-white text-white py-32">
|
||||||
@@ -45,8 +34,10 @@ export default function GallerySection() {
|
|||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLightboxIndex(idx);
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
setLightboxOpen(true);
|
params.set('photo', idx.toString());
|
||||||
|
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||||
|
// Since we're using derive-from-url, the component will re-render with the new value
|
||||||
}}
|
}}
|
||||||
className="relative aspect-[4/3] overflow-hidden rounded-3xl group shadow-lg hover:shadow-2xl transition-all duration-700 cursor-pointer"
|
className="relative aspect-[4/3] overflow-hidden rounded-3xl group shadow-lg hover:shadow-2xl transition-all duration-700 cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -55,7 +46,7 @@ export default function GallerySection() {
|
|||||||
alt={`${t('alt')} ${idx + 1}`}
|
alt={`${t('alt')} ${idx + 1}`}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
unoptimized
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-dark/20 group-hover:bg-transparent transition-all duration-500" />
|
<div className="absolute inset-0 bg-primary-dark/20 group-hover:bg-transparent transition-all duration-500" />
|
||||||
<div className="absolute inset-0 border-0 group-hover:border-[16px] border-white/10 transition-all duration-500 pointer-events-none" />
|
<div className="absolute inset-0 border-0 group-hover:border-[16px] border-white/10 transition-all duration-500 pointer-events-none" />
|
||||||
@@ -68,7 +59,11 @@ export default function GallerySection() {
|
|||||||
isOpen={lightboxOpen}
|
isOpen={lightboxOpen}
|
||||||
images={images}
|
images={images}
|
||||||
initialIndex={lightboxIndex}
|
initialIndex={lightboxIndex}
|
||||||
onClose={() => setLightboxOpen(false)}
|
onClose={() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete('photo');
|
||||||
|
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
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 HeroIllustration from './HeroIllustration';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
|
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
const t = useTranslations('Home.hero');
|
const t = useTranslations('Home.hero');
|
||||||
|
const locale = useLocale();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
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">
|
||||||
@@ -19,7 +24,10 @@ export default function Hero() {
|
|||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
>
|
>
|
||||||
<motion.div variants={headingVariants}>
|
<motion.div variants={headingVariants}>
|
||||||
<Heading level={1} className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]">
|
<Heading
|
||||||
|
level={1}
|
||||||
|
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
||||||
|
>
|
||||||
{t.rich('title', {
|
{t.rich('title', {
|
||||||
green: (chunks) => (
|
green: (chunks) => (
|
||||||
<span className="relative inline-block">
|
<span className="relative inline-block">
|
||||||
@@ -36,7 +44,7 @@ export default function Hero() {
|
|||||||
<Scribble variant="circle" />
|
<Scribble variant="circle" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</span>
|
</span>
|
||||||
)
|
),
|
||||||
})}
|
})}
|
||||||
</Heading>
|
</Heading>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -50,13 +58,35 @@ export default function Hero() {
|
|||||||
variants={buttonContainerVariants}
|
variants={buttonContainerVariants}
|
||||||
>
|
>
|
||||||
<motion.div variants={buttonVariants}>
|
<motion.div variants={buttonVariants}>
|
||||||
<Button href="/contact" variant="accent" size="lg" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg">
|
<Button
|
||||||
|
href="/contact"
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('cta'),
|
||||||
|
location: 'home_hero_primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
{t('cta')}
|
{t('cta')}
|
||||||
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div variants={buttonVariants}>
|
<motion.div variants={buttonVariants}>
|
||||||
<Button href="/products" variant="white" size="lg" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none">
|
<Button
|
||||||
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
|
variant="white"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('exploreProducts'),
|
||||||
|
location: 'home_hero_secondary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
{t('exploreProducts')}
|
{t('exploreProducts')}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -77,7 +107,7 @@ export default function Hero() {
|
|||||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 1, ease: "easeOut", delay: 3 }}
|
transition={{ duration: 1, ease: 'easeOut', delay: 3 }}
|
||||||
>
|
>
|
||||||
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -86,7 +116,7 @@ export default function Hero() {
|
|||||||
transition={{
|
transition={{
|
||||||
duration: 1.5,
|
duration: 1.5,
|
||||||
repeat: Infinity,
|
repeat: Infinity,
|
||||||
ease: "easeInOut"
|
ease: 'easeInOut',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,9 +131,9 @@ const containerVariants = {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.12,
|
staggerChildren: 0.12,
|
||||||
delayChildren: 0.4
|
delayChildren: 0.4,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const headingVariants = {
|
const headingVariants = {
|
||||||
@@ -112,8 +142,8 @@ const headingVariants = {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] }
|
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const accentVariants = {
|
const accentVariants = {
|
||||||
@@ -122,8 +152,8 @@ const accentVariants = {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
rotate: 0,
|
rotate: 0,
|
||||||
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] }
|
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const scribbleVariants = {
|
const scribbleVariants = {
|
||||||
@@ -132,8 +162,8 @@ const scribbleVariants = {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
rotate: 0,
|
rotate: 0,
|
||||||
transition: { duration: 1, type: "spring", stiffness: 300, damping: 20 }
|
transition: { duration: 1, type: 'spring', stiffness: 300, damping: 20 },
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const subtitleVariants = {
|
const subtitleVariants = {
|
||||||
@@ -142,8 +172,8 @@ const subtitleVariants = {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
transition: { duration: 1, ease: [0.25, 0.46, 0.45, 0.94] }
|
transition: { duration: 1, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const buttonContainerVariants = {
|
const buttonContainerVariants = {
|
||||||
@@ -152,9 +182,9 @@ const buttonContainerVariants = {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.15,
|
staggerChildren: 0.15,
|
||||||
delayChildren: 0.4
|
delayChildren: 0.4,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const buttonVariants = {
|
const buttonVariants = {
|
||||||
@@ -163,6 +193,6 @@ const buttonVariants = {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
transition: { type: "spring", stiffness: 400, damping: 20 }
|
transition: { type: 'spring', stiffness: 400, damping: 20 },
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,43 +11,53 @@ export default function MeetTheTeam() {
|
|||||||
return (
|
return (
|
||||||
<Section className="relative py-32 md:py-48 overflow-hidden">
|
<Section className="relative py-32 md:py-48 overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/uploads/2024/12/DSC08036-Large.webp"
|
src="/uploads/2024/12/DSC08036-Large.webp"
|
||||||
alt={t('subtitle')}
|
alt={t('subtitle')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 animate-slow-zoom"
|
className="object-cover scale-105 animate-slow-zoom"
|
||||||
unoptimized
|
sizes="100vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-3xl text-white animate-slide-up">
|
<div className="max-w-3xl text-white animate-slide-up">
|
||||||
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-8">
|
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-8">
|
||||||
<span className="text-white">{t('title')}</span>
|
<span className="text-white">{t('title')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<div className="relative mb-12">
|
<div className="relative mb-12">
|
||||||
<div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" />
|
<div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" />
|
||||||
<p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8">
|
<p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8">
|
||||||
"{t('description')}"
|
"{t('description')}"
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-8 items-center">
|
<div className="flex flex-wrap gap-8 items-center">
|
||||||
<Button href={`/${locale}/team`} variant="accent" size="xl" className="group">
|
<Button href={`/${locale}/team`} variant="accent" size="xl" className="group">
|
||||||
{t('cta')}
|
{t('cta')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex -space-x-4">
|
<div className="flex -space-x-4">
|
||||||
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
|
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
|
||||||
<Image src="/uploads/2024/12/DSC07768-Large.webp" alt={teamT('michael.name')} fill className="object-cover" />
|
<Image
|
||||||
|
src="/uploads/2024/12/DSC07768-Large.webp"
|
||||||
|
alt={teamT('michael.name')}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
|
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
|
||||||
<Image src="/uploads/2024/12/DSC07963-Large.webp" alt={teamT('klaus.name')} fill className="object-cover" />
|
<Image
|
||||||
|
src="/uploads/2024/12/DSC07963-Large.webp"
|
||||||
|
alt={teamT('klaus.name')}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
|
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
|
||||||
|
|||||||
@@ -8,63 +8,77 @@ 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'}`,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{categories.map((category, idx) => (
|
{categories.map((category, idx) => (
|
||||||
<Link key={idx} href={category.href} className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0">
|
<Link
|
||||||
<Image
|
key={idx}
|
||||||
src={category.img}
|
href={category.href}
|
||||||
|
className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={category.img}
|
||||||
alt={category.title}
|
alt={category.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
|
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
|
||||||
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
|
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
|
||||||
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
|
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
|
||||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-white/10 backdrop-blur-md rounded-xl flex items-center justify-center mb-4 md:mb-6 border border-white/20">
|
<div className="w-12 h-12 md:w-16 md:h-16 bg-white/10 backdrop-blur-md rounded-xl flex items-center justify-center mb-4 md:mb-6 border border-white/20">
|
||||||
<Image src={category.icon} alt="" width={40} height={40} className="w-8 h-8 md:w-10 md:h-10 brightness-0 invert" unoptimized />
|
<Image
|
||||||
|
src={category.icon}
|
||||||
|
alt=""
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="w-8 h-8 md:w-10 md:h-10 brightness-0 invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight">{category.title}</h3>
|
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight">
|
||||||
|
{category.title}
|
||||||
|
</h3>
|
||||||
<p className="text-white/80 text-sm md:text-base line-clamp-3 opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 max-h-24 md:max-h-0 group-hover:max-h-32">
|
<p className="text-white/80 text-sm md:text-base line-clamp-3 opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 max-h-24 md:max-h-0 group-hover:max-h-32">
|
||||||
{category.desc}
|
{category.desc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
|
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
|
||||||
{t('exploreCategory')} <span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
{t('exploreCategory')}{' '}
|
||||||
|
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPosts } from '@/lib/blog';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
@@ -22,8 +23,11 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
|||||||
<Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
|
<Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
|
||||||
{t('allArticles')}
|
{t('allArticles')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Link href={`/${locale}/blog`} className="group flex items-center text-primary font-bold text-base md:text-lg touch-target">
|
<Link
|
||||||
{t('allArticles')}
|
href={`/${locale}/blog`}
|
||||||
|
className="group flex items-center text-primary font-bold text-base md:text-lg touch-target"
|
||||||
|
>
|
||||||
|
{t('allArticles')}
|
||||||
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,10 +38,12 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
|||||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl">
|
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl">
|
||||||
{post.frontmatter.featuredImage && (
|
{post.frontmatter.featuredImage && (
|
||||||
<div className="relative h-64 overflow-hidden">
|
<div className="relative h-64 overflow-hidden">
|
||||||
<img
|
<Image
|
||||||
src={post.frontmatter.featuredImage}
|
src={post.frontmatter.featuredImage}
|
||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
|
fill
|
||||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
|
sizes="(max-width: 768px) 100vw, 33vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
@@ -53,7 +59,7 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
|||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric'
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
|
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
|
||||||
@@ -61,8 +67,18 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
||||||
{t('readMore')}
|
{t('readMore')}
|
||||||
<svg className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
118
components/record-mode/PickingHelper.tsx
Normal file
118
components/record-mode/PickingHelper.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { finder } from '@medv/finder';
|
||||||
|
|
||||||
|
export function PickingHelper() {
|
||||||
|
const [pickingMode, setPickingMode] = useState<'click' | 'scroll' | null>(null);
|
||||||
|
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMessage = (e: MessageEvent) => {
|
||||||
|
if (e.data.type === 'START_PICKING') {
|
||||||
|
setPickingMode(e.data.mode);
|
||||||
|
} else if (e.data.type === 'STOP_PICKING') {
|
||||||
|
setPickingMode(null);
|
||||||
|
setHoveredElement(null);
|
||||||
|
} else if (e.data.type === 'SET_HOVER_SELECTOR') {
|
||||||
|
const selector = e.data.selector;
|
||||||
|
if (selector) {
|
||||||
|
const el = document.querySelector(selector) as HTMLElement;
|
||||||
|
setHoveredElement(el || null);
|
||||||
|
} else {
|
||||||
|
setHoveredElement(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
return () => window.removeEventListener('message', handleMessage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pickingMode) return;
|
||||||
|
|
||||||
|
const handleMouseOver = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('.record-mode-ignore') || target.closest('.feedback-ui-ignore')) return;
|
||||||
|
setHoveredElement(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (hoveredElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const selector = finder(hoveredElement, {
|
||||||
|
root: document.body,
|
||||||
|
seedMinLength: 3,
|
||||||
|
optimizedMinLength: 2,
|
||||||
|
className: (name) =>
|
||||||
|
!name.startsWith('record-mode-') &&
|
||||||
|
!name.startsWith('feedback-') &&
|
||||||
|
!name.includes('[') &&
|
||||||
|
!name.includes('/') &&
|
||||||
|
!name.match(/^[a-z]-[0-9]/) &&
|
||||||
|
!name.match(/[0-9]{4,}/), // Avoid dynamic IDs in classnames
|
||||||
|
idName: (name) => !name.startsWith('__next') && !name.includes(':') && !name.match(/[0-9]{5,}/),
|
||||||
|
});
|
||||||
|
const rect = hoveredElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'ELEMENT_SELECTED',
|
||||||
|
selector,
|
||||||
|
rect: {
|
||||||
|
x: rect.left,
|
||||||
|
y: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height
|
||||||
|
},
|
||||||
|
tagName: hoveredElement.tagName.toLowerCase()
|
||||||
|
}, '*');
|
||||||
|
|
||||||
|
setPickingMode(null);
|
||||||
|
setHoveredElement(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setPickingMode(null);
|
||||||
|
setHoveredElement(null);
|
||||||
|
window.parent.postMessage({ type: 'PICKING_CANCELLED' }, '*');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mouseover', handleMouseOver);
|
||||||
|
window.addEventListener('click', handleClick, true);
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mouseover', handleMouseOver);
|
||||||
|
window.removeEventListener('click', handleClick, true);
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [pickingMode, hoveredElement]);
|
||||||
|
|
||||||
|
if (!hoveredElement) return null;
|
||||||
|
// Don't show highlight if we are in picking mode but NOT hovering anything (handled by logic above)
|
||||||
|
// but DO show if we have a hoveredElement (from message or mouseover)
|
||||||
|
|
||||||
|
const rect = hoveredElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed pointer-events-none border-2 border-[#82ed20] bg-[#82ed20]/15 transition-all z-[9999] shadow-[0_0_20px_rgba(130,237,32,0.3)] rounded-sm"
|
||||||
|
style={{
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 right-0 bg-[#82ed20] text-black text-[10px] font-black px-1.5 py-1 transform -translate-y-full uppercase tracking-tighter shadow-xl">
|
||||||
|
{hoveredElement.tagName.toLowerCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
components/record-mode/PlaybackCursor.tsx
Normal file
90
components/record-mode/PlaybackCursor.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
|
||||||
|
export function PlaybackCursor() {
|
||||||
|
const { isPlaying, cursorPosition, isClicking } = useRecordMode();
|
||||||
|
const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// Track scroll so cursor stays locked to the correct element
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
setScrollOffset({ x: window.scrollX, y: window.scrollY });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleScroll(); // Init
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
if (!isPlaying) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="fixed z-[10000] pointer-events-none"
|
||||||
|
animate={{
|
||||||
|
x: cursorPosition.x,
|
||||||
|
y: cursorPosition.y,
|
||||||
|
scale: isClicking ? 0.8 : 1,
|
||||||
|
rotateX: isClicking ? 15 : 0,
|
||||||
|
rotateY: isClicking ? -15 : 0,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||||
|
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||||
|
scale: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
|
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
|
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
|
}}
|
||||||
|
style={{ perspective: '1000px' }}
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isClicking && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
|
animate={{ scale: 2.5, opacity: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||||
|
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Outer Pulse Ring */}
|
||||||
|
<div
|
||||||
|
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Visual Cursor */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* Soft Glow */}
|
||||||
|
<div
|
||||||
|
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pointer Arrow */}
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={`drop-shadow-[0_2px_8px_rgba(130,237,32,0.5)] transition-transform ${isClicking ? 'translate-y-0.5' : ''}`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z"
|
||||||
|
fill={isClicking ? '#82ed20' : 'white'}
|
||||||
|
stroke="black"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="transition-colors duration-150"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
392
components/record-mode/RecordModeContext.tsx
Normal file
392
components/record-mode/RecordModeContext.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||||
|
import { RecordEvent, RecordingSession } from '@/types/record-mode';
|
||||||
|
|
||||||
|
interface RecordModeContextType {
|
||||||
|
isActive: boolean;
|
||||||
|
setIsActive: (active: boolean) => void;
|
||||||
|
events: RecordEvent[];
|
||||||
|
addEvent: (event: Omit<RecordEvent, 'id' | 'timestamp'>) => void;
|
||||||
|
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
|
||||||
|
removeEvent: (id: string) => void;
|
||||||
|
clearEvents: () => void;
|
||||||
|
setEvents: (events: RecordEvent[]) => void;
|
||||||
|
isPlaying: boolean;
|
||||||
|
playEvents: () => void;
|
||||||
|
stopPlayback: () => void;
|
||||||
|
cursorPosition: { x: number; y: number };
|
||||||
|
zoomLevel: number;
|
||||||
|
isBlurry: boolean;
|
||||||
|
currentSession: RecordingSession | null;
|
||||||
|
saveSession: (name: string) => void;
|
||||||
|
isFeedbackActive: boolean;
|
||||||
|
setIsFeedbackActive: (active: boolean) => void;
|
||||||
|
reorderEvents: (startIndex: number, endIndex: number) => void;
|
||||||
|
hoveredEventId: string | null;
|
||||||
|
setHoveredEventId: (id: string | null) => void;
|
||||||
|
isClicking: boolean;
|
||||||
|
isEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecordModeContext = createContext<RecordModeContextType | null>(null);
|
||||||
|
|
||||||
|
export function useRecordMode(): RecordModeContextType {
|
||||||
|
const context = useContext(RecordModeContext);
|
||||||
|
if (!context) {
|
||||||
|
return {
|
||||||
|
isActive: false,
|
||||||
|
setIsActive: () => {},
|
||||||
|
events: [],
|
||||||
|
addEvent: () => {},
|
||||||
|
updateEvent: () => {},
|
||||||
|
removeEvent: () => {},
|
||||||
|
clearEvents: () => {},
|
||||||
|
isPlaying: false,
|
||||||
|
playEvents: () => {},
|
||||||
|
stopPlayback: () => {},
|
||||||
|
cursorPosition: { x: 0, y: 0 },
|
||||||
|
zoomLevel: 1,
|
||||||
|
isBlurry: false,
|
||||||
|
currentSession: null,
|
||||||
|
isFeedbackActive: false,
|
||||||
|
setIsFeedbackActive: () => {},
|
||||||
|
saveSession: () => {},
|
||||||
|
reorderEvents: () => {},
|
||||||
|
hoveredEventId: null,
|
||||||
|
setHoveredEventId: () => {},
|
||||||
|
setEvents: () => {},
|
||||||
|
isClicking: false,
|
||||||
|
isEnabled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordModeProvider({
|
||||||
|
children,
|
||||||
|
isEnabled = false,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [isActive, setIsActiveState] = useState(false);
|
||||||
|
const [events, setEvents] = useState<RecordEvent[]>([]);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(1);
|
||||||
|
const [isBlurry, setIsBlurry] = useState(false);
|
||||||
|
const [isFeedbackActive, setIsFeedbackActiveState] = useState(false);
|
||||||
|
const [hoveredEventId, setHoveredEventId] = useState<string | null>(null);
|
||||||
|
const [isClicking, setIsClicking] = useState(false);
|
||||||
|
const [isEmbedded, setIsEmbedded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[RecordModeProvider] Mounted with isEnabled:', isEnabled);
|
||||||
|
}, [isEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
const embedded =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
(window.location.search.includes('embedded=true') ||
|
||||||
|
window.name === 'record-mode-iframe' ||
|
||||||
|
window.self !== window.top);
|
||||||
|
setIsEmbedded(embedded);
|
||||||
|
}, [isEnabled]);
|
||||||
|
|
||||||
|
const setIsActive = (active: boolean) => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
setIsActiveState(active);
|
||||||
|
if (active) setIsFeedbackActiveState(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setIsFeedbackActive = (active: boolean) => {
|
||||||
|
setIsFeedbackActiveState(active);
|
||||||
|
if (active && isEnabled) setIsActiveState(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPlayingRef = useRef(false);
|
||||||
|
const isLoadedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
const savedEvents = localStorage.getItem('klz-record-events');
|
||||||
|
const savedActive = localStorage.getItem('klz-record-active');
|
||||||
|
if (savedEvents) setEvents(JSON.parse(savedEvents));
|
||||||
|
if (savedActive) setIsActive(JSON.parse(savedActive));
|
||||||
|
isLoadedRef.current = true;
|
||||||
|
}, [isEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEnabled || !isLoadedRef.current) return;
|
||||||
|
localStorage.setItem('klz-record-events', JSON.stringify(events));
|
||||||
|
}, [events, isEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
localStorage.setItem('klz-record-active', JSON.stringify(isActive));
|
||||||
|
}, [isActive, isEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
if (isEmbedded) {
|
||||||
|
const handlePlaybackMessage = (e: MessageEvent) => {
|
||||||
|
if (e.data.type === 'PLAY_EVENT') {
|
||||||
|
const { event } = e.data;
|
||||||
|
const el = event.selector
|
||||||
|
? (document.querySelector(event.selector) as HTMLElement)
|
||||||
|
: null;
|
||||||
|
if (el) {
|
||||||
|
if (event.type === 'scroll') {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
} else if (event.type === 'mouse') {
|
||||||
|
const currentRect = el.getBoundingClientRect();
|
||||||
|
let targetX = currentRect.left + currentRect.width / 2;
|
||||||
|
let targetY = currentRect.top + currentRect.height / 2;
|
||||||
|
|
||||||
|
if (event.clickOrigin === 'top-left') {
|
||||||
|
targetX = currentRect.left + 5;
|
||||||
|
targetY = currentRect.top + 5;
|
||||||
|
} else if (event.clickOrigin === 'top-right') {
|
||||||
|
targetX = currentRect.right - 5;
|
||||||
|
targetY = currentRect.top + 5;
|
||||||
|
} else if (event.clickOrigin === 'bottom-left') {
|
||||||
|
targetX = currentRect.left + 5;
|
||||||
|
targetY = currentRect.bottom - 5;
|
||||||
|
} else if (event.clickOrigin === 'bottom-right') {
|
||||||
|
targetX = currentRect.right - 5;
|
||||||
|
targetY = currentRect.bottom - 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventCoords = { clientX: targetX, clientY: targetY };
|
||||||
|
const dispatchMouse = (type: string) => {
|
||||||
|
el.dispatchEvent(
|
||||||
|
new MouseEvent(type, {
|
||||||
|
view: window,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
...eventCoords,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.interactionType === 'click') {
|
||||||
|
setIsClicking(true);
|
||||||
|
dispatchMouse('mousedown');
|
||||||
|
setTimeout(() => {
|
||||||
|
dispatchMouse('mouseup');
|
||||||
|
if (event.realClick) {
|
||||||
|
dispatchMouse('click');
|
||||||
|
el.click();
|
||||||
|
}
|
||||||
|
setIsClicking(false);
|
||||||
|
}, 150);
|
||||||
|
} else {
|
||||||
|
dispatchMouse('mousemove');
|
||||||
|
dispatchMouse('mouseover');
|
||||||
|
dispatchMouse('mouseenter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', handlePlaybackMessage);
|
||||||
|
return () => window.removeEventListener('message', handlePlaybackMessage);
|
||||||
|
}
|
||||||
|
}, [isEmbedded, isEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEnabled || isEmbedded || !isActive) return;
|
||||||
|
const event = events.find((e) => e.id === hoveredEventId);
|
||||||
|
const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement;
|
||||||
|
if (iframe?.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage(
|
||||||
|
{ type: 'SET_HOVER_SELECTOR', selector: event?.selector || null },
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [hoveredEventId, events, isActive, isEmbedded, isEnabled]);
|
||||||
|
|
||||||
|
const addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
const newEvent: RecordEvent = {
|
||||||
|
realClick: false,
|
||||||
|
...event,
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
setEvents((prev) => [...prev, newEvent]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
setEvents((prev) =>
|
||||||
|
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reorderEvents = (startIndex: number, endIndex: number) => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
const result = Array.from(events);
|
||||||
|
const [removed] = result.splice(startIndex, 1);
|
||||||
|
result.splice(endIndex, 0, removed);
|
||||||
|
setEvents(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEvent = (id: string) => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
setEvents((prev) => prev.filter((event) => event.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearEvents = () => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
if (confirm('Clear all recorded events?')) setEvents([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentSession: RecordingSession | null =
|
||||||
|
events.length > 0
|
||||||
|
? {
|
||||||
|
id: 'draft',
|
||||||
|
name: 'Draft Session',
|
||||||
|
events,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const saveSession = (name: string) => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
console.log('Saving session:', name, events);
|
||||||
|
};
|
||||||
|
|
||||||
|
const playEvents = async () => {
|
||||||
|
if (!isEnabled || events.length === 0 || isPlayingRef.current) return;
|
||||||
|
setIsPlaying(true);
|
||||||
|
isPlayingRef.current = true;
|
||||||
|
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||||
|
|
||||||
|
for (const event of sortedEvents) {
|
||||||
|
if (!isPlayingRef.current) break;
|
||||||
|
if (event.rect && !isEmbedded) {
|
||||||
|
const iframe = document.querySelector(
|
||||||
|
'iframe[name="record-mode-iframe"]',
|
||||||
|
) as HTMLIFrameElement;
|
||||||
|
const iframeRect = iframe?.getBoundingClientRect();
|
||||||
|
setCursorPosition({
|
||||||
|
x: (iframeRect?.left || 0) + event.rect.x + event.rect.width / 2,
|
||||||
|
y: (iframeRect?.top || 0) + event.rect.y + event.rect.height / 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.selector) {
|
||||||
|
if (!isEmbedded) {
|
||||||
|
const iframe = document.querySelector(
|
||||||
|
'iframe[name="record-mode-iframe"]',
|
||||||
|
) as HTMLIFrameElement;
|
||||||
|
if (iframe?.contentWindow)
|
||||||
|
iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*');
|
||||||
|
} else {
|
||||||
|
const el = document.querySelector(event.selector) as HTMLElement;
|
||||||
|
if (el) {
|
||||||
|
if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
else if (event.type === 'mouse') {
|
||||||
|
const currentRect = el.getBoundingClientRect();
|
||||||
|
let targetX = currentRect.left + currentRect.width / 2;
|
||||||
|
let targetY = currentRect.top + currentRect.height / 2;
|
||||||
|
|
||||||
|
if (event.clickOrigin === 'top-left') {
|
||||||
|
targetX = currentRect.left + 5;
|
||||||
|
targetY = currentRect.top + 5;
|
||||||
|
} else if (event.clickOrigin === 'top-right') {
|
||||||
|
targetX = currentRect.right - 5;
|
||||||
|
targetY = currentRect.top + 5;
|
||||||
|
} else if (event.clickOrigin === 'bottom-left') {
|
||||||
|
targetX = currentRect.left + 5;
|
||||||
|
targetY = currentRect.bottom - 5;
|
||||||
|
} else if (event.clickOrigin === 'bottom-right') {
|
||||||
|
targetX = currentRect.right - 5;
|
||||||
|
targetY = currentRect.bottom - 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventCoords = { clientX: targetX, clientY: targetY };
|
||||||
|
const dispatchMouse = (type: string) => {
|
||||||
|
el.dispatchEvent(
|
||||||
|
new MouseEvent(type, {
|
||||||
|
view: window,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
...eventCoords,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.interactionType === 'click') {
|
||||||
|
setIsClicking(true);
|
||||||
|
dispatchMouse('mousedown');
|
||||||
|
setTimeout(() => {
|
||||||
|
dispatchMouse('mouseup');
|
||||||
|
if (event.realClick) {
|
||||||
|
dispatchMouse('click');
|
||||||
|
el.click();
|
||||||
|
}
|
||||||
|
setIsClicking(false);
|
||||||
|
}, 150);
|
||||||
|
} else {
|
||||||
|
dispatchMouse('mousemove');
|
||||||
|
dispatchMouse('mouseover');
|
||||||
|
dispatchMouse('mouseenter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.zoom) setZoomLevel(event.zoom);
|
||||||
|
if (event.motionBlur) setIsBlurry(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, event.duration || 1000));
|
||||||
|
setIsBlurry(false);
|
||||||
|
}
|
||||||
|
setIsPlaying(false);
|
||||||
|
isPlayingRef.current = false;
|
||||||
|
setZoomLevel(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPlayback = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
isPlayingRef.current = false;
|
||||||
|
setZoomLevel(1);
|
||||||
|
setIsBlurry(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RecordModeContext.Provider
|
||||||
|
value={{
|
||||||
|
isActive,
|
||||||
|
setIsActive,
|
||||||
|
events,
|
||||||
|
addEvent,
|
||||||
|
updateEvent,
|
||||||
|
removeEvent,
|
||||||
|
clearEvents,
|
||||||
|
setEvents,
|
||||||
|
isPlaying,
|
||||||
|
playEvents,
|
||||||
|
stopPlayback,
|
||||||
|
cursorPosition,
|
||||||
|
zoomLevel,
|
||||||
|
isBlurry,
|
||||||
|
currentSession,
|
||||||
|
saveSession,
|
||||||
|
isFeedbackActive,
|
||||||
|
setIsFeedbackActive,
|
||||||
|
reorderEvents,
|
||||||
|
hoveredEventId,
|
||||||
|
setHoveredEventId,
|
||||||
|
isClicking,
|
||||||
|
isEnabled,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RecordModeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
583
components/record-mode/RecordModeOverlay.tsx
Normal file
583
components/record-mode/RecordModeOverlay.tsx
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
import { Reorder, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
MousePointer2,
|
||||||
|
Scroll,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Edit2,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
Download,
|
||||||
|
Settings2,
|
||||||
|
GripVertical,
|
||||||
|
Clock,
|
||||||
|
Maximize2,
|
||||||
|
Box,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { RecordEvent } from '@/types/record-mode';
|
||||||
|
import { PlaybackCursor } from './PlaybackCursor';
|
||||||
|
|
||||||
|
export function RecordModeOverlay() {
|
||||||
|
const {
|
||||||
|
isActive,
|
||||||
|
setIsActive,
|
||||||
|
events,
|
||||||
|
addEvent,
|
||||||
|
updateEvent,
|
||||||
|
removeEvent,
|
||||||
|
isPlaying,
|
||||||
|
playEvents,
|
||||||
|
saveSession,
|
||||||
|
clearEvents,
|
||||||
|
reorderEvents,
|
||||||
|
setHoveredEventId,
|
||||||
|
setEvents, // Added setEvents here
|
||||||
|
} = useRecordMode();
|
||||||
|
|
||||||
|
const [pickingMode, setPickingMode] = useState<'mouse' | 'scroll' | null>(null);
|
||||||
|
const [lastInteractionType, setLastInteractionType] = useState<'click' | 'hover'>('click');
|
||||||
|
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||||
|
const [editingEventId, setEditingEventId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Edit form state
|
||||||
|
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || !isActive) return;
|
||||||
|
|
||||||
|
const handleMessage = (e: MessageEvent) => {
|
||||||
|
if (e.data.type === 'ELEMENT_SELECTED') {
|
||||||
|
const { selector, rect, tagName } = e.data;
|
||||||
|
|
||||||
|
if (pickingMode === 'mouse') {
|
||||||
|
addEvent({
|
||||||
|
type: 'mouse',
|
||||||
|
interactionType: lastInteractionType,
|
||||||
|
selector,
|
||||||
|
duration: lastInteractionType === 'click' ? 1000 : 1500,
|
||||||
|
zoom: 1,
|
||||||
|
description: `Mouse ${lastInteractionType === 'click' ? '(Click)' : '(Hover)'} on ${tagName}`,
|
||||||
|
motionBlur: false,
|
||||||
|
realClick: false,
|
||||||
|
rect,
|
||||||
|
});
|
||||||
|
} else if (pickingMode === 'scroll') {
|
||||||
|
addEvent({
|
||||||
|
type: 'scroll',
|
||||||
|
selector,
|
||||||
|
duration: 1500,
|
||||||
|
zoom: 1,
|
||||||
|
description: `Scroll to ${tagName}`,
|
||||||
|
motionBlur: false,
|
||||||
|
rect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setPickingMode(null);
|
||||||
|
} else if (e.data.type === 'PICKING_CANCELLED') {
|
||||||
|
setPickingMode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
if (pickingMode) {
|
||||||
|
// Find the iframe and signal start picking
|
||||||
|
const iframe = document.querySelector('iframe');
|
||||||
|
if (iframe?.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage({ type: 'START_PICKING', mode: pickingMode }, '*');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Signal stop picking
|
||||||
|
const iframe = document.querySelector('iframe');
|
||||||
|
if (iframe?.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage({ type: 'STOP_PICKING' }, '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
};
|
||||||
|
}, [isActive, pickingMode, addEvent, mounted]);
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
if (editingEventId) {
|
||||||
|
updateEvent(editingEventId, editForm);
|
||||||
|
setEditingEventId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [showEvents, setShowEvents] = useState(true);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
// Failsafe: Never render host toggle in embedded mode
|
||||||
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
(window.self !== window.top ||
|
||||||
|
window.name === 'record-mode-iframe' ||
|
||||||
|
window.location.search.includes('embedded=true'))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsActive(true)}
|
||||||
|
className="fixed bottom-6 left-6 z-[9999] bg-[#82ed20]/20 hover:bg-[#82ed20]/30 text-[#82ed20] p-4 rounded-full shadow-2xl transition-all hover:scale-110 record-mode-ignore border border-[#82ed20]/30 backdrop-blur-md animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="w-5 h-5 rounded-[4px] border-2 border-[#82ed20]" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
||||||
|
{/* 1. Global Toolbar - Slim Industrial Bar */}
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||||
|
<div className="bg-black/80 backdrop-blur-2xl border border-white/10 p-2 rounded-[24px] shadow-[0_32px_80px_rgba(0,0,0,0.8)] flex items-center gap-2">
|
||||||
|
{/* Identity Tag */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
|
||||||
|
Event Builder
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
|
||||||
|
Manual Mode
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
|
|
||||||
|
{/* Action Tools */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPickingMode('mouse');
|
||||||
|
setLastInteractionType('click');
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<MousePointer2 size={16} />
|
||||||
|
<span>Mouse</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setPickingMode('scroll')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<Scroll size={16} />
|
||||||
|
<span>Scroll</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
addEvent({
|
||||||
|
type: 'wait',
|
||||||
|
duration: 2000,
|
||||||
|
zoom: 1,
|
||||||
|
description: 'Wait for 2s',
|
||||||
|
motionBlur: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>Wait</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
|
|
||||||
|
{/* Sequence Controls */}
|
||||||
|
<div className="flex items-center gap-1 p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={playEvents}
|
||||||
|
disabled={isPlaying || events.length === 0}
|
||||||
|
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
|
||||||
|
title="Preview Sequence"
|
||||||
|
>
|
||||||
|
<Play size={18} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEvents(!showEvents)}
|
||||||
|
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<Edit2 size={18} />
|
||||||
|
{events.length > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
|
||||||
|
{events.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const session = { events, name: 'Recording', createdAt: new Date().toISOString() };
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/save-session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(session),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
// Visual feedback could be improved, but alert is fine for dev tool
|
||||||
|
alert('Session saved to remotion/session.json');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Failed to save: ${err.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Error saving session');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="p-3 bg-white/5 hover:bg-green-500/20 rounded-2xl disabled:opacity-30 transition-all text-green-400"
|
||||||
|
title="Save to Project (Dev)"
|
||||||
|
>
|
||||||
|
<Save size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const data = JSON.stringify(
|
||||||
|
{ events, name: 'Recording', createdAt: new Date().toISOString() },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const blob = new Blob([data], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'remotion-session.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="p-3 bg-white/5 hover:bg-blue-500/20 rounded-2xl disabled:opacity-30 transition-all text-blue-400"
|
||||||
|
title="Download JSON"
|
||||||
|
>
|
||||||
|
<Download size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsActive(false)}
|
||||||
|
className="p-2.5 text-red-500 hover:bg-red-500/10 rounded-[14px] transition-all mx-1"
|
||||||
|
title="Exit Studio"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Event Timeline Popover */}
|
||||||
|
{showEvents && (
|
||||||
|
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
|
||||||
|
<div className="bg-black/90 backdrop-blur-3xl border border-white/10 rounded-[32px] p-6 shadow-[0_40px_100px_rgba(0,0,0,0.9)] max-h-[500px] overflow-hidden flex flex-col scale-in">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-bold text-lg leading-none">Recording Track</h4>
|
||||||
|
<p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">
|
||||||
|
{events.length} Actions Recorded
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearEvents}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="text-red-400/40 hover:text-red-400 transition-colors p-2 hover:bg-red-500/10 rounded-xl disabled:opacity-10"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
values={events}
|
||||||
|
onReorder={setEvents}
|
||||||
|
className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide"
|
||||||
|
>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="py-12 flex flex-col items-center justify-center text-white/10">
|
||||||
|
<Plus size={40} strokeWidth={1} />
|
||||||
|
<p className="text-xs mt-4">Timeline is empty</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
events.map((event, index) => (
|
||||||
|
<Reorder.Item
|
||||||
|
key={event.id}
|
||||||
|
value={event}
|
||||||
|
className="group flex items-center gap-3 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
|
||||||
|
onMouseEnter={() => setHoveredEventId(event.id)}
|
||||||
|
onMouseLeave={() => setHoveredEventId(null)}
|
||||||
|
>
|
||||||
|
<div className="cursor-grab active:cursor-grabbing text-white/10 hover:text-white/30 transition-colors">
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white text-[10px] font-black uppercase tracking-widest">
|
||||||
|
{event.type === 'mouse' ? `Mouse (${event.interactionType})` : event.type}
|
||||||
|
</span>
|
||||||
|
{event.clickOrigin &&
|
||||||
|
event.clickOrigin !== 'center' &&
|
||||||
|
event.interactionType === 'click' && (
|
||||||
|
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
|
||||||
|
{event.clickOrigin}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
|
||||||
|
{event.duration}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
|
||||||
|
{event.selector || 'system:wait'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingEventId(event.id);
|
||||||
|
setEditForm(event);
|
||||||
|
}}
|
||||||
|
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Settings2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeEvent(event.id)}
|
||||||
|
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Reorder.Item>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Reorder.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
|
||||||
|
|
||||||
|
{/* Picking Tooltip */}
|
||||||
|
{pickingMode && (
|
||||||
|
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||||
|
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
|
||||||
|
<span className="font-black uppercase tracking-widest text-xs">
|
||||||
|
Assigning {pickingMode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-6 bg-primary-dark/20" />
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPickingMode(null);
|
||||||
|
setHoveredElement(null);
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
|
||||||
|
>
|
||||||
|
ESC to Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PlaybackCursor />
|
||||||
|
|
||||||
|
{/* 3. Event Options Panel (Sidebar-like) */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{editingEventId && (
|
||||||
|
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="text-white font-black uppercase tracking-tighter text-xl">
|
||||||
|
Event Options
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingEventId(null)}
|
||||||
|
className="p-2 text-white/40 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
|
||||||
|
{/* Type Display */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||||
|
Interaction Type
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'click' }))
|
||||||
|
}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<MousePointer2 size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Click</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'hover' }))
|
||||||
|
}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Hover</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Scroll size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Scroll</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Clock size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Wait</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Precise Click Origin */}
|
||||||
|
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||||
|
Click Origin
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
|
||||||
|
{[
|
||||||
|
{ id: 'top-left', label: 'TL' },
|
||||||
|
{ id: 'top-right', label: 'TR' },
|
||||||
|
{ id: 'center', label: 'CTR' },
|
||||||
|
{ id: 'bottom-left', label: 'BL' },
|
||||||
|
{ id: 'bottom-right', label: 'BR' },
|
||||||
|
].map((origin) => (
|
||||||
|
<button
|
||||||
|
key={origin.id}
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
|
||||||
|
}
|
||||||
|
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
|
||||||
|
>
|
||||||
|
{origin.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timing */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
|
||||||
|
<span>Timeline Allocation</span>
|
||||||
|
<span className="text-accent">{editForm.duration}ms</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="5000"
|
||||||
|
step="100"
|
||||||
|
value={editForm.duration || 1000}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom & Effects */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Maximize2 size={18} className="text-white/40" />
|
||||||
|
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
||||||
|
Zoom Shift
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
value={editForm.zoom || 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))}
|
||||||
|
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Box size={18} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">Motion Blur</span>
|
||||||
|
</div>
|
||||||
|
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))}
|
||||||
|
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.realClick ? 'bg-orange-500/10 border-orange-500/30 text-orange-400' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ExternalLink size={18} />
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">
|
||||||
|
Trigger Navigation
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] opacity-60">
|
||||||
|
Allows URL transitions in Studio
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={saveEdit}
|
||||||
|
className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
|
||||||
|
>
|
||||||
|
Commit Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
components/record-mode/RecordModeVisuals.tsx
Normal file
261
components/record-mode/RecordModeVisuals.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
|
||||||
|
export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isActive, isPlaying, zoomLevel, cursorPosition, isBlurry } = useRecordMode();
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
const [isEmbedded, setIsEmbedded] = React.useState(false);
|
||||||
|
const [iframeUrl, setIframeUrl] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
// Explicit non-magical detection
|
||||||
|
const embedded =
|
||||||
|
window.location.search.includes('embedded=true') || window.name === 'record-mode-iframe';
|
||||||
|
setIsEmbedded(embedded);
|
||||||
|
|
||||||
|
if (!embedded) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('embedded', 'true');
|
||||||
|
setIframeUrl(url.toString());
|
||||||
|
}
|
||||||
|
}, [isEmbedded]);
|
||||||
|
|
||||||
|
// Hydration Guard: Match server on first render
|
||||||
|
if (!mounted) return <>{children}</>;
|
||||||
|
|
||||||
|
// Recursion Guard: If we are already in an embedded iframe,
|
||||||
|
// strictly return just the children to prevent Inception.
|
||||||
|
if (isEmbedded) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
/* Harder Isolation: Hide ALL potentially duplicate overlays and DEV TOOLS */
|
||||||
|
#nextjs-portal,
|
||||||
|
#nextjs-portal-root,
|
||||||
|
[data-nextjs-toast-wrapper],
|
||||||
|
.nextjs-static-indicator,
|
||||||
|
[data-nextjs-indicator],
|
||||||
|
[class*="nextjs-"],
|
||||||
|
[id*="nextjs-"],
|
||||||
|
nextjs-portal,
|
||||||
|
#feedback-overlay,
|
||||||
|
.feedback-ui-root,
|
||||||
|
.feedback-ui-ignore,
|
||||||
|
[class*="z-[9999]"],
|
||||||
|
[class*="z-[10000]"],
|
||||||
|
[style*="z-index: 9999"],
|
||||||
|
[style*="z-index: 10000"],
|
||||||
|
.fixed.bottom-6.left-6,
|
||||||
|
.fixed.bottom-6.left-1/2,
|
||||||
|
.feedback-ui-overlay,
|
||||||
|
[id^="feedback-"],
|
||||||
|
[class^="feedback-"] {
|
||||||
|
display: none !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
z-index: -10000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nuclear Option 2.0: Kill ALL scrollbars on ALL elements */
|
||||||
|
* {
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
border-radius: 3rem;
|
||||||
|
background: #050505 !important;
|
||||||
|
color: white !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Global Style for Body Lock */}
|
||||||
|
{isActive && (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
html, body {
|
||||||
|
overflow: hidden !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
position: fixed !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
}
|
||||||
|
/* Kill Next.js Dev tools on host while Studio is active */
|
||||||
|
#nextjs-portal,
|
||||||
|
[data-nextjs-toast-wrapper],
|
||||||
|
.nextjs-static-indicator {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-1000 ${isActive ? 'fixed inset-0 z-[9997] bg-[#020202] flex items-center justify-center p-6 md:p-12 lg:p-20' : 'relative w-full'}`}
|
||||||
|
>
|
||||||
|
{/* Studio Background - Only visible when active */}
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-[#03110a] via-[#020202] to-[#030a11] animate-pulse duration-[10s]" />
|
||||||
|
<div
|
||||||
|
className="absolute -top-[60%] -left-[50%] w-[140%] h-[140%] rounded-full opacity-[0.7]"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(circle, #10b981 0%, transparent 70%)',
|
||||||
|
filter: 'blur(160px)',
|
||||||
|
animation: 'mesh-float-1 18s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-[60%] -right-[50%] w-[130%] h-[130%] rounded-full opacity-[0.55]"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(circle, #06b6d4 0%, transparent 70%)',
|
||||||
|
filter: 'blur(150px)',
|
||||||
|
animation: 'mesh-float-2 22s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -top-[30%] -right-[40%] w-[100%] h-[100%] rounded-full opacity-[0.5]"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(circle, #82ed20 0%, transparent 70%)',
|
||||||
|
filter: 'blur(130px)',
|
||||||
|
animation: 'mesh-float-3 14s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-[50%] -left-[40%] w-[110%] h-[110%] rounded-full opacity-[0.45]"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(circle, #2563eb 0%, transparent 70%)',
|
||||||
|
filter: 'blur(140px)',
|
||||||
|
animation: 'mesh-float-4 20s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.12] mix-blend-overlay"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
|
||||||
|
backgroundSize: '128px 128px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.06]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-700 ease-in-out relative z-10 w-full ${isActive ? 'h-full max-h-[1000px] max-w-[1600px] drop-shadow-[0_60px_150px_rgba(0,0,0,1)] scale-in' : 'h-full'}`}
|
||||||
|
style={{
|
||||||
|
transform: isPlaying ? `scale(${zoomLevel})` : undefined,
|
||||||
|
transformOrigin: isPlaying ? `${cursorPosition.x}px ${cursorPosition.y}px` : 'center',
|
||||||
|
filter: isBlurry ? 'blur(4px)' : 'none',
|
||||||
|
willChange: 'transform, filter',
|
||||||
|
WebkitBackfaceVisibility: 'hidden',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? 'relative h-full w-full rounded-[3rem] overflow-hidden bg-[#050505] isolate'
|
||||||
|
: 'w-full h-full'
|
||||||
|
}
|
||||||
|
style={{ transform: isActive ? 'translateZ(0)' : 'none' }}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 rounded-[3rem] border border-white/[0.08] pointer-events-none z-50" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-[-2px] rounded-[3rem] pointer-events-none z-20"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(130,237,32,0.15))',
|
||||||
|
animation: 'pulse-ring 4s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#82ed20]/[0.05] to-transparent h-[15%] w-full top-[-15%] animate-scan-slow z-50 pointer-events-none opacity-20" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? 'w-full h-full rounded-[3rem] overflow-hidden relative'
|
||||||
|
: 'w-full h-full relative'
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: isActive ? '-webkit-radial-gradient(white, black)' : 'none',
|
||||||
|
transform: isActive ? 'translateZ(0)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isActive && iframeUrl ? (
|
||||||
|
<iframe
|
||||||
|
src={iframeUrl}
|
||||||
|
name="record-mode-iframe"
|
||||||
|
className="w-full h-full border-0 block"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#050505',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
msOverflowStyle: 'none',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? 'blur-2xl opacity-20 pointer-events-none scale-95 transition-all duration-700'
|
||||||
|
: 'transition-all duration-700'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
@keyframes mesh-float-1 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(15%, 10%) scale(1.1) rotate(5deg); } 66% { transform: translate(-10%, 20%) scale(0.9) rotate(-3deg); } }
|
||||||
|
@keyframes mesh-float-2 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(-20%, -15%) scale(1.2) rotate(-8deg); } 66% { transform: translate(15%, -10%) scale(0.8) rotate(4deg); } }
|
||||||
|
@keyframes mesh-float-3 { 0%, 100% { transform: translate(0, 0) scale(1.2); } 50% { transform: translate(20%, -25%) scale(0.7); } }
|
||||||
|
@keyframes mesh-float-4 { 0%, 100% { transform: translate(0, 0) scale(1); } 50% { transform: translate(-15%, 25%) scale(1.1); } }
|
||||||
|
@keyframes pulse-ring { 0%, 100% { opacity: 0.15; transform: scale(1); } 50% { opacity: 0.4; transform: scale(1.005); } }
|
||||||
|
@keyframes scan-slow { 0% { transform: translateY(-100%); opacity: 0; } 5% { opacity: 0.2; } 95% { opacity: 0.2; } 100% { transform: translateY(800%); opacity: 0; } }
|
||||||
|
@keyframes scale-in { 0% { transform: scale(0.95); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
|
||||||
|
.scale-in { animation: scale-in 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
components/record-mode/ToolCoordinator.tsx
Normal file
66
components/record-mode/ToolCoordinator.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
import { FeedbackOverlay } from '@mintel/next-feedback/FeedbackOverlay';
|
||||||
|
import { RecordModeOverlay } from './RecordModeOverlay';
|
||||||
|
import { PickingHelper } from './PickingHelper';
|
||||||
|
|
||||||
|
interface ToolCoordinatorProps {
|
||||||
|
isEmbedded?: boolean;
|
||||||
|
feedbackEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolCoordinator({
|
||||||
|
isEmbedded: isEmbeddedProp,
|
||||||
|
feedbackEnabled = false,
|
||||||
|
}: ToolCoordinatorProps) {
|
||||||
|
const { isActive, setIsActive, isFeedbackActive, setIsFeedbackActive, isEnabled } =
|
||||||
|
useRecordMode();
|
||||||
|
const [isEmbedded, setIsEmbedded] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
const embedded =
|
||||||
|
isEmbeddedProp ||
|
||||||
|
window.location.search.includes('embedded=true') ||
|
||||||
|
window.name === 'record-mode-iframe' ||
|
||||||
|
window.self !== window.top;
|
||||||
|
setIsEmbedded(embedded);
|
||||||
|
}, [isEmbeddedProp]);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
// Nothing enabled → render nothing
|
||||||
|
if (!feedbackEnabled && !isEnabled) return null;
|
||||||
|
|
||||||
|
// Iframe → only PickingHelper
|
||||||
|
if (isEmbedded) return <PickingHelper />;
|
||||||
|
|
||||||
|
// Record Mode active and enabled
|
||||||
|
if (isActive && isEnabled) return <RecordModeOverlay />;
|
||||||
|
|
||||||
|
// Feedback active and enabled
|
||||||
|
if (isFeedbackActive && feedbackEnabled) {
|
||||||
|
return (
|
||||||
|
<FeedbackOverlay
|
||||||
|
isActive={isFeedbackActive}
|
||||||
|
onActiveChange={(active) => setIsFeedbackActive(active)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baseline: toggle buttons
|
||||||
|
return (
|
||||||
|
<div className="feedback-ui-ignore">
|
||||||
|
{feedbackEnabled && (
|
||||||
|
<FeedbackOverlay
|
||||||
|
isActive={false}
|
||||||
|
onActiveChange={(active) => setIsFeedbackActive(active)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isEnabled && <RecordModeOverlay />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29977
data/umami-import.json
29977
data/umami-import.json
File diff suppressed because it is too large
Load Diff
83
directus/schema/minimal_schema.yaml
Normal file
83
directus/schema/minimal_schema.yaml
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
version: 1
|
||||||
|
directus: 11.14.1
|
||||||
|
vendor: postgres
|
||||||
|
collections:
|
||||||
|
- collection: contact_submissions
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
collapse: open
|
||||||
|
collection: contact_submissions
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{name}} | {{email}}'
|
||||||
|
hidden: false
|
||||||
|
icon: contact_mail
|
||||||
|
singleton: false
|
||||||
|
schema:
|
||||||
|
name: contact_submissions
|
||||||
|
fields:
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
sort: 1
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: uuid
|
||||||
|
is_nullable: false
|
||||||
|
is_primary_key: true
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: name
|
||||||
|
interface: input
|
||||||
|
sort: 2
|
||||||
|
schema:
|
||||||
|
name: name
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: character varying
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: email
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: email
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
schema:
|
||||||
|
name: email
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: character varying
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: message
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: message
|
||||||
|
interface: textarea
|
||||||
|
sort: 4
|
||||||
|
schema:
|
||||||
|
name: message
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: text
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: date_created
|
||||||
|
interface: datetime
|
||||||
|
readonly: true
|
||||||
|
sort: 5
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
relations: []
|
||||||
@@ -6,51 +6,210 @@ collections:
|
|||||||
meta:
|
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
|
||||||
|
|
||||||
|
fields:
|
||||||
|
# contact_submissions
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
sort: 1
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: uuid
|
||||||
|
is_nullable: false
|
||||||
|
is_primary_key: true
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: name
|
||||||
|
interface: input
|
||||||
|
sort: 2
|
||||||
|
schema:
|
||||||
|
name: name
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: character varying
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: email
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: email
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
schema:
|
||||||
|
name: email
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: character varying
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: message
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: message
|
||||||
|
interface: textarea
|
||||||
|
sort: 4
|
||||||
|
schema:
|
||||||
|
name: message
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: text
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: date_created
|
||||||
|
interface: datetime
|
||||||
|
readonly: true
|
||||||
|
sort: 5
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
|
||||||
|
# product_requests
|
||||||
|
- collection: product_requests
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: product_requests
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
sort: 1
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: product_requests
|
||||||
|
data_type: uuid
|
||||||
|
is_nullable: false
|
||||||
|
is_primary_key: true
|
||||||
|
- collection: product_requests
|
||||||
|
field: product_name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: product_requests
|
||||||
|
field: product_name
|
||||||
|
interface: input
|
||||||
|
sort: 2
|
||||||
|
schema:
|
||||||
|
name: product_name
|
||||||
|
table: product_requests
|
||||||
|
data_type: character varying
|
||||||
|
- collection: product_requests
|
||||||
|
field: email
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: product_requests
|
||||||
|
field: email
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
schema:
|
||||||
|
name: email
|
||||||
|
table: product_requests
|
||||||
|
data_type: character varying
|
||||||
|
- collection: product_requests
|
||||||
|
field: message
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: product_requests
|
||||||
|
field: message
|
||||||
|
interface: textarea
|
||||||
|
sort: 4
|
||||||
|
schema:
|
||||||
|
name: message
|
||||||
|
table: product_requests
|
||||||
|
data_type: text
|
||||||
|
- collection: product_requests
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: product_requests
|
||||||
|
field: date_created
|
||||||
|
interface: datetime
|
||||||
|
readonly: true
|
||||||
|
sort: 5
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: product_requests
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
|
||||||
|
# products
|
||||||
|
- collection: products
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: products
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
sort: 1
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: products
|
||||||
|
data_type: uuid
|
||||||
|
is_nullable: false
|
||||||
|
is_primary_key: true
|
||||||
|
|
||||||
|
# products_translations
|
||||||
|
- collection: products_translations
|
||||||
|
field: id
|
||||||
|
type: integer
|
||||||
|
meta:
|
||||||
|
collection: products_translations
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: products_translations
|
||||||
|
data_type: integer
|
||||||
|
is_primary_key: true
|
||||||
|
has_auto_increment: true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
systemFields:
|
systemFields:
|
||||||
- collection: directus_activity
|
- collection: directus_activity
|
||||||
field: timestamp
|
field: timestamp
|
||||||
@@ -64,4 +223,5 @@ systemFields:
|
|||||||
field: parent
|
field: parent
|
||||||
schema:
|
schema:
|
||||||
is_indexed: true
|
is_indexed: true
|
||||||
relations: []
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +1,45 @@
|
|||||||
services:
|
services:
|
||||||
klz-app:
|
klz-app:
|
||||||
image: node:20-alpine
|
build:
|
||||||
working_dir: /app
|
context: .
|
||||||
command: sh -c "npm install --legacy-peer-deps && npx next dev"
|
dockerfile: Dockerfile
|
||||||
networks:
|
target: development
|
||||||
- default
|
|
||||||
- infra
|
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- /app/.next
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
- WATCHPACK_POLLING=true # Useful for Docker volume mounting issues on some systems
|
||||||
# Docker Internal Communication
|
restart: "no"
|
||||||
DIRECTUS_URL: http://directus:8055
|
container_name: klz-app-dev
|
||||||
INTERNAL_DIRECTUS_URL: http://directus:8055
|
labels:
|
||||||
INFRA_DIRECTUS_URL: http://cms-infra-infra-cms-1:8055
|
- "traefik.enable=true"
|
||||||
GATEKEEPER_URL: http://gatekeeper:3000
|
# Clear any production middlewares/headers redirect
|
||||||
DIRECTUS_API_TOKEN: ${DIRECTUS_API_TOKEN}
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares="
|
||||||
INFRA_DIRECTUS_TOKEN: ${INFRA_DIRECTUS_TOKEN}
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=Host(`${TRAEFIK_HOST:-klz.localhost}`)"
|
||||||
NEXT_PUBLIC_FEEDBACK_ENABLED: ${NEXT_PUBLIC_FEEDBACK_ENABLED}
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
|
||||||
GATEKEEPER_BYPASS_ENABLED: ${GATEKEEPER_BYPASS_ENABLED}
|
# Configure main router for local HTTP without auth
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(`${TRAEFIK_HOST:-klz.localhost}`)"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=web"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares="
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=false"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
|
klz-cms:
|
||||||
|
container_name: klz-cms-dev
|
||||||
|
restart: "no"
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "8055:8055"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# Global local settings
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.rule=Host(`${DIRECTUS_HOST:-cms.klz.localhost}`)"
|
||||||
- "traefik.http.routers.klz-cables-local.entrypoints=web"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.klz-cables-local.rule=Host(`klz.localhost`)"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.service=${PROJECT_NAME:-klz-cables}-cms"
|
||||||
- "traefik.http.routers.klz-cables-local.tls=false"
|
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-cms.loadbalancer.server.port=8055"
|
||||||
- "traefik.http.routers.klz-cables-local.middlewares="
|
|
||||||
- "traefik.http.routers.klz-cables-local.service=klz-cables-local"
|
|
||||||
- "traefik.http.services.klz-cables-local.loadbalancer.server.port=3000"
|
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
# Web direct router
|
klz-db:
|
||||||
- "traefik.http.routers.klz-cables-local-web.entrypoints=web"
|
restart: "no"
|
||||||
- "traefik.http.routers.klz-cables-local-web.rule=Host(`klz.localhost`)"
|
|
||||||
- "traefik.http.routers.klz-cables-local-web.tls=false"
|
|
||||||
- "traefik.http.routers.klz-cables-local-web.middlewares="
|
|
||||||
- "traefik.http.routers.klz-cables-local-web.service=klz-cables-local"
|
|
||||||
|
|
||||||
directus:
|
klz-gatekeeper:
|
||||||
networks:
|
restart: "no"
|
||||||
- default
|
|
||||||
- infra
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.klz-cables-directus-local.entrypoints=web"
|
|
||||||
- "traefik.http.routers.klz-cables-directus-local.rule=Host(`cms.klz.localhost`)"
|
|
||||||
- "traefik.http.routers.klz-cables-directus-local.tls=false"
|
|
||||||
- "traefik.http.routers.klz-cables-directus-local.middlewares="
|
|
||||||
- "traefik.http.routers.klz-cables-directus-local.service=klz-cables-directus-local"
|
|
||||||
- "traefik.http.services.klz-cables-directus-local.loadbalancer.server.port=8055"
|
|
||||||
- "traefik.docker.network=infra"
|
|
||||||
ports:
|
|
||||||
- "${DIRECTUS_PORT:-8055}:8055"
|
|
||||||
environment:
|
|
||||||
PUBLIC_URL: http://cms.klz.localhost
|
|
||||||
|
|
||||||
gatekeeper:
|
|
||||||
image: node:20-alpine
|
|
||||||
working_dir: /app/packages/gatekeeper
|
|
||||||
command: sh -c "corepack enable && CI=true NPM_TOKEN=dummy pnpm install --no-frozen-lockfile && pnpm dev"
|
|
||||||
volumes:
|
|
||||||
- /Users/marcmintel/Projects/at-mintel:/app
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
- infra
|
|
||||||
environment:
|
|
||||||
DIRECTUS_URL: http://directus:8055
|
|
||||||
NEXT_PUBLIC_BASE_URL: http://gatekeeper.klz.localhost
|
|
||||||
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
|
||||||
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
|
||||||
COOKIE_DOMAIN: localhost
|
|
||||||
NODE_ENV: development
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.klz-cables-gatekeeper-local.entrypoints=web"
|
|
||||||
- "traefik.http.routers.klz-cables-gatekeeper-local.rule=Host(`gatekeeper.klz.localhost`)"
|
|
||||||
- "traefik.http.routers.klz-cables-gatekeeper-local.tls=false"
|
|
||||||
- "traefik.http.routers.klz-cables-gatekeeper-local.service=klz-cables-gatekeeper-local"
|
|
||||||
- "traefik.http.services.klz-cables-gatekeeper-local.loadbalancer.server.port=3000"
|
|
||||||
- "traefik.docker.network=infra"
|
|
||||||
|
|||||||
@@ -1,92 +1,105 @@
|
|||||||
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:
|
||||||
|
aliases:
|
||||||
|
- klz.localhost
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
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}-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}-web.entrypoints=web"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.middlewares=redirect-https"
|
||||||
# HTTPS router (Protected)
|
# HTTPS router (Standard)
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}.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}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls=${TRAEFIK_TLS:-false}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}.service=${PROJECT_NAME:-klz}-app-svc"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-${PROJECT_NAME:-klz-cables}-compress}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-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}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathRegexp(`^/([a-z]{2}/)?api/og`) || PathRegexp(`^/([a-z]{2}/)?opengraph-image$`) || PathRegexp(`^/([a-z]{2}/)?blog/opengraph-image$`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`))"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.service=${PROJECT_NAME:-klz-cables}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.service=${PROJECT_NAME:-klz}-app-svc"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-klz-ratelimit,klz-forward,klz-compress}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.priority=2000"
|
||||||
|
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.scheme=http"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
|
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
|
||||||
|
- "caddy.reverse_proxy={{upstreams 3000}}"
|
||||||
|
|
||||||
# Middleware Definitions
|
# Middleware Definitions
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-compress.compress=true"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-compress.compress=true"
|
||||||
|
|
||||||
# Forwarded Headers
|
# Forwarded Headers
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||||
|
|
||||||
# Middleware Definitions
|
# Authentication Middleware (ForwardAuth)
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.address=http://${PROJECT_NAME:-klz}-gatekeeper:3000/gatekeeper/api/verify"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.trustForwardHeader=true"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/gatekeeper/api/verify"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.trustForwardHeader=true"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
|
||||||
|
|
||||||
gatekeeper:
|
# Rate Limit Middleware
|
||||||
image: registry.infra.mintel.me/mintel/gatekeeper:latest
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.average=100"
|
||||||
container_name: ${PROJECT_NAME:-klz-cables}-gatekeeper
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.burst=50"
|
||||||
restart: always
|
healthcheck:
|
||||||
|
test: [ "CMD", "curl", "-f", "http://127.0.0.1:3000/health" ]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 45s
|
||||||
|
|
||||||
|
klz-gatekeeper:
|
||||||
|
profiles: [ "gatekeeper" ]
|
||||||
|
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
|
||||||
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
default:
|
|
||||||
infra:
|
infra:
|
||||||
aliases:
|
aliases:
|
||||||
- ${PROJECT_NAME:-klz-cables}-gatekeeper
|
- ${PROJECT_NAME:-klz}-gatekeeper
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
|
PROJECT_NAME: ${PROJECT_NAME:-KLZ Cables}
|
||||||
|
PROJECT_COLOR: "#82ed20"
|
||||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
|
||||||
AUTH_COOKIE_NAME: klz_gatekeeper_session
|
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-klz_gatekeeper_session}
|
||||||
NEXT_PUBLIC_BASE_URL: https://gatekeeper.${TRAEFIK_HOST:-klz-cables.com}
|
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD}
|
||||||
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-klz2026}
|
NEXT_PUBLIC_BASE_URL: ${GATEKEEPER_ORIGIN}
|
||||||
DIRECTUS_URL: ${DIRECTUS_URL}
|
|
||||||
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
|
||||||
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# Gatekeeper Router (Shared Host + dedicated Subdomain)
|
- "traefik.docker.network=infra"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-klz-cables.com}`) && PathPrefix(`/gatekeeper`)) || Host(`gatekeeper.${TRAEFIK_HOST:-klz-cables.com}`)"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-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}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-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}-gatekeeper.service=${PROJECT_NAME:-klz}-gatekeeper-svc"
|
||||||
|
- "traefik.http.services.${PROJECT_NAME:-klz}-gatekeeper-svc.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:
|
klz-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:
|
||||||
@@ -95,45 +108,48 @@ 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:-120in09oenaoinsd9iaidon}
|
||||||
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.${PROJECT_NAME:-klz}-cms.rule=Host(`${DIRECTUS_HOST:-cms.klz-cables.com}`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.entrypoints=websecure"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.priority=5000"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.tls=true"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.middlewares=${PROJECT_NAME:-klz-cables}-forward,compress"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.tls.certresolver=le"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-directus.loadbalancer.server.port=8055"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.service=${PROJECT_NAME:-klz}-cms-svc"
|
||||||
|
- "traefik.http.services.${PROJECT_NAME:-klz}-cms-svc.loadbalancer.server.port=8055"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
- "caddy=http://${DIRECTUS_HOST:-cms.klz-cables.com}"
|
||||||
directus-db:
|
- "caddy.reverse_proxy={{upstreams 8055}}"
|
||||||
|
klz-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:
|
||||||
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
|
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
|
||||||
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
|
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
|
||||||
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}
|
||||||
volumes:
|
volumes:
|
||||||
- directus-db-data:/var/lib/postgresql/data
|
- directus-db-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- infra
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ import { getRequestConfig } from 'next-intl/server';
|
|||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
export default getRequestConfig(async ({ requestLocale }) => {
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
let locale = await requestLocale;
|
// Hardened locale validation: only allow 'en' or 'de'
|
||||||
|
// Use a temporary variable to validate before assigning to locale
|
||||||
|
const rawLocale = await requestLocale;
|
||||||
|
const supportedLocales = ['en', 'de'];
|
||||||
|
const locale =
|
||||||
|
typeof rawLocale === 'string' && supportedLocales.includes(rawLocale) ? rawLocale : 'en';
|
||||||
|
|
||||||
// Ensure that a valid locale is used
|
// Silent fallback for missing locales to support internal requests (e.g. OG generation)
|
||||||
if (!locale || !['en', 'de'].includes(locale)) {
|
if (!rawLocale || !supportedLocales.includes(rawLocale as string)) {
|
||||||
locale = 'en';
|
// console.debug(`[i18n] Fallback to "en" for locale: "${rawLocale}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -26,6 +31,6 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
return 'fallback';
|
return 'fallback';
|
||||||
}
|
},
|
||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Centralized configuration management for the application.
|
* Centralized configuration management for the application.
|
||||||
* This file provides a type-safe way to access environment variables.
|
* This file provides a type-safe way to access environment variables.
|
||||||
*/
|
*/
|
||||||
import { env, getRawEnv } from './env';
|
import { getRawEnv } from './env';
|
||||||
|
|
||||||
let memoizedConfig: ReturnType<typeof createConfig> | undefined;
|
let memoizedConfig: ReturnType<typeof createConfig> | undefined;
|
||||||
|
|
||||||
@@ -15,6 +15,11 @@ function createConfig() {
|
|||||||
|
|
||||||
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
|
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
|
||||||
|
|
||||||
|
console.log('[Config] Initializing Toggles:', {
|
||||||
|
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||||
|
recordModeEnabled: env.NEXT_PUBLIC_RECORD_MODE_ENABLED,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
env: env.NODE_ENV,
|
env: env.NODE_ENV,
|
||||||
target,
|
target,
|
||||||
@@ -23,6 +28,7 @@ function createConfig() {
|
|||||||
isTesting: target === 'testing',
|
isTesting: target === 'testing',
|
||||||
isDevelopment: target === 'development',
|
isDevelopment: target === 'development',
|
||||||
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||||
|
recordModeEnabled: env.NEXT_PUBLIC_RECORD_MODE_ENABLED,
|
||||||
gatekeeperUrl: env.GATEKEEPER_URL,
|
gatekeeperUrl: env.GATEKEEPER_URL,
|
||||||
|
|
||||||
baseUrl: env.NEXT_PUBLIC_BASE_URL,
|
baseUrl: env.NEXT_PUBLIC_BASE_URL,
|
||||||
@@ -50,7 +56,7 @@ function createConfig() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
logging: {
|
logging: {
|
||||||
level: env.LOG_LEVEL,
|
level: env.LOG_LEVEL || 'info',
|
||||||
},
|
},
|
||||||
|
|
||||||
mail: {
|
mail: {
|
||||||
@@ -144,6 +150,9 @@ export const config = {
|
|||||||
get feedbackEnabled() {
|
get feedbackEnabled() {
|
||||||
return getConfig().feedbackEnabled;
|
return getConfig().feedbackEnabled;
|
||||||
},
|
},
|
||||||
|
get recordModeEnabled() {
|
||||||
|
return getConfig().recordModeEnabled;
|
||||||
|
},
|
||||||
get infraCMS() {
|
get infraCMS() {
|
||||||
return getConfig().infraCMS;
|
return getConfig().infraCMS;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,8 +3,20 @@ import { createMintelDirectusClient, ensureDirectusAuthenticated } from '@mintel
|
|||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
import { getServerAppServices } from './services/create-services.server';
|
import { getServerAppServices } from './services/create-services.server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directus Schema Definitions
|
||||||
|
*/
|
||||||
|
export interface Schema {
|
||||||
|
products: any[];
|
||||||
|
categories: any[];
|
||||||
|
contact_submissions: any[];
|
||||||
|
product_requests: any[];
|
||||||
|
translations: any[];
|
||||||
|
categories_link: any[];
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize client using Mintel standards (environment-aware)
|
// Initialize client using Mintel standards (environment-aware)
|
||||||
const client = createMintelDirectusClient();
|
const client = createMintelDirectusClient<Schema>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to determine if we should show detailed errors
|
* Helper to determine if we should show detailed errors
|
||||||
|
|||||||
55
lib/env.ts
55
lib/env.ts
@@ -1,14 +1,21 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { validateMintelEnv, mintelEnvSchema } from '@mintel/next-utils';
|
import { validateMintelEnv, mintelEnvSchema, withMintelRefinements } from '@mintel/next-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Robust boolean preprocessor for environment variables.
|
||||||
|
* Handles strings 'true'/'false' and actual booleans.
|
||||||
|
*/
|
||||||
|
const booleanSchema = z.preprocess((val) => {
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
if (val.toLowerCase() === 'true') return true;
|
||||||
|
if (val.toLowerCase() === 'false') return false;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}, z.boolean());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Environment variable schema.
|
* Environment variable schema.
|
||||||
* Extends the default Mintel environment schema which already includes:
|
* Extends the default Mintel environment schema.
|
||||||
* - Directus (URL, TOKEN, INTERNAL_URL, etc.)
|
|
||||||
* - Mail (HOST, PORT, etc.)
|
|
||||||
* - Gotify
|
|
||||||
* - Logging
|
|
||||||
* - Analytics
|
|
||||||
*/
|
*/
|
||||||
const envExtension = {
|
const envExtension = {
|
||||||
// Project specific overrides or additions
|
// Project specific overrides or additions
|
||||||
@@ -16,19 +23,37 @@ const envExtension = {
|
|||||||
|
|
||||||
// Gatekeeper specifics not in base
|
// Gatekeeper specifics not in base
|
||||||
GATEKEEPER_URL: z.string().url().default('http://gatekeeper:3000'),
|
GATEKEEPER_URL: z.string().url().default('http://gatekeeper:3000'),
|
||||||
GATEKEEPER_BYPASS_ENABLED: z.preprocess(
|
GATEKEEPER_BYPASS_ENABLED: booleanSchema.default(false),
|
||||||
(val) => val === 'true' || val === true,
|
NEXT_PUBLIC_FEEDBACK_ENABLED: booleanSchema.default(false),
|
||||||
z.boolean().default(false),
|
NEXT_PUBLIC_RECORD_MODE_ENABLED: booleanSchema.default(false),
|
||||||
),
|
|
||||||
NEXT_PUBLIC_FEEDBACK_ENABLED: z.preprocess(
|
|
||||||
(val) => val === 'true' || val === true,
|
|
||||||
z.boolean().default(false),
|
|
||||||
),
|
|
||||||
|
|
||||||
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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full schema including Mintel base and refinements
|
||||||
|
*/
|
||||||
|
export const envSchema = withMintelRefinements(z.object(mintelEnvSchema).extend(envExtension));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validated environment object.
|
* Validated environment object.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
import { ReactElement } from 'react';
|
|
||||||
|
|
||||||
let transporterInstance: nodemailer.Transporter | null = null;
|
let transporterInstance: nodemailer.Transporter | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -206,7 +206,8 @@
|
|||||||
"title": "Produktportfolio | Hochwertige Kabel für jede Anwendung",
|
"title": "Produktportfolio | Hochwertige Kabel für jede Anwendung",
|
||||||
"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 Produkte",
|
"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",
|
||||||
|
|||||||
@@ -206,7 +206,8 @@
|
|||||||
"title": "Product Portfolio | High-Quality Cables for Every Application",
|
"title": "Product Portfolio | High-Quality Cables for Every Application",
|
||||||
"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 Products",
|
"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",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import createMiddleware from 'next-intl/middleware';
|
import createMiddleware from 'next-intl/middleware';
|
||||||
import { NextResponse, NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
// Create the internationalization middleware
|
// Create the internationalization middleware
|
||||||
const intlMiddleware = createMiddleware({
|
const intlMiddleware = createMiddleware({
|
||||||
@@ -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.includes('/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> = {};
|
||||||
@@ -30,11 +42,8 @@ export default function middleware(request: NextRequest) {
|
|||||||
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
|
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
|
||||||
const hostHeader =
|
const hostHeader =
|
||||||
headers.get('x-forwarded-host') || headers.get('host') || 'testing.klz-cables.com';
|
headers.get('x-forwarded-host') || headers.get('host') || 'testing.klz-cables.com';
|
||||||
const [publicHostname] = hostHeader.split(':');
|
|
||||||
|
|
||||||
urlObj.protocol = proto;
|
urlObj.protocol = proto;
|
||||||
// urlObj.hostname = publicHostname; // Don't rewrite hostname yet as it breaks internal fetches in dev
|
|
||||||
// urlObj.port = ''; // DON'T clear internal port (3000) anymore
|
|
||||||
|
|
||||||
effectiveRequest = new NextRequest(urlObj, {
|
effectiveRequest = new NextRequest(urlObj, {
|
||||||
headers: request.headers,
|
headers: request.headers,
|
||||||
@@ -43,13 +52,35 @@ export default function middleware(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`🛡️ Middleware: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
|
`🛡️ Proxy: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Apply internationalization middleware
|
// Apply internationalization middleware
|
||||||
const response = intlMiddleware(effectiveRequest);
|
const response = intlMiddleware(effectiveRequest);
|
||||||
|
|
||||||
|
// Upgrade 307 (Temporary Redirect) to 308 (Permanent Redirect)
|
||||||
|
// This improves compatibility with scanners (Website Carbon, PageSpeed) and SEO.
|
||||||
|
if (response.status === 307) {
|
||||||
|
const location = response.headers.get('Location');
|
||||||
|
if (location) {
|
||||||
|
const url = new URL(location, request.url);
|
||||||
|
return Response.redirect(url, 308);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow iframe embedding from recorder domains
|
||||||
|
const referer = headers.get('referer') || '';
|
||||||
|
const recorderDomains = ['recorder.localhost', 'recorder.mintel.me'];
|
||||||
|
const isRecorderRequest = recorderDomains.some((domain) => referer.includes(domain));
|
||||||
|
|
||||||
|
if (isRecorderRequest) {
|
||||||
|
response.headers.delete('x-frame-options');
|
||||||
|
response.headers.delete('content-security-policy');
|
||||||
|
response.headers.set('Access-Control-Allow-Origin', '*');
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -61,6 +92,7 @@ export default function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
// Match only internationalized pathnames
|
matcher: [
|
||||||
matcher: ['/((?!api|_next|_vercel|stats|errors|health|.*\\..*).*)', '/', '/(de|en)/:path*'],
|
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf)$).*)',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <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.
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import createNextIntlPlugin from 'next-intl/plugin';
|
|
||||||
import withMintelConfig from '@mintel/next-config';
|
import withMintelConfig from '@mintel/next-config';
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin();
|
console.log('🚀 NEXT CONFIG LOADED FROM:', process.cwd());
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
onDemandEntries: {
|
||||||
|
// Make sure entries are not disposed too quickly
|
||||||
|
maxInactiveAge: 60 * 1000,
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
fetches: {
|
||||||
|
fullUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
@@ -322,6 +330,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 [
|
||||||
@@ -333,6 +350,4 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextIntlConfig = withNextIntl(nextConfig);
|
export default withMintelConfig(nextConfig);
|
||||||
|
|
||||||
export default withMintelConfig(nextIntlConfig);
|
|
||||||
|
|||||||
47
package.json
47
package.json
@@ -1,10 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"name": "klz-cables-nextjs",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@directus/sdk": "^18.0.3",
|
"@directus/sdk": "^21.0.0",
|
||||||
"@mintel/mail": "^1.6.0",
|
"@medv/finder": "^4.0.2",
|
||||||
"@mintel/next-config": "^1.6.0",
|
"@mintel/mail": "1.8.3",
|
||||||
"@mintel/next-feedback": "^1.6.0",
|
"@mintel/next-config": "1.8.3",
|
||||||
"@mintel/next-utils": "^1.7.8",
|
"@mintel/next-feedback": "1.8.10",
|
||||||
|
"@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",
|
||||||
"@sentry/nextjs": "^10.38.0",
|
"@sentry/nextjs": "^10.38.0",
|
||||||
@@ -16,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",
|
||||||
@@ -36,14 +39,18 @@
|
|||||||
"svg-to-pdfkit": "^0.1.8",
|
"svg-to-pdfkit": "^0.1.8",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^4.3.6"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@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.6.0",
|
"@mintel/eslint-config": "1.8.3",
|
||||||
"@mintel/tsconfig": "^1.6.0",
|
"@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",
|
||||||
@@ -53,23 +60,26 @@
|
|||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
|
"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",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
},
|
},
|
||||||
"name": "klz-cables-nextjs",
|
|
||||||
"private": true,
|
|
||||||
"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 klz-cms klz-db klz-gatekeeper",
|
||||||
|
"dev:infra": "docker network create infra 2>/dev/null || true && docker-compose up -d klz-cms klz-db klz-gatekeeper",
|
||||||
"dev:local": "next dev",
|
"dev:local": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
@@ -77,7 +87,8 @@
|
|||||||
"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",
|
||||||
"cms:branding:local": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
"check:og": "tsx scripts/check-og-images.ts",
|
||||||
|
"cms:branding:local": "DIRECTUS_URL=${DIRECTUS_URL:-http://cms.klz.localhost} npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||||
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
"cms:branding: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",
|
||||||
"cms:branding:prod": "DIRECTUS_URL=https://cms.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
"cms:branding:prod": "DIRECTUS_URL=https://cms.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||||
@@ -97,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"
|
||||||
},
|
},
|
||||||
@@ -105,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1253
pnpm-lock.yaml
generated
1253
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
'@tailwindcss/postcss': {},
|
'@tailwindcss/postcss': {},
|
||||||
File diff suppressed because one or more lines are too long
BIN
public/fonts/Inter-Bold.woff
Normal file
BIN
public/fonts/Inter-Bold.woff
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
public/fonts/Inter-Regular.woff
Normal file
BIN
public/fonts/Inter-Regular.woff
Normal file
Binary file not shown.
32
remotion/Root.tsx
Normal file
32
remotion/Root.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Composition } from 'remotion';
|
||||||
|
import { WebsiteVideo } from './WebsiteVideo';
|
||||||
|
import sessionData from './session.json';
|
||||||
|
import { RecordingSession } from '../types/record-mode';
|
||||||
|
|
||||||
|
const FPS = 60;
|
||||||
|
|
||||||
|
export const RemotionRoot: React.FC = () => {
|
||||||
|
// Calculate duration based on last event + padding
|
||||||
|
const durationMs = (sessionData as unknown as RecordingSession).events.reduce((max, e) => {
|
||||||
|
return Math.max(max, e.timestamp + (e.duration || 1000));
|
||||||
|
}, 0);
|
||||||
|
const durationInFrames = Math.ceil((durationMs + 2000) / 1000 * FPS);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Composition
|
||||||
|
id="WebsiteVideo"
|
||||||
|
component={WebsiteVideo}
|
||||||
|
durationInFrames={durationInFrames}
|
||||||
|
fps={FPS}
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
defaultProps={{
|
||||||
|
session: sessionData as unknown as RecordingSession,
|
||||||
|
siteUrl: 'http://localhost:3000'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
127
remotion/WebsiteVideo.tsx
Normal file
127
remotion/WebsiteVideo.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
AbsoluteFill,
|
||||||
|
useVideoConfig,
|
||||||
|
useCurrentFrame,
|
||||||
|
interpolate,
|
||||||
|
spring,
|
||||||
|
Easing,
|
||||||
|
} from 'remotion';
|
||||||
|
import { RecordingSession, RecordEvent } from '../types/record-mode';
|
||||||
|
|
||||||
|
export const WebsiteVideo: React.FC<{
|
||||||
|
session: RecordingSession | null;
|
||||||
|
siteUrl: string;
|
||||||
|
}> = ({ session, siteUrl }) => {
|
||||||
|
const { fps, width, height, durationInFrames } = useVideoConfig();
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
|
const sortedEvents = useMemo(() => {
|
||||||
|
if (!session) return [];
|
||||||
|
return [...session.events].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
if (!session || !session.events.length) {
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'black',
|
||||||
|
color: 'white',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No session data found.
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedTimeMs = (frame / fps) * 1000;
|
||||||
|
|
||||||
|
// --- Interpolation Logic ---
|
||||||
|
|
||||||
|
// 1. Find the current window (between which two events are we?)
|
||||||
|
const nextEventIndex = sortedEvents.findIndex((e) => e.timestamp > elapsedTimeMs);
|
||||||
|
let currentEventIndex;
|
||||||
|
|
||||||
|
if (nextEventIndex === -1) {
|
||||||
|
// We are past the last event, stay at the end
|
||||||
|
currentEventIndex = sortedEvents.length - 1;
|
||||||
|
} else {
|
||||||
|
currentEventIndex = Math.max(0, nextEventIndex - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEvent = sortedEvents[currentEventIndex];
|
||||||
|
// If there is no next event, we just stay at current (next=current)
|
||||||
|
const nextEvent = nextEventIndex !== -1 ? sortedEvents[nextEventIndex] : currentEvent;
|
||||||
|
|
||||||
|
// 2. Calculate Progress between events
|
||||||
|
const gap = nextEvent.timestamp - currentEvent.timestamp;
|
||||||
|
const progress = gap > 0 ? (elapsedTimeMs - currentEvent.timestamp) / gap : 1;
|
||||||
|
const easedProgress = Easing.cubic(Math.min(Math.max(progress, 0), 1));
|
||||||
|
|
||||||
|
// 3. Calculate Cursor Position from Rects
|
||||||
|
const getCenter = (event: RecordEvent) => {
|
||||||
|
if (event.rect) {
|
||||||
|
return {
|
||||||
|
x: event.rect.x + event.rect.width / 2,
|
||||||
|
y: event.rect.y + event.rect.height / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { x: width / 2, y: height / 2 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const p1 = getCenter(currentEvent);
|
||||||
|
const p2 = getCenter(nextEvent);
|
||||||
|
|
||||||
|
const cursorX = interpolate(easedProgress, [0, 1], [p1.x, p2.x]);
|
||||||
|
const cursorY = interpolate(easedProgress, [0, 1], [p1.y, p2.y]);
|
||||||
|
|
||||||
|
// 4. Zoom & Blur
|
||||||
|
const zoom = interpolate(easedProgress, [0, 1], [currentEvent.zoom || 1, nextEvent.zoom || 1]);
|
||||||
|
const isBlurry = currentEvent.motionBlur || nextEvent.motionBlur;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: '#000' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
transform: `scale(${zoom})`,
|
||||||
|
transformOrigin: `${cursorX}px ${cursorY}px`,
|
||||||
|
filter: isBlurry ? 'blur(8px)' : 'none',
|
||||||
|
transition: 'filter 0.1s ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
src={siteUrl}
|
||||||
|
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||||
|
title="Website"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Cursor */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: cursorX,
|
||||||
|
top: cursorY,
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '3px solid black',
|
||||||
|
boxShadow: '0 4px 15px rgba(0,0,0,0.4)',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: 12, height: 12, backgroundColor: '#3b82f6', borderRadius: '50%' }} />
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
remotion/index.ts
Normal file
4
remotion/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerRoot } from 'remotion';
|
||||||
|
import { RemotionRoot } from './Root';
|
||||||
|
|
||||||
|
registerRoot(RemotionRoot);
|
||||||
35
remotion/session.json
Normal file
35
remotion/session.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"id": "sample-session",
|
||||||
|
"name": "Sample Recording",
|
||||||
|
"createdAt": "2024-03-20T10:00:00.000Z",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"type": "click",
|
||||||
|
"timestamp": 1000,
|
||||||
|
"duration": 1000,
|
||||||
|
"zoom": 1,
|
||||||
|
"selector": "body",
|
||||||
|
"rect": {
|
||||||
|
"x": 100,
|
||||||
|
"y": 100,
|
||||||
|
"width": 50,
|
||||||
|
"height": 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"type": "scroll",
|
||||||
|
"timestamp": 2500,
|
||||||
|
"duration": 1500,
|
||||||
|
"zoom": 1,
|
||||||
|
"selector": "footer",
|
||||||
|
"rect": {
|
||||||
|
"x": 500,
|
||||||
|
"y": 800,
|
||||||
|
"width": 100,
|
||||||
|
"height": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
78
scripts/check-og-images.ts
Normal file
78
scripts/check-og-images.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { SITE_URL } from '../lib/schema.js';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.TEST_URL || SITE_URL;
|
||||||
|
|
||||||
|
console.log(`\n🚀 Starting OG Image Verification for ${BASE_URL}\n`);
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
'/de/opengraph-image',
|
||||||
|
'/en/opengraph-image',
|
||||||
|
'/de/blog/opengraph-image',
|
||||||
|
'/de/api/og/product?slug=nay2y',
|
||||||
|
'/en/api/og/product?slug=medium-voltage-cables',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function verifyImage(path: string): Promise<boolean> {
|
||||||
|
const url = `${BASE_URL}${path}`;
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
console.log(`Checking ${url}...`);
|
||||||
|
|
||||||
|
const body = await response.clone().text();
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
console.log(` Headers: ${JSON.stringify(Object.fromEntries(response.headers))}`);
|
||||||
|
console.log(` Status Text: ${response.statusText}`);
|
||||||
|
throw new Error(
|
||||||
|
`Status: ${response.status}. Body preview: ${body.substring(0, 1000).replace(/\n/g, ' ')}...`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentType?.includes('image/png')) {
|
||||||
|
throw new Error(
|
||||||
|
`Content-Type: ${contentType}. Body preview: ${body.substring(0, 1000).replace(/\n/g, ' ')}...`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
// PNG Signature: 89 50 4E 47 0D 0A 1A 0A
|
||||||
|
if (bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) {
|
||||||
|
throw new Error('Invalid PNG signature');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.length < 10000) {
|
||||||
|
throw new Error(`Image too small (${bytes.length} bytes), likely blank`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ OK (${bytes.length} bytes, ${duration}ms)`);
|
||||||
|
return true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(` ❌ FAILED:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
let allOk = true;
|
||||||
|
for (const route of routes) {
|
||||||
|
const ok = await verifyImage(route);
|
||||||
|
if (!ok) allOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allOk) {
|
||||||
|
console.log('\n✨ All OG images verified successfully!\n');
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.error('\n⚠️ Some OG images failed verification.\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -9,11 +9,11 @@ if [ -z "$ENV" ]; then
|
|||||||
exit 1
|
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)
|
||||||
CONTAINER=$(docker compose ps -q directus)
|
CONTAINER=$(docker compose ps -q klz-cms)
|
||||||
if [ -z "$CONTAINER" ]; then
|
if [ -z "$CONTAINER" ]; then
|
||||||
echo "❌ Local directus container not found."
|
echo "❌ Local directus container not found."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
59
scripts/fix-directus-token.ts
Normal file
59
scripts/fix-directus-token.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import client, { ensureAuthenticated } from '../lib/directus';
|
||||||
|
import { readUsers, updateUser } from '@directus/sdk';
|
||||||
|
import { config } from '../lib/config';
|
||||||
|
|
||||||
|
async function fixToken() {
|
||||||
|
console.log('🔑 Ensuring Directus Admin Token is set...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Authenticate with credentials
|
||||||
|
await ensureAuthenticated();
|
||||||
|
|
||||||
|
// 2. Find admin user
|
||||||
|
const users = await client.request(
|
||||||
|
readUsers({
|
||||||
|
filter: {
|
||||||
|
email: { _eq: config.directus.adminEmail },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
console.error(`❌ Could not find user with email ${config.directus.adminEmail}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = users[0];
|
||||||
|
const targetToken = config.directus.token;
|
||||||
|
|
||||||
|
if (!targetToken) {
|
||||||
|
console.error('❌ No DIRECTUS_API_TOKEN configured in environment.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (admin.token === targetToken) {
|
||||||
|
console.log('✅ Token is already correctly set.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update token
|
||||||
|
console.log(`📡 Updating token for ${config.directus.adminEmail}...`);
|
||||||
|
await client.request(
|
||||||
|
updateUser(admin.id, {
|
||||||
|
token: targetToken,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✨ Token successfully updated!');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error fixing token:');
|
||||||
|
if (error.errors) {
|
||||||
|
console.error(JSON.stringify(error.errors, null, 2));
|
||||||
|
} else {
|
||||||
|
console.error(error.message || error);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fixToken();
|
||||||
68
scripts/manual-schema-fix.ts
Normal file
68
scripts/manual-schema-fix.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import client, { ensureAuthenticated } from '../lib/directus';
|
||||||
|
import { createCollection, createField } from '@directus/sdk';
|
||||||
|
|
||||||
|
async function setupSchema() {
|
||||||
|
console.log('🏗️ Manually creating contact_submissions collection...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Authenticate (using token from config)
|
||||||
|
await ensureAuthenticated();
|
||||||
|
|
||||||
|
// 2. Create collection
|
||||||
|
console.log('📡 Creating "contact_submissions" collection...');
|
||||||
|
await client.request(
|
||||||
|
createCollection({
|
||||||
|
collection: 'contact_submissions',
|
||||||
|
meta: {
|
||||||
|
icon: 'contact_mail',
|
||||||
|
color: '#002b49',
|
||||||
|
display_template: '{{name}} | {{email}}',
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
name: 'contact_submissions',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Create fields
|
||||||
|
console.log('📡 Creating fields for "contact_submissions"...');
|
||||||
|
|
||||||
|
// name
|
||||||
|
await client.request(
|
||||||
|
createField('contact_submissions', {
|
||||||
|
field: 'name',
|
||||||
|
type: 'string',
|
||||||
|
meta: { interface: 'input' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// email
|
||||||
|
await client.request(
|
||||||
|
createField('contact_submissions', {
|
||||||
|
field: 'email',
|
||||||
|
type: 'string',
|
||||||
|
meta: { interface: 'input' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// message
|
||||||
|
await client.request(
|
||||||
|
createField('contact_submissions', {
|
||||||
|
field: 'message',
|
||||||
|
type: 'text',
|
||||||
|
meta: { interface: 'textarea' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✨ Collection and fields created successfully!');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error creating schema:');
|
||||||
|
if (error.errors) {
|
||||||
|
console.error(JSON.stringify(error.errors, null, 2));
|
||||||
|
} else {
|
||||||
|
console.error(error.message || error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSchema();
|
||||||
205
scripts/merge-umami-data.ts
Normal file
205
scripts/merge-umami-data.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const CSV_PATHS = [
|
||||||
|
'/Users/marcmintel/Downloads/pages.csv',
|
||||||
|
'/Users/marcmintel/Downloads/pages(1).csv',
|
||||||
|
'/Users/marcmintel/Downloads/pages(2).csv',
|
||||||
|
];
|
||||||
|
const JSON_OUTPUT_PATH = path.join(__dirname, '../data/umami-import-merged.json');
|
||||||
|
const SQL_OUTPUT_PATH = path.join(__dirname, '../data/umami-import-new.sql');
|
||||||
|
const WEBSITE_ID = '59a7db94-0100-4c7e-98ef-99f45b17f9c3';
|
||||||
|
const HOSTNAME = 'klz-cables.com';
|
||||||
|
|
||||||
|
function parseCSV(content: string) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
if (lines.length === 0) return [];
|
||||||
|
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
|
||||||
|
const data = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
if (!lines[i].trim()) continue;
|
||||||
|
|
||||||
|
// Simple CSV parser that handles quotes
|
||||||
|
const values: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let j = 0; j < lines[i].length; j++) {
|
||||||
|
const char = lines[i][j];
|
||||||
|
if (char === '"') inQuotes = !inQuotes;
|
||||||
|
else if (char === ',' && !inQuotes) {
|
||||||
|
values.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values.push(current.trim());
|
||||||
|
|
||||||
|
const row: any = {};
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
row[header] = values[index]?.replace(/^"|"$/g, '');
|
||||||
|
});
|
||||||
|
data.push(row);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeURL(url: string) {
|
||||||
|
if (!url) return '/';
|
||||||
|
if (url.startsWith('http')) {
|
||||||
|
try {
|
||||||
|
return new URL(url).pathname;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url.startsWith('/') ? url : `/${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mergeData() {
|
||||||
|
console.log('Reading CSVs...');
|
||||||
|
const aggregatedData: Record<string, { views: number; visitors: number; title: string }> = {};
|
||||||
|
|
||||||
|
for (const csvPath of CSV_PATHS) {
|
||||||
|
if (!fs.existsSync(csvPath)) {
|
||||||
|
console.warn(`File not found: ${csvPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const csvContent = fs.readFileSync(csvPath, 'utf-8');
|
||||||
|
const csvData = parseCSV(csvContent);
|
||||||
|
|
||||||
|
for (const row of csvData) {
|
||||||
|
const url = normalizeURL(row.URL);
|
||||||
|
const views = parseInt(row.Views) || 0;
|
||||||
|
const visitors = parseInt(row.Visitors) || 0;
|
||||||
|
const title = row.Title || '';
|
||||||
|
|
||||||
|
if (!aggregatedData[url]) {
|
||||||
|
aggregatedData[url] = { views, visitors, title };
|
||||||
|
} else {
|
||||||
|
aggregatedData[url].views = Math.max(aggregatedData[url].views, views);
|
||||||
|
aggregatedData[url].visitors = Math.max(aggregatedData[url].visitors, visitors);
|
||||||
|
if (!aggregatedData[url].title && title) {
|
||||||
|
aggregatedData[url].title = title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonEvents = [];
|
||||||
|
const sqlStatements = [];
|
||||||
|
|
||||||
|
// Spread data across the whole period since early 2025 launch
|
||||||
|
const START_DATE = new Date('2025-01-01T08:00:00Z');
|
||||||
|
const END_DATE = new Date('2026-02-13T20:00:00Z');
|
||||||
|
const startTs = START_DATE.getTime();
|
||||||
|
const endTs = END_DATE.getTime();
|
||||||
|
const totalDays = Math.ceil((endTs - startTs) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// Cleanup for the target period
|
||||||
|
sqlStatements.push(`-- Cleanup previous artificial imports (Full Year 2025 and 2026 until now)
|
||||||
|
DELETE FROM website_event WHERE website_id = '${WEBSITE_ID}' AND created_at >= '2025-01-01 00:00:00' AND created_at <= '2026-02-13 23:59:59' AND hostname = '${HOSTNAME}';
|
||||||
|
DELETE FROM session WHERE website_id = '${WEBSITE_ID}' AND created_at >= '2025-01-01 00:00:00' AND created_at <= '2026-02-13 23:59:59';
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Helper for weighted random date selection
|
||||||
|
function getRandomWeightedDate() {
|
||||||
|
while (true) {
|
||||||
|
const randomDays = Math.random() * totalDays;
|
||||||
|
const date = new Date(startTs + randomDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// 1. Growth Factor (0.2 at start to 1.0 at end)
|
||||||
|
const growthWeight = 0.2 + (randomDays / totalDays) * 0.8;
|
||||||
|
|
||||||
|
// 2. Weekend Factor (30% traffic on weekends)
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
const weekendWeight = dayOfWeek === 0 || dayOfWeek === 6 ? 0.3 : 1.0;
|
||||||
|
|
||||||
|
// 3. Seasonality (simple sine wave)
|
||||||
|
const month = date.getMonth();
|
||||||
|
const seasonWeight = 0.8 + Math.sin((month / 12) * Math.PI * 2) * 0.2;
|
||||||
|
|
||||||
|
// Combined weight
|
||||||
|
const combinedWeight = growthWeight * weekendWeight * seasonWeight;
|
||||||
|
|
||||||
|
// Pick based on weight
|
||||||
|
if (Math.random() < combinedWeight) {
|
||||||
|
// Return timestamp with random hour/minute
|
||||||
|
date.setHours(Math.floor(Math.random() * 12) + 8); // Business hours mostly
|
||||||
|
date.setMinutes(Math.floor(Math.random() * 60));
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = Object.keys(aggregatedData);
|
||||||
|
console.log(`Processing ${urls.length} aggregated URLs...`);
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
const { views, visitors, title } = aggregatedData[url];
|
||||||
|
if (views === 0) continue;
|
||||||
|
|
||||||
|
// We distribute views across visitors
|
||||||
|
const sessionData = [];
|
||||||
|
for (let v = 0; v < (visitors || 1); v++) {
|
||||||
|
const sessionId = crypto.randomUUID();
|
||||||
|
const visitId = crypto.randomUUID();
|
||||||
|
|
||||||
|
const sessionDate = getRandomWeightedDate();
|
||||||
|
const dateStr = sessionDate.toISOString().replace('T', ' ').split('.')[0];
|
||||||
|
|
||||||
|
sessionData.push({ sessionId, visitId, date: sessionDate });
|
||||||
|
|
||||||
|
sqlStatements.push(`INSERT INTO session (session_id, website_id, browser, os, device, screen, language, country, created_at)
|
||||||
|
VALUES ('${sessionId}', '${WEBSITE_ID}', 'Chrome', 'Windows', 'desktop', '1920x1080', 'en', 'DE', '${dateStr}')
|
||||||
|
ON CONFLICT (session_id) DO NOTHING;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribute views across these sessions
|
||||||
|
for (let i = 0; i < views; i++) {
|
||||||
|
const sIdx = i % sessionData.length;
|
||||||
|
const session = sessionData[sIdx];
|
||||||
|
const sessionId = session.sessionId;
|
||||||
|
const visitId = session.visitId;
|
||||||
|
const eventId = crypto.randomUUID();
|
||||||
|
|
||||||
|
// Event date should be close to session date
|
||||||
|
const eventDate = new Date(session.date.getTime() + Math.random() * 1000 * 60 * 30); // within 30 mins
|
||||||
|
const timestamp = eventDate.toISOString();
|
||||||
|
const dateStr = timestamp.replace('T', ' ').split('.')[0];
|
||||||
|
|
||||||
|
// JSON Format
|
||||||
|
jsonEvents.push({
|
||||||
|
website_id: WEBSITE_ID,
|
||||||
|
hostname: HOSTNAME,
|
||||||
|
path: url,
|
||||||
|
referrer: '',
|
||||||
|
event_name: null,
|
||||||
|
pageview: true,
|
||||||
|
session: true,
|
||||||
|
duration: Math.floor(Math.random() * 120) + 10,
|
||||||
|
created_at: timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// SQL Format
|
||||||
|
sqlStatements.push(`INSERT INTO website_event (event_id, website_id, session_id, created_at, url_path, url_query, referrer_path, referrer_query, referrer_domain, page_title, event_type, event_name, visit_id, hostname)
|
||||||
|
VALUES ('${eventId}', '${WEBSITE_ID}', '${sessionId}', '${dateStr}', '${url}', '', '', '', '', '${title.replace(/'/g, "''")}', 1, NULL, '${visitId}', '${HOSTNAME}');`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Writing ${jsonEvents.length} events to ${JSON_OUTPUT_PATH}...`);
|
||||||
|
fs.writeFileSync(JSON_OUTPUT_PATH, JSON.stringify(jsonEvents, null, 2));
|
||||||
|
|
||||||
|
console.log(`Writing SQL statements to ${SQL_OUTPUT_PATH}...`);
|
||||||
|
fs.writeFileSync(SQL_OUTPUT_PATH, sqlStatements.join('\n'));
|
||||||
|
|
||||||
|
console.log('✅ Refined Restoration Script complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeData().catch(console.error);
|
||||||
@@ -5,7 +5,7 @@ REMOTE_HOST="root@alpha.mintel.me"
|
|||||||
REMOTE_DIR="/home/deploy/sites/klz-cables.com"
|
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-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
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ const dsn = process.env.SENTRY_DSN;
|
|||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn,
|
dsn,
|
||||||
enabled: Boolean(dsn),
|
enabled: Boolean(dsn),
|
||||||
tracesSampleRate: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1,
|
||||||
|
|
||||||
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
|
debug: false,
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ const dsn = process.env.SENTRY_DSN;
|
|||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn,
|
dsn,
|
||||||
enabled: Boolean(dsn),
|
enabled: Boolean(dsn),
|
||||||
tracesSampleRate: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1,
|
||||||
|
|
||||||
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
|
debug: false,
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,21 +2,24 @@
|
|||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans:
|
--font-sans:
|
||||||
'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
var(--font-inter), system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
Arial, sans-serif;
|
'Helvetica Neue', Arial, sans-serif;
|
||||||
--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;
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
content: [
|
||||||
@@ -147,7 +148,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
// Custom plugin for responsive utilities
|
// Custom plugin for responsive utilities
|
||||||
function({ addUtilities }) {
|
function ({ addUtilities }) {
|
||||||
const newUtilities = {
|
const newUtilities = {
|
||||||
// Touch target utilities
|
// Touch target utilities
|
||||||
'.touch-target': {
|
'.touch-target': {
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
21
types/record-mode.ts
Normal file
21
types/record-mode.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface RecordEvent {
|
||||||
|
id: string;
|
||||||
|
type: 'mouse' | 'scroll' | 'wait';
|
||||||
|
interactionType?: 'click' | 'hover';
|
||||||
|
selector?: string; // CSS selector
|
||||||
|
timestamp: number; // Time in ms since start of recording
|
||||||
|
duration: number; // Duration allocated for this action in playback
|
||||||
|
zoom?: number; // Zoom level during event
|
||||||
|
description?: string; // Optional label
|
||||||
|
motionBlur?: boolean; // Enable motion blur effect
|
||||||
|
rect?: { x: number; y: number; width: number; height: number }; // Element position for rendering
|
||||||
|
clickOrigin?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||||
|
realClick?: boolean; // Trigger real browser action (navigation)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordingSession {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
events: RecordEvent[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
25
vitest.config.mts
Normal file
25
vitest.config.mts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': __dirname,
|
||||||
|
'next/server': 'next/server.js',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ssr: {
|
||||||
|
noExternal: ['@mintel/next-utils', 'next-intl'],
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
globals: true,
|
||||||
|
exclude: ['**/node_modules/**', '**/.next/**', '**/dist/**'],
|
||||||
|
include: ['**/*.test.{ts,tsx}'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user