Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f86575471 | |||
| 97467cbda9 | |||
| 076aa5c13d | |||
| a010a63281 | |||
| 9880a3f8dd | |||
| 531e1a49f8 | |||
| 324cfb0ca3 | |||
| 69d2c42ef7 | |||
| 6fb46278e0 | |||
| 73886406a4 | |||
| b2701d6bcd | |||
| 4f46825fb4 | |||
| c63766711d | |||
| c738001f9a | |||
| 69dd79dfad | |||
| a6c4cc53f9 | |||
| 29f83f8151 | |||
| cec08e3ab3 | |||
| 837567c1f4 | |||
| 4dcc0061fc | |||
| bd25ec935d | |||
| b3b85202eb |
@@ -47,14 +47,7 @@ jobs:
|
||||
run: |
|
||||
echo "=== System Disk Usage ==="
|
||||
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 "=== 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 ==="
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" || true
|
||||
echo "=== Host Docker Info ==="
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { getAppServices } from '@/lib/services/create-services';
|
||||
import { Button, Heading } from '@/components/ui';
|
||||
import { Terminal, Activity, AlertTriangle, RefreshCw, Home, ShieldAlert } from 'lucide-react';
|
||||
import { m } from 'framer-motion';
|
||||
@@ -26,9 +25,7 @@ export default function Error({
|
||||
return;
|
||||
}
|
||||
|
||||
const services = getAppServices();
|
||||
services.errors.captureException(error);
|
||||
services.logger.error('Application error caught by boundary', {
|
||||
console.error('Application error caught by boundary', {
|
||||
message: error?.message || 'Unknown error',
|
||||
stack: error?.stack,
|
||||
digest: error?.digest,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DynamicMobileBottomNav as MobileBottomNav } from '@/components/layout/D
|
||||
|
||||
import { DynamicCorporateBackground as CorporateBackground } from '@/components/decorations/DynamicCorporateBackground';
|
||||
import { DynamicPageTransitionShutter as PageTransitionShutter } from '@/components/providers/DynamicPageTransitionShutter';
|
||||
import { DynamicFramerMotion as FramerMotionProvider } from '@/components/providers/DynamicFramerMotion';
|
||||
|
||||
const inter = Inter({
|
||||
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>
|
||||
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
||||
<TransitionProvider>
|
||||
<CorporateBackground />
|
||||
<InitialLoader shouldShowLoader={!hasSeenLoader} />
|
||||
<PageTransitionShutter />
|
||||
<SkipLink />
|
||||
<Header navLinks={navLinks} />
|
||||
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} />
|
||||
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-grow overflow-visible"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<FramerMotionProvider>
|
||||
<CorporateBackground />
|
||||
<InitialLoader shouldShowLoader={!hasSeenLoader} />
|
||||
<PageTransitionShutter />
|
||||
<SkipLink />
|
||||
<Header navLinks={navLinks} />
|
||||
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} />
|
||||
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-grow overflow-visible"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<Footer companyInfo={companyInfo} />
|
||||
<JsonLd />
|
||||
<AnalyticsShell />
|
||||
<Footer companyInfo={companyInfo} />
|
||||
<JsonLd />
|
||||
<AnalyticsShell />
|
||||
|
||||
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
|
||||
<FeedbackClientWrapper />
|
||||
</FramerMotionProvider>
|
||||
</TransitionProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
|
||||
@@ -7,8 +7,7 @@ import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import Image from 'next/image';
|
||||
import { standorteData, standorteLocations } from '@/lib/standorte-data';
|
||||
import nextDynamic from 'next/dynamic';
|
||||
const InteractiveGermanyMap = nextDynamic(() => import('@/components/blocks/InteractiveGermanyMap').then(mod => mod.InteractiveGermanyMap));
|
||||
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
|
||||
@@ -88,34 +88,30 @@
|
||||
}
|
||||
/* trigger rebuild */
|
||||
|
||||
@layer utilities {
|
||||
@keyframes hero-fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
@keyframes hero-fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.animate-hero-fade-in-up {
|
||||
animation: hero-fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@keyframes hero-zoom-out {
|
||||
from {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
@utility animate-hero-fade-in-up {
|
||||
animation: hero-fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes hero-zoom-out {
|
||||
from {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.animate-hero-zoom-out {
|
||||
animation: hero-zoom-out 1.5s ease-out forwards;
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-hero-zoom-out {
|
||||
animation: hero-zoom-out 1.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function ContactForm() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<Heading level={3} className="mb-4">
|
||||
<Heading level={3} align="center" className="mb-4">
|
||||
{t('form.successTitle') || 'Message Sent!'}
|
||||
</Heading>
|
||||
<p className="text-text-secondary text-lg mb-8">
|
||||
@@ -115,7 +115,7 @@ export default function ContactForm() {
|
||||
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<Heading level={3} className="mb-4 text-destructive font-black">
|
||||
<Heading level={3} align="center" className="mb-4 text-destructive font-black">
|
||||
{t('form.errorTitle') || 'Submission Failed!'}
|
||||
</Heading>
|
||||
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const FeedbackOverlay = dynamic(
|
||||
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
|
||||
{ ssr: false },
|
||||
);
|
||||
const IS_ENABLED = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||
|
||||
interface FeedbackClientWrapperProps {
|
||||
feedbackEnabled: boolean;
|
||||
}
|
||||
const FeedbackOverlay = IS_ENABLED
|
||||
? dynamic(
|
||||
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
|
||||
{ ssr: false },
|
||||
)
|
||||
: () => null;
|
||||
|
||||
export default function FeedbackClientWrapper({ feedbackEnabled }: FeedbackClientWrapperProps) {
|
||||
if (!feedbackEnabled) return null;
|
||||
export default function FeedbackClientWrapper() {
|
||||
if (!IS_ENABLED) return null;
|
||||
|
||||
return <FeedbackOverlay />;
|
||||
}
|
||||
|
||||
@@ -2,34 +2,28 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { getAppServices } from '@/lib/services/create-services';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
|
||||
/**
|
||||
* AnalyticsProvider Component
|
||||
*
|
||||
* Automatically tracks pageviews on client-side route changes.
|
||||
* 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() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { trackPageview } = useAnalytics();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
|
||||
const services = getAppServices();
|
||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
||||
|
||||
// Track pageview with the full URL
|
||||
// The service will relay this to our internal proxy which injects the Website ID
|
||||
services.analytics.trackPageview(url);
|
||||
|
||||
// Services like logger are already sub-initialized in getAppServices()
|
||||
// so we don't need to log here manually.
|
||||
}, [pathname, searchParams]);
|
||||
trackPageview(url);
|
||||
}, [pathname, searchParams, trackPageview]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,73 +1,69 @@
|
||||
'use client';
|
||||
|
||||
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.
|
||||
*
|
||||
* 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() {
|
||||
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(
|
||||
(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') {
|
||||
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(
|
||||
(url?: string) => {
|
||||
services.analytics.trackPageview(url);
|
||||
sendAnalyticsPayload('event', {
|
||||
url: url || window.location.pathname + window.location.search,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[Umami] Tracked pageview:', url ?? 'current location');
|
||||
}
|
||||
},
|
||||
[services]
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -60,26 +60,26 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
||||
className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`}
|
||||
>
|
||||
{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}
|
||||
</Badge>
|
||||
</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">
|
||||
{title}
|
||||
</Heading>
|
||||
</div>
|
||||
{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' : ''}`}>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{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
|
||||
href={ctaHref}
|
||||
variant="accent"
|
||||
|
||||
@@ -86,12 +86,14 @@ export function HeroVideo(props: HeroVideoProps) {
|
||||
src={posterSrc}
|
||||
alt={posterAlt}
|
||||
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"
|
||||
quality={25}
|
||||
priority
|
||||
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 */}
|
||||
{videoUrl && (
|
||||
|
||||
@@ -88,13 +88,12 @@ export function InteractiveGermanyMap({
|
||||
|
||||
const mapBackground = useMemo(() => (
|
||||
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200 pointer-events-none transform-gpu">
|
||||
<Image
|
||||
<img
|
||||
src="/germany-map.svg"
|
||||
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
className="w-full h-full object-cover"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
), [locale]);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
|
||||
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 Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
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 }) => (
|
||||
<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'}`}
|
||||
@@ -76,7 +78,7 @@ export default function InteractiveMapPins({
|
||||
handleMouseLeave: () => void
|
||||
}) {
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
{locations.filter(l => l.type === 'minor_node').map((loc, idx) => (
|
||||
<MinorNode
|
||||
key={`minor-${loc.id || idx}`}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
showFairs,
|
||||
fairsTitle = t('fairsTitle'),
|
||||
fairs = [
|
||||
{ name: 'Intersolar München', date: 'Termin folgt', type: 'Energie-Messe', location: 'Messe München' },
|
||||
{ name: 'Intersolar München', date: '8. - 10. Juni 2027', type: 'Energie-Messe', location: 'Messe München' },
|
||||
{ name: 'Windenergietage Linstow', date: 'Termin folgt', type: 'Fachkongress', location: 'Linstow' },
|
||||
{ name: 'Kabelwerkstatt Wiesbaden', date: 'Termin folgt', type: 'Fachmesse', location: 'Wiesbaden' }
|
||||
],
|
||||
|
||||
@@ -47,19 +47,6 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
||||
const isDraggingRef = React.useRef(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 title = props.title || data?.title || t('title');
|
||||
|
||||
@@ -25,34 +25,45 @@ function MagneticRing({
|
||||
// Only run on client with mouse
|
||||
if (window.matchMedia("(pointer: coarse)").matches) return;
|
||||
|
||||
let ticking = false;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!ref.current) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!ref.current) {
|
||||
ticking = false;
|
||||
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;
|
||||
const distY = e.clientY - centerY;
|
||||
const distance = Math.sqrt(distX * Math.pow(distX, 2) + Math.pow(distY, 2)); // Fix: correct pythagoras
|
||||
const distX = e.clientX - centerX;
|
||||
const distY = e.clientY - centerY;
|
||||
const actualDistance = Math.sqrt(distX * distX + distY * distY);
|
||||
|
||||
// Calculate actual distance correctly
|
||||
const actualDistance = Math.sqrt(distX * distX + distY * distY);
|
||||
|
||||
if (actualDistance < pullRadius) {
|
||||
// Exponential falloff for natural magnetic feel
|
||||
const pullFactor = Math.pow(1 - (actualDistance / pullRadius), 2);
|
||||
|
||||
// Target offset (fraction of the distance based on strength)
|
||||
x.set(distX * pullFactor * (pullStrength / 100));
|
||||
y.set(distY * pullFactor * (pullStrength / 100));
|
||||
} else {
|
||||
// Return to origin smoothly
|
||||
x.set(0);
|
||||
y.set(0);
|
||||
if (actualDistance < pullRadius) {
|
||||
// Exponential falloff for natural magnetic feel
|
||||
const pullFactor = Math.pow(1 - (actualDistance / pullRadius), 2);
|
||||
|
||||
// Target offset (fraction of the distance based on strength)
|
||||
x.set(distX * pullFactor * (pullStrength / 100));
|
||||
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);
|
||||
}, [x, y, pullRadius, pullStrength]);
|
||||
|
||||
|
||||
7
components/providers/DynamicFramerMotion.tsx
Normal file
7
components/providers/DynamicFramerMotion.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
export const DynamicFramerMotion = dynamic(
|
||||
() => import('./FramerMotionProvider').then((mod) => mod.FramerMotionProvider),
|
||||
{ ssr: true }
|
||||
);
|
||||
11
components/providers/FramerMotionProvider.tsx
Normal file
11
components/providers/FramerMotionProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||
import { LazyMotion } from 'framer-motion';
|
||||
|
||||
const loadFeatures = () => import('@/lib/framer-features').then(res => res.default);
|
||||
|
||||
interface TransitionContextType {
|
||||
isTransitioning: boolean;
|
||||
@@ -71,9 +68,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
||||
setTransitionMessage={setTransitionMessage}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
{children}
|
||||
</LazyMotion>
|
||||
{children}
|
||||
</TransitionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ layout: "fullBleed"
|
||||
showFairs={true}
|
||||
showJobs={false}
|
||||
fairs={[
|
||||
{ name: 'Intersolar München', date: '23. - 25. Juni 2026', type: 'Messe', location: 'München', url: 'https://www.intersolar.de/' },
|
||||
{ name: 'Windenergietage Linstow', date: '10. - 12. November 2026', type: 'Fachkongress', location: 'Linstow', url: 'https://www.windenergietage.de/' },
|
||||
{ name: 'Werkstatt Kabel 2026', date: '24. - 25. November 2026', type: 'Fachtagung', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' }
|
||||
{ name: 'Werkstatt Kabel 2026', date: '24. - 25. November 2026', type: 'Fachtagung', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' },
|
||||
{ name: 'Intersolar München', date: '8. - 10. Juni 2027', type: 'Messe', location: 'München', url: 'https://www.intersolar.de/' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -20,9 +20,9 @@ layout: "fullBleed"
|
||||
showFairs={true}
|
||||
showJobs={false}
|
||||
fairs={[
|
||||
{ name: 'Intersolar Munich', date: 'June 23–25, 2026', type: 'Fair', location: 'Munich', url: 'https://www.intersolar.de/' },
|
||||
{ name: 'Wind Energy Days Linstow', date: 'November 10–12, 2026', type: 'Specialist Congress', location: 'Linstow', url: 'https://www.windenergietage.de/' },
|
||||
{ name: 'Werkstatt Kabel 2026', date: 'November 24–25, 2026', type: 'Symposium', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' }
|
||||
{ name: 'Werkstatt Kabel 2026', date: 'November 24–25, 2026', type: 'Symposium', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' },
|
||||
{ name: 'Intersolar Munich', date: 'June 8–10, 2027', type: 'Fair', location: 'Munich', url: 'https://www.intersolar.de/' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,9 @@ import { join } from 'path';
|
||||
* 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.
|
||||
*/
|
||||
let cachedFonts: any[] | null = null;
|
||||
export async function getOgFonts() {
|
||||
if (cachedFonts) return cachedFonts;
|
||||
const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.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)`,
|
||||
);
|
||||
|
||||
return [
|
||||
cachedFonts = [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: boldFont,
|
||||
@@ -42,28 +44,35 @@ export async function getOgFonts() {
|
||||
style: 'normal' as const,
|
||||
},
|
||||
];
|
||||
return cachedFonts;
|
||||
} catch (error) {
|
||||
console.error(`[OG] Failed to load fonts from ${process.cwd()}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
let cachedBackground: string | null = null;
|
||||
export function getOgBackground() {
|
||||
if (cachedBackground) return cachedBackground;
|
||||
try {
|
||||
const bgPath = join(process.cwd(), 'public/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg');
|
||||
const bgBase64 = readFileSync(bgPath, 'base64');
|
||||
return `data:image/jpeg;base64,${bgBase64}`;
|
||||
cachedBackground = `data:image/jpeg;base64,${bgBase64}`;
|
||||
return cachedBackground;
|
||||
} catch (err) {
|
||||
console.error('[OG] Failed to load background', err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let cachedLogo: string | null = null;
|
||||
export function getOgLogo() {
|
||||
if (cachedLogo) return cachedLogo;
|
||||
try {
|
||||
const logoPath = join(process.cwd(), 'public/assets/logo-white.png');
|
||||
const logoBase64 = readFileSync(logoPath, 'base64');
|
||||
return `data:image/png;base64,${logoBase64}`;
|
||||
cachedLogo = `data:image/png;base64,${logoBase64}`;
|
||||
return cachedLogo;
|
||||
} catch (err) {
|
||||
console.error('[OG] Failed to load logo', err);
|
||||
return undefined;
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "2.2.86",
|
||||
"version": "2.4.27",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('Messen Content and Translations', () => {
|
||||
|
||||
// 1. Intersolar München
|
||||
expect(content).toContain("name: 'Intersolar München'");
|
||||
expect(content).toContain("date: '23. - 25. Juni 2026'");
|
||||
expect(content).toContain("date: '8. - 10. Juni 2027'");
|
||||
expect(content).toContain("type: 'Messe'");
|
||||
expect(content).toContain("location: 'München'");
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('Messen Content and Translations', () => {
|
||||
|
||||
// 1. Intersolar Munich
|
||||
expect(content).toContain("name: 'Intersolar Munich'");
|
||||
expect(content).toContain("date: 'June 23–25, 2026'");
|
||||
expect(content).toContain("date: 'June 8–10, 2027'");
|
||||
expect(content).toContain("type: 'Fair'");
|
||||
expect(content).toContain("location: 'Munich'");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user