Compare commits

..

3 Commits

Author SHA1 Message Date
8652dd722e perf(ci): optimize pipeline via parallelization, caching and conditional audits
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🏗️ Build (push) Successful in 6m35s
Build & Deploy / 🧪 QA (push) Successful in 15m18s
Build & Deploy / 🚀 Deploy (push) Successful in 36s
Build & Deploy / 🧪 Smoke Test (push) Failing after 10s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
2026-02-23 12:42:05 +01:00
5e48c75a83 fix(routing): resolve 404 on German product pages via rewrites and localized links
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 18s
Build & Deploy / 🧪 QA (push) Successful in 3m7s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has started running
2026-02-23 12:36:37 +01:00
50fc8a0554 fix(analytics): remove conflicting next config rewrite and enable proxy client
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m53s
Build & Deploy / 🏗️ Build (push) Successful in 7m35s
Build & Deploy / 🚀 Deploy (push) Successful in 37s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m8s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m25s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-20 15:00:18 +01:00
27 changed files with 1158 additions and 1411 deletions

View File

@@ -14,6 +14,7 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: 'pnpm'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3

View File

@@ -42,7 +42,7 @@ jobs:
run: | run: |
echo "Purging old build layers and dangling images..." echo "Purging old build layers and dangling images..."
docker image prune -f docker image prune -f
docker builder prune -f --filter "until=6h" docker builder prune -f --filter "until=24h"
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -158,6 +158,7 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: 'pnpm'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
@@ -180,7 +181,7 @@ jobs:
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
build: build:
name: 🏗️ Build name: 🏗️ Build
needs: [prepare, qa] needs: [prepare]
if: needs.prepare.outputs.target != 'skip' if: needs.prepare.outputs.target != 'skip'
runs-on: docker runs-on: docker
container: container:
@@ -202,7 +203,7 @@ 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 }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }} 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' }} 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 }}
@@ -254,7 +255,7 @@ jobs:
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
# Analytics # Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }} 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' }} UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -321,7 +322,7 @@ jobs:
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN" echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
echo "" echo ""
echo "# Analytics" echo "# Analytics"
echo "NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID" echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT" echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
echo "" echo ""
echo "TARGET=$TARGET" echo "TARGET=$TARGET"
@@ -379,7 +380,7 @@ jobs:
smoke_test: smoke_test:
name: 🧪 Smoke Test name: 🧪 Smoke Test
needs: [prepare, deploy] needs: [prepare, deploy]
if: needs.deploy.result == 'success' if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
runs-on: docker runs-on: docker
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -390,6 +391,7 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: 'pnpm'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
@@ -411,7 +413,7 @@ jobs:
lighthouse: lighthouse:
name: ⚡ Lighthouse name: ⚡ Lighthouse
needs: [prepare, deploy] needs: [prepare, deploy]
if: success() && needs.prepare.outputs.target != 'skip' if: success() && needs.prepare.outputs.target != 'skip' && needs.prepare.outputs.target != 'branch'
runs-on: docker runs-on: docker
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -422,6 +424,7 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: 'pnpm'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:

View File

@@ -72,7 +72,7 @@ export default async function Layout(props: {
setRequestLocale(safeLocale); setRequestLocale(safeLocale);
let messages: Record<string, any> = {}; let messages = {};
try { try {
messages = await getMessages(); messages = await getMessages();
} catch (error) { } catch (error) {
@@ -80,15 +80,6 @@ export default async function Layout(props: {
messages = {}; messages = {};
} }
// Pick only the namespaces required by client components to reduce the hydration payload size
const clientKeys = ['Footer', 'Navigation', 'Contact', 'Products', 'Team', 'Home'];
const clientMessages: Record<string, any> = {};
for (const key of clientKeys) {
if (messages[key]) {
clientMessages[key] = messages[key];
}
}
const { getServerAppServices } = await import('@/lib/services/create-services.server'); const { getServerAppServices } = await import('@/lib/services/create-services.server');
const serverServices = getServerAppServices(); const serverServices = getServerAppServices();
@@ -127,7 +118,7 @@ export default async function Layout(props: {
<link rel="preconnect" href="https://img.infra.mintel.me" /> <link rel="preconnect" href="https://img.infra.mintel.me" />
</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={clientMessages} locale={safeLocale}> <NextIntlClientProvider messages={messages} locale={safeLocale}>
<RecordModeProvider isEnabled={recordModeEnabled}> <RecordModeProvider isEnabled={recordModeEnabled}>
<RecordModeVisuals> <RecordModeVisuals>
<SkipLink /> <SkipLink />

View File

@@ -1,12 +1,11 @@
import Hero from '@/components/home/Hero'; import Hero from '@/components/home/Hero';
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 ProductCategories from '@/components/home/ProductCategories';
import WhatWeDo from '@/components/home/WhatWeDo';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import Reveal from '@/components/Reveal'; import Reveal from '@/components/Reveal';
const ProductCategories = dynamic(() => import('@/components/home/ProductCategories'));
const WhatWeDo = dynamic(() => import('@/components/home/WhatWeDo'));
const RecentPosts = dynamic(() => import('@/components/home/RecentPosts')); const RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
const Experience = dynamic(() => import('@/components/home/Experience')); const Experience = dynamic(() => import('@/components/home/Experience'));
const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs')); const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs'));

View File

@@ -53,17 +53,17 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
title: categoryTitle, title: categoryTitle,
description: categoryDesc, description: categoryDesc,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/products/${productSlug}`, canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
languages: { languages: {
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`, de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`, en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`, 'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
}, },
}, },
openGraph: { openGraph: {
title: `${categoryTitle} | KLZ Cables`, title: `${categoryTitle} | KLZ Cables`,
description: categoryDesc, description: categoryDesc,
url: `${SITE_URL}/${locale}/products/${productSlug}`, url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale), images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
}, },
twitter: { twitter: {
@@ -81,18 +81,18 @@ 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: `${SITE_URL}/${locale}/products/${slug.join('/')}`, canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
languages: { languages: {
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`, de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`, en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`, 'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
}, },
}, },
openGraph: { openGraph: {
title: `${product.frontmatter.title} | KLZ Cables`, title: `${product.frontmatter.title} | KLZ Cables`,
description: product.frontmatter.description, description: product.frontmatter.description,
type: 'website', type: 'website',
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`, url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale), images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
}, },
twitter: { twitter: {
@@ -236,7 +236,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
{productsWithTranslatedSlugs.map((product) => ( {productsWithTranslatedSlugs.map((product) => (
<Link <Link
key={product.slug} key={product.slug}
href={`/${locale}/products/${productSlug}/${product.translatedSlug}`} href={`/${locale}/${productSlug}/${product.translatedSlug}`}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5" className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
> >
<Card tag="article" className="premium-card-reset"> <Card tag="article" className="premium-card-reset">
@@ -381,7 +381,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
</Link> </Link>
<span className="mx-4 opacity-20">/</span> <span className="mx-4 opacity-20">/</span>
<Link <Link
href={`/${locale}/products/${categorySlug}`} href={`/${locale}/${productSlug}`}
className="hover:text-accent transition-colors" className="hover:text-accent transition-colors"
> >
{categoryTitle} {categoryTitle}
@@ -504,7 +504,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
'@type': 'Offer', '@type': 'Offer',
availability: 'https://schema.org/InStock', availability: 'https://schema.org/InStock',
priceCurrency: 'EUR', priceCurrency: 'EUR',
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`, url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
itemCondition: 'https://schema.org/NewCondition', itemCondition: 'https://schema.org/NewCondition',
}, },
additionalProperty: technicalItems.map((item: any) => ({ additionalProperty: technicalItems.map((item: any) => ({
@@ -515,7 +515,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
category: product.frontmatter.categories.join(', '), category: product.frontmatter.categories.join(', '),
mainEntityOfPage: { mainEntityOfPage: {
'@type': 'WebPage', '@type': 'WebPage',
'@id': `${SITE_URL}/${locale}/products/${slug.join('/')}`, '@id': `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
}, },
} as any } as any
} }

View File

@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { m, LazyMotion, domAnimation } from 'framer-motion'; import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Button } from './ui'; import { Button } from './ui';
@@ -114,264 +114,55 @@ export default function Header() {
return ( return (
<> <>
<LazyMotion strict features={domAnimation}> <motion.header
<m.header className={headerClass}
className={headerClass} initial={{ y: -100, opacity: 0 }}
initial={{ y: -100, opacity: 0 }} animate={{ y: 0, opacity: 1 }}
animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.8, ease: 'easeOut' }}
transition={{ duration: 0.8, ease: 'easeOut' }} >
> <div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between"> <motion.div
<m.div className="flex-shrink-0 group touch-target"
className="flex-shrink-0 group touch-target" initial={{ scale: 0.8, opacity: 0 }}
initial={{ scale: 0.8, opacity: 0 }} 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}`}
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
target: 'home_logo',
location: 'header',
})
}
>
<Image
src={logoSrc}
alt={t('home')}
width={120}
height={120}
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
priority
/>
</Link>
</m.div>
<m.div
className="flex items-center gap-4 md:gap-12"
initial="hidden"
animate="visible"
variants={{
visible: {
transition: {
staggerChildren: 0.08,
delayChildren: 0.3,
},
},
}}
>
<m.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
{menuItems.map((item, _idx) => (
<m.div key={item.href} variants={navLinkVariants}>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label,
href: item.href,
location: 'header_nav',
});
}}
className={cn(
textColorClass,
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
)}
>
{item.label}
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
</Link>
</m.div>
))}
</m.nav>
<m.div
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
variants={headerRightVariants}
>
<m.div
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
>
<m.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.65 }}
>
<Link
href={getPathForLocale('en')}
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
type: 'language',
from: currentLocale,
to: 'en',
location: 'header',
})
}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
</m.div>
<m.div
className="w-px h-4 bg-current opacity-20"
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
<m.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.75 }}
>
<Link
href={getPathForLocale('de')}
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
type: 'language',
from: currentLocale,
to: 'de',
location: 'header',
})
}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</m.div>
</m.div>
<m.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
>
<Button
href={`/${currentLocale}/contact`}
variant="white"
size="md"
className="px-8 shadow-xl"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('contact'),
location: 'header_cta',
})
}
>
{t('contact')}
</Button>
</m.div>
</m.div>
{/* Mobile Menu Button */}
<m.button
className={cn(
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
textColorClass,
)}
aria-label={t('toggleMenu')}
aria-expanded={isMobileMenuOpen}
aria-controls="mobile-menu"
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{
duration: 0.6,
type: 'spring',
stiffness: 300,
damping: 20,
delay: 0.5,
}}
onClick={() => {
const newState = !isMobileMenuOpen;
setIsMobileMenuOpen(newState);
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
type: 'mobile_menu',
action: newState ? 'open' : 'close',
});
}}
>
<m.svg
className="w-7 h-7"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.6 }}
>
{isMobileMenuOpen ? (
<m.path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
) : (
<m.path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
)}
</m.svg>
</m.button>
</m.div>
</div>
{/* Mobile Menu Overlay */}
<div
className={cn(
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
isMobileMenuOpen
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none',
)}
id="mobile-menu"
role="dialog"
aria-modal="true"
aria-label={t('menu')}
ref={mobileMenuRef}
> >
<m.nav <Link
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8" href={`/${currentLocale}`}
initial="closed" onClick={() =>
animate={isMobileMenuOpen ? 'open' : 'closed'} trackEvent(AnalyticsEvents.BUTTON_CLICK, {
variants={{ target: 'home_logo',
open: { location: 'header',
transition: { })
staggerChildren: 0.1, }
delayChildren: 0.2,
},
},
}}
> >
{menuItems.map((item, idx) => ( <Image
<m.div src={logoSrc}
key={item.href} alt={t('home')}
variants={{ width={120}
closed: { opacity: 0, y: 50, scale: 0.9 }, height={120}
open: { className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
opacity: 1, priority
y: 0, />
scale: 1, </Link>
transition: { </motion.div>
duration: 0.6,
ease: 'easeOut', <motion.div
delay: idx * 0.08, className="flex items-center gap-4 md:gap-12"
}, initial="hidden"
}, animate="visible"
}} variants={{
> visible: {
transition: {
staggerChildren: 0.08,
delayChildren: 0.3,
},
},
}}
>
<motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
{menuItems.map((item, _idx) => (
<motion.div key={item.href} variants={navLinkVariants}>
<Link <Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`} href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => { onClick={() => {
@@ -379,95 +170,302 @@ export default function Header() {
trackEvent(AnalyticsEvents.LINK_CLICK, { trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label, label: item.label,
href: item.href, href: item.href,
location: 'mobile_menu', location: 'header_nav',
}); });
}} }}
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4" className={cn(
textColorClass,
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
)}
> >
{item.label} {item.label}
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
</Link> </Link>
</m.div> </motion.div>
))} ))}
</motion.nav>
<m.div <motion.div
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8" className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
initial={{ opacity: 0, y: 30 }} variants={headerRightVariants}
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }} >
transition={{ duration: 0.5, delay: 0.8 }} <motion.div
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
> >
<m.div <motion.div
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
initial={{ opacity: 0, scale: 0.8 }} initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.9 }} transition={{ duration: 0.4, delay: 0.65 }}
> >
<m.div <Link
initial={{ opacity: 0 }} href={getPathForLocale('en')}
animate={{ opacity: 1 }} onClick={() =>
transition={{ duration: 0.3, delay: 1.0 }} 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'}`}
> >
<Link EN
href={getPathForLocale('en')} </Link>
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`} </motion.div>
> <motion.div
EN className="w-px h-4 bg-current opacity-20"
</Link> initial={{ scaleY: 0 }}
</m.div> animate={{ scaleY: 1 }}
<m.div transition={{ duration: 0.4, delay: 0.7 }}
className="w-px h-6 bg-white/20" />
initial={{ scaleX: 0 }} <motion.div
animate={{ scaleX: 1 }} initial={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.4, delay: 1.05 }} animate={{ opacity: 1, scale: 1 }}
/> transition={{ duration: 0.4, delay: 0.75 }}
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 1.1 }}
>
<Link
href={getPathForLocale('de')}
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</m.div>
</m.div>
<m.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
> >
<Button <Link
href={`/${currentLocale}/contact`} href={getPathForLocale('de')}
variant="accent" onClick={() =>
size="lg" trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl" 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'}`}
> >
{t('contact')} DE
</Button> </Link>
</m.div> </motion.div>
</m.div> </motion.div>
{/* Bottom Branding */} <motion.div
<m.div initial={{ scale: 0.9, opacity: 0 }}
className="p-12 flex justify-center opacity-20" animate={{ scale: 1, opacity: 1 }}
initial={{ opacity: 0, scale: 0.8 }} transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.5, delay: 1.4 }}
> >
<m.div <Button
initial={{ scale: 0.5 }} href={`/${currentLocale}/contact`}
animate={{ scale: 1 }} variant="white"
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }} size="md"
className="px-8 shadow-xl"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('contact'),
location: 'header_cta',
})
}
> >
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized /> {t('contact')}
</m.div> </Button>
</m.div> </motion.div>
</m.nav> </motion.div>
</div>
</m.header> {/* Mobile Menu Button */}
</LazyMotion> <motion.button
className={cn(
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
textColorClass,
)}
aria-label={t('toggleMenu')}
aria-expanded={isMobileMenuOpen}
aria-controls="mobile-menu"
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{
duration: 0.6,
type: 'spring',
stiffness: 300,
damping: 20,
delay: 0.5,
}}
onClick={() => {
const newState = !isMobileMenuOpen;
setIsMobileMenuOpen(newState);
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
type: 'mobile_menu',
action: newState ? 'open' : 'close',
});
}}
>
<motion.svg
className="w-7 h-7"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.6 }}
>
{isMobileMenuOpen ? (
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
) : (
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
)}
</motion.svg>
</motion.button>
</motion.div>
</div>
{/* Mobile Menu Overlay */}
<div
className={cn(
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
isMobileMenuOpen
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none',
)}
id="mobile-menu"
role="dialog"
aria-modal="true"
aria-label={t('menu')}
ref={mobileMenuRef}
>
<motion.nav
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
initial="closed"
animate={isMobileMenuOpen ? 'open' : 'closed'}
variants={{
open: {
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}}
>
{menuItems.map((item, idx) => (
<motion.div
key={item.href}
variants={{
closed: { opacity: 0, y: 50, scale: 0.9 },
open: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.6,
ease: 'easeOut',
delay: idx * 0.08,
},
},
}}
>
<Link
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"
>
{item.label}
</Link>
</motion.div>
))}
<motion.div
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
initial={{ opacity: 0, y: 30 }}
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
transition={{ duration: 0.5, delay: 0.8 }}
>
<motion.div
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.9 }}
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 1.0 }}
>
<Link
href={getPathForLocale('en')}
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
</motion.div>
<motion.div
className="w-px h-6 bg-white/20"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.4, delay: 1.05 }}
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 1.1 }}
>
<Link
href={getPathForLocale('de')}
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</motion.div>
</motion.div>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
>
<Button
href={`/${currentLocale}/contact`}
variant="accent"
size="lg"
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
>
{t('contact')}
</Button>
</motion.div>
</motion.div>
{/* Bottom Branding */}
<motion.div
className="p-12 flex justify-center opacity-20"
initial={{ opacity: 0, scale: 0.8 }}
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.5, delay: 1.4 }}
>
<motion.div
initial={{ scale: 0.5 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
>
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</motion.div>
</motion.div>
</motion.nav>
</div>
</motion.header>
</> </>
); );
} }

View File

@@ -3,7 +3,7 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface LightboxProps { interface LightboxProps {
@@ -139,120 +139,118 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
if (!mounted) return null; if (!mounted) return null;
return createPortal( return createPortal(
<LazyMotion strict features={domAnimation}> <AnimatePresence>
<AnimatePresence> {isOpen && (
{isOpen && ( <div
<div className="fixed inset-0 z-[99999] flex items-center justify-center"
className="fixed inset-0 z-[99999] flex items-center justify-center" role="dialog"
role="dialog" aria-modal="true"
aria-modal="true" >
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
onClick={handleClose}
/>
<motion.button
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ delay: 0.1, duration: 0.4 }}
ref={closeButtonRef}
onClick={handleClose}
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
aria-label="Close lightbox"
> >
<m.div <div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
initial={{ opacity: 0 }} <span className="text-3xl font-extralight leading-none mb-1">×</span>
animate={{ opacity: 1 }} </div>
exit={{ opacity: 0 }} </motion.button>
transition={{ duration: 0.5 }}
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
onClick={handleClose}
/>
<m.button <motion.button
initial={{ opacity: 0, scale: 0.5 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.5 }} exit={{ opacity: 0, x: -20 }}
transition={{ delay: 0.1, duration: 0.4 }} transition={{ delay: 0.2, duration: 0.4 }}
ref={closeButtonRef} onClick={prevImage}
onClick={handleClose} className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10" aria-label="Previous image"
aria-label="Close lightbox" >
> <span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
<span className="text-3xl font-extralight leading-none mb-1">×</span> </span>
</motion.button>
<motion.button
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={nextImage}
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Next image"
>
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
</span>
</motion.button>
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center p-4 md:p-12 z-20 pointer-events-none"
>
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentIndex}
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full"
>
<Image
src={images[currentIndex]}
alt={`Gallery image ${currentIndex + 1}`}
fill
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
unoptimized
/>
</motion.div>
</AnimatePresence>
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
{/* Premium Reflection: Subtle gradient to give material feel */}
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
</div> </div>
</m.button>
<m.button <motion.div
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }} exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.2, duration: 0.4 }} transition={{ delay: 0.3, duration: 0.4 }}
onClick={prevImage} className="mt-8 flex items-center gap-4"
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10" >
aria-label="Previous image" <div className="h-px w-12 bg-white/20" />
> <div className="bg-white/5 backdrop-blur-2xl text-white px-6 py-2 rounded-full border border-white/10 text-[11px] font-bold tracking-[0.2em] uppercase">
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500"> {currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
</span>
</m.button>
<m.button
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={nextImage}
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Next image"
>
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
</span>
</m.button>
<m.div
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center p-4 md:p-12 z-20 pointer-events-none"
>
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
<AnimatePresence mode="wait" initial={false}>
<m.div
key={currentIndex}
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full"
>
<Image
src={images[currentIndex]}
alt={`Gallery image ${currentIndex + 1}`}
fill
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
unoptimized
/>
</m.div>
</AnimatePresence>
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
{/* Premium Reflection: Subtle gradient to give material feel */}
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
</div> </div>
<div className="h-px w-12 bg-white/20" />
<m.div </motion.div>
initial={{ opacity: 0, y: 10 }} </div>
animate={{ opacity: 1, y: 0 }} </motion.div>
exit={{ opacity: 0, y: 10 }} </div>
transition={{ delay: 0.3, duration: 0.4 }} )}
className="mt-8 flex items-center gap-4" </AnimatePresence>,
>
<div className="h-px w-12 bg-white/20" />
<div className="bg-white/5 backdrop-blur-2xl text-white px-6 py-2 rounded-full border border-white/10 text-[11px] font-bold tracking-[0.2em] uppercase">
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
</div>
<div className="h-px w-12 bg-white/20" />
</m.div>
</div>
</m.div>
</div>
)}
</AnimatePresence>
</LazyMotion>,
document.body, document.body,
); );
} }

View File

@@ -2,6 +2,7 @@ import { getAllProducts } from '@/lib/mdx';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import Image from 'next/image'; import Image from 'next/image';
import { RelatedProductLink } from './RelatedProductLink'; import { RelatedProductLink } from './RelatedProductLink';
import { mapFileSlugToTranslated } from '@/lib/slugs';
interface RelatedProductsProps { interface RelatedProductsProps {
currentSlug: string; currentSlug: string;
@@ -16,6 +17,7 @@ export default async function RelatedProducts({
}: RelatedProductsProps) { }: RelatedProductsProps) {
const products = await getAllProducts(locale); const products = await getAllProducts(locale);
const t = await getTranslations('Products'); const t = await getTranslations('Products');
const productsSlug = await mapFileSlugToTranslated('products', locale);
// Filter products: same category, not current product // Filter products: same category, not current product
const related = products const related = products
@@ -61,7 +63,7 @@ export default async function RelatedProducts({
return ( return (
<RelatedProductLink <RelatedProductLink
key={product.slug} key={product.slug}
href={`/${locale}/products/${catSlug}/${product.slug}`} href={`/${locale}/${productsSlug}/${catSlug}/${product.slug}`}
productSlug={product.slug} productSlug={product.slug}
productTitle={product.frontmatter.title} 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"

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion'; import { motion, Variants } from 'framer-motion';
import { cn } from '@/components/ui'; import { cn } from '@/components/ui';
interface ScribbleProps { interface ScribbleProps {
@@ -25,53 +25,49 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
if (variant === 'circle') { if (variant === 'circle') {
return ( return (
<LazyMotion strict features={domAnimation}> <svg
<svg className={cn('absolute pointer-events-none', className)}
className={cn('absolute pointer-events-none', className)} aria-hidden="true"
aria-hidden="true" viewBox="0 0 800 350"
viewBox="0 0 800 350" preserveAspectRatio="none"
preserveAspectRatio="none" >
> <motion.path
<m.path variants={pathVariants}
variants={pathVariants} initial="hidden"
initial="hidden" whileInView="visible"
whileInView="visible" viewport={{ once: true }}
viewport={{ once: true }} transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)" strokeLinejoin="miter"
strokeLinejoin="miter" fillOpacity="0"
fillOpacity="0" strokeMiterlimit="4"
strokeMiterlimit="4" stroke={color}
stroke={color} strokeOpacity="1"
strokeOpacity="1" strokeWidth="20"
strokeWidth="20" d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734" />
/> </svg>
</svg>
</LazyMotion>
); );
} }
if (variant === 'underline') { if (variant === 'underline') {
return ( return (
<LazyMotion strict features={domAnimation}> <svg
<svg className={cn('absolute pointer-events-none', className)}
className={cn('absolute pointer-events-none', className)} aria-hidden="true"
aria-hidden="true" viewBox="-400 -55 730 60"
viewBox="-400 -55 730 60" preserveAspectRatio="none"
preserveAspectRatio="none" >
> <motion.path
<m.path variants={pathVariants}
variants={pathVariants} initial="hidden"
initial="hidden" whileInView="visible"
whileInView="visible" viewport={{ once: true }}
viewport={{ once: true }} d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15" stroke={color}
stroke={color} strokeWidth="20"
strokeWidth="20" fill="none"
fill="none" />
/> </svg>
</svg>
</LazyMotion>
); );
} }

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { Suspense, useEffect, useState } from 'react'; import { Suspense } from 'react';
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), { const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
ssr: false, ssr: false,
@@ -11,20 +11,6 @@ const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'),
}); });
export default function AnalyticsShell() { export default function AnalyticsShell() {
const [shouldLoad, setShouldLoad] = useState(false);
useEffect(() => {
// Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 });
} else {
const timer = setTimeout(() => setShouldLoad(true), 2500);
return () => clearTimeout(timer);
}
}, []);
if (!shouldLoad) return null;
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<DynamicAnalyticsProvider /> <DynamicAnalyticsProvider />

View File

@@ -2,7 +2,7 @@
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 { m, LazyMotion, domAnimation } from 'framer-motion'; import { motion } from 'framer-motion';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useAnalytics } from '../analytics/useAnalytics'; import { useAnalytics } from '../analytics/useAnalytics';
@@ -16,113 +16,111 @@ export default function Hero() {
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">
<LazyMotion strict features={domAnimation}> <Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none"> <motion.div
<m.div className="max-w-5xl mx-auto md:mx-0"
className="max-w-5xl mx-auto md:mx-0" initial="hidden"
initial="hidden" animate="visible"
animate="visible" variants={containerVariants}
variants={containerVariants} >
> <motion.div variants={headingVariants}>
<m.div variants={headingVariants}> <Heading
<Heading level={1}
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]"
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', {
green: (chunks) => (
<span className="relative inline-block">
<m.span
className="relative z-10 text-accent italic"
variants={accentVariants}
>
{chunks}
</m.span>
<m.div
variants={scribbleVariants}
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
>
<Scribble variant="circle" />
</m.div>
</span>
),
})}
</Heading>
</m.div>
<m.div variants={subtitleVariants}>
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
{t('subtitle')}
</p>
</m.div>
<m.div
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
variants={buttonContainerVariants}
> >
<m.div variants={buttonVariants}> {t.rich('title', {
<Button green: (chunks) => (
href="/contact" <span className="relative inline-block">
variant="accent" <motion.span
size="lg" className="relative z-10 text-accent italic"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg" variants={accentVariants}
onClick={() => >
trackEvent(AnalyticsEvents.BUTTON_CLICK, { {chunks}
label: t('cta'), </motion.span>
location: 'home_hero_primary', <motion.div
}) variants={scribbleVariants}
} className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
> >
{t('cta')} <Scribble variant="circle" />
<span className="transition-transform group-hover/btn:translate-x-1">&rarr;</span> </motion.div>
</Button> </span>
</m.div> ),
<m.div variants={buttonVariants}> })}
<Button </Heading>
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`} </motion.div>
variant="white" <motion.div variants={subtitleVariants}>
size="lg" <p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
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" {t('subtitle')}
onClick={() => </p>
trackEvent(AnalyticsEvents.BUTTON_CLICK, { </motion.div>
label: t('exploreProducts'), <motion.div
location: 'home_hero_secondary', className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
}) variants={buttonContainerVariants}
} >
> <motion.div variants={buttonVariants}>
{t('exploreProducts')} <Button
</Button> href="/contact"
</m.div> variant="accent"
</m.div> size="lg"
</m.div> className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg"
</Container> onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('cta'),
location: 'home_hero_primary',
})
}
>
{t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1">&rarr;</span>
</Button>
</motion.div>
<motion.div variants={buttonVariants}>
<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')}
</Button>
</motion.div>
</motion.div>
</motion.div>
</Container>
<m.div <motion.div
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none" className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none"
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }} initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }} animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }} transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
> >
<HeroIllustration /> <HeroIllustration />
</m.div> </motion.div>
<m.div <motion.div
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">
<m.div <motion.div
className="w-1 h-2 bg-white rounded-full" className="w-1 h-2 bg-white rounded-full"
animate={{ y: [0, -10, 0] }} animate={{ y: [0, -10, 0] }}
transition={{ transition={{
duration: 1.5, duration: 1.5,
repeat: Infinity, repeat: Infinity,
ease: 'easeInOut', ease: 'easeInOut',
}} }}
/> />
</div> </div>
</m.div> </motion.div>
</LazyMotion>
</Section> </Section>
); );
} }

View File

@@ -232,12 +232,16 @@ export default function HeroIllustration() {
stroke="url(#energy-pulse)" stroke="url(#energy-pulse)"
strokeWidth="3" strokeWidth="3"
strokeLinecap="round" strokeLinecap="round"
style={{ strokeDasharray={`${length * 0.2} ${length * 0.8}`}
strokeDasharray: `${length * 0.2} ${length * 0.8}`, >
strokeDashoffset: length, <animate
animation: `flow ${1.5 + (i % 3) * 0.5}s linear infinite`, attributeName="stroke-dashoffset"
}} from={length}
/> to={0}
dur={`${1.5 + (i % 3) * 0.5}s`}
repeatCount="indefinite"
/>
</line>
); );
})} })}
</g> </g>
@@ -263,13 +267,14 @@ export default function HeroIllustration() {
strokeWidth="1" strokeWidth="1"
strokeOpacity="0.3" strokeOpacity="0.3"
/> />
<circle <circle r="3" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
r="3" <animate
fill="#82ed20" attributeName="fillOpacity"
fillOpacity="0.3" values="0.2;0.5;0.2"
filter="url(#soft-glow)" dur="2s"
style={{ animation: 'solar-pulse 2s ease-in-out infinite' }} repeatCount="indefinite"
/> />
</circle>
</g> </g>
); );
})} })}
@@ -289,26 +294,28 @@ export default function HeroIllustration() {
strokeOpacity="0.3" strokeOpacity="0.3"
/> />
<g transform="translate(0, -60)"> <g transform="translate(0, -60)">
<g {[0, 120, 240].map((angle, j) => (
style={{ <line
transformOrigin: '0px 0px', key={`blade-${i}-${j}`}
animation: `spin-slow ${3 + i}s linear infinite`, x1="0"
}} y1="0"
> x2="0"
{[0, 120, 240].map((angle, j) => ( y2="-30"
<line stroke="white"
key={`blade-${i}-${j}`} strokeWidth="1.5"
x1="0" strokeOpacity="0.4"
y1="0" transform={`rotate(${angle})`}
x2="0" >
y2="-30" <animateTransform
stroke="white" attributeName="transform"
strokeWidth="1.5" type="rotate"
strokeOpacity="0.4" from={`${angle} 0 0`}
transform={`rotate(${angle})`} to={`${angle + 360} 0 0`}
dur={`${3 + i}s`}
repeatCount="indefinite"
/> />
))} </line>
</g> ))}
</g> </g>
</g> </g>
); );

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useRecordMode } from './RecordModeContext'; import { useRecordMode } from './RecordModeContext';
export function PlaybackCursor() { export function PlaybackCursor() {
@@ -24,69 +24,67 @@ export function PlaybackCursor() {
if (!isPlaying) return null; if (!isPlaying) return null;
return ( return (
<LazyMotion strict features={domAnimation}> <motion.div
<m.div className="fixed z-[10000] pointer-events-none"
className="fixed z-[10000] pointer-events-none" animate={{
animate={{ x: cursorPosition.x,
x: cursorPosition.x, y: cursorPosition.y,
y: cursorPosition.y, scale: isClicking ? 0.8 : 1,
scale: isClicking ? 0.8 : 1, rotateX: isClicking ? 15 : 0,
rotateX: isClicking ? 15 : 0, rotateY: isClicking ? -15 : 0,
rotateY: isClicking ? -15 : 0, }}
}} transition={{
transition={{ x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 }, y: { 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 },
scale: { type: 'spring', damping: 15, stiffness: 400 }, rotateX: { type: 'spring', damping: 15, stiffness: 400 },
rotateX: { type: 'spring', damping: 15, stiffness: 400 }, rotateY: { type: 'spring', damping: 15, stiffness: 400 },
rotateY: { type: 'spring', damping: 15, stiffness: 400 }, }}
}} style={{ perspective: '1000px' }}
style={{ perspective: '1000px' }} >
> <AnimatePresence>
<AnimatePresence> {isClicking && (
{isClicking && ( <motion.div
<m.div initial={{ scale: 0.5, opacity: 0 }}
initial={{ scale: 0.5, opacity: 0 }} animate={{ scale: 2.5, opacity: 0 }}
animate={{ scale: 2.5, opacity: 0 }} exit={{ opacity: 0 }}
exit={{ opacity: 0 }} transition={{ duration: 0.4, ease: 'easeOut' }}
transition={{ duration: 0.4, ease: 'easeOut' }} className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]" />
/> )}
)} </AnimatePresence>
</AnimatePresence>
{/* Outer Pulse Ring */} {/* 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 <div
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`} className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
/> />
{/* Visual Cursor */} {/* Pointer Arrow */}
<div className="relative"> <svg
{/* Soft Glow */} width="24"
<div height="24"
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`} 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>
{/* Pointer Arrow */} </div>
<svg </motion.div>
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>
</m.div>
</LazyMotion>
); );
} }

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useRecordMode } from './RecordModeContext'; import { useRecordMode } from './RecordModeContext';
import { Reorder, AnimatePresence, LazyMotion, domAnimation } from 'framer-motion'; import { Reorder, AnimatePresence } from 'framer-motion';
import { import {
Play, Play,
Square, Square,
@@ -146,460 +146,438 @@ export function RecordModeOverlay() {
} }
return ( return (
<LazyMotion strict features={domAnimation}> <div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans"> {/* 1. Global Toolbar - Slim Industrial Bar */}
{/* 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="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">
<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 */}
{/* 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="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="w-2 h-2 rounded-full bg-accent animate-pulse" /> <div className="flex flex-col">
<div className="flex flex-col"> <span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none"> Event Builder
Event Builder </span>
</span> <span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1"> Manual Mode
Manual Mode </span>
</span>
</div>
</div> </div>
</div>
<div className="w-px h-6 bg-white/10 mx-1" /> <div className="w-px h-6 bg-white/10 mx-1" />
{/* Action Tools */} {/* Action Tools */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={() => { onClick={() => {
setPickingMode('mouse'); setPickingMode('mouse');
setLastInteractionType('click'); 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'}`} 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} /> <MousePointer2 size={16} />
<span>Mouse</span> <span>Mouse</span>
</button> </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 <button
onClick={() => setIsActive(false)} onClick={() => setPickingMode('scroll')}
className="p-2.5 text-red-500 hover:bg-red-500/10 rounded-[14px] transition-all mx-1" 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'}`}
title="Exit Studio"
> >
<X size={20} /> <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> </button>
</div> </div>
</div> </div>
)}
{/* 2. Event Timeline Popover */} <PlaybackCursor />
{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 {/* 3. Event Options Panel (Sidebar-like) */}
axis="y" <AnimatePresence>
values={events} {editingEventId && (
onReorder={setEvents} <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">
className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide" <div className="flex items-center justify-between mb-8">
> <h3 className="text-white font-black uppercase tracking-tighter text-xl">
{events.length === 0 ? ( Event Options
<div className="py-12 flex flex-col items-center justify-center text-white/10"> </h3>
<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 <button
onClick={() => { onClick={() => setEditingEventId(null)}
setPickingMode(null); className="p-2 text-white/40 hover:text-white transition-colors"
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 <X size={20} />
</button> </button>
</div> </div>
</div>
)}
<PlaybackCursor /> <div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
{/* Type Display */}
{/* 3. Event Options Panel (Sidebar-like) */} <div className="space-y-3">
<AnimatePresence> <label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
{editingEventId && ( Interaction Type
<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"> </label>
<div className="flex items-center justify-between mb-8"> <div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
<h3 className="text-white font-black uppercase tracking-tighter text-xl"> <button
Event Options onClick={() =>
</h3> setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'click' }))
<button }
onClick={() => setEditingEventId(null)} 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'}`}
className="p-2 text-white/40 hover:text-white transition-colors" >
> <MousePointer2 size={14} />
<X size={20} /> <span className="text-[10px] font-black uppercase">Click</span>
</button> </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> </div>
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide"> {/* Precise Click Origin */}
{/* Type Display */} {editForm.type === 'mouse' && editForm.interactionType === 'click' && (
<div className="space-y-3"> <div className="space-y-4">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none"> <label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
Interaction Type Click Origin
</label> </label>
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5"> <div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
<button {[
onClick={() => { id: 'top-left', label: 'TL' },
setEditForm((prev) => ({ { id: 'top-right', label: 'TR' },
...prev, { id: 'center', label: 'CTR' },
type: 'mouse', { id: 'bottom-left', label: 'BL' },
interactionType: 'click', { id: 'bottom-right', label: 'BR' },
})) ].map((origin) => (
} <button
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'}`} key={origin.id}
> onClick={() =>
<MousePointer2 size={14} /> setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
<span className="text-[10px] font-black uppercase">Click</span> }
</button> 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'}`}
<button >
onClick={() => {origin.label}
setEditForm((prev) => ({ </button>
...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>
</div> </div>
)}
{/* Precise Click Origin */} {/* Timing */}
{editForm.type === 'mouse' && editForm.interactionType === 'click' && ( <div className="space-y-4">
<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">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none"> <span>Timeline Allocation</span>
Click Origin <span className="text-accent">{editForm.duration}ms</span>
</label> </label>
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5"> <input
{[ type="range"
{ id: 'top-left', label: 'TL' }, min="0"
{ id: 'top-right', label: 'TR' }, max="5000"
{ id: 'center', label: 'CTR' }, step="100"
{ id: 'bottom-left', label: 'BL' }, value={editForm.duration || 1000}
{ id: 'bottom-right', label: 'BR' }, onChange={(e) =>
].map((origin) => ( setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
<button }
key={origin.id} className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
onClick={() => />
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any })) </div>
}
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'}`} {/* Zoom & Effects */}
> <div className="space-y-6">
{origin.label} <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">
</button> <div className="flex items-center gap-3">
))} <Maximize2 size={18} className="text-white/40" />
</div> <span className="text-xs font-bold text-white uppercase tracking-wider">
Zoom Shift
</span>
</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 <input
type="range" type="number"
min="0" step="0.1"
max="5000" min="1"
step="100" max="3"
value={editForm.duration || 1000} value={editForm.zoom || 1}
onChange={(e) => onChange={(e) =>
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) })) setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
} }
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent" 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> </div>
{/* Zoom & Effects */} <button
<div className="space-y-6"> onClick={() => setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))}
<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"> 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"> >
<Maximize2 size={18} className="text-white/40" /> <div className="flex items-center gap-3">
<span className="text-xs font-bold text-white uppercase tracking-wider"> <Box size={18} />
Zoom Shift <span className="text-xs font-bold uppercase tracking-wider">Motion Blur</span>
</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> </div>
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
</button>
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
<button <button
onClick={() => onClick={() => setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))}
setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur })) 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'}`}
}
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"> <div className="flex items-center gap-3">
<Box size={18} /> <ExternalLink size={18} />
<span className="text-xs font-bold uppercase tracking-wider"> <div className="flex flex-col items-start">
Motion Blur <span className="text-xs font-bold uppercase tracking-wider">
</span> Trigger Navigation
</div> </span>
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />} <span className="text-[8px] opacity-60">
</button> Allows URL transitions in Studio
</span>
{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> </div>
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />} </div>
</button> {editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}
)} </button>
</div> )}
</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> </div>
)}
</AnimatePresence> <button
</div> onClick={saveEdit}
</LazyMotion> className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
>
Commit Changes
</button>
</div>
)}
</AnimatePresence>
</div>
); );
} }

View File

@@ -13,7 +13,7 @@
"categories:performance": [ "categories:performance": [
"error", "error",
{ {
"minScore": 0.9 "minScore": 0.8
} }
], ],
"categories:accessibility": [ "categories:accessibility": [

View File

@@ -1,65 +0,0 @@
---
title: 'Herzlich willkommen bei KLZ: Johannes Gleich startet als Senior Key Account Manager durch'
date: '2026-02-20T14:50:00'
featuredImage: /uploads/2026/01/1767353529807.jpg
locale: de
category: Kabel Technologie
excerpt: 'KLZ Cables startet mit einer starken Verstärkung ins neue Jahr: Johannes Gleich übernimmt die Rolle des Senior Key Account Managers. Erfahren Sie mehr über unseren neuen Experten für Infrastruktur und Energieversorger.'
---
# Herzlich willkommen bei KLZ: Johannes Gleich startet als Senior Key Account Manager durch
KLZ Cables startet mit einer starken Verstärkung ins neue Jahr: Seit Januar 2026 übernimmt Johannes Gleich die Rolle des Senior Key Account Managers. Mit ihm gewinnen wir nicht nur zusätzliche Vertriebskraft, sondern auch jahrzehntelange Erfahrung und ein wertvolles Branchennetzwerk.
### **1. Ein bekanntes Gesicht für eine effektive Zusammenarbeit**
Johannes ist für KLZ kein Neuling: Bereits während seiner über zehnjährigen Tätigkeit bei der LAPP Gruppe hat unser Team die Zusammenarbeit mit ihm kennengelernt und sehr geschätzt. Diese bestehende Vertrautheit und das gegenseitige Vertrauen erleichtern den Einstieg enorm und versprechen eine produktive Kooperation von Tag eins an.
### **2. Beruflicher Hintergrund: Erfahrung trifft technische Tiefe**
Mit rund 50 Jahren verbindet Johannes fundierte Berufserfahrung mit frischer Motivation. Seine Basis ist eine technische Ausbildung im Bereich Elektrotechnik. Dieses Fundament ermöglicht es ihm, unsere Produkte nicht nur zu vertreiben, sondern sie in ihrer gesamten technischen Tiefe zu erklären und einzuordnen.
**Sein Werdegang im Überblick:**
<TechnicalGrid
title="Karrierestationen"
items={[
{ label: "Seit Jan. 2026", value: "Senior Key Account Manager bei KLZ Vertriebs GmbH (Remote)" },
{ label: "2015 2026", value: "Projektmanager Infrastrukturbereich Stadtwerke & Energieversorger bei der LAPP Gruppe (Stuttgart)" }
]}
/>
In den vergangenen elf Jahren hat er sich als Experte für die Anforderungen großer Infrastrukturanbieter etabliert. Er kennt die Herausforderungen der Branche technisch, wirtschaftlich und strategisch aus erster Hand.
### **3. Expertise: Ausschreibungen, Normen und Markttrends**
Was Johannes besonders wertvoll für unser Team macht, ist sein spezialisiertes Fachwissen:
<TechnicalGrid
title="Kernkompetenzen"
items={[
{ label: "Tender-Management", value: "Seine umfassende Erfahrung macht ihn zu einem sicheren Partner bei komplexen Ausschreibungen." },
{ label: "Normen & Fertigung", value: "Er verfügt über tiefgehende Kenntnisse im Bereich Kabelnormen und der Kabelfertigung." },
{ label: "Marktkenntnis", value: "Trends, Preisentwicklungen und Beschaffungsstrategien im deutschen Kabelmarkt sind ihm bestens vertraut." },
{ label: "Logistik", value: "Fundierte Kenntnisse in der Lieferkette runden sein Profil ab." }
]}
/>
### **4. Ein verlässlicher Partner auf Augenhöhe**
Johannes genießt bei Kunden eine hohe Wertschätzung als echter „Kümmerer“. Er übernimmt Verantwortung und zeichnet sich durch eine ausgleichende, aber in der Sache klare Verhandlungsführung aus. Seine Fähigkeit, komplexe Anforderungen strukturiert umzusetzen, hat sich bereits in früheren gemeinsamen Projekten mit KLZ bewährt.
### **5. Neue Rolle und Ziele bei KLZ Cables**
In seiner neuen Position wird Johannes den Vertrieb strategisch verstärken und die Geschäftsführung operativ entlasten.
**Seine Kernaufgaben umfassen:**
- **Gezielte Betreuung:** Fokus auf Stadtwerke, Netzbetreiber und Energieversorger.
- **Markterschließung:** Aufbau von Kontakten in den Bereichen Renewables und Tiefbau.
- **Strategische Planung:** Umsetzung von Vertriebsaktivitäten ohne administrative Grenzen, um maximale Dynamik zu entfalten.
### **6. Ausblick**
Wir freuen uns besonders, dass Johannes bei KLZ den Raum findet, sein gesamtes Wissen optimal für unsere Kunden einzusetzen. Mit seiner Kombination aus technischem Know-how, Markterfahrung und menschlicher Integrität ist er genau am richtigen Ort, um das Wachstum von KLZ Cables nachhaltig zu fördern.
Herzlich willkommen im Team, Johannes! Wir freuen uns auf die gemeinsamen Projekte.

View File

@@ -1,65 +0,0 @@
---
title: 'Welcome to KLZ: Johannes Gleich starts as Senior Key Account Manager'
date: '2026-02-20T14:50:00'
featuredImage: /uploads/2026/01/1767353529807.jpg
locale: en
category: Cable Technology
excerpt: 'KLZ Cables kicks off the new year with a strong addition: Johannes Gleich takes on the role of Senior Key Account Manager. Learn more about our new expert for infrastructure and energy suppliers.'
---
# Welcome to KLZ: Johannes Gleich starts as Senior Key Account Manager
KLZ Cables kicks off the new year with a strong addition to the team: Since January 2026, Johannes Gleich has taken on the role of Senior Key Account Manager. With him, we gain not only additional sales power, but also decades of experience and a valuable industry network.
### **1. A familiar face for effective collaboration**
Johannes is no stranger to KLZ: During his more than ten years at the LAPP Group, our team had the pleasure of working with him and greatly appreciated the collaboration. This existing familiarity and mutual trust make his start enormously easier and promise productive cooperation from day one.
### **2. Professional background: Experience meets technical depth**
At around 50 years of age, Johannes combines solid professional experience with fresh motivation. His foundation is a technical education in electrical engineering. This basis enables him not only to sell our products, but also to explain and classify them in their full technical depth.
**His career at a glance:**
<TechnicalGrid
title="Career Stations"
items={[
{ label: "Since Jan. 2026", value: "Senior Key Account Manager at KLZ Vertriebs GmbH (Remote)" },
{ label: "2015 2026", value: "Project Manager Infrastructure Municipal Utilities & Energy Suppliers at the LAPP Group (Stuttgart)" }
]}
/>
Over the past eleven years, he has established himself as an expert in the requirements of large infrastructure providers. He knows the industry's challenges—technical, economic, and strategic—firsthand.
### **3. Expertise: Tenders, standards, and market trends**
What makes Johannes particularly valuable to our team is his specialized expertise:
<TechnicalGrid
title="Core Competencies"
items={[
{ label: "Tender Management", value: "His extensive experience makes him a reliable partner for complex tenders." },
{ label: "Standards & Production", value: "He has deeply rooted knowledge in cable standards and cable manufacturing." },
{ label: "Market Knowledge", value: "He is highly familiar with trends, price developments, and procurement strategies in the German cable market." },
{ label: "Logistics", value: "Solid knowledge of the supply chain rounds out his profile." }
]}
/>
### **4. A reliable partner at eye level**
Johannes is highly valued by customers as a true "caretaker". He takes responsibility and stands out for his balanced yet clear negotiation skills. His ability to implement complex requirements in a structured manner has already proven itself in past joint projects with KLZ.
### **5. New role and goals at KLZ Cables**
In his new position, Johannes will strategically strengthen sales and operatively relieve the management.
**His core responsibilities include:**
- **Targeted Support:** Focus on municipal utilities, grid operators, and energy suppliers.
- **Market Penetration:** Building contacts in the renewables and civil engineering sectors.
- **Strategic Planning:** Implementing sales activities without administrative boundaries to unfold maximum dynamism.
### **6. Outlook**
We are especially pleased that Johannes has found the space at KLZ to optimally use all his knowledge for our customers. With his combination of technical know-how, market experience, and personal integrity, he is exactly in the right place to sustainably promote the growth of KLZ Cables.
Welcome to the team, Johannes! We look forward to our future projects together.

View File

@@ -35,7 +35,7 @@ function createConfig() {
analytics: { analytics: {
umami: { umami: {
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID, websiteId: env.UMAMI_WEBSITE_ID,
apiEndpoint: env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me', apiEndpoint: env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me',
enabled: typeof window !== 'undefined' || Boolean(env.UMAMI_WEBSITE_ID), enabled: typeof window !== 'undefined' || Boolean(env.UMAMI_WEBSITE_ID),
}, },

View File

@@ -31,7 +31,7 @@ const envExtension = {
INFRA_DIRECTUS_TOKEN: z.string().optional(), INFRA_DIRECTUS_TOKEN: z.string().optional(),
// Analytics // Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(), UMAMI_WEBSITE_ID: z.string().optional(),
UMAMI_API_ENDPOINT: z.string().optional(), UMAMI_API_ENDPOINT: z.string().optional(),
// Mail Configuration // Mail Configuration

View File

@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/nextjs';
import type { import type {
ErrorReportingLevel, ErrorReportingLevel,
ErrorReportingService, ErrorReportingService,
@@ -6,66 +7,32 @@ import type {
import type { NotificationService } from '../notifications/notification-service'; import type { NotificationService } from '../notifications/notification-service';
import type { LoggerService } from '../logging/logger-service'; import type { LoggerService } from '../logging/logger-service';
type SentryLike = typeof Sentry;
export type GlitchtipErrorReportingServiceOptions = { export type GlitchtipErrorReportingServiceOptions = {
enabled: boolean; enabled: boolean;
}; };
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN. // GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
// Sentry is dynamically imported to avoid a ~100KB main-thread execution penalty on initial load.
export class GlitchtipErrorReportingService implements ErrorReportingService { export class GlitchtipErrorReportingService implements ErrorReportingService {
private logger: LoggerService; private logger: LoggerService;
private sentryPromise: Promise<typeof import('@sentry/nextjs')> | null = null;
constructor( constructor(
private readonly options: GlitchtipErrorReportingServiceOptions, private readonly options: GlitchtipErrorReportingServiceOptions,
logger: LoggerService, logger: LoggerService,
private readonly notifications?: NotificationService, private readonly notifications?: NotificationService,
private readonly sentry: SentryLike = Sentry,
) { ) {
this.logger = logger.child({ component: 'error-reporting-glitchtip' }); this.logger = logger.child({ component: 'error-reporting-glitchtip' });
if (this.options.enabled) {
if (typeof window !== 'undefined') {
// On client-side, wait until idle before fetching Sentry
if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => {
this.getSentry();
});
} else {
setTimeout(() => {
this.getSentry();
}, 3000);
}
} else {
// Pre-fetch on server-side
this.getSentry();
}
}
}
private getSentry(): Promise<typeof import('@sentry/nextjs')> {
if (!this.sentryPromise) {
this.sentryPromise = import('@sentry/nextjs').then((Sentry) => {
// Client-side initialization must happen here since sentry.client.config.ts is empty
if (typeof window !== 'undefined') {
Sentry.init({
dsn: 'https://public@errors.infra.mintel.me/1',
tunnel: '/errors/api/relay',
enabled: true,
tracesSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
});
}
return Sentry;
});
}
return this.sentryPromise;
} }
async captureException(error: unknown, context?: Record<string, unknown>) { async captureException(error: unknown, context?: Record<string, unknown>) {
if (!this.options.enabled) return undefined; if (!this.options.enabled) return undefined;
const result = this.sentry.captureException(error, context as any) as any;
// Send to Gotify if it's considered critical or if we just want all exceptions there // Send to Gotify if it's considered critical or if we just want all exceptions there
// For now, let's send all exceptions to Gotify as requested "notify me via gotify about critical error messages"
// We'll treat all captureException calls as potentially critical or at least noteworthy
if (this.notifications) { if (this.notifications) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : ''; const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : '';
@@ -77,33 +44,34 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
}); });
} }
const Sentry = await this.getSentry(); return result;
return Sentry.captureException(error, context as any) as any;
} }
async captureMessage(message: string, level: ErrorReportingLevel = 'error') { captureMessage(message: string, level: ErrorReportingLevel = 'error') {
if (!this.options.enabled) return undefined; if (!this.options.enabled) return undefined;
const Sentry = await this.getSentry(); return this.sentry.captureMessage(message, level as any) as any;
return Sentry.captureMessage(message, level as any) as any;
} }
setUser(user: ErrorReportingUser | null) { setUser(user: ErrorReportingUser | null) {
if (!this.options.enabled) return; if (!this.options.enabled) return;
this.getSentry().then((Sentry) => Sentry.setUser(user as any)); this.sentry.setUser(user as any);
} }
setTag(key: string, value: string) { setTag(key: string, value: string) {
if (!this.options.enabled) return; if (!this.options.enabled) return;
this.getSentry().then((Sentry) => Sentry.setTag(key, value)); this.sentry.setTag(key, value);
} }
withScope<T>(fn: () => T, context?: Record<string, unknown>): T { withScope<T>(fn: () => T, context?: Record<string, unknown>) {
if (!this.options.enabled) return fn(); if (!this.options.enabled) return fn();
// Since withScope mandates executing fn() synchronously to return T, return this.sentry.withScope((scope) => {
// and Sentry load is async, if context mapping is absolutely required if (context) {
// for this feature we would need an async API. for (const [key, value] of Object.entries(context)) {
// For now we degrade gracefully by just executing the function. scope.setExtra(key, value);
return fn(); }
}
return fn();
});
} }
} }

View File

@@ -10,10 +10,6 @@ const nextConfig = {
// Make sure entries are not disposed too quickly // Make sure entries are not disposed too quickly
maxInactiveAge: 60 * 1000, maxInactiveAge: 60 * 1000,
}, },
experimental: {
optimizeCss: true,
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
},
productionBrowserSourceMaps: false, productionBrowserSourceMaps: false,
logging: { logging: {
fetches: { fetches: {
@@ -348,6 +344,14 @@ const nextConfig = {
} }
return [ return [
{
source: '/de/produkte',
destination: '/de/products',
},
{
source: '/de/produkte/:path*',
destination: '/de/products/:path*',
},
{ {
source: '/cms/:path*', source: '/cms/:path*',
destination: `${directusUrl}/:path*`, destination: `${directusUrl}/:path*`,

View File

@@ -67,7 +67,6 @@
"@vitest/ui": "^4.0.16", "@vitest/ui": "^4.0.16",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"critters": "^0.0.25",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"happy-dom": "^20.6.1", "happy-dom": "^20.6.1",
"husky": "^9.1.7", "husky": "^9.1.7",

22
pnpm-lock.yaml generated
View File

@@ -198,9 +198,6 @@ importers:
cheerio: cheerio:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0 version: 1.2.0
critters:
specifier: ^0.0.25
version: 0.0.25
eslint: eslint:
specifier: ^9.18.0 specifier: ^9.18.0
version: 9.39.2(jiti@2.6.1) version: 9.39.2(jiti@2.6.1)
@@ -3697,10 +3694,6 @@ packages:
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
hasBin: true hasBin: true
critters@0.0.25:
resolution: {integrity: sha512-ROF/tjJyyRdM8/6W0VqoN5Ql05xAGnkf5b7f3sTEl1bI5jTQQf8O918RD/V9tEb9pRY/TKcvJekDbJtniHyPtQ==}
deprecated: Ownership of Critters has moved to the Nuxt team, who will be maintaining the project going forward. If you'd like to keep using Critters, please switch to the actively-maintained fork at https://github.com/danielroe/beasties
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -6122,9 +6115,6 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
postcss-media-query-parser@0.2.3:
resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==}
postcss-modules-extract-imports@3.1.0: postcss-modules-extract-imports@3.1.0:
resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==}
engines: {node: ^10 || ^12 || >= 14} engines: {node: ^10 || ^12 || >= 14}
@@ -11246,16 +11236,6 @@ snapshots:
crc-32@1.2.2: {} crc-32@1.2.2: {}
critters@0.0.25:
dependencies:
chalk: 4.1.2
css-select: 5.2.2
dom-serializer: 2.0.0
domhandler: 5.0.3
htmlparser2: 8.0.2
postcss: 8.5.6
postcss-media-query-parser: 0.2.3
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -14236,8 +14216,6 @@ snapshots:
possible-typed-array-names@1.1.0: {} possible-typed-array-names@1.1.0: {}
postcss-media-query-parser@0.2.3: {}
postcss-modules-extract-imports@3.1.0(postcss@8.5.6): postcss-modules-extract-imports@3.1.0(postcss@8.5.6):
dependencies: dependencies:
postcss: 8.5.6 postcss: 8.5.6

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

View File

@@ -1,21 +0,0 @@
const fs = require('fs');
const files = [
'/Users/marcmintel/Projects/klz-2026/components/Header.tsx',
'/Users/marcmintel/Projects/klz-2026/components/Scribble.tsx',
'/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx',
'/Users/marcmintel/Projects/klz-2026/components/record-mode/RecordModeOverlay.tsx',
'/Users/marcmintel/Projects/klz-2026/components/record-mode/PlaybackCursor.tsx'
];
for (const file of files) {
let content = fs.readFileSync(file, 'utf8');
content = content.replace(/import { motion } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation } from 'framer-motion';");
content = content.replace(/import { motion, Variants } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';");
content = content.replace(/import { motion, AnimatePresence } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';");
content = content.replace(/<motion\./g, '<m.');
content = content.replace(/<\/motion\./g, '</m.');
fs.writeFileSync(file, content);
}
console.log('Replaced motion with m in ' + files.length + ' files');

View File

@@ -1,4 +1,19 @@
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading import * as Sentry from '@sentry/nextjs';
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
// from being included in the initial JS bundle. // We use a placeholder DSN on the client because the real DSN is injected
export {}; // by our server-side relay at /errors/api/relay.
// This keeps our environment clean of NEXT_PUBLIC_ variables.
const CLIENT_DSN = 'https://public@errors.infra.mintel.me/1';
Sentry.init({
dsn: CLIENT_DSN,
// Relay events through our own server to hide the real DSN and bypass ad-blockers
tunnel: '/errors/api/relay',
// Enable even if no DSN is provided, because we have the tunnel
enabled: true,
tracesSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
});

View File

@@ -43,11 +43,11 @@
--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%;
@@ -135,31 +135,10 @@
transform: translate(0, 0) scale(1); transform: translate(0, 0) scale(1);
} }
} }
@keyframes spin-slow {
to {
transform: rotate(360deg);
}
}
@keyframes flow {
to {
stroke-dashoffset: 0;
}
}
@keyframes solar-pulse {
0%,
100% {
fill-opacity: 0.2;
}
50% {
fill-opacity: 0.5;
}
}
} }
@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;
@@ -342,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;
} }