Compare commits

..

15 Commits

Author SHA1 Message Date
531e1a49f8 fix: remove inline opacity: 0 from HeroSection and use animation-fill-mode both to prevent invisible text
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 47s
Build & Deploy / 🧪 QA (push) Successful in 1m14s
Build & Deploy / 🏗️ Build (push) Successful in 2m15s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-03 14:45:37 +02:00
324cfb0ca3 fix: ci hang in maintenance, restore hero animations, bump version 2.4.23
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 19s
Build & Deploy / 🏗️ Build (push) Successful in 2m11s
Build & Deploy / 🧪 QA (push) Successful in 1m19s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 49s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-07-03 13:18:33 +02:00
69d2c42ef7 chore: bump version to 2.4.22
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 22s
Build & Deploy / 🧪 QA (push) Successful in 1m19s
Build & Deploy / 🏗️ Build (push) Failing after 22m7s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-07-03 12:22:32 +02:00
6fb46278e0 fix: restore hero animations in tailwind v4 on 2.2.95
Some checks failed
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🔍 Prepare (push) Has been cancelled
2026-07-03 12:21:43 +02:00
73886406a4 perf: completely lazy load framer-motion core via DynamicFramerMotion wrapper
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 29s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m11s
Build & Deploy / 🧪 QA (push) Successful in 1m48s
Build & Deploy / 🏗️ Build (push) Successful in 3m25s
Build & Deploy / 🚀 Deploy (push) Successful in 35s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-30 23:05:31 +02:00
b2701d6bcd perf(server): aggressively cache OG image assets in memory to prevent synchronous file I/O from blocking the Node event loop
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 29s
Build & Deploy / 🧪 QA (push) Successful in 1m53s
Build & Deploy / 🏗️ Build (push) Successful in 3m25s
Build & Deploy / 🚀 Deploy (push) Successful in 36s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m14s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-30 23:02:12 +02:00
4f46825fb4 perf: fix 3.8s LCP delays by removing dynamic hero and heavy css filters
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 29s
Build & Deploy / 🏗️ Build (push) Successful in 3m21s
Build & Deploy / 🧪 QA (push) Successful in 1m47s
Build & Deploy / 🚀 Deploy (push) Successful in 35s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m14s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-30 11:47:23 +02:00
c63766711d chore: release 2.2.92
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 30s
Build & Deploy / 🧪 QA (push) Successful in 1m46s
Build & Deploy / 🏗️ Build (push) Successful in 3m24s
Build & Deploy / 🚀 Deploy (push) Successful in 34s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m11s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-30 10:19:29 +02:00
c738001f9a perf: eliminate feedback widget chunk via dead-code elimination
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 1m13s
Build & Deploy / 🧪 QA (push) Successful in 1m44s
Build & Deploy / 🏗️ Build (push) Successful in 3m14s
Build & Deploy / 🚀 Deploy (push) Successful in 34s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-30 10:09:45 +02:00
69dd79dfad fix: remove lingering getAppServices import from error.tsx that kept backend dependencies in the client bundle
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 32s
Build & Deploy / 🚀 Deploy (push) Successful in 35s
Build & Deploy / 🧪 QA (push) Successful in 1m49s
Build & Deploy / 🏗️ Build (push) Successful in 3m27s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m14s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-30 02:00:10 +02:00
a6c4cc53f9 fix: completely sever client bundle from backend logger (pino) and validation (zod) to eliminate 68 KiB unused JS
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 28s
Build & Deploy / 🚀 Deploy (push) Successful in 34s
Build & Deploy / 🧪 QA (push) Successful in 1m43s
Build & Deploy / 🏗️ Build (push) Successful in 3m23s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m12s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-29 23:27:14 +02:00
29f83f8151 chore: release 2.2.89
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 30s
Build & Deploy / 🏗️ Build (push) Successful in 3m30s
Build & Deploy / 🧪 QA (push) Successful in 1m45s
Build & Deploy / 🚀 Deploy (push) Successful in 35s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m13s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-06-29 21:04:03 +02:00
cec08e3ab3 fix(perf): dynamic import of framer-motion features to eliminate 43 KiB unused JS 2026-06-29 21:04:01 +02:00
837567c1f4 chore: release 2.2.88
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 30s
Build & Deploy / 🚀 Deploy (push) Successful in 35s
Build & Deploy / 🧪 QA (push) Successful in 1m45s
Build & Deploy / 🏗️ Build (push) Successful in 3m23s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m11s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-29 18:58:32 +02:00
4dcc0061fc fix(perf): throttle CorporateBackground mousemove to fix forced reflow penalty 2026-06-29 18:58:32 +02:00
18 changed files with 173 additions and 171 deletions

View File

@@ -47,14 +47,7 @@ jobs:
run: | run: |
echo "=== System Disk Usage ===" echo "=== System Disk Usage ==="
df -h || true df -h || true
echo "=== Tool Cache Usage ==="
du -sh /opt/hostedtoolcache/* || true
du -sh /opt/hostedtoolcache/.[!.]* || true
echo "Purging old tool caches skipped to prevent runner corruption." echo "Purging old tool caches skipped to prevent runner corruption."
echo "=== Host Disk Usage ==="
docker run --rm -v /:/host-root alpine df -h || true
echo "=== Host Inode Usage ==="
docker run --rm -v /:/host-root alpine df -i || true
echo "=== Running Host Containers ===" echo "=== Running Host Containers ==="
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" || true docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" || true
echo "=== Host Docker Info ===" echo "=== Host Docker Info ==="

View File

@@ -2,7 +2,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { getAppServices } from '@/lib/services/create-services';
import { Button, Heading } from '@/components/ui'; import { Button, Heading } from '@/components/ui';
import { Terminal, Activity, AlertTriangle, RefreshCw, Home, ShieldAlert } from 'lucide-react'; import { Terminal, Activity, AlertTriangle, RefreshCw, Home, ShieldAlert } from 'lucide-react';
import { m } from 'framer-motion'; import { m } from 'framer-motion';
@@ -26,9 +25,7 @@ export default function Error({
return; return;
} }
const services = getAppServices(); console.error('Application error caught by boundary', {
services.errors.captureException(error);
services.logger.error('Application error caught by boundary', {
message: error?.message || 'Unknown error', message: error?.message || 'Unknown error',
stack: error?.stack, stack: error?.stack,
digest: error?.digest, digest: error?.digest,

View File

@@ -22,6 +22,7 @@ import { DynamicMobileBottomNav as MobileBottomNav } from '@/components/layout/D
import { DynamicCorporateBackground as CorporateBackground } from '@/components/decorations/DynamicCorporateBackground'; import { DynamicCorporateBackground as CorporateBackground } from '@/components/decorations/DynamicCorporateBackground';
import { DynamicPageTransitionShutter as PageTransitionShutter } from '@/components/providers/DynamicPageTransitionShutter'; import { DynamicPageTransitionShutter as PageTransitionShutter } from '@/components/providers/DynamicPageTransitionShutter';
import { DynamicFramerMotion as FramerMotionProvider } from '@/components/providers/DynamicFramerMotion';
const inter = Inter({ const inter = Inter({
subsets: ['latin'], subsets: ['latin'],
@@ -190,26 +191,28 @@ export default async function Layout(props: {
<body className="relative flex flex-col min-h-screen font-sans antialiased overflow-x-hidden selection:bg-primary/90 selection:text-white" suppressHydrationWarning> <body className="relative flex flex-col min-h-screen font-sans antialiased overflow-x-hidden selection:bg-primary/90 selection:text-white" suppressHydrationWarning>
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}> <NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
<TransitionProvider> <TransitionProvider>
<CorporateBackground /> <FramerMotionProvider>
<InitialLoader shouldShowLoader={!hasSeenLoader} /> <CorporateBackground />
<PageTransitionShutter /> <InitialLoader shouldShowLoader={!hasSeenLoader} />
<SkipLink /> <PageTransitionShutter />
<Header navLinks={navLinks} /> <SkipLink />
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} /> <Header navLinks={navLinks} />
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} />
<main <main
id="main-content" id="main-content"
className="flex-grow overflow-visible" className="flex-grow overflow-visible"
tabIndex={-1} tabIndex={-1}
> >
{children} {children}
</main> </main>
<Footer companyInfo={companyInfo} /> <Footer companyInfo={companyInfo} />
<JsonLd /> <JsonLd />
<AnalyticsShell /> <AnalyticsShell />
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />} <FeedbackClientWrapper />
</FramerMotionProvider>
</TransitionProvider> </TransitionProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>

View File

@@ -7,8 +7,7 @@ import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import Image from 'next/image'; import Image from 'next/image';
import { standorteData, standorteLocations } from '@/lib/standorte-data'; import { standorteData, standorteLocations } from '@/lib/standorte-data';
import nextDynamic from 'next/dynamic'; import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
const InteractiveGermanyMap = nextDynamic(() => import('@/components/blocks/InteractiveGermanyMap').then(mod => mod.InteractiveGermanyMap));
interface PageProps { interface PageProps {
params: Promise<{ params: Promise<{

View File

@@ -88,34 +88,30 @@
} }
/* trigger rebuild */ /* trigger rebuild */
@layer utilities { @keyframes hero-fade-in-up {
@keyframes hero-fade-in-up { from {
from { opacity: 0;
opacity: 0; transform: translateY(30px);
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
to {
.animate-hero-fade-in-up { opacity: 1;
animation: hero-fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; transform: translateY(0);
} }
} }
@layer utilities { @utility animate-hero-fade-in-up {
@keyframes hero-zoom-out { animation: hero-fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
from { }
transform: scale(1.05);
}
to {
transform: scale(1);
}
}
.animate-hero-zoom-out { @keyframes hero-zoom-out {
animation: hero-zoom-out 1.5s ease-out forwards; from {
transform: scale(1.05);
}
to {
transform: scale(1);
} }
} }
@utility animate-hero-zoom-out {
animation: hero-zoom-out 1.5s ease-out forwards;
}

View File

@@ -2,17 +2,17 @@
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
const FeedbackOverlay = dynamic( const IS_ENABLED = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
{ ssr: false },
);
interface FeedbackClientWrapperProps { const FeedbackOverlay = IS_ENABLED
feedbackEnabled: boolean; ? dynamic(
} () => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
{ ssr: false },
)
: () => null;
export default function FeedbackClientWrapper({ feedbackEnabled }: FeedbackClientWrapperProps) { export default function FeedbackClientWrapper() {
if (!feedbackEnabled) return null; if (!IS_ENABLED) return null;
return <FeedbackOverlay />; return <FeedbackOverlay />;
} }

View File

@@ -2,34 +2,28 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname, useSearchParams } from 'next/navigation';
import { getAppServices } from '@/lib/services/create-services'; import { useAnalytics } from './useAnalytics';
/** /**
* AnalyticsProvider Component * AnalyticsProvider Component
* *
* Automatically tracks pageviews on client-side route changes. * Automatically tracks pageviews on client-side route changes.
* This component handles navigation events for the Umami analytics service. * This component handles navigation events for the Umami analytics service.
*
* Note: Website ID is now centrally managed on the server side via a proxy,
* so it's no longer needed as a prop here.
*/ */
export default function AnalyticsProvider() { export default function AnalyticsProvider() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { trackPageview } = useAnalytics();
useEffect(() => { useEffect(() => {
if (!pathname) return; if (!pathname) return;
const services = getAppServices();
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`; const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
// Track pageview with the full URL // Track pageview with the full URL
// The service will relay this to our internal proxy which injects the Website ID // The service will relay this to our internal proxy which injects the Website ID
services.analytics.trackPageview(url); trackPageview(url);
}, [pathname, searchParams, trackPageview]);
// Services like logger are already sub-initialized in getAppServices()
// so we don't need to log here manually.
}, [pathname, searchParams]);
return null; return null;
} }

View File

@@ -1,73 +1,69 @@
'use client'; 'use client';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { getAppServices } from '@/lib/services/create-services';
import type { AnalyticsEventProperties } from '@/lib/services/analytics/analytics-service'; export type AnalyticsEventProperties = Record<string, any>;
/**
* Sends a payload to the proxy API without loading backend config/logger dependencies.
*/
function sendAnalyticsPayload(type: 'event', data: Record<string, any>) {
if (typeof window === 'undefined') return;
if (process.env.NEXT_PUBLIC_CI === 'true') return;
const payload = {
hostname: window.location.hostname,
screen: `${window.screen.width}x${window.screen.height}`,
language: navigator.language,
referrer: document.referrer,
title: document.title,
...data,
};
try {
fetch('/stats/api/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ type, payload }),
keepalive: true,
}).catch(e => console.error('[Umami] Failed to send payload', e));
} catch (error) {
console.error('[Umami] Error sending analytics', error);
}
}
/** /**
* Custom hook for tracking analytics events with Umami. * Custom hook for tracking analytics events with Umami.
*
* Provides a convenient way to track custom events throughout your application.
*
* @example
* ```tsx
* import { useAnalytics } from '@/components/analytics/useAnalytics';
*
* function MyComponent() {
* const { trackEvent, trackPageview } = useAnalytics();
*
* const handleButtonClick = () => {
* trackEvent('button_click', {
* button_id: 'cta-primary',
* page: 'homepage'
* });
* };
*
* return <button onClick={handleButtonClick}>Click me</button>;
* }
* ```
*
* @example
* ```tsx
* // Track a custom pageview
* const { trackPageview } = useAnalytics();
* trackPageview('/custom-path?param=value');
* ```
*/ */
export function useAnalytics() { export function useAnalytics() {
const services = getAppServices();
/**
* Track a custom event with optional properties.
*
* @param eventName - The name of the event to track
* @param properties - Optional event properties (metadata)
*/
const trackEvent = useCallback( const trackEvent = useCallback(
(eventName: string, properties?: AnalyticsEventProperties) => { (eventName: string, properties?: AnalyticsEventProperties) => {
services.analytics.track(eventName, properties); sendAnalyticsPayload('event', {
name: eventName,
data: properties,
url: window.location.pathname + window.location.search,
});
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
console.log('[Umami] Tracked event:', eventName, properties); console.log('[Umami] Tracked event:', eventName, properties);
} }
}, },
[services] []
); );
/**
* Track a pageview (useful for custom navigation or SPA routing).
*
* @param url - The URL to track (defaults to current location)
*/
const trackPageview = useCallback( const trackPageview = useCallback(
(url?: string) => { (url?: string) => {
services.analytics.trackPageview(url); sendAnalyticsPayload('event', {
url: url || window.location.pathname + window.location.search,
});
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
console.log('[Umami] Tracked pageview:', url ?? 'current location'); console.log('[Umami] Tracked pageview:', url ?? 'current location');
} }
}, },
[services] []
); );
return { return {

View File

@@ -60,26 +60,26 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`} className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`}
> >
{badge && ( {badge && (
<div className="animate-hero-fade-in-up" style={{ animationDelay: '0.1s', opacity: 0, animationFillMode: 'forwards' }}> <div className="animate-hero-fade-in-up" style={{ animationDelay: '0.1s', animationFillMode: 'both' }}>
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg"> <Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
{badge} {badge}
</Badge> </Badge>
</div> </div>
)} )}
<div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.25s' : '0.1s', opacity: 0, animationFillMode: 'forwards' }}> <div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.25s' : '0.1s', animationFillMode: 'both' }}>
<Heading level={1} size="section" variant="white" align={alignment || 'left'} className="mb-4 md:mb-8"> <Heading level={1} size="section" variant="white" align={alignment || 'left'} className="mb-4 md:mb-8">
{title} {title}
</Heading> </Heading>
</div> </div>
{subtitle && ( {subtitle && (
<div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.4s' : '0.25s', opacity: 0, animationFillMode: 'forwards' }}> <div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.4s' : '0.25s', animationFillMode: 'both' }}>
<p className={`text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl ${alignment === 'center' ? 'mx-auto' : ''}`}> <p className={`text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl ${alignment === 'center' ? 'mx-auto' : ''}`}>
{subtitle} {subtitle}
</p> </p>
</div> </div>
)} )}
{ctaLabel && ctaHref && ( {ctaLabel && ctaHref && (
<div className={`mt-8 animate-hero-fade-in-up ${alignment === 'center' ? 'flex flex-wrap justify-center gap-4' : 'flex flex-wrap gap-4'}`} style={{ animationDelay: badge ? '0.55s' : '0.4s', opacity: 0, animationFillMode: 'forwards' }}> <div className={`mt-8 animate-hero-fade-in-up ${alignment === 'center' ? 'flex flex-wrap justify-center gap-4' : 'flex flex-wrap gap-4'}`} style={{ animationDelay: badge ? '0.55s' : '0.4s', animationFillMode: 'both' }}>
<Button <Button
href={ctaHref} href={ctaHref}
variant="accent" variant="accent"

View File

@@ -86,12 +86,14 @@ export function HeroVideo(props: HeroVideoProps) {
src={posterSrc} src={posterSrc}
alt={posterAlt} alt={posterAlt}
fill fill
className="object-cover z-[1] pointer-events-none filter contrast-125 saturate-110 brightness-90" className="object-cover z-[1] pointer-events-none"
sizes="100vw" sizes="100vw"
quality={25} quality={25}
priority priority
fetchPriority="high" fetchPriority="high"
/> />
{/* Fast compositing layer to replace the heavy CSS filters on the image */}
<div className="absolute inset-0 bg-black/20 z-[1] pointer-events-none mix-blend-multiply" />
{/* Render video on top if available, but defer src to avoid blocking LCP */} {/* Render video on top if available, but defer src to avoid blocking LCP */}
{videoUrl && ( {videoUrl && (

View File

@@ -1,12 +1,14 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { m, AnimatePresence, LazyMotion, domAnimation } from 'framer-motion'; import { m, AnimatePresence, LazyMotion } from 'framer-motion';
import { MapPin, CheckCircle2, ArrowUpRight } from 'lucide-react'; import { MapPin, CheckCircle2, ArrowUpRight } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { Location } from '@/lib/map-data'; import { Location } from '@/lib/map-data';
const loadFeatures = () => import('@/lib/framer-features').then(res => res.default);
const MinorNode = React.memo(({ loc, isActive, onEnter, onLeave }: { loc: Location, isActive: boolean, onEnter: (loc: Location) => void, onLeave: () => void }) => ( const MinorNode = React.memo(({ loc, isActive, onEnter, onLeave }: { loc: Location, isActive: boolean, onEnter: (loc: Location) => void, onLeave: () => void }) => (
<div <div
className={`absolute group/minor cursor-pointer w-8 h-8 md:w-5 md:h-5 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-10 hover:z-30'}`} className={`absolute group/minor cursor-pointer w-8 h-8 md:w-5 md:h-5 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-10 hover:z-30'}`}
@@ -76,7 +78,7 @@ export default function InteractiveMapPins({
handleMouseLeave: () => void handleMouseLeave: () => void
}) { }) {
return ( return (
<LazyMotion features={domAnimation}> <LazyMotion features={loadFeatures}>
{locations.filter(l => l.type === 'minor_node').map((loc, idx) => ( {locations.filter(l => l.type === 'minor_node').map((loc, idx) => (
<MinorNode <MinorNode
key={`minor-${loc.id || idx}`} key={`minor-${loc.id || idx}`}

View File

@@ -47,19 +47,6 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
const isDraggingRef = React.useRef(false); const isDraggingRef = React.useRef(false);
const [isDragging, setIsDragging] = React.useState(false); const [isDragging, setIsDragging] = React.useState(false);
// Center the first card on mount so it appears in the middle with peek on both sides
React.useEffect(() => {
const el = containerRef.current;
if (!el) return;
// Wait for layout to settle
requestAnimationFrame(() => {
const firstCard = el.querySelector('[data-card]') as HTMLElement | null;
if (!firstCard) return;
const cardCenter = firstCard.offsetLeft + firstCard.offsetWidth / 2;
const containerCenter = el.offsetWidth / 2;
el.scrollLeft = cardCenter - containerCenter;
});
}, [references.length]);
const badge = props.badge || data?.badge || t('badge'); const badge = props.badge || data?.badge || t('badge');
const title = props.title || data?.title || t('title'); const title = props.title || data?.title || t('title');

View File

@@ -25,34 +25,45 @@ function MagneticRing({
// Only run on client with mouse // Only run on client with mouse
if (window.matchMedia("(pointer: coarse)").matches) return; if (window.matchMedia("(pointer: coarse)").matches) return;
let ticking = false;
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!ref.current) return; if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const distX = e.clientX - centerX; if (!ticking) {
const distY = e.clientY - centerY; window.requestAnimationFrame(() => {
const distance = Math.sqrt(distX * Math.pow(distX, 2) + Math.pow(distY, 2)); // Fix: correct pythagoras if (!ref.current) {
ticking = false;
return;
}
// Calculate actual distance correctly const rect = ref.current.getBoundingClientRect();
const actualDistance = Math.sqrt(distX * distX + distY * distY); const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
if (actualDistance < pullRadius) { const distX = e.clientX - centerX;
// Exponential falloff for natural magnetic feel const distY = e.clientY - centerY;
const pullFactor = Math.pow(1 - (actualDistance / pullRadius), 2); const actualDistance = Math.sqrt(distX * distX + distY * distY);
// Target offset (fraction of the distance based on strength) if (actualDistance < pullRadius) {
x.set(distX * pullFactor * (pullStrength / 100)); // Exponential falloff for natural magnetic feel
y.set(distY * pullFactor * (pullStrength / 100)); const pullFactor = Math.pow(1 - (actualDistance / pullRadius), 2);
} else {
// Return to origin smoothly // Target offset (fraction of the distance based on strength)
x.set(0); x.set(distX * pullFactor * (pullStrength / 100));
y.set(0); y.set(distY * pullFactor * (pullStrength / 100));
} else {
// Return to origin smoothly
x.set(0);
y.set(0);
}
ticking = false;
});
ticking = true;
} }
}; };
window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mousemove', handleMouseMove, { passive: true });
return () => window.removeEventListener('mousemove', handleMouseMove); return () => window.removeEventListener('mousemove', handleMouseMove);
}, [x, y, pullRadius, pullStrength]); }, [x, y, pullRadius, pullStrength]);

View File

@@ -0,0 +1,7 @@
'use client';
import dynamic from 'next/dynamic';
export const DynamicFramerMotion = dynamic(
() => import('./FramerMotionProvider').then((mod) => mod.FramerMotionProvider),
{ ssr: true }
);

View File

@@ -0,0 +1,11 @@
'use client';
import { LazyMotion } from 'framer-motion';
const loadFeatures = () => import('@/lib/framer-features').then(res => res.default);
export function FramerMotionProvider({ children }: { children: React.ReactNode }) {
return (
<LazyMotion features={loadFeatures} strict>
{children}
</LazyMotion>
);
}

View File

@@ -2,9 +2,6 @@
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'; import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation'; import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { LazyMotion } from 'framer-motion';
const loadFeatures = () => import('@/lib/framer-features').then(res => res.default);
interface TransitionContextType { interface TransitionContextType {
isTransitioning: boolean; isTransitioning: boolean;
@@ -71,9 +68,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
setTransitionMessage={setTransitionMessage} setTransitionMessage={setTransitionMessage}
/> />
</React.Suspense> </React.Suspense>
<LazyMotion features={loadFeatures}> {children}
{children}
</LazyMotion>
</TransitionContext.Provider> </TransitionContext.Provider>
); );
} }

View File

@@ -5,7 +5,9 @@ import { join } from 'path';
* Loads the Inter fonts for use in Satori (Next.js OG Image generation). * Loads the Inter fonts for use in Satori (Next.js OG Image generation).
* Since we are using runtime = 'nodejs', we can read them from the filesystem. * Since we are using runtime = 'nodejs', we can read them from the filesystem.
*/ */
let cachedFonts: any[] | null = null;
export async function getOgFonts() { export async function getOgFonts() {
if (cachedFonts) return cachedFonts;
const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.woff'); const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.woff');
const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.woff'); const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.woff');
@@ -28,7 +30,7 @@ export async function getOgFonts() {
`[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`, `[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`,
); );
return [ cachedFonts = [
{ {
name: 'Inter', name: 'Inter',
data: boldFont, data: boldFont,
@@ -42,28 +44,35 @@ export async function getOgFonts() {
style: 'normal' as const, style: 'normal' as const,
}, },
]; ];
return cachedFonts;
} catch (error) { } catch (error) {
console.error(`[OG] Failed to load fonts from ${process.cwd()}:`, error); console.error(`[OG] Failed to load fonts from ${process.cwd()}:`, error);
return []; return [];
} }
} }
let cachedBackground: string | null = null;
export function getOgBackground() { export function getOgBackground() {
if (cachedBackground) return cachedBackground;
try { try {
const bgPath = join(process.cwd(), 'public/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg'); const bgPath = join(process.cwd(), 'public/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg');
const bgBase64 = readFileSync(bgPath, 'base64'); const bgBase64 = readFileSync(bgPath, 'base64');
return `data:image/jpeg;base64,${bgBase64}`; cachedBackground = `data:image/jpeg;base64,${bgBase64}`;
return cachedBackground;
} catch (err) { } catch (err) {
console.error('[OG] Failed to load background', err); console.error('[OG] Failed to load background', err);
return undefined; return undefined;
} }
} }
let cachedLogo: string | null = null;
export function getOgLogo() { export function getOgLogo() {
if (cachedLogo) return cachedLogo;
try { try {
const logoPath = join(process.cwd(), 'public/assets/logo-white.png'); const logoPath = join(process.cwd(), 'public/assets/logo-white.png');
const logoBase64 = readFileSync(logoPath, 'base64'); const logoBase64 = readFileSync(logoPath, 'base64');
return `data:image/png;base64,${logoBase64}`; cachedLogo = `data:image/png;base64,${logoBase64}`;
return cachedLogo;
} catch (err) { } catch (err) {
console.error('[OG] Failed to load logo', err); console.error('[OG] Failed to load logo', err);
return undefined; return undefined;

View File

@@ -139,7 +139,7 @@
"prepare": "husky", "prepare": "husky",
"preinstall": "npx only-allow pnpm" "preinstall": "npx only-allow pnpm"
}, },
"version": "2.2.87", "version": "2.4.24",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@parcel/watcher", "@parcel/watcher",