713 lines
25 KiB
TypeScript
713 lines
25 KiB
TypeScript
import { useState, useEffect, useRef, forwardRef } from "react";
|
|
import {
|
|
Star,
|
|
Menu,
|
|
X,
|
|
ShoppingBag,
|
|
ChevronDown,
|
|
Globe,
|
|
} from "lucide-react";
|
|
import { motion, AnimatePresence } from "motion/react";
|
|
import { ImageWithFallback } from "./figma/ImageWithFallback";
|
|
import { Button } from "./ui/button";
|
|
import { CitySubmenu } from "./CitySubmenu";
|
|
import imgRectangle4429 from "figma:asset/43f3bc1f9c8cc5b8f60f3f6be0bc1ad29eded0d7.png";
|
|
import cityCardsLogo from "figma:asset/e961451f70697dd054c4240fc1dcad81e08ce31e.png";
|
|
|
|
// City list data for right sidebar
|
|
const cityList = [
|
|
{
|
|
id: 1,
|
|
name: "Sydney",
|
|
attractions: 65,
|
|
image:
|
|
"https://images.unsplash.com/photo-1695018228065-2e0026c654af?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzeWRuZXklMjBoYXJib3IlMjBicmlkZ2UlMjBvcGVyYSUyMGhvdXNlfGVufDF8fHx8MTc1NjEyNDA2NXww&ixlib=rb-4.1.0&q=80&w=1080",
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "Melbourne",
|
|
attractions: 48,
|
|
image:
|
|
"https://images.unsplash.com/photo-1679731980101-503d93bbec27?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBjb2ZmZWUlMjBsYW5ld2F5c3xlbnwxfHx8fDE3NTYxMjQwNzN8MA&ixlib=rb-4.1.0&q=80&w=1080",
|
|
},
|
|
{
|
|
id: 3,
|
|
name: "Brisbane",
|
|
attractions: 32,
|
|
image:
|
|
"https://images.unsplash.com/photo-1548661625-a30d197ce439?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxicmlzYmFuZSUyMHJpdmVyJTIwY2l0eSUyMHNreWxpbmV8ZW58MXx8fHwxNzU2MTI0MDc3fDA&ixlib=rb-4.1.0&q=80&w=1080",
|
|
},
|
|
];
|
|
|
|
// Interface for dropdown items
|
|
interface DropdownItem {
|
|
id: string;
|
|
label: string;
|
|
badge?: string;
|
|
icon?: React.ReactNode;
|
|
action?: () => void;
|
|
}
|
|
|
|
// Forward ref for dropdown component
|
|
const Dropdown = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
isOpen: boolean;
|
|
onToggle: () => void;
|
|
items: DropdownItem[];
|
|
title: string;
|
|
trigger: React.ReactNode;
|
|
}
|
|
>(({ isOpen, onToggle, items, title, trigger }, ref) => {
|
|
return (
|
|
<div ref={ref} className="relative">
|
|
<div onClick={onToggle}>{trigger}</div>
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<motion.div
|
|
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.15,
|
|
ease: [0.25, 0.1, 0.25, 1],
|
|
}}
|
|
className="absolute right-0 mt-2 w-64 bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white/20 py-2 z-50"
|
|
>
|
|
<div className="px-4 py-2 border-b border-gray-200/50">
|
|
<h3 className="font-poppins font-medium text-gray-900 text-sm">
|
|
{title}
|
|
</h3>
|
|
</div>
|
|
{items.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
onClick={item.action}
|
|
className="px-4 py-3 hover:bg-gray-50/50 cursor-pointer transition-colors duration-150 flex items-center justify-between"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{item.icon}
|
|
<span className="font-poppins text-gray-700 text-sm font-normal">
|
|
{item.label}
|
|
</span>
|
|
</div>
|
|
{item.badge && (
|
|
<span className="bg-primary/10 text-primary text-xs px-2 py-1 rounded-full font-medium">
|
|
{item.badge}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
Dropdown.displayName = "Dropdown";
|
|
|
|
interface HeroSectionProps {
|
|
onSignInClick?: () => void;
|
|
onPassesClick?: () => void;
|
|
onMelbourneClick?: () => void;
|
|
onAttractionsClick?: () => void;
|
|
onHomeClick?: () => void;
|
|
onBlogsClick?: () => void;
|
|
onHowItWorksClick?: () => void;
|
|
currentPage?: string;
|
|
}
|
|
|
|
export function HeroSection({
|
|
onSignInClick,
|
|
onPassesClick,
|
|
onMelbourneClick,
|
|
onAttractionsClick,
|
|
onHomeClick,
|
|
onBlogsClick,
|
|
onHowItWorksClick,
|
|
currentPage,
|
|
}: HeroSectionProps) {
|
|
const [hoveredCity, setHoveredCity] = useState<number | null>(
|
|
null,
|
|
);
|
|
const [isScrolled, setIsScrolled] = useState(false);
|
|
const [activeLanguageDropdown, setActiveLanguageDropdown] =
|
|
useState(false);
|
|
const [activeCartDropdown, setActiveCartDropdown] =
|
|
useState(false);
|
|
const [scrollY, setScrollY] = useState(0);
|
|
|
|
// Refs for dropdowns and parallax
|
|
const languageRef = useRef<HTMLDivElement>(null);
|
|
const cartRef = useRef<HTMLDivElement>(null);
|
|
const heroRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Sample cart data
|
|
const [cartItems] = useState([
|
|
{
|
|
id: "1",
|
|
name: "Melbourne City Card",
|
|
price: "$49",
|
|
quantity: 1,
|
|
},
|
|
{
|
|
id: "2",
|
|
name: "Sydney City Card",
|
|
price: "$59",
|
|
quantity: 1,
|
|
},
|
|
]);
|
|
|
|
const cartTotal = cartItems.reduce(
|
|
(sum, item) =>
|
|
sum + parseFloat(item.price.replace("$", "")),
|
|
0,
|
|
);
|
|
|
|
// Language options
|
|
const languages = [
|
|
{
|
|
id: "en",
|
|
label: "English",
|
|
action: () => console.log("English selected"),
|
|
},
|
|
{
|
|
id: "es",
|
|
label: "Español",
|
|
action: () => console.log("Spanish selected"),
|
|
},
|
|
{
|
|
id: "fr",
|
|
label: "Français",
|
|
action: () => console.log("French selected"),
|
|
},
|
|
{
|
|
id: "de",
|
|
label: "Deutsch",
|
|
action: () => console.log("German selected"),
|
|
},
|
|
];
|
|
|
|
// Navigation handlers
|
|
const handleNavClick = (action: string) => {
|
|
switch (action) {
|
|
case "about":
|
|
console.log("Navigate to About Us");
|
|
break;
|
|
case "products":
|
|
console.log("Navigate to Cities");
|
|
break;
|
|
|
|
case "attractions":
|
|
if (onAttractionsClick) {
|
|
onAttractionsClick();
|
|
}
|
|
break;
|
|
case "card":
|
|
if (onPassesClick) onPassesClick();
|
|
break;
|
|
case "offer":
|
|
console.log("Navigate to Deals");
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
|
|
const isNavItemActive = (action: string) => {
|
|
if (action === "card" && currentPage === "passes")
|
|
return true;
|
|
if (action === "about" && currentPage === "home")
|
|
return true;
|
|
if (action === "attractions" && currentPage === "attractions")
|
|
return true;
|
|
|
|
return false;
|
|
};
|
|
|
|
// Handle scroll effects with parallax (throttled for performance)
|
|
useEffect(() => {
|
|
let ticking = false;
|
|
|
|
const handleScroll = () => {
|
|
if (!ticking) {
|
|
requestAnimationFrame(() => {
|
|
const currentScrollY = window.scrollY;
|
|
setIsScrolled(currentScrollY > 20);
|
|
setScrollY(currentScrollY);
|
|
ticking = false;
|
|
});
|
|
ticking = true;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("scroll", handleScroll, {
|
|
passive: true,
|
|
});
|
|
return () =>
|
|
window.removeEventListener("scroll", handleScroll);
|
|
}, []);
|
|
|
|
// Calculate parallax values
|
|
const scrollProgress = Math.min(
|
|
scrollY / window.innerHeight,
|
|
1,
|
|
);
|
|
const backgroundScale = 1.2 - scrollProgress * 0.2; // Scale from 1.2 to 1
|
|
const backgroundY = scrollY * 0.5; // Parallax movement
|
|
const borderOpacity = 1 - scrollProgress * 0.3; // Border fade effect
|
|
|
|
// Close dropdowns when clicking outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
languageRef.current &&
|
|
!languageRef.current.contains(event.target as Node)
|
|
) {
|
|
setActiveLanguageDropdown(false);
|
|
}
|
|
if (
|
|
cartRef.current &&
|
|
!cartRef.current.contains(event.target as Node)
|
|
) {
|
|
setActiveCartDropdown(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () =>
|
|
document.removeEventListener(
|
|
"mousedown",
|
|
handleClickOutside,
|
|
);
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={heroRef}
|
|
className="bg-[#ffffff] relative min-h-screen overflow-hidden"
|
|
data-name="MacBook Pro 14' - 2"
|
|
>
|
|
{/* Background Image with Parallax */}
|
|
<motion.div
|
|
className="absolute bg-[position:50%_50%] bg-size-cover h-full left-0 top-0 w-full origin-center"
|
|
style={{
|
|
backgroundImage: `url('${imgRectangle4429}')`,
|
|
scale: backgroundScale,
|
|
y: backgroundY,
|
|
willChange: "transform",
|
|
}}
|
|
/>
|
|
|
|
{/* Black Gradient Overlay */}
|
|
<div className="absolute h-full left-0 top-0 w-full bg-gradient-to-b from-black/0 to-black/55" />
|
|
|
|
{/* Main Content Frame with Border Animation */}
|
|
<motion.div
|
|
className="absolute h-[840.118px] rounded-[56px] top-[100.11px] translate-x-[-50%] w-[1449.56px] max-w-[95vw]"
|
|
style={{ left: "calc(50% + 7.086px)" }}
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{
|
|
duration: 1.2,
|
|
ease: [0.25, 0.1, 0.25, 1],
|
|
}}
|
|
>
|
|
<motion.div
|
|
aria-hidden="true"
|
|
className="absolute border-2 border-[#ffffff] border-solid inset-0 pointer-events-none rounded-[56px]"
|
|
style={{
|
|
opacity: borderOpacity,
|
|
willChange: "opacity",
|
|
}}
|
|
initial={{
|
|
clipPath: "polygon(0 0, 0 0, 0 100%, 0 100%)", // Start from top-left
|
|
}}
|
|
animate={{
|
|
clipPath: "polygon(0 0, 100% 0, 100% 100%, 0 100%)", // Expand to full
|
|
}}
|
|
transition={{
|
|
duration: 1.2,
|
|
ease: [0.25, 0.1, 0.25, 1],
|
|
delay: 0.3,
|
|
}}
|
|
/>
|
|
</motion.div>
|
|
|
|
{/* Glassmorphism Navigation Bar with Menu */}
|
|
<motion.nav
|
|
className="absolute top-[17.79px] left-[38.31px] right-[38.31px] z-40"
|
|
initial={{ y: -100, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
transition={{
|
|
duration: 0.6,
|
|
ease: [0.25, 0.1, 0.25, 1],
|
|
delay: 0.2,
|
|
}}
|
|
>
|
|
<motion.div
|
|
className="backdrop-blur-[10px] backdrop-filter bg-[rgba(255,255,255,0.2)] h-[66.549px] rounded-[33.275px] w-full px-8 py-4 shadow-lg shadow-black/5 border border-white/20"
|
|
animate={{
|
|
scale: isScrolled ? 0.98 : 1,
|
|
y: isScrolled ? 2 : 0,
|
|
boxShadow: isScrolled
|
|
? "0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 10px 10px -5px rgba(0, 0, 0, 0.04)"
|
|
: "0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.03)",
|
|
}}
|
|
transition={{
|
|
duration: 0.3,
|
|
ease: [0.25, 0.1, 0.25, 1],
|
|
}}
|
|
>
|
|
{/* Navigation Content */}
|
|
<div className="flex items-center justify-between w-full h-full">
|
|
{/* CityCards Logo Section */}
|
|
<motion.div
|
|
className="flex items-center cursor-pointer flex-shrink-0"
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={() => onHomeClick?.()}
|
|
>
|
|
<img
|
|
src={cityCardsLogo}
|
|
alt="CityCards"
|
|
className="h-10 w-auto"
|
|
/>
|
|
</motion.div>
|
|
|
|
{/* Navigation Links */}
|
|
<div className="hidden md:flex items-center gap-[30px] lg:gap-[51px]">
|
|
{[
|
|
{ label: "About Us", action: "about" },
|
|
{ label: "Cities", action: "products" },
|
|
{ label: "Attractions", action: "attractions" },
|
|
{ label: "Your Card", action: "card" },
|
|
{ label: "Deals", action: "offer" },
|
|
].map((item) => (
|
|
<motion.button
|
|
key={item.action}
|
|
onClick={() => handleNavClick(item.action)}
|
|
className={`relative px-0 py-2 text-base font-medium transition-all duration-200 whitespace-nowrap group capitalize ${
|
|
isNavItemActive(item.action)
|
|
? "text-white"
|
|
: "text-white/80 hover:text-white"
|
|
}`}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
{item.label}
|
|
|
|
{/* Active indicator */}
|
|
<motion.div
|
|
className="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-primary to-secondary rounded-full"
|
|
initial={{ width: 0 }}
|
|
animate={{
|
|
width: isNavItemActive(item.action)
|
|
? "100%"
|
|
: 0,
|
|
opacity: isNavItemActive(item.action)
|
|
? 1
|
|
: 0,
|
|
}}
|
|
whileHover={{
|
|
width: "100%",
|
|
opacity: 1,
|
|
}}
|
|
transition={{ duration: 0.2 }}
|
|
/>
|
|
|
|
{/* Hover background */}
|
|
<motion.div
|
|
className="absolute inset-0 bg-white/10 rounded-lg -z-10"
|
|
initial={{ scale: 0, opacity: 0 }}
|
|
whileHover={{ scale: 1, opacity: 0.5 }}
|
|
transition={{ duration: 0.2 }}
|
|
/>
|
|
</motion.button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Right Section */}
|
|
<div className="flex items-center gap-5">
|
|
{/* Language Dropdown */}
|
|
<div className="hidden lg:block">
|
|
<Dropdown
|
|
ref={languageRef}
|
|
isOpen={activeLanguageDropdown}
|
|
onToggle={() =>
|
|
setActiveLanguageDropdown(
|
|
!activeLanguageDropdown,
|
|
)
|
|
}
|
|
items={languages}
|
|
title="Select Language"
|
|
trigger={
|
|
<div className="flex items-center space-x-2 text-white/80 hover:text-white px-0 py-2 text-base font-medium transition-colors duration-200 cursor-pointer rounded-lg hover:bg-white/10 uppercase">
|
|
<Globe className="w-5 h-5" />
|
|
<span>ENG</span>
|
|
<ChevronDown
|
|
className={`w-4 h-4 transition-transform duration-200 ${activeLanguageDropdown ? "rotate-180" : ""}`}
|
|
/>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Shopping Cart */}
|
|
<div className="hidden lg:block">
|
|
<Dropdown
|
|
ref={cartRef}
|
|
isOpen={activeCartDropdown}
|
|
onToggle={() =>
|
|
setActiveCartDropdown(!activeCartDropdown)
|
|
}
|
|
items={[
|
|
...cartItems.map((item) => ({
|
|
id: item.id,
|
|
label: `${item.name} - ${item.price}`,
|
|
badge: `${item.quantity}x`,
|
|
})),
|
|
{
|
|
id: "total",
|
|
label: `Total: $${cartTotal.toFixed(2)}`,
|
|
icon: <ShoppingBag className="w-4 h-4" />,
|
|
},
|
|
{
|
|
id: "checkout",
|
|
label: "Proceed to Checkout",
|
|
action: () => console.log("Checkout"),
|
|
},
|
|
]}
|
|
title="Shopping Cart"
|
|
trigger={
|
|
<div className="relative text-white/80 hover:text-white p-0 transition-colors duration-200 rounded-lg hover:bg-white/10 cursor-pointer">
|
|
<ShoppingBag className="w-7 h-7" />
|
|
<motion.div
|
|
className="absolute -top-2 -right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center"
|
|
animate={{ scale: [1, 1.1, 1] }}
|
|
transition={{
|
|
duration: 2,
|
|
repeat: Infinity,
|
|
ease: "easeInOut",
|
|
}}
|
|
>
|
|
<span className="text-xs text-primary-foreground font-bold">
|
|
{cartItems.length}
|
|
</span>
|
|
</motion.div>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Mobile Menu Button */}
|
|
<div className="md:hidden">
|
|
<button
|
|
onClick={() => console.log('Mobile menu toggled')}
|
|
className="text-white/80 hover:text-white p-2"
|
|
>
|
|
<Menu className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* CTA Button */}
|
|
<Button
|
|
onClick={onSignInClick}
|
|
withShine={true}
|
|
className="hidden md:block h-[52px] min-w-[120px] lg:min-w-[180px] px-4 lg:px-6 py-4 rounded-full text-white font-medium bg-gradient-to-r from-primary to-secondary text-sm lg:text-base"
|
|
>
|
|
GET A CITY CARD
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</motion.nav>
|
|
|
|
{/* City Submenu */}
|
|
<CitySubmenu
|
|
onClose={() => {}} // Empty function since it's always shown on homepage
|
|
currentPage={currentPage}
|
|
onHomeClick={onHomeClick}
|
|
onMelbourneClick={onMelbourneClick}
|
|
onAttractionsClick={onAttractionsClick}
|
|
onPassesClick={onPassesClick}
|
|
onBlogsClick={onBlogsClick}
|
|
onHowItWorksClick={onHowItWorksClick}
|
|
/>
|
|
|
|
{/* Main Content Container */}
|
|
<div className="relative z-10 min-h-screen flex items-center">
|
|
<div className="container mx-auto px-4 sm:px-6 lg:px-12">
|
|
<div className="flex justify-between items-center w-full">
|
|
{/* Left Content */}
|
|
<div className="max-w-4xl xl:max-w-5xl 2xl:max-w-6xl flex-1">
|
|
{/* Main Headline */}
|
|
<motion.div
|
|
className="absolute content-stretch flex flex-col gap-7 items-start justify-start leading-[0] left-[91.29px] not-italic top-[582px] w-[849.639px] max-w-[70vw]"
|
|
initial={{ opacity: 0, y: 30 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{
|
|
duration: 0.8,
|
|
delay: 0.5,
|
|
ease: [0.25, 0.1, 0.25, 1],
|
|
}}
|
|
style={{
|
|
y: scrollY * -0.1, // Subtle counter-parallax for content
|
|
willChange: "transform",
|
|
}}
|
|
>
|
|
<div className="font-merchant leading-[64px] relative shrink-0 text-[#ffffff] text-[48px] sm:text-[52px] md:text-[60px] lg:text-[64px] w-[771.315px] max-w-full">
|
|
<p className="mb-0">
|
|
<span className="font-light">Melbourne</span>{' '}
|
|
<span className="font-bold italic bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">City</span>{' '}
|
|
<span className="font-normal">Card.</span>
|
|
</p>
|
|
<p>
|
|
<span className="font-light">See</span>{' '}
|
|
<span className="font-semibold text-emphasis">More</span>{', '}
|
|
<span className="font-light">Spend</span>{' '}
|
|
<span className="font-bold">Less</span>{'.'}
|
|
</p>
|
|
</div>
|
|
<div
|
|
className="font-poppins font-normal min-w-full relative shrink-0 text-[22px] sm:text-[18px] md:text-[20px] lg:text-[22px] text-[rgba(255,255,255,0.74)]"
|
|
style={{ width: "min-content" }}
|
|
>
|
|
<p className="leading-[40px]">
|
|
Instant QR access to 40+ attractions,
|
|
exclusive perks, and savings up to 30%
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* CTA Button */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 30 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{
|
|
duration: 0.8,
|
|
delay: 0.7,
|
|
ease: [0.25, 0.1, 0.25, 1],
|
|
}}
|
|
className="absolute left-[91.29px] top-[834.87px]"
|
|
style={{
|
|
y: scrollY * -0.05, // Minimal counter-parallax for button
|
|
willChange: "transform",
|
|
}}
|
|
>
|
|
<Button
|
|
withShine={true}
|
|
className="font-poppins font-semibold h-[61.805px] min-w-[262.349px] px-8 py-4 rounded-full text-white bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90 transition-all duration-200 text-[20px]"
|
|
>
|
|
Explore Melbourne
|
|
</Button>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Right Side City List */}
|
|
<div className="hidden xl:block absolute right-[100px] top-[640px]">
|
|
<motion.div
|
|
className="space-y-6"
|
|
initial={{ opacity: 0, x: 30 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{
|
|
duration: 0.8,
|
|
delay: 0.6,
|
|
ease: [0.25, 0.1, 0.25, 1],
|
|
}}
|
|
style={{
|
|
y: scrollY * -0.08, // Subtle counter-parallax for city list
|
|
willChange: "transform",
|
|
}}
|
|
>
|
|
{cityList.map((city, index) => (
|
|
<motion.div
|
|
key={city.id}
|
|
className="relative"
|
|
onMouseEnter={() => setHoveredCity(city.id)}
|
|
onMouseLeave={() => setHoveredCity(null)}
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{
|
|
duration: 0.6,
|
|
delay: 0.6 + index * 0.1,
|
|
}}
|
|
>
|
|
{/* City Name */}
|
|
<div className="flex items-center cursor-pointer group">
|
|
<span className="font-poppins text-white/90 group-hover:text-white transition-colors duration-300 text-lg font-semibold">
|
|
— {city.name}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Hover Card */}
|
|
<AnimatePresence>
|
|
{hoveredCity === city.id && (
|
|
<motion.div
|
|
className="absolute right-full top-0 mr-6 z-30"
|
|
initial={{
|
|
opacity: 0,
|
|
scale: 0.9,
|
|
x: 10,
|
|
}}
|
|
animate={{
|
|
opacity: 1,
|
|
scale: 1,
|
|
x: 0,
|
|
}}
|
|
exit={{
|
|
opacity: 0,
|
|
scale: 0.9,
|
|
x: 10,
|
|
}}
|
|
transition={{
|
|
duration: 0.2,
|
|
ease: [0.16, 1, 0.3, 1],
|
|
}}
|
|
>
|
|
<div className="bg-white/95 backdrop-blur-md rounded-2xl p-4 shadow-2xl border border-white/20 min-w-64">
|
|
{/* Image */}
|
|
<div className="relative h-32 mb-3 rounded-xl overflow-hidden">
|
|
<ImageWithFallback
|
|
src={city.image}
|
|
alt={city.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="space-y-2">
|
|
<h4 className="text-lg font-semibold text-gray-900">
|
|
{city.name}
|
|
</h4>
|
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
|
<Star className="w-4 h-4 text-primary fill-current" />
|
|
<span className="font-medium">
|
|
{city.attractions} attractions
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-gray-500">
|
|
Discover the best experiences
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Arrow Pointer */}
|
|
<div className="absolute right-0 top-1/2 transform -translate-y-1/2 translate-x-2">
|
|
<div className="w-0 h-0 border-t-8 border-b-8 border-l-8 border-transparent border-l-white/95" />
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
))}
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |