Some checks failed
Build & Deploy / 🔍 Prepare (push) Failing after 4s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
320 lines
16 KiB
TypeScript
320 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { MapPin, Factory, Zap, CheckCircle2, ArrowUpRight } from 'lucide-react';
|
|
import Image from 'next/image';
|
|
import { useRouter, usePathname } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
|
|
import { Location, defaultLocations, minorLocations, projectLocations } from '@/lib/map-data';
|
|
import { standorteLocations } from '@/lib/standorte-data';
|
|
import { useLocale, useTranslations } from 'next-intl';
|
|
|
|
interface Stat {
|
|
value: string;
|
|
suffix?: string;
|
|
label: string;
|
|
href?: string;
|
|
}
|
|
|
|
interface InteractiveGermanyMapProps {
|
|
badge?: string;
|
|
title?: React.ReactNode;
|
|
description?: string;
|
|
stats?: Stat[];
|
|
locations?: Location[];
|
|
isHero?: boolean;
|
|
hideStandorte?: boolean;
|
|
}
|
|
|
|
export function InteractiveGermanyMap({
|
|
badge,
|
|
title,
|
|
description,
|
|
stats,
|
|
locations = projectLocations,
|
|
isHero = false,
|
|
hideStandorte = false
|
|
}: InteractiveGermanyMapProps) {
|
|
const [activeLocation, setActiveLocation] = useState<Location | null>(null);
|
|
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>(null);
|
|
|
|
const handleMouseEnter = (loc: Location) => {
|
|
if (hoverTimeout) clearTimeout(hoverTimeout);
|
|
setActiveLocation(loc);
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
const timeout = setTimeout(() => {
|
|
setActiveLocation(null);
|
|
}, 200);
|
|
setHoverTimeout(timeout);
|
|
};
|
|
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const locale = useLocale();
|
|
const t = useTranslations('InteractiveGermanyMap');
|
|
const tStandard = useTranslations('StandardPage');
|
|
|
|
const finalStats = stats || [
|
|
{ value: '100', suffix: '%', label: locale === 'en' ? 'Nationwide Reach' : 'Überregionale Reichweite' },
|
|
{ value: '3', suffix: '', label: locale === 'en' ? 'Operational Locations' : 'Operative Standorte', href: `/${locale}/standorte` },
|
|
];
|
|
|
|
const finalBadge = badge || tStandard('badge');
|
|
// the map is mostly used with specific props, so we leave title & description to fallbacks if not provided
|
|
const finalTitle = title || (locale === 'en' ? <>Nationwide<br/>in operation for you.</> : <>Deutschlandweit<br/>für Sie im Einsatz.</>);
|
|
const finalDescription = description || (locale === 'en' ? 'From our strategic locations in Guben, Kirchheilingen and Bülstedt, we control and implement complex infrastructure projects nationwide.' : 'Von unseren strategischen Standorten in Guben, Kirchheilingen und Bülstedt steuern und realisieren wir komplexe Infrastrukturprojekte im gesamten Bundesgebiet.');
|
|
|
|
const finalLocations = React.useMemo(() => {
|
|
if (hideStandorte) return locations;
|
|
|
|
// Ensure the 3 standorte are always included, without duplicating if already present by ID
|
|
const base = [...locations];
|
|
standorteLocations.forEach(sl => {
|
|
if (!base.some(l => l.id === sl.id)) {
|
|
base.push(sl as Location);
|
|
}
|
|
});
|
|
return base;
|
|
}, [locations, hideStandorte]);
|
|
|
|
return (
|
|
<div key={isHero ? `hero-map-${pathname}` : undefined} className={isHero ? "relative w-full" : "relative w-full max-w-7xl mx-auto py-12 md:py-24 px-4 sm:px-6 mt-16 md:mt-20"}>
|
|
<div className={`bg-[#050B14] ${isHero ? 'pt-32 pb-16 md:pt-40 md:pb-24' : 'rounded-[2.5rem] md:rounded-[3.5rem] shadow-2xl border border-white/5'} overflow-visible relative group/map`}>
|
|
{/* Animated Border Glow */}
|
|
{!isHero && <AnimatedGlossyBorder opacity={0.7} className="z-30" />}
|
|
|
|
{/* Background Effects */}
|
|
<div className={`absolute inset-0 bg-gradient-to-br from-[#050B14] via-[#0A1322] to-[#050B14] ${!isHero && 'rounded-[2.5rem] md:rounded-[3.5rem]'} overflow-hidden`} />
|
|
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-primary/5 rounded-full blur-[120px] pointer-events-none translate-x-1/3 -translate-y-1/3 overflow-hidden" />
|
|
|
|
<div className={`relative z-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-12 gap-12 items-center min-h-[600px] ${isHero ? 'max-w-7xl mx-auto px-4 sm:px-6' : 'p-8 md:p-12 lg:p-16'}`}>
|
|
|
|
{/* Content Left */}
|
|
<div className="xl:col-span-5 text-white z-20">
|
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20 text-primary text-xs font-bold uppercase tracking-wider mb-8">
|
|
<MapPin className="w-4 h-4" />
|
|
<span>{finalBadge}</span>
|
|
</div>
|
|
|
|
{isHero ? (
|
|
<h1 className="font-heading text-3xl md:text-4xl lg:text-5xl xl:text-6xl font-extrabold mb-6 leading-[1.1] text-transparent bg-clip-text bg-gradient-to-r from-white to-white/70 text-balance">
|
|
{finalTitle}
|
|
</h1>
|
|
) : (
|
|
<h3 className="font-heading text-3xl md:text-4xl lg:text-5xl xl:text-6xl font-extrabold mb-6 leading-[1.1] text-transparent bg-clip-text bg-gradient-to-r from-white to-white/70 text-balance">
|
|
{finalTitle}
|
|
</h3>
|
|
)}
|
|
|
|
<p className="text-white/60 text-lg mb-12 leading-relaxed">
|
|
{finalDescription}
|
|
</p>
|
|
|
|
{/* Industrial Stats Grid */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{finalStats.map((stat, i) => {
|
|
const StatCard = () => (
|
|
<div className={`bg-white/5 border border-white/10 rounded-2xl p-6 backdrop-blur-md relative overflow-hidden group h-full ${stat.href ? 'cursor-pointer' : ''}`}>
|
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
<div className="relative z-10 flex flex-col h-full justify-between">
|
|
<div className="text-3xl md:text-4xl font-extrabold text-white mb-1 flex items-baseline gap-1">
|
|
{stat.value}<span className="text-xl text-primary">{stat.suffix}</span>
|
|
</div>
|
|
<div className={`text-sm text-white/50 font-medium flex items-center ${stat.href ? 'group-hover:text-primary transition-colors' : ''}`}>
|
|
{stat.label}
|
|
{stat.href && <ArrowUpRight className="w-4 h-4 ml-1 opacity-0 -translate-y-1 translate-x-1 group-hover:opacity-100 group-hover:translate-y-0 group-hover:translate-x-0 transition-all duration-300" />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return stat.href ? (
|
|
<Link key={i} href={stat.href} className="block group/link hover:-translate-y-1 transition-transform duration-300">
|
|
<StatCard />
|
|
</Link>
|
|
) : (
|
|
<div key={i}>
|
|
<StatCard />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Map Right */}
|
|
<div className="xl:col-span-7 relative flex items-center justify-center min-h-[500px] lg:min-h-[700px] z-10 pointer-events-none">
|
|
{/* Map Container - Enforce strict aspect ratio matching the SVG (1024x1024) */}
|
|
<div className="relative w-full max-w-[600px] aspect-square group pointer-events-auto">
|
|
|
|
{/* Map SVG */}
|
|
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200 pointer-events-none">
|
|
<Image
|
|
src="/germany-map.svg"
|
|
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
|
|
fill
|
|
className="object-cover"
|
|
priority
|
|
/>
|
|
</div>
|
|
|
|
{/* Minor Location Markers (The 100+ generic projects) */}
|
|
{finalLocations.filter(l => l.type === 'minor_node').map((loc, idx) => (
|
|
<div
|
|
key={`minor-${idx}`}
|
|
className="absolute z-10 group/minor cursor-pointer w-10 h-10 flex items-center justify-center"
|
|
style={{
|
|
left: `${loc.x}%`,
|
|
top: `${loc.y}%`,
|
|
transform: 'translate(-50%, -50%)',
|
|
}}
|
|
onMouseEnter={() => handleMouseEnter(loc)}
|
|
onMouseLeave={handleMouseLeave}
|
|
onClick={() => handleMouseEnter(loc)}
|
|
>
|
|
<div className="w-1.5 h-1.5 bg-primary/80 rounded-full group-hover/minor:bg-white group-hover/minor:scale-150 transition-all duration-300" />
|
|
<div className="absolute inset-0 m-auto w-2 h-2 bg-primary rounded-full opacity-0 group-hover/minor:animate-ping" />
|
|
</div>
|
|
))}
|
|
|
|
{/* Major Location Markers (HQ, Branch, Project) */}
|
|
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => {
|
|
const isHQ = loc.type === 'hq';
|
|
const isBranch = loc.type === 'branch';
|
|
const isActive = activeLocation?.id === loc.id;
|
|
|
|
return (
|
|
<div
|
|
key={loc.id}
|
|
className={`absolute transform -translate-x-1/2 -translate-y-1/2 group/pin cursor-pointer w-16 h-16 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-20'}`}
|
|
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
|
|
onMouseEnter={() => handleMouseEnter(loc)}
|
|
onMouseLeave={handleMouseLeave}
|
|
onClick={() => handleMouseEnter(loc)}
|
|
>
|
|
{/* Ping Animation for HQ / Branch */}
|
|
{(isHQ || isBranch) && (
|
|
<div className="absolute inset-0 m-auto w-6 h-6 rounded-full animate-ping bg-primary/50 scale-150" />
|
|
)}
|
|
|
|
{/* Elegant Marker Dot */}
|
|
<motion.div
|
|
initial={{ scale: 0 }}
|
|
animate={{ scale: 1 }}
|
|
transition={{ type: 'spring', delay: idx * 0.1 }}
|
|
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.6)] transition-transform duration-300 ${
|
|
isActive ? 'scale-125 z-30 ring-4 ring-primary/40' : 'scale-100 group-hover/pin:scale-110'
|
|
} ${
|
|
isHQ || isBranch
|
|
? 'w-7 h-7 bg-primary text-[#050B14]'
|
|
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
|
|
}`}
|
|
>
|
|
{(isHQ || isBranch) && <MapPin className="w-4 h-4" />}
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Global Tooltip */}
|
|
<AnimatePresence>
|
|
{activeLocation && (
|
|
<motion.div
|
|
key="tooltip"
|
|
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="absolute z-[9999] pointer-events-auto"
|
|
style={{
|
|
left: `${activeLocation.x}%`,
|
|
top: `${activeLocation.y}%`,
|
|
transform: activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
|
}}
|
|
onMouseEnter={() => handleMouseEnter(activeLocation)}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl min-w-[200px] w-max max-w-[300px] text-white overflow-hidden">
|
|
|
|
{activeLocation.featuredImage && (
|
|
<div className="relative w-full h-32 bg-neutral-900">
|
|
<Image
|
|
src={activeLocation.featuredImage}
|
|
alt={activeLocation.name}
|
|
fill
|
|
className="object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-[#050B14] to-transparent opacity-90" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="p-4">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<MapPin className="w-3.5 h-3.5 text-primary" />
|
|
<span className="text-xs font-bold text-primary uppercase tracking-wider">
|
|
{t(`types.${activeLocation.type === 'minor_node' ? 'project' : activeLocation.type}`)}
|
|
</span>
|
|
</div>
|
|
<div className="font-heading font-bold text-lg leading-tight mb-2">
|
|
{activeLocation.name}
|
|
</div>
|
|
{activeLocation.type !== 'minor_node' ? (
|
|
<div className="text-white/60 text-sm line-clamp-3 mb-3">
|
|
{activeLocation.description}
|
|
</div>
|
|
) : (
|
|
<div className="text-white/60 text-xs mb-3">
|
|
{t(`descriptions.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}
|
|
</div>
|
|
)}
|
|
|
|
{(activeLocation.details || activeLocation.type === 'minor_node') && (
|
|
<ul className="mb-3 space-y-1.5 border-t border-white/10 pt-3">
|
|
{activeLocation.type === 'minor_node' ? (
|
|
<>
|
|
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
|
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
|
<span>{t(`features.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}</span>
|
|
</li>
|
|
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
|
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
|
<span>{t('features.method')}</span>
|
|
</li>
|
|
</>
|
|
) : (
|
|
activeLocation.details?.map((detail, i) => (
|
|
<li key={i} className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
|
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
|
<span>{detail}</span>
|
|
</li>
|
|
))
|
|
)}
|
|
</ul>
|
|
)}
|
|
|
|
{activeLocation.href && (activeLocation.type === 'hq' || activeLocation.type === 'branch') && (
|
|
<Link
|
|
href={activeLocation.href.startsWith('/') ? `/${locale}${activeLocation.href}` : activeLocation.href}
|
|
className="inline-flex items-center text-xs font-bold text-primary hover:text-white transition-colors mt-2"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{t('learnMore')}
|
|
<ArrowUpRight className="w-3.5 h-3.5 ml-1" />
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|