Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c646815a3a | |||
| 23bf327670 | |||
| c77f99ef37 | |||
| bffcc98820 | |||
| 7519e17280 | |||
| 5bd7421764 | |||
| d7aba218d9 | |||
| e20d7f42c0 | |||
| 16d06d3275 |
@@ -202,7 +202,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 }}
|
||||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
NEXT_PUBLIC_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 +254,7 @@ jobs:
|
|||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
NEXT_PUBLIC_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 +321,7 @@ jobs:
|
|||||||
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
|
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
|
||||||
echo ""
|
echo ""
|
||||||
echo "# Analytics"
|
echo "# Analytics"
|
||||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
echo "NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_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"
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default async function Layout(props: {
|
|||||||
|
|
||||||
setRequestLocale(safeLocale);
|
setRequestLocale(safeLocale);
|
||||||
|
|
||||||
let messages = {};
|
let messages: Record<string, any> = {};
|
||||||
try {
|
try {
|
||||||
messages = await getMessages();
|
messages = await getMessages();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -80,6 +80,15 @@ 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();
|
||||||
|
|
||||||
@@ -118,7 +127,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={messages} locale={safeLocale}>
|
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
||||||
<RecordModeProvider isEnabled={recordModeEnabled}>
|
<RecordModeProvider isEnabled={recordModeEnabled}>
|
||||||
<RecordModeVisuals>
|
<RecordModeVisuals>
|
||||||
<SkipLink />
|
<SkipLink />
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
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'));
|
||||||
|
|||||||
@@ -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 { motion } from 'framer-motion';
|
import { m, LazyMotion, domAnimation } 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,55 +114,264 @@ export default function Header() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.header
|
<LazyMotion strict features={domAnimation}>
|
||||||
className={headerClass}
|
<m.header
|
||||||
initial={{ y: -100, opacity: 0 }}
|
className={headerClass}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
initial={{ y: -100, opacity: 0 }}
|
||||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
>
|
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">
|
>
|
||||||
<motion.div
|
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||||
className="flex-shrink-0 group touch-target"
|
<m.div
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
className="flex-shrink-0 group touch-target"
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
>
|
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
||||||
<Link
|
|
||||||
href={`/${currentLocale}`}
|
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
target: 'home_logo',
|
|
||||||
location: 'header',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Image
|
<Link
|
||||||
src={logoSrc}
|
href={`/${currentLocale}`}
|
||||||
alt={t('home')}
|
onClick={() =>
|
||||||
width={120}
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
height={120}
|
target: 'home_logo',
|
||||||
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
location: 'header',
|
||||||
priority
|
})
|
||||||
/>
|
}
|
||||||
</Link>
|
>
|
||||||
</motion.div>
|
<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>
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
className="flex items-center gap-4 md:gap-12"
|
className="flex items-center gap-4 md:gap-12"
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
variants={{
|
variants={{
|
||||||
visible: {
|
visible: {
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.08,
|
staggerChildren: 0.08,
|
||||||
delayChildren: 0.3,
|
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}
|
||||||
>
|
>
|
||||||
<motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
|
<m.nav
|
||||||
{menuItems.map((item, _idx) => (
|
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
||||||
<motion.div key={item.href} variants={navLinkVariants}>
|
initial="closed"
|
||||||
|
animate={isMobileMenuOpen ? 'open' : 'closed'}
|
||||||
|
variants={{
|
||||||
|
open: {
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menuItems.map((item, idx) => (
|
||||||
|
<m.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
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -170,302 +379,95 @@ 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: 'header_nav',
|
location: 'mobile_menu',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
||||||
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>
|
||||||
</motion.div>
|
</m.div>
|
||||||
))}
|
))}
|
||||||
</motion.nav>
|
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
|
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
||||||
variants={headerRightVariants}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
>
|
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
|
||||||
<motion.div
|
transition={{ duration: 0.5, delay: 0.8 }}
|
||||||
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 }}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<m.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.65 }}
|
transition={{ duration: 0.4, delay: 0.9 }}
|
||||||
>
|
>
|
||||||
<Link
|
<m.div
|
||||||
href={getPathForLocale('en')}
|
initial={{ opacity: 0 }}
|
||||||
onClick={() =>
|
animate={{ opacity: 1 }}
|
||||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
transition={{ duration: 0.3, delay: 1.0 }}
|
||||||
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
|
||||||
</Link>
|
href={getPathForLocale('en')}
|
||||||
</motion.div>
|
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||||
<motion.div
|
>
|
||||||
className="w-px h-4 bg-current opacity-20"
|
EN
|
||||||
initial={{ scaleY: 0 }}
|
</Link>
|
||||||
animate={{ scaleY: 1 }}
|
</m.div>
|
||||||
transition={{ duration: 0.4, delay: 0.7 }}
|
<m.div
|
||||||
/>
|
className="w-px h-6 bg-white/20"
|
||||||
<motion.div
|
initial={{ scaleX: 0 }}
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
animate={{ scaleX: 1 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
transition={{ duration: 0.4, delay: 1.05 }}
|
||||||
transition={{ duration: 0.4, delay: 0.75 }}
|
/>
|
||||||
>
|
<m.div
|
||||||
<Link
|
initial={{ opacity: 0 }}
|
||||||
href={getPathForLocale('de')}
|
animate={{ opacity: 1 }}
|
||||||
onClick={() =>
|
transition={{ duration: 0.3, delay: 1.1 }}
|
||||||
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
|
||||||
</Link>
|
href={getPathForLocale('de')}
|
||||||
</motion.div>
|
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||||
</motion.div>
|
>
|
||||||
|
DE
|
||||||
|
</Link>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
|
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
|
||||||
>
|
|
||||||
<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
|
||||||
</Button>
|
href={`/${currentLocale}/contact`}
|
||||||
</motion.div>
|
variant="accent"
|
||||||
</motion.div>
|
size="lg"
|
||||||
|
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
|
||||||
|
>
|
||||||
|
{t('contact')}
|
||||||
|
</Button>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Bottom Branding */}
|
||||||
<motion.button
|
<m.div
|
||||||
className={cn(
|
className="p-12 flex justify-center opacity-20"
|
||||||
'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 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
|
||||||
transition={{ duration: 0.4, delay: 0.9 }}
|
transition={{ duration: 0.5, delay: 1.4 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ scale: 0.5 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ scale: 1 }}
|
||||||
transition={{ duration: 0.3, delay: 1.0 }}
|
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
|
||||||
>
|
>
|
||||||
<Link
|
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||||
href={getPathForLocale('en')}
|
</m.div>
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
</m.div>
|
||||||
>
|
</m.nav>
|
||||||
EN
|
</div>
|
||||||
</Link>
|
</m.header>
|
||||||
</motion.div>
|
</LazyMotion>
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { motion, AnimatePresence } from 'framer-motion';
|
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
|
||||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
interface LightboxProps {
|
interface LightboxProps {
|
||||||
@@ -139,118 +139,120 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<AnimatePresence>
|
<LazyMotion strict features={domAnimation}>
|
||||||
{isOpen && (
|
<AnimatePresence>
|
||||||
<div
|
{isOpen && (
|
||||||
className="fixed inset-0 z-[99999] flex items-center justify-center"
|
<div
|
||||||
role="dialog"
|
className="fixed inset-0 z-[99999] flex items-center justify-center"
|
||||||
aria-modal="true"
|
role="dialog"
|
||||||
>
|
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"
|
|
||||||
>
|
>
|
||||||
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
|
<m.div
|
||||||
<span className="text-3xl font-extralight leading-none mb-1">×</span>
|
initial={{ opacity: 0 }}
|
||||||
</div>
|
animate={{ opacity: 1 }}
|
||||||
</motion.button>
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
|
||||||
<motion.button
|
<m.button
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, scale: 0.5 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, x: -20 }}
|
exit={{ opacity: 0, scale: 0.5 }}
|
||||||
transition={{ delay: 0.2, duration: 0.4 }}
|
transition={{ delay: 0.1, duration: 0.4 }}
|
||||||
onClick={prevImage}
|
ref={closeButtonRef}
|
||||||
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"
|
onClick={handleClose}
|
||||||
aria-label="Previous image"
|
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"
|
||||||
<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>
|
<span className="text-3xl font-extralight leading-none mb-1">×</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>
|
||||||
|
|
||||||
<motion.div
|
<m.button
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, y: 10 }}
|
exit={{ opacity: 0, x: -20 }}
|
||||||
transition={{ delay: 0.3, duration: 0.4 }}
|
transition={{ delay: 0.2, duration: 0.4 }}
|
||||||
className="mt-8 flex items-center gap-4"
|
onClick={prevImage}
|
||||||
>
|
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"
|
||||||
<div className="h-px w-12 bg-white/20" />
|
aria-label="Previous image"
|
||||||
<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}
|
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
|
||||||
|
‹
|
||||||
|
</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" />
|
|
||||||
</motion.div>
|
<m.div
|
||||||
</div>
|
initial={{ opacity: 0, y: 10 }}
|
||||||
</motion.div>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</div>
|
exit={{ opacity: 0, y: 10 }}
|
||||||
)}
|
transition={{ delay: 0.3, duration: 0.4 }}
|
||||||
</AnimatePresence>,
|
className="mt-8 flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion, Variants } from 'framer-motion';
|
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';
|
||||||
import { cn } from '@/components/ui';
|
import { cn } from '@/components/ui';
|
||||||
|
|
||||||
interface ScribbleProps {
|
interface ScribbleProps {
|
||||||
@@ -25,49 +25,53 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
|||||||
|
|
||||||
if (variant === 'circle') {
|
if (variant === 'circle') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<LazyMotion strict features={domAnimation}>
|
||||||
className={cn('absolute pointer-events-none', className)}
|
<svg
|
||||||
aria-hidden="true"
|
className={cn('absolute pointer-events-none', className)}
|
||||||
viewBox="0 0 800 350"
|
aria-hidden="true"
|
||||||
preserveAspectRatio="none"
|
viewBox="0 0 800 350"
|
||||||
>
|
preserveAspectRatio="none"
|
||||||
<motion.path
|
>
|
||||||
variants={pathVariants}
|
<m.path
|
||||||
initial="hidden"
|
variants={pathVariants}
|
||||||
whileInView="visible"
|
initial="hidden"
|
||||||
viewport={{ once: true }}
|
whileInView="visible"
|
||||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
viewport={{ once: true }}
|
||||||
strokeLinejoin="miter"
|
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||||
fillOpacity="0"
|
strokeLinejoin="miter"
|
||||||
strokeMiterlimit="4"
|
fillOpacity="0"
|
||||||
stroke={color}
|
strokeMiterlimit="4"
|
||||||
strokeOpacity="1"
|
stroke={color}
|
||||||
strokeWidth="20"
|
strokeOpacity="1"
|
||||||
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"
|
strokeWidth="20"
|
||||||
/>
|
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
|
||||||
</svg>
|
/>
|
||||||
|
</svg>
|
||||||
|
</LazyMotion>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === 'underline') {
|
if (variant === 'underline') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<LazyMotion strict features={domAnimation}>
|
||||||
className={cn('absolute pointer-events-none', className)}
|
<svg
|
||||||
aria-hidden="true"
|
className={cn('absolute pointer-events-none', className)}
|
||||||
viewBox="-400 -55 730 60"
|
aria-hidden="true"
|
||||||
preserveAspectRatio="none"
|
viewBox="-400 -55 730 60"
|
||||||
>
|
preserveAspectRatio="none"
|
||||||
<motion.path
|
>
|
||||||
variants={pathVariants}
|
<m.path
|
||||||
initial="hidden"
|
variants={pathVariants}
|
||||||
whileInView="visible"
|
initial="hidden"
|
||||||
viewport={{ once: true }}
|
whileInView="visible"
|
||||||
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"
|
viewport={{ once: true }}
|
||||||
stroke={color}
|
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"
|
||||||
strokeWidth="20"
|
stroke={color}
|
||||||
fill="none"
|
strokeWidth="20"
|
||||||
/>
|
fill="none"
|
||||||
</svg>
|
/>
|
||||||
|
</svg>
|
||||||
|
</LazyMotion>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { Suspense } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -11,6 +11,20 @@ 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 />
|
||||||
|
|||||||
@@ -136,18 +136,14 @@ function AddToCartButton({ product, quantity = 1 }) {
|
|||||||
product_category: product.category,
|
product_category: product.category,
|
||||||
price: product.price,
|
price: product.price,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
cart_total: 150.00, // Current cart total
|
cart_total: 150.0, // Current cart total
|
||||||
});
|
});
|
||||||
|
|
||||||
// Actual add to cart logic
|
// Actual add to cart logic
|
||||||
// addToCart(product, quantity);
|
// addToCart(product, quantity);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <button onClick={handleAddToCart}>Add to Cart</button>;
|
||||||
<button onClick={handleAddToCart}>
|
|
||||||
Add to Cart
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -171,7 +167,7 @@ function CheckoutComplete({ order }) {
|
|||||||
transaction_tax: order.tax,
|
transaction_tax: order.tax,
|
||||||
transaction_shipping: order.shipping,
|
transaction_shipping: order.shipping,
|
||||||
product_count: order.items.length,
|
product_count: order.items.length,
|
||||||
products: order.items.map(item => ({
|
products: order.items.map((item) => ({
|
||||||
product_id: item.product.id,
|
product_id: item.product.id,
|
||||||
product_name: item.product.name,
|
product_name: item.product.name,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
@@ -198,27 +194,21 @@ function WishlistButton({ product }) {
|
|||||||
|
|
||||||
const toggleWishlist = () => {
|
const toggleWishlist = () => {
|
||||||
const newState = !isInWishlist;
|
const newState = !isInWishlist;
|
||||||
|
|
||||||
trackEvent(
|
trackEvent(
|
||||||
newState
|
newState ? AnalyticsEvents.PRODUCT_WISHLIST_ADD : AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
|
||||||
? AnalyticsEvents.PRODUCT_WISHLIST_ADD
|
|
||||||
: AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
|
|
||||||
{
|
{
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
product_name: product.name,
|
product_name: product.name,
|
||||||
product_category: product.category,
|
product_category: product.category,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
setIsInWishlist(newState);
|
setIsInWishlist(newState);
|
||||||
// Update wishlist in backend
|
// Update wishlist in backend
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <button onClick={toggleWishlist}>{isInWishlist ? '❤️' : '🤍'}</button>;
|
||||||
<button onClick={toggleWishlist}>
|
|
||||||
{isInWishlist ? '❤️' : '🤍'}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -268,7 +258,7 @@ function ContactForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
setFormData(prev => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[e.target.name]: e.target.value,
|
[e.target.name]: e.target.value,
|
||||||
}));
|
}));
|
||||||
@@ -310,9 +300,7 @@ function NewsletterSignup() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<input placeholder="Enter email" />
|
<input placeholder="Enter email" />
|
||||||
<button onClick={() => handleSubscribe('user@example.com')}>
|
<button onClick={() => handleSubscribe('user@example.com')}>Subscribe</button>
|
||||||
Subscribe
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -396,10 +384,12 @@ function LoginForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => {
|
<form
|
||||||
e.preventDefault();
|
onSubmit={(e) => {
|
||||||
handleLogin('user@example.com', 'password');
|
e.preventDefault();
|
||||||
}}>
|
handleLogin('user@example.com', 'password');
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Form fields */}
|
{/* Form fields */}
|
||||||
<button type="submit">Login</button>
|
<button type="submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -418,11 +408,7 @@ import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
|||||||
function SignupForm() {
|
function SignupForm() {
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
const handleSignup = (userData: {
|
const handleSignup = (userData: { email: string; name: string; company?: string }) => {
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
company?: string;
|
|
||||||
}) => {
|
|
||||||
trackEvent(AnalyticsEvents.USER_SIGNUP, {
|
trackEvent(AnalyticsEvents.USER_SIGNUP, {
|
||||||
user_email: userData.email,
|
user_email: userData.email,
|
||||||
user_name: userData.name,
|
user_name: userData.name,
|
||||||
@@ -436,14 +422,16 @@ function SignupForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => {
|
<form
|
||||||
e.preventDefault();
|
onSubmit={(e) => {
|
||||||
handleSignup({
|
e.preventDefault();
|
||||||
email: 'user@example.com',
|
handleSignup({
|
||||||
name: 'John Doe',
|
email: 'user@example.com',
|
||||||
company: 'ACME Corp',
|
name: 'John Doe',
|
||||||
});
|
company: 'ACME Corp',
|
||||||
}}>
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Form fields */}
|
{/* Form fields */}
|
||||||
<button type="submit">Sign Up</button>
|
<button type="submit">Sign Up</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -483,7 +471,7 @@ function SearchBar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search products..."
|
placeholder="Search products..."
|
||||||
@@ -549,7 +537,7 @@ function ProductFilters() {
|
|||||||
<option value="cables">Cables</option>
|
<option value="cables">Cables</option>
|
||||||
<option value="connectors">Connectors</option>
|
<option value="connectors">Connectors</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button onClick={handleClearFilters}>Clear Filters</button>
|
<button onClick={handleClearFilters}>Clear Filters</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -631,11 +619,7 @@ function VideoPlayer({ videoId, videoTitle }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<video
|
<video onPlay={handlePlay} onPause={handlePause} onEnded={handleComplete}>
|
||||||
onPlay={handlePlay}
|
|
||||||
onPause={handlePause}
|
|
||||||
onEnded={handleComplete}
|
|
||||||
>
|
|
||||||
<source src="/video.mp4" type="video/mp4" />
|
<source src="/video.mp4" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
);
|
);
|
||||||
@@ -665,11 +649,7 @@ function DownloadButton({ fileName, fileType, fileSize }) {
|
|||||||
// window.location.href = `/downloads/${fileName}`;
|
// window.location.href = `/downloads/${fileName}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <button onClick={handleDownload}>Download {fileName}</button>;
|
||||||
<button onClick={handleDownload}>
|
|
||||||
Download {fileName}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -700,7 +680,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps> {
|
|||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
trackEvent(AnalyticsEvents.ERROR, {
|
trackEvent(AnalyticsEvents.ERROR, {
|
||||||
error_message: error.message,
|
error_message: error.message,
|
||||||
error_stack: error.stack,
|
error_stack: error.stack,
|
||||||
@@ -742,14 +722,14 @@ function ApiClient() {
|
|||||||
const fetchData = async (endpoint: string) => {
|
const fetchData = async (endpoint: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint);
|
const response = await fetch(endpoint);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
trackEvent(AnalyticsEvents.API_ERROR, {
|
trackEvent(AnalyticsEvents.API_ERROR, {
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
status_code: response.status,
|
status_code: response.status,
|
||||||
error_message: response.statusText,
|
error_message: response.statusText,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,7 +745,7 @@ function ApiClient() {
|
|||||||
error_message: error.message,
|
error_message: error.message,
|
||||||
error_type: error.name,
|
error_type: error.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -963,15 +943,9 @@ function CableProductPage({ cable }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{cable.name}</h1>
|
<h1>{cable.name}</h1>
|
||||||
<button onClick={handleTechnicalSpecDownload}>
|
<button onClick={handleTechnicalSpecDownload}>Download Technical Specs</button>
|
||||||
Download Technical Specs
|
<button onClick={handleRequestQuote}>Request Quote</button>
|
||||||
</button>
|
<button onClick={handleBrochureDownload}>Download Brochure</button>
|
||||||
<button onClick={handleRequestQuote}>
|
|
||||||
Request Quote
|
|
||||||
</button>
|
|
||||||
<button onClick={handleBrochureDownload}>
|
|
||||||
Download Brochure
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1010,12 +984,8 @@ function WindFarmProjectPage({ project }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{project.name}</h1>
|
<h1>{project.name}</h1>
|
||||||
<button onClick={handleProjectInquiry}>
|
<button onClick={handleProjectInquiry}>Request Project Consultation</button>
|
||||||
Request Project Consultation
|
<button onClick={handleCableCalculation}>Calculate Cable Requirements</button>
|
||||||
</button>
|
|
||||||
<button onClick={handleCableCalculation}>
|
|
||||||
Calculate Cable Requirements
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1066,7 +1036,7 @@ test('tracks button click', () => {
|
|||||||
// [Umami] Tracked pageview: /products/123
|
// [Umami] Tracked pageview: /products/123
|
||||||
|
|
||||||
// To test without sending data to Umami:
|
// To test without sending data to Umami:
|
||||||
// 1. Remove NEXT_PUBLIC_UMAMI_WEBSITE_ID from .env
|
// 1. Remove UMAMI_WEBSITE_ID from .env
|
||||||
// 2. Or set it to an empty string
|
// 2. Or set it to an empty string
|
||||||
// 3. Check console logs to verify events are being tracked
|
// 3. Check console logs to verify events are being tracked
|
||||||
```
|
```
|
||||||
@@ -1169,7 +1139,9 @@ function WebVitalsTracker() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe({ entryTypes: ['largest-contentful-paint', 'first-input-delay', 'layout-shift'] });
|
observer.observe({
|
||||||
|
entryTypes: ['largest-contentful-paint', 'first-input-delay', 'layout-shift'],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1194,6 +1166,7 @@ This examples file demonstrates how to implement comprehensive analytics trackin
|
|||||||
- ✅ **Business-specific events** (KLZ Cables, wind farms)
|
- ✅ **Business-specific events** (KLZ Cables, wind farms)
|
||||||
|
|
||||||
Remember to:
|
Remember to:
|
||||||
|
|
||||||
1. Use the `useAnalytics` hook for client-side tracking
|
1. Use the `useAnalytics` hook for client-side tracking
|
||||||
2. Import events from `AnalyticsEvents` for consistency
|
2. Import events from `AnalyticsEvents` for consistency
|
||||||
3. Include relevant context in your events
|
3. Include relevant context in your events
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Setup Checklist
|
## Setup Checklist
|
||||||
|
|
||||||
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
|
- [ ] Add `UMAMI_WEBSITE_ID` to `.env` file
|
||||||
- [ ] Verify `UmamiScript` is in your layout
|
- [ ] Verify `UmamiScript` is in your layout
|
||||||
- [ ] Verify `AnalyticsProvider` is in your layout
|
- [ ] Verify `AnalyticsProvider` is in your layout
|
||||||
- [ ] Test in development mode
|
- [ ] Test in development mode
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required
|
# Required
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
|
|
||||||
# Optional (defaults to https://analytics.infra.mintel.me/script.js)
|
# Optional (defaults to https://analytics.infra.mintel.me/script.js)
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
@@ -86,16 +86,16 @@ function ProductCard({ product }) {
|
|||||||
|
|
||||||
## Common Events
|
## Common Events
|
||||||
|
|
||||||
| Event | When to Use | Example Properties |
|
| Event | When to Use | Example Properties |
|
||||||
|-------|-------------|-------------------|
|
| --------------------- | ------------------- | ------------------------------------------------- |
|
||||||
| `pageview` | Page loads | `{ url: '/products/123' }` |
|
| `pageview` | Page loads | `{ url: '/products/123' }` |
|
||||||
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
|
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
|
||||||
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
|
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
|
||||||
| `product_view` | Product page viewed | `{ product_id: '123', price: 99.99 }` |
|
| `product_view` | Product page viewed | `{ product_id: '123', price: 99.99 }` |
|
||||||
| `product_add_to_cart` | Add to cart | `{ product_id: '123', quantity: 1 }` |
|
| `product_add_to_cart` | Add to cart | `{ product_id: '123', quantity: 1 }` |
|
||||||
| `search` | Search performed | `{ search_query: 'cable', results: 42 }` |
|
| `search` | Search performed | `{ search_query: 'cable', results: 42 }` |
|
||||||
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
||||||
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ In development, you'll see console logs:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env.local
|
# .env.local
|
||||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
# UMAMI_WEBSITE_ID=
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -120,8 +120,9 @@ In development, you'll see console logs:
|
|||||||
### Analytics Not Working?
|
### Analytics Not Working?
|
||||||
|
|
||||||
1. **Check environment variables:**
|
1. **Check environment variables:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
echo $UMAMI_WEBSITE_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Verify script is loading:**
|
2. **Verify script is loading:**
|
||||||
@@ -136,12 +137,12 @@ In development, you'll see console logs:
|
|||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
| Issue | Solution |
|
| Issue | Solution |
|
||||||
|-------|----------|
|
| ------------------- | ----------------------------------- |
|
||||||
| No data in Umami | Check website ID and script URL |
|
| No data in Umami | Check website ID and script URL |
|
||||||
| Events not tracking | Verify `useAnalytics` hook is used |
|
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||||
| Script not loading | Check network connection, CORS |
|
| Script not loading | Check network connection, CORS |
|
||||||
| Wrong data | Verify event properties are correct |
|
| Wrong data | Verify event properties are correct |
|
||||||
|
|
||||||
## Performance Tips
|
## Performance Tips
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Add these to your `.env` file:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required: Your Umami website ID
|
# Required: Your Umami website ID
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
|
|
||||||
# Optional: Custom Umami script URL (defaults to https://analytics.infra.mintel.me/script.js)
|
# Optional: Custom Umami script URL (defaults to https://analytics.infra.mintel.me/script.js)
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
@@ -32,7 +32,7 @@ The `docker-compose.yml` already includes the environment variables:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
|
||||||
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -75,11 +75,7 @@ function ProductCard({ product }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <button onClick={handleAddToCart}>Add to Cart</button>;
|
||||||
<button onClick={handleAddToCart}>
|
|
||||||
Add to Cart
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -96,7 +92,7 @@ function CustomNavigation() {
|
|||||||
const navigateToCustomPage = () => {
|
const navigateToCustomPage = () => {
|
||||||
// Track a custom pageview
|
// Track a custom pageview
|
||||||
trackPageview('/custom-path?param=value');
|
trackPageview('/custom-path?param=value');
|
||||||
|
|
||||||
// Then perform navigation
|
// Then perform navigation
|
||||||
window.location.href = '/custom-path?param=value';
|
window.location.href = '/custom-path?param=value';
|
||||||
};
|
};
|
||||||
@@ -277,11 +273,7 @@ function ErrorBoundary({ children }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <ErrorBoundary onError={handleError}>{children}</ErrorBoundary>;
|
||||||
<ErrorBoundary onError={handleError}>
|
|
||||||
{children}
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -289,20 +281,20 @@ function ErrorBoundary({ children }) {
|
|||||||
|
|
||||||
### Common Events
|
### Common Events
|
||||||
|
|
||||||
| Event Name | Description | Example Properties |
|
| Event Name | Description | Example Properties |
|
||||||
|------------|-------------|-------------------|
|
| --------------------- | --------------------- | ------------------------------------------------------------ |
|
||||||
| `pageview` | Page view | `{ url: '/products/123' }` |
|
| `pageview` | Page view | `{ url: '/products/123' }` |
|
||||||
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
|
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
|
||||||
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
|
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
|
||||||
| `form_submit` | Form submitted | `{ form_id: 'contact-form', form_name: 'Contact Us' }` |
|
| `form_submit` | Form submitted | `{ form_id: 'contact-form', form_name: 'Contact Us' }` |
|
||||||
| `product_view` | Product page viewed | `{ product_id: '123', product_name: 'Cable', price: 99.99 }` |
|
| `product_view` | Product page viewed | `{ product_id: '123', product_name: 'Cable', price: 99.99 }` |
|
||||||
| `product_add_to_cart` | Product added to cart | `{ product_id: '123', quantity: 1 }` |
|
| `product_add_to_cart` | Product added to cart | `{ product_id: '123', quantity: 1 }` |
|
||||||
| `product_purchase` | Product purchased | `{ product_id: '123', transaction_id: 'TXN-123' }` |
|
| `product_purchase` | Product purchased | `{ product_id: '123', transaction_id: 'TXN-123' }` |
|
||||||
| `search` | Search performed | `{ search_query: 'cable', search_results_count: 42 }` |
|
| `search` | Search performed | `{ search_query: 'cable', search_results_count: 42 }` |
|
||||||
| `filter_apply` | Filter applied | `{ filter_type: 'category', filter_value: 'high-voltage' }` |
|
| `filter_apply` | Filter applied | `{ filter_type: 'category', filter_value: 'high-voltage' }` |
|
||||||
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
||||||
| `user_signup` | User signed up | `{ user_email: 'user@example.com' }` |
|
| `user_signup` | User signed up | `{ user_email: 'user@example.com' }` |
|
||||||
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
||||||
|
|
||||||
### Custom Events
|
### Custom Events
|
||||||
|
|
||||||
@@ -385,8 +377,9 @@ The analytics system includes development mode logging:
|
|||||||
### Analytics Not Working
|
### Analytics Not Working
|
||||||
|
|
||||||
1. **Check environment variables:**
|
1. **Check environment variables:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
echo $UMAMI_WEBSITE_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Verify the script is loading:**
|
2. **Verify the script is loading:**
|
||||||
@@ -405,11 +398,11 @@ In development mode, you'll see console logs for all tracked events. This helps
|
|||||||
|
|
||||||
### Disabling Analytics
|
### Disabling Analytics
|
||||||
|
|
||||||
To disable analytics (e.g., for local development), simply remove the `NEXT_PUBLIC_UMAMI_WEBSITE_ID` environment variable:
|
To disable analytics (e.g., for local development), simply remove the `UMAMI_WEBSITE_ID` environment variable:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env.local (not committed to git)
|
# .env.local (not committed to git)
|
||||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
# UMAMI_WEBSITE_ID=
|
||||||
```
|
```
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
@@ -438,6 +431,7 @@ The analytics implementation is optimized for performance:
|
|||||||
## Support
|
## Support
|
||||||
|
|
||||||
For issues or questions about the analytics implementation, check:
|
For issues or questions about the analytics implementation, check:
|
||||||
|
|
||||||
1. This README for usage examples
|
1. This README for usage examples
|
||||||
2. The component source code for implementation details
|
2. The component source code for implementation details
|
||||||
3. The Umami documentation for platform-specific questions
|
3. The Umami documentation for platform-specific questions
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ The project already had a solid foundation:
|
|||||||
## What Was Enhanced
|
## What Was Enhanced
|
||||||
|
|
||||||
### 1. **Enhanced UmamiScript** (`components/analytics/UmamiScript.tsx`)
|
### 1. **Enhanced UmamiScript** (`components/analytics/UmamiScript.tsx`)
|
||||||
|
|
||||||
- ✅ Added TypeScript props interface for customization
|
- ✅ Added TypeScript props interface for customization
|
||||||
- ✅ Added JSDoc documentation with usage examples
|
- ✅ Added JSDoc documentation with usage examples
|
||||||
- ✅ Added error handling for script loading failures
|
- ✅ Added error handling for script loading failures
|
||||||
@@ -23,11 +24,13 @@ The project already had a solid foundation:
|
|||||||
- ✅ Improved type safety and comments
|
- ✅ Improved type safety and comments
|
||||||
|
|
||||||
### 2. **Enhanced AnalyticsProvider** (`components/analytics/AnalyticsProvider.tsx`)
|
### 2. **Enhanced AnalyticsProvider** (`components/analytics/AnalyticsProvider.tsx`)
|
||||||
|
|
||||||
- ✅ Added comprehensive JSDoc documentation
|
- ✅ Added comprehensive JSDoc documentation
|
||||||
- ✅ Added development mode logging
|
- ✅ Added development mode logging
|
||||||
- ✅ Improved code comments
|
- ✅ Improved code comments
|
||||||
|
|
||||||
### 3. **New Custom Hook** (`components/analytics/useAnalytics.ts`)
|
### 3. **New Custom Hook** (`components/analytics/useAnalytics.ts`)
|
||||||
|
|
||||||
- ✅ Type-safe `useAnalytics` hook for easy event tracking
|
- ✅ Type-safe `useAnalytics` hook for easy event tracking
|
||||||
- ✅ `trackEvent()` method for custom events
|
- ✅ `trackEvent()` method for custom events
|
||||||
- ✅ `trackPageview()` method for manual pageview tracking
|
- ✅ `trackPageview()` method for manual pageview tracking
|
||||||
@@ -35,12 +38,14 @@ The project already had a solid foundation:
|
|||||||
- ✅ Development mode logging
|
- ✅ Development mode logging
|
||||||
|
|
||||||
### 4. **Event Definitions** (`components/analytics/analytics-events.ts`)
|
### 4. **Event Definitions** (`components/analytics/analytics-events.ts`)
|
||||||
|
|
||||||
- ✅ Centralized event constants for consistency
|
- ✅ Centralized event constants for consistency
|
||||||
- ✅ Type-safe event names
|
- ✅ Type-safe event names
|
||||||
- ✅ Helper functions for common event properties
|
- ✅ Helper functions for common event properties
|
||||||
- ✅ 30+ predefined events for various use cases
|
- ✅ 30+ predefined events for various use cases
|
||||||
|
|
||||||
### 5. **Comprehensive Documentation**
|
### 5. **Comprehensive Documentation**
|
||||||
|
|
||||||
- ✅ **README.md** - Full documentation with setup, usage, and best practices
|
- ✅ **README.md** - Full documentation with setup, usage, and best practices
|
||||||
- ✅ **EXAMPLES.md** - 20+ practical examples for different scenarios
|
- ✅ **EXAMPLES.md** - 20+ practical examples for different scenarios
|
||||||
- ✅ **QUICK_REFERENCE.md** - Quick start guide and troubleshooting
|
- ✅ **QUICK_REFERENCE.md** - Quick start guide and troubleshooting
|
||||||
@@ -63,12 +68,14 @@ components/analytics/
|
|||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
### 🚀 Modern Implementation
|
### 🚀 Modern Implementation
|
||||||
|
|
||||||
- Uses Next.js `Script` component (not old-school `<script>` tags)
|
- Uses Next.js `Script` component (not old-school `<script>` tags)
|
||||||
- TypeScript for type safety
|
- TypeScript for type safety
|
||||||
- React hooks for clean API
|
- React hooks for clean API
|
||||||
- Environment variable configuration
|
- Environment variable configuration
|
||||||
|
|
||||||
### 📊 Comprehensive Tracking
|
### 📊 Comprehensive Tracking
|
||||||
|
|
||||||
- Automatic pageview tracking on route changes
|
- Automatic pageview tracking on route changes
|
||||||
- Custom event tracking with properties
|
- Custom event tracking with properties
|
||||||
- E-commerce events (products, cart, purchases)
|
- E-commerce events (products, cart, purchases)
|
||||||
@@ -77,6 +84,7 @@ components/analytics/
|
|||||||
- Error and performance tracking
|
- Error and performance tracking
|
||||||
|
|
||||||
### 🎯 Developer Experience
|
### 🎯 Developer Experience
|
||||||
|
|
||||||
- Type-safe event tracking
|
- Type-safe event tracking
|
||||||
- Centralized event definitions
|
- Centralized event definitions
|
||||||
- Development mode logging
|
- Development mode logging
|
||||||
@@ -84,6 +92,7 @@ components/analytics/
|
|||||||
- 20+ practical examples
|
- 20+ practical examples
|
||||||
|
|
||||||
### 🔒 Privacy & Performance
|
### 🔒 Privacy & Performance
|
||||||
|
|
||||||
- No PII tracking by default
|
- No PII tracking by default
|
||||||
- Script loads after page is interactive
|
- Script loads after page is interactive
|
||||||
- Minimal performance impact
|
- Minimal performance impact
|
||||||
@@ -95,7 +104,7 @@ The project is already configured in `docker-compose.yml`:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
|
||||||
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -104,7 +113,7 @@ environment:
|
|||||||
Add to your `.env` file:
|
Add to your `.env` file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
@@ -188,7 +197,7 @@ In development, you'll see console logs:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env.local
|
# .env.local
|
||||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
# UMAMI_WEBSITE_ID=
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -196,8 +205,9 @@ In development, you'll see console logs:
|
|||||||
### Analytics Not Working?
|
### Analytics Not Working?
|
||||||
|
|
||||||
1. **Check environment variables:**
|
1. **Check environment variables:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
echo $UMAMI_WEBSITE_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Verify script is loading:**
|
2. **Verify script is loading:**
|
||||||
@@ -212,12 +222,12 @@ In development, you'll see console logs:
|
|||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
| Issue | Solution |
|
| Issue | Solution |
|
||||||
|-------|----------|
|
| ------------------- | ----------------------------------- |
|
||||||
| No data in Umami | Check website ID and script URL |
|
| No data in Umami | Check website ID and script URL |
|
||||||
| Events not tracking | Verify `useAnalytics` hook is used |
|
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||||
| Script not loading | Check network connection, CORS |
|
| Script not loading | Check network connection, CORS |
|
||||||
| Wrong data | Verify event properties are correct |
|
| Wrong data | Verify event properties are correct |
|
||||||
|
|
||||||
## Performance Tips
|
## Performance Tips
|
||||||
|
|
||||||
@@ -239,13 +249,13 @@ In development, you'll see console logs:
|
|||||||
1. ✅ **Setup complete** - All files are in place
|
1. ✅ **Setup complete** - All files are in place
|
||||||
2. ✅ **Documentation complete** - README, EXAMPLES, QUICK_REFERENCE
|
2. ✅ **Documentation complete** - README, EXAMPLES, QUICK_REFERENCE
|
||||||
3. ✅ **Code enhanced** - Better TypeScript, error handling, docs
|
3. ✅ **Code enhanced** - Better TypeScript, error handling, docs
|
||||||
4. 📝 **Add to .env** - Set `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
|
4. 📝 **Add to .env** - Set `UMAMI_WEBSITE_ID`
|
||||||
5. 🧪 **Test in development** - Verify events are tracked
|
5. 🧪 **Test in development** - Verify events are tracked
|
||||||
6. 🚀 **Deploy** - Analytics will work in production
|
6. 🚀 **Deploy** - Analytics will work in production
|
||||||
|
|
||||||
## Quick Start Checklist
|
## Quick Start Checklist
|
||||||
|
|
||||||
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
|
- [ ] Add `UMAMI_WEBSITE_ID` to `.env` file
|
||||||
- [ ] Verify `UmamiScript` is in `app/[locale]/layout.tsx`
|
- [ ] Verify `UmamiScript` is in `app/[locale]/layout.tsx`
|
||||||
- [ ] Verify `AnalyticsProvider` is in `app/[locale]/layout.tsx`
|
- [ ] Verify `AnalyticsProvider` is in `app/[locale]/layout.tsx`
|
||||||
- [ ] Test in development mode (check console logs)
|
- [ ] Test in development mode (check console logs)
|
||||||
|
|||||||
@@ -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 { motion } from 'framer-motion';
|
import { m, LazyMotion, domAnimation } 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,111 +16,113 @@ 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">
|
||||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
<LazyMotion strict features={domAnimation}>
|
||||||
<motion.div
|
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||||
className="max-w-5xl mx-auto md:mx-0"
|
<m.div
|
||||||
initial="hidden"
|
className="max-w-5xl mx-auto md:mx-0"
|
||||||
animate="visible"
|
initial="hidden"
|
||||||
variants={containerVariants}
|
animate="visible"
|
||||||
>
|
variants={containerVariants}
|
||||||
<motion.div variants={headingVariants}>
|
|
||||||
<Heading
|
|
||||||
level={1}
|
|
||||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
|
||||||
>
|
|
||||||
{t.rich('title', {
|
|
||||||
green: (chunks) => (
|
|
||||||
<span className="relative inline-block">
|
|
||||||
<motion.span
|
|
||||||
className="relative z-10 text-accent italic"
|
|
||||||
variants={accentVariants}
|
|
||||||
>
|
|
||||||
{chunks}
|
|
||||||
</motion.span>
|
|
||||||
<motion.div
|
|
||||||
variants={scribbleVariants}
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
|
|
||||||
>
|
|
||||||
<Scribble variant="circle" />
|
|
||||||
</motion.div>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</Heading>
|
|
||||||
</motion.div>
|
|
||||||
<motion.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>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
|
|
||||||
variants={buttonContainerVariants}
|
|
||||||
>
|
>
|
||||||
<motion.div variants={buttonVariants}>
|
<m.div variants={headingVariants}>
|
||||||
<Button
|
<Heading
|
||||||
href="/contact"
|
level={1}
|
||||||
variant="accent"
|
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]"
|
||||||
size="lg"
|
|
||||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg"
|
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
label: t('cta'),
|
|
||||||
location: 'home_hero_primary',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t('cta')}
|
{t.rich('title', {
|
||||||
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
green: (chunks) => (
|
||||||
</Button>
|
<span className="relative inline-block">
|
||||||
</motion.div>
|
<m.span
|
||||||
<motion.div variants={buttonVariants}>
|
className="relative z-10 text-accent italic"
|
||||||
<Button
|
variants={accentVariants}
|
||||||
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
>
|
||||||
variant="white"
|
{chunks}
|
||||||
size="lg"
|
</m.span>
|
||||||
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"
|
<m.div
|
||||||
onClick={() =>
|
variants={scribbleVariants}
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
|
||||||
label: t('exploreProducts'),
|
>
|
||||||
location: 'home_hero_secondary',
|
<Scribble variant="circle" />
|
||||||
})
|
</m.div>
|
||||||
}
|
</span>
|
||||||
>
|
),
|
||||||
{t('exploreProducts')}
|
})}
|
||||||
</Button>
|
</Heading>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</motion.div>
|
<m.div variants={subtitleVariants}>
|
||||||
</motion.div>
|
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
|
||||||
</Container>
|
{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}>
|
||||||
|
<Button
|
||||||
|
href="/contact"
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('cta'),
|
||||||
|
location: 'home_hero_primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('cta')}
|
||||||
|
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
||||||
|
</Button>
|
||||||
|
</m.div>
|
||||||
|
<m.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>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
</Container>
|
||||||
|
|
||||||
<motion.div
|
<m.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 />
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
|
||||||
<motion.div
|
<m.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">
|
||||||
<motion.div
|
<m.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>
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
</LazyMotion>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,16 +232,12 @@ export default function HeroIllustration() {
|
|||||||
stroke="url(#energy-pulse)"
|
stroke="url(#energy-pulse)"
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeDasharray={`${length * 0.2} ${length * 0.8}`}
|
style={{
|
||||||
>
|
strokeDasharray: `${length * 0.2} ${length * 0.8}`,
|
||||||
<animate
|
strokeDashoffset: length,
|
||||||
attributeName="stroke-dashoffset"
|
animation: `flow ${1.5 + (i % 3) * 0.5}s linear infinite`,
|
||||||
from={length}
|
}}
|
||||||
to={0}
|
/>
|
||||||
dur={`${1.5 + (i % 3) * 0.5}s`}
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</line>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</g>
|
</g>
|
||||||
@@ -267,14 +263,13 @@ export default function HeroIllustration() {
|
|||||||
strokeWidth="1"
|
strokeWidth="1"
|
||||||
strokeOpacity="0.3"
|
strokeOpacity="0.3"
|
||||||
/>
|
/>
|
||||||
<circle r="3" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
|
<circle
|
||||||
<animate
|
r="3"
|
||||||
attributeName="fillOpacity"
|
fill="#82ed20"
|
||||||
values="0.2;0.5;0.2"
|
fillOpacity="0.3"
|
||||||
dur="2s"
|
filter="url(#soft-glow)"
|
||||||
repeatCount="indefinite"
|
style={{ animation: 'solar-pulse 2s ease-in-out infinite' }}
|
||||||
/>
|
/>
|
||||||
</circle>
|
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -294,28 +289,26 @@ export default function HeroIllustration() {
|
|||||||
strokeOpacity="0.3"
|
strokeOpacity="0.3"
|
||||||
/>
|
/>
|
||||||
<g transform="translate(0, -60)">
|
<g transform="translate(0, -60)">
|
||||||
{[0, 120, 240].map((angle, j) => (
|
<g
|
||||||
<line
|
style={{
|
||||||
key={`blade-${i}-${j}`}
|
transformOrigin: '0px 0px',
|
||||||
x1="0"
|
animation: `spin-slow ${3 + i}s linear infinite`,
|
||||||
y1="0"
|
}}
|
||||||
x2="0"
|
>
|
||||||
y2="-30"
|
{[0, 120, 240].map((angle, j) => (
|
||||||
stroke="white"
|
<line
|
||||||
strokeWidth="1.5"
|
key={`blade-${i}-${j}`}
|
||||||
strokeOpacity="0.4"
|
x1="0"
|
||||||
transform={`rotate(${angle})`}
|
y1="0"
|
||||||
>
|
x2="0"
|
||||||
<animateTransform
|
y2="-30"
|
||||||
attributeName="transform"
|
stroke="white"
|
||||||
type="rotate"
|
strokeWidth="1.5"
|
||||||
from={`${angle} 0 0`}
|
strokeOpacity="0.4"
|
||||||
to={`${angle + 360} 0 0`}
|
transform={`rotate(${angle})`}
|
||||||
dur={`${3 + i}s`}
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
/>
|
||||||
</line>
|
))}
|
||||||
))}
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
|
||||||
import { useRecordMode } from './RecordModeContext';
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
|
||||||
export function PlaybackCursor() {
|
export function PlaybackCursor() {
|
||||||
@@ -24,67 +24,69 @@ export function PlaybackCursor() {
|
|||||||
if (!isPlaying) return null;
|
if (!isPlaying) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<LazyMotion strict features={domAnimation}>
|
||||||
className="fixed z-[10000] pointer-events-none"
|
<m.div
|
||||||
animate={{
|
className="fixed z-[10000] pointer-events-none"
|
||||||
x: cursorPosition.x,
|
animate={{
|
||||||
y: cursorPosition.y,
|
x: cursorPosition.x,
|
||||||
scale: isClicking ? 0.8 : 1,
|
y: cursorPosition.y,
|
||||||
rotateX: isClicking ? 15 : 0,
|
scale: isClicking ? 0.8 : 1,
|
||||||
rotateY: isClicking ? -15 : 0,
|
rotateX: isClicking ? 15 : 0,
|
||||||
}}
|
rotateY: isClicking ? -15 : 0,
|
||||||
transition={{
|
}}
|
||||||
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
transition={{
|
||||||
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||||
scale: { type: 'spring', damping: 15, stiffness: 400 },
|
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||||
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
|
scale: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
|
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
}}
|
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
style={{ perspective: '1000px' }}
|
}}
|
||||||
>
|
style={{ perspective: '1000px' }}
|
||||||
<AnimatePresence>
|
>
|
||||||
{isClicking && (
|
<AnimatePresence>
|
||||||
<motion.div
|
{isClicking && (
|
||||||
initial={{ scale: 0.5, opacity: 0 }}
|
<m.div
|
||||||
animate={{ scale: 2.5, opacity: 0 }}
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
animate={{ scale: 2.5, opacity: 0 }}
|
||||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
exit={{ opacity: 0 }}
|
||||||
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
|
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||||
/>
|
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-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
|
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pointer Arrow */}
|
{/* Visual Cursor */}
|
||||||
<svg
|
<div className="relative">
|
||||||
width="24"
|
{/* Soft Glow */}
|
||||||
height="24"
|
<div
|
||||||
viewBox="0 0 24 24"
|
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
|
||||||
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>
|
{/* Pointer Arrow */}
|
||||||
</motion.div>
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={`drop-shadow-[0_2px_8px_rgba(130,237,32,0.5)] transition-transform ${isClicking ? 'translate-y-0.5' : ''}`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z"
|
||||||
|
fill={isClicking ? '#82ed20' : 'white'}
|
||||||
|
stroke="black"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="transition-colors duration-150"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</m.div>
|
||||||
|
</LazyMotion>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } from 'framer-motion';
|
import { Reorder, AnimatePresence, LazyMotion, domAnimation } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
Square,
|
Square,
|
||||||
@@ -146,438 +146,460 @@ export function RecordModeOverlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
<LazyMotion strict features={domAnimation}>
|
||||||
{/* 1. Global Toolbar - Slim Industrial Bar */}
|
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
||||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
{/* 1. Global Toolbar - Slim Industrial Bar */}
|
||||||
<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="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||||
{/* Identity Tag */}
|
<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="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
|
{/* Identity Tag */}
|
||||||
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
<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 flex-col">
|
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||||
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
|
<div className="flex flex-col">
|
||||||
Event Builder
|
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
|
||||||
</span>
|
Event Builder
|
||||||
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
|
|
||||||
Manual Mode
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
|
||||||
|
|
||||||
{/* Action Tools */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setPickingMode('mouse');
|
|
||||||
setLastInteractionType('click');
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
|
||||||
>
|
|
||||||
<MousePointer2 size={16} />
|
|
||||||
<span>Mouse</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setPickingMode('scroll')}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
|
||||||
>
|
|
||||||
<Scroll size={16} />
|
|
||||||
<span>Scroll</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
addEvent({
|
|
||||||
type: 'wait',
|
|
||||||
duration: 2000,
|
|
||||||
zoom: 1,
|
|
||||||
description: 'Wait for 2s',
|
|
||||||
motionBlur: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
<span>Wait</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
|
||||||
|
|
||||||
{/* Sequence Controls */}
|
|
||||||
<div className="flex items-center gap-1 p-0.5">
|
|
||||||
<button
|
|
||||||
onClick={playEvents}
|
|
||||||
disabled={isPlaying || events.length === 0}
|
|
||||||
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
|
|
||||||
title="Preview Sequence"
|
|
||||||
>
|
|
||||||
<Play size={18} fill="currentColor" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowEvents(!showEvents)}
|
|
||||||
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
|
||||||
>
|
|
||||||
<Edit2 size={18} />
|
|
||||||
{events.length > 0 && (
|
|
||||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
|
|
||||||
{events.length}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
|
||||||
</button>
|
Manual Mode
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
onClick={async () => {
|
|
||||||
const session = { events, name: 'Recording', createdAt: new Date().toISOString() };
|
{/* Action Tools */}
|
||||||
try {
|
<div className="flex items-center gap-1">
|
||||||
const res = await fetch('/api/save-session', {
|
<button
|
||||||
method: 'POST',
|
onClick={() => {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
setPickingMode('mouse');
|
||||||
body: JSON.stringify(session),
|
setLastInteractionType('click');
|
||||||
});
|
}}
|
||||||
if (res.ok) {
|
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'}`}
|
||||||
// Visual feedback could be improved, but alert is fine for dev tool
|
>
|
||||||
alert('Session saved to remotion/session.json');
|
<MousePointer2 size={16} />
|
||||||
} else {
|
<span>Mouse</span>
|
||||||
const err = await res.json();
|
</button>
|
||||||
alert(`Failed to save: ${err.error}`);
|
|
||||||
}
|
<button
|
||||||
} catch (e) {
|
onClick={() => setPickingMode('scroll')}
|
||||||
console.error(e);
|
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'}`}
|
||||||
alert('Error saving session');
|
>
|
||||||
|
<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"
|
||||||
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} />
|
<Plus size={16} />
|
||||||
|
<span>Wait</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Reorder.Group
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
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">
|
{/* Sequence Controls */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1 p-0.5">
|
||||||
<span className="text-white text-[10px] font-black uppercase tracking-widest">
|
|
||||||
{event.type === 'mouse' ? `Mouse (${event.interactionType})` : event.type}
|
|
||||||
</span>
|
|
||||||
{event.clickOrigin &&
|
|
||||||
event.clickOrigin !== 'center' &&
|
|
||||||
event.interactionType === 'click' && (
|
|
||||||
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
|
|
||||||
{event.clickOrigin}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
|
|
||||||
{event.duration}ms
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
|
|
||||||
{event.selector || 'system:wait'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingEventId(event.id);
|
|
||||||
setEditForm(event);
|
|
||||||
}}
|
|
||||||
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
|
||||||
>
|
|
||||||
<Settings2 size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => removeEvent(event.id)}
|
|
||||||
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Reorder.Item>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Reorder.Group>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
|
|
||||||
|
|
||||||
{/* Picking Tooltip */}
|
|
||||||
{pickingMode && (
|
|
||||||
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
|
||||||
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
|
|
||||||
<span className="font-black uppercase tracking-widest text-xs">
|
|
||||||
Assigning {pickingMode}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-px h-6 bg-primary-dark/20" />
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setPickingMode(null);
|
|
||||||
setHoveredElement(null);
|
|
||||||
}}
|
|
||||||
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
|
|
||||||
>
|
|
||||||
ESC to Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlaybackCursor />
|
|
||||||
|
|
||||||
{/* 3. Event Options Panel (Sidebar-like) */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{editingEventId && (
|
|
||||||
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<h3 className="text-white font-black uppercase tracking-tighter text-xl">
|
|
||||||
Event Options
|
|
||||||
</h3>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingEventId(null)}
|
onClick={playEvents}
|
||||||
className="p-2 text-white/40 hover:text-white transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<Play size={18} fill="currentColor" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
|
<button
|
||||||
{/* Type Display */}
|
onClick={() => setShowEvents(!showEvents)}
|
||||||
<div className="space-y-3">
|
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'}`}
|
||||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
>
|
||||||
Interaction Type
|
<Edit2 size={18} />
|
||||||
</label>
|
{events.length > 0 && (
|
||||||
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
|
<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">
|
||||||
<button
|
{events.length}
|
||||||
onClick={() =>
|
</span>
|
||||||
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'click' }))
|
)}
|
||||||
|
</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}`);
|
||||||
}
|
}
|
||||||
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'}`}
|
} catch (e) {
|
||||||
>
|
console.error(e);
|
||||||
<MousePointer2 size={14} />
|
alert('Error saving session');
|
||||||
<span className="text-[10px] font-black uppercase">Click</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'hover' }))
|
|
||||||
}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
|
||||||
>
|
|
||||||
<Eye size={14} />
|
|
||||||
<span className="text-[10px] font-black uppercase">Hover</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
|
||||||
>
|
|
||||||
<Scroll size={14} />
|
|
||||||
<span className="text-[10px] font-black uppercase">Scroll</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
|
||||||
>
|
|
||||||
<Clock size={14} />
|
|
||||||
<span className="text-[10px] font-black uppercase">Wait</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Precise Click Origin */}
|
|
||||||
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
|
||||||
Click Origin
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
|
|
||||||
{[
|
|
||||||
{ id: 'top-left', label: 'TL' },
|
|
||||||
{ id: 'top-right', label: 'TR' },
|
|
||||||
{ id: 'center', label: 'CTR' },
|
|
||||||
{ id: 'bottom-left', label: 'BL' },
|
|
||||||
{ id: 'bottom-right', label: 'BR' },
|
|
||||||
].map((origin) => (
|
|
||||||
<button
|
|
||||||
key={origin.id}
|
|
||||||
onClick={() =>
|
|
||||||
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
|
|
||||||
}
|
|
||||||
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
|
|
||||||
>
|
|
||||||
{origin.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timing */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
|
|
||||||
<span>Timeline Allocation</span>
|
|
||||||
<span className="text-accent">{editForm.duration}ms</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="5000"
|
|
||||||
step="100"
|
|
||||||
value={editForm.duration || 1000}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
|
|
||||||
}
|
}
|
||||||
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
|
}}
|
||||||
/>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Zoom & Effects */}
|
<Reorder.Group
|
||||||
<div className="space-y-6">
|
axis="y"
|
||||||
<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">
|
values={events}
|
||||||
<div className="flex items-center gap-3">
|
onReorder={setEvents}
|
||||||
<Maximize2 size={18} className="text-white/40" />
|
className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide"
|
||||||
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
>
|
||||||
Zoom Shift
|
{events.length === 0 ? (
|
||||||
</span>
|
<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>
|
</div>
|
||||||
|
) : (
|
||||||
|
events.map((event, index) => (
|
||||||
|
<Reorder.Item
|
||||||
|
key={event.id}
|
||||||
|
value={event}
|
||||||
|
className="group flex items-center gap-3 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
|
||||||
|
onMouseEnter={() => setHoveredEventId(event.id)}
|
||||||
|
onMouseLeave={() => setHoveredEventId(null)}
|
||||||
|
>
|
||||||
|
<div className="cursor-grab active:cursor-grabbing text-white/10 hover:text-white/30 transition-colors">
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white text-[10px] font-black uppercase tracking-widest">
|
||||||
|
{event.type === 'mouse'
|
||||||
|
? `Mouse (${event.interactionType})`
|
||||||
|
: event.type}
|
||||||
|
</span>
|
||||||
|
{event.clickOrigin &&
|
||||||
|
event.clickOrigin !== 'center' &&
|
||||||
|
event.interactionType === 'click' && (
|
||||||
|
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
|
||||||
|
{event.clickOrigin}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
|
||||||
|
{event.duration}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
|
||||||
|
{event.selector || 'system:wait'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingEventId(event.id);
|
||||||
|
setEditForm(event);
|
||||||
|
}}
|
||||||
|
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Settings2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeEvent(event.id)}
|
||||||
|
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Reorder.Item>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Reorder.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
|
||||||
|
|
||||||
|
{/* Picking Tooltip */}
|
||||||
|
{pickingMode && (
|
||||||
|
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||||
|
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
|
||||||
|
<span className="font-black uppercase tracking-widest text-xs">
|
||||||
|
Assigning {pickingMode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-6 bg-primary-dark/20" />
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPickingMode(null);
|
||||||
|
setHoveredElement(null);
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
|
||||||
|
>
|
||||||
|
ESC to Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PlaybackCursor />
|
||||||
|
|
||||||
|
{/* 3. Event Options Panel (Sidebar-like) */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{editingEventId && (
|
||||||
|
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="text-white font-black uppercase tracking-tighter text-xl">
|
||||||
|
Event Options
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingEventId(null)}
|
||||||
|
className="p-2 text-white/40 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
|
||||||
|
{/* Type Display */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||||
|
Interaction Type
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: 'mouse',
|
||||||
|
interactionType: 'click',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<MousePointer2 size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Click</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: 'mouse',
|
||||||
|
interactionType: 'hover',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Hover</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Scroll size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Scroll</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Clock size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Wait</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Precise Click Origin */}
|
||||||
|
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||||
|
Click Origin
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
|
||||||
|
{[
|
||||||
|
{ id: 'top-left', label: 'TL' },
|
||||||
|
{ id: 'top-right', label: 'TR' },
|
||||||
|
{ id: 'center', label: 'CTR' },
|
||||||
|
{ id: 'bottom-left', label: 'BL' },
|
||||||
|
{ id: 'bottom-right', label: 'BR' },
|
||||||
|
].map((origin) => (
|
||||||
|
<button
|
||||||
|
key={origin.id}
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
|
||||||
|
}
|
||||||
|
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
|
||||||
|
>
|
||||||
|
{origin.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timing */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
|
||||||
|
<span>Timeline Allocation</span>
|
||||||
|
<span className="text-accent">{editForm.duration}ms</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="range"
|
||||||
step="0.1"
|
min="0"
|
||||||
min="1"
|
max="5000"
|
||||||
max="3"
|
step="100"
|
||||||
value={editForm.zoom || 1}
|
value={editForm.duration || 1000}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
|
setEditForm((prev) => ({ ...prev, duration: parseInt(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"
|
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{/* Zoom & Effects */}
|
||||||
onClick={() => setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))}
|
<div className="space-y-6">
|
||||||
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 justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
|
||||||
>
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<Maximize2 size={18} className="text-white/40" />
|
||||||
<Box size={18} />
|
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
||||||
<span className="text-xs font-bold uppercase tracking-wider">Motion Blur</span>
|
Zoom Shift
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
value={editForm.zoom || 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))}
|
onClick={() =>
|
||||||
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'}`}
|
setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))
|
||||||
|
}
|
||||||
|
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ExternalLink size={18} />
|
<Box size={18} />
|
||||||
<div className="flex flex-col items-start">
|
<span className="text-xs font-bold uppercase tracking-wider">
|
||||||
<span className="text-xs font-bold uppercase tracking-wider">
|
Motion Blur
|
||||||
Trigger Navigation
|
</span>
|
||||||
</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]" />}
|
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||||
onClick={saveEdit}
|
<button
|
||||||
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"
|
onClick={() =>
|
||||||
>
|
setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))
|
||||||
Commit Changes
|
}
|
||||||
</button>
|
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>
|
>
|
||||||
)}
|
<div className="flex items-center gap-3">
|
||||||
</AnimatePresence>
|
<ExternalLink size={18} />
|
||||||
</div>
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">
|
||||||
|
Trigger Navigation
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] opacity-60">
|
||||||
|
Allows URL transitions in Studio
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={saveEdit}
|
||||||
|
className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
|
||||||
|
>
|
||||||
|
Commit Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</LazyMotion>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"categories:performance": [
|
"categories:performance": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"minScore": 0.8
|
"minScore": 0.9
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories:accessibility": [
|
"categories:accessibility": [
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -39,7 +39,7 @@ NODE_ENV=production
|
|||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
|
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-umami-website-id
|
UMAMI_WEBSITE_ID=your-umami-website-id
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
|
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
# Error Tracking (GlitchTip/Sentry)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function createConfig() {
|
|||||||
umami: {
|
umami: {
|
||||||
websiteId: 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: Boolean(env.UMAMI_WEBSITE_ID),
|
enabled: typeof window !== 'undefined' || Boolean(env.UMAMI_WEBSITE_ID),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface AnalyticsService {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
|
|
||||||
- Type-safe event properties
|
- Type-safe event properties
|
||||||
- Consistent API across implementations
|
- Consistent API across implementations
|
||||||
- Well-documented with JSDoc comments
|
- Well-documented with JSDoc comments
|
||||||
@@ -39,6 +40,7 @@ export interface AnalyticsService {
|
|||||||
Implements the `AnalyticsService` interface for Umami analytics.
|
Implements the `AnalyticsService` interface for Umami analytics.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
- Type-safe event tracking
|
- Type-safe event tracking
|
||||||
- Automatic pageview tracking
|
- Automatic pageview tracking
|
||||||
- Browser environment detection
|
- Browser environment detection
|
||||||
@@ -46,6 +48,7 @@ Implements the `AnalyticsService` interface for Umami analytics.
|
|||||||
- Comprehensive JSDoc documentation
|
- Comprehensive JSDoc documentation
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { UmamiAnalyticsService } from '@/lib/services/analytics/umami-analytics-service';
|
import { UmamiAnalyticsService } from '@/lib/services/analytics/umami-analytics-service';
|
||||||
|
|
||||||
@@ -59,12 +62,14 @@ service.trackPageview('/products/123');
|
|||||||
A no-op implementation used as a fallback when analytics are disabled.
|
A no-op implementation used as a fallback when analytics are disabled.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
- Maintains the same API as other services
|
- Maintains the same API as other services
|
||||||
- Safe to call even when analytics are disabled
|
- Safe to call even when analytics are disabled
|
||||||
- No performance impact
|
- No performance impact
|
||||||
- Comprehensive JSDoc documentation
|
- Comprehensive JSDoc documentation
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { NoopAnalyticsService } from '@/lib/services/analytics/noop-analytics-service';
|
import { NoopAnalyticsService } from '@/lib/services/analytics/noop-analytics-service';
|
||||||
|
|
||||||
@@ -79,7 +84,7 @@ The service layer automatically selects the appropriate implementation based on
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// In lib/services/create-services.ts
|
// In lib/services/create-services.ts
|
||||||
const umamiEnabled = Boolean(process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID);
|
const umamiEnabled = Boolean(process.env.UMAMI_WEBSITE_ID);
|
||||||
|
|
||||||
const analytics = umamiEnabled
|
const analytics = umamiEnabled
|
||||||
? new UmamiAnalyticsService({ enabled: true })
|
? new UmamiAnalyticsService({ enabled: true })
|
||||||
@@ -91,7 +96,7 @@ const analytics = umamiEnabled
|
|||||||
### Required for Umami
|
### Required for Umami
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
```
|
```
|
||||||
|
|
||||||
### Optional (defaults provided)
|
### Optional (defaults provided)
|
||||||
@@ -109,10 +114,12 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
|||||||
Track a custom event with optional properties.
|
Track a custom event with optional properties.
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
|
|
||||||
- `eventName` - The name of the event to track
|
- `eventName` - The name of the event to track
|
||||||
- `props` - Optional event properties (metadata)
|
- `props` - Optional event properties (metadata)
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
service.track('product_add_to_cart', {
|
service.track('product_add_to_cart', {
|
||||||
product_id: '123',
|
product_id: '123',
|
||||||
@@ -127,9 +134,11 @@ service.track('product_add_to_cart', {
|
|||||||
Track a pageview.
|
Track a pageview.
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
|
|
||||||
- `url` - The URL to track (defaults to current location)
|
- `url` - The URL to track (defaults to current location)
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Track current page
|
// Track current page
|
||||||
service.trackPageview();
|
service.trackPageview();
|
||||||
@@ -147,9 +156,11 @@ new UmamiAnalyticsService(options: UmamiAnalyticsServiceOptions)
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Options:**
|
**Options:**
|
||||||
|
|
||||||
- `enabled: boolean` - Whether analytics are enabled
|
- `enabled: boolean` - Whether analytics are enabled
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const service = new UmamiAnalyticsService({ enabled: true });
|
const service = new UmamiAnalyticsService({ enabled: true });
|
||||||
```
|
```
|
||||||
@@ -159,10 +170,11 @@ const service = new UmamiAnalyticsService({ enabled: true });
|
|||||||
#### Constructor
|
#### Constructor
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
new NoopAnalyticsService()
|
new NoopAnalyticsService();
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const service = new NoopAnalyticsService();
|
const service = new NoopAnalyticsService();
|
||||||
```
|
```
|
||||||
@@ -172,13 +184,11 @@ const service = new NoopAnalyticsService();
|
|||||||
### AnalyticsEventProperties
|
### AnalyticsEventProperties
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type AnalyticsEventProperties = Record<
|
type AnalyticsEventProperties = Record<string, string | number | boolean | null | undefined>;
|
||||||
string,
|
|
||||||
string | number | boolean | null | undefined
|
|
||||||
>;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const properties: AnalyticsEventProperties = {
|
const properties: AnalyticsEventProperties = {
|
||||||
product_id: '123',
|
product_id: '123',
|
||||||
@@ -253,7 +263,7 @@ services.analytics.track('button_click', {
|
|||||||
The service layer gracefully handles disabled analytics:
|
The service layer gracefully handles disabled analytics:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// When NEXT_PUBLIC_UMAMI_WEBSITE_ID is not set:
|
// When UMAMI_WEBSITE_ID is not set:
|
||||||
// - NoopAnalyticsService is used
|
// - NoopAnalyticsService is used
|
||||||
// - All calls are safe (no-op)
|
// - All calls are safe (no-op)
|
||||||
// - No errors are thrown
|
// - No errors are thrown
|
||||||
@@ -366,13 +376,13 @@ import { getAppServices } from '@/lib/services/create-services';
|
|||||||
|
|
||||||
async function MyServerComponent() {
|
async function MyServerComponent() {
|
||||||
const services = getAppServices();
|
const services = getAppServices();
|
||||||
|
|
||||||
// Note: Analytics won't work in server components
|
// Note: Analytics won't work in server components
|
||||||
// Use client components for analytics tracking
|
// Use client components for analytics tracking
|
||||||
// But you can still access other services like cache
|
// But you can still access other services like cache
|
||||||
|
|
||||||
const data = await services.cache.get('key');
|
const data = await services.cache.get('key');
|
||||||
|
|
||||||
return <div>{data}</div>;
|
return <div>{data}</div>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -382,14 +392,16 @@ async function MyServerComponent() {
|
|||||||
### Analytics Not Working
|
### Analytics Not Working
|
||||||
|
|
||||||
1. **Check environment variables:**
|
1. **Check environment variables:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
echo $UMAMI_WEBSITE_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Verify service selection:**
|
2. **Verify service selection:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { getAppServices } from '@/lib/services/create-services';
|
import { getAppServices } from '@/lib/services/create-services';
|
||||||
|
|
||||||
const services = getAppServices();
|
const services = getAppServices();
|
||||||
console.log(services.analytics); // Should be UmamiAnalyticsService
|
console.log(services.analytics); // Should be UmamiAnalyticsService
|
||||||
```
|
```
|
||||||
@@ -401,12 +413,12 @@ async function MyServerComponent() {
|
|||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
| Issue | Solution |
|
| Issue | Solution |
|
||||||
|-------|----------|
|
| ------------------- | ----------------------------------- |
|
||||||
| No data in Umami | Check website ID and script URL |
|
| No data in Umami | Check website ID and script URL |
|
||||||
| Events not tracking | Verify service is being used |
|
| Events not tracking | Verify service is being used |
|
||||||
| Script not loading | Check network connection, CORS |
|
| Script not loading | Check network connection, CORS |
|
||||||
| Wrong data | Verify event properties are correct |
|
| Wrong data | Verify event properties are correct |
|
||||||
|
|
||||||
## Related Files
|
## Related Files
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type { AnalyticsEventProperties, AnalyticsService } from './analytics-ser
|
|||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* // Automatic fallback in create-services.ts
|
* // Automatic fallback in create-services.ts
|
||||||
* const umamiEnabled = Boolean(process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID);
|
* const umamiEnabled = Boolean(process.env.UMAMI_WEBSITE_ID);
|
||||||
* const analytics = umamiEnabled
|
* const analytics = umamiEnabled
|
||||||
* ? new UmamiAnalyticsService({ enabled: true })
|
* ? new UmamiAnalyticsService({ enabled: true })
|
||||||
* : new NoopAnalyticsService(); // Fallback when no website ID
|
* : new NoopAnalyticsService(); // Fallback when no website ID
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ let singleton: AppServices | undefined;
|
|||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* // Automatic service selection based on environment
|
* // Automatic service selection based on environment
|
||||||
* // If NEXT_PUBLIC_UMAMI_WEBSITE_ID is set:
|
* // If UMAMI_WEBSITE_ID is set:
|
||||||
* // services.analytics = UmamiAnalyticsService
|
* // services.analytics = UmamiAnalyticsService
|
||||||
* // If not set:
|
* // If not set:
|
||||||
* // services.analytics = NoopAnalyticsService (safe no-op)
|
* // services.analytics = NoopAnalyticsService (safe no-op)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Sentry from '@sentry/nextjs';
|
|
||||||
import type {
|
import type {
|
||||||
ErrorReportingLevel,
|
ErrorReportingLevel,
|
||||||
ErrorReportingService,
|
ErrorReportingService,
|
||||||
@@ -7,32 +6,66 @@ 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)}` : '';
|
||||||
@@ -44,34 +77,33 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
const Sentry = await this.getSentry();
|
||||||
|
return Sentry.captureException(error, context as any) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
captureMessage(message: string, level: ErrorReportingLevel = 'error') {
|
async captureMessage(message: string, level: ErrorReportingLevel = 'error') {
|
||||||
if (!this.options.enabled) return undefined;
|
if (!this.options.enabled) return undefined;
|
||||||
return this.sentry.captureMessage(message, level as any) as any;
|
const Sentry = await this.getSentry();
|
||||||
|
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.sentry.setUser(user as any);
|
this.getSentry().then((Sentry) => 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.sentry.setTag(key, value);
|
this.getSentry().then((Sentry) => Sentry.setTag(key, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
withScope<T>(fn: () => T, context?: Record<string, unknown>) {
|
withScope<T>(fn: () => T, context?: Record<string, unknown>): T {
|
||||||
if (!this.options.enabled) return fn();
|
if (!this.options.enabled) return fn();
|
||||||
|
|
||||||
return this.sentry.withScope((scope) => {
|
// Since withScope mandates executing fn() synchronously to return T,
|
||||||
if (context) {
|
// and Sentry load is async, if context mapping is absolutely required
|
||||||
for (const [key, value] of Object.entries(context)) {
|
// for this feature we would need an async API.
|
||||||
scope.setExtra(key, value);
|
// For now we degrade gracefully by just executing the function.
|
||||||
}
|
return fn();
|
||||||
}
|
|
||||||
return fn();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ 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: {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
"@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
22
pnpm-lock.yaml
generated
@@ -198,6 +198,9 @@ 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)
|
||||||
@@ -3694,6 +3697,10 @@ 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'}
|
||||||
@@ -6115,6 +6122,9 @@ 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}
|
||||||
@@ -11236,6 +11246,16 @@ 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
|
||||||
@@ -14216,6 +14236,8 @@ 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
|
||||||
|
|||||||
BIN
public/uploads/2026/01/1767353529807.jpg
Normal file
BIN
public/uploads/2026/01/1767353529807.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
21
scripts/replace-motion.cjs
Normal file
21
scripts/replace-motion.cjs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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');
|
||||||
@@ -1,19 +1,4 @@
|
|||||||
import * as Sentry from '@sentry/nextjs';
|
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
|
||||||
|
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
|
||||||
// We use a placeholder DSN on the client because the real DSN is injected
|
// from being included in the initial JS bundle.
|
||||||
// by our server-side relay at /errors/api/relay.
|
export {};
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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 cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
--animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s
|
||||||
|
cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
--animate-gradient-x: gradient-x 15s ease infinite;
|
--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,10 +135,31 @@
|
|||||||
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;
|
||||||
@@ -321,4 +342,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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user