first commit

This commit is contained in:
priyanshuvish
2025-10-09 19:09:36 +05:30
parent 333b9ea76a
commit 19c07db67e
164 changed files with 31671 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Node modules
node_modules/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Environment files
.env
.env.local
.env.*.local
# Build output
dist/
build/
.next/
out/
# IDE files
.vscode/
.idea/
*.swp
# Mac / Linux / Windows system files
.DS_Store
Thumbs.db
# Temporary
*.tmp

15
index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CityCards Landing page</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4247
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

64
package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "CityCards Landing page",
"version": "0.1.0",
"private": true,
"dependencies": {
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-aspect-ratio": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-context-menu": "^2.2.6",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-menubar": "^1.1.6",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/postcss": "^4.1.14",
"class-variance-authority": "^0.7.1",
"clsx": "*",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.487.0",
"motion": "*",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.55.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"sonner": "^2.0.3",
"tailwind-merge": "*",
"tailwindcss": "^4.1.14",
"vaul": "^1.1.2"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react-swc": "^3.10.2",
"vite": "6.3.5"
},
"scripts": {
"dev": "vite",
"build": "vite build"
}
}

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
}
}

249
src/App.tsx Normal file
View File

@@ -0,0 +1,249 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { QrCode, X, Smartphone } from 'lucide-react';
import cityCardsLogo from 'figma:asset/4d07c3035c8f965d162e4e0d20cb3910fd5fa6fe.png';
import { HomePage } from './components/HomePage';
import { SignInPage } from './components/SignInPage';
import { PassesPage } from './components/PassesPage';
import {
heroVariants,
sectionVariants,
cardVariants,
staggerContainer,
fastStaggerContainer,
headingVariants,
textVariants,
ctaVariants,
iconVariants,
buttonHoverVariants,
cardHoverVariants,
pageTransition,
easeOutQuart,
easeOutExpo,
easeOutCubic
} from './utils/animations';
function App() {
const [selectedCity, setSelectedCity] = useState<string>('');
const [currentPage, setCurrentPage] = useState<'home' | 'signin' | 'passes'>('home');
const [isMobile, setIsMobile] = useState(false);
const [showQRCard, setShowQRCard] = useState(false);
// Detect mobile for optimized animations
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// Generate a realistic QR code pattern
const generateQRPattern = () => {
const size = 27; // Even larger QR code size for the bigger widget
const pattern = [];
for (let i = 0; i < size * size; i++) {
const row = Math.floor(i / size);
const col = i % size;
// Corner squares (7x7 for larger QR)
const isCornerSquare =
(row < 7 && col < 7) || // Top-left
(row < 7 && col >= 20) || // Top-right
(row >= 20 && col < 7); // Bottom-left
// Finder patterns within corner squares
const isFinderPattern = isCornerSquare && (
(row === 0 || row === 6 || col === 0 || col === 6) ||
(row >= 2 && row <= 4 && col >= 2 && col <= 4)
);
// Timing patterns
const isTimingPattern = (row === 6 && col >= 8 && col <= 18) || (col === 6 && row >= 8 && row <= 18);
// Random data pattern for other areas
const isDataPattern = !isCornerSquare && !isTimingPattern && Math.random() > 0.38;
pattern.push(isFinderPattern || isTimingPattern || isDataPattern);
}
return pattern;
};
const qrPattern = generateQRPattern();
const renderPage = () => {
switch (currentPage) {
case 'signin':
return (
<motion.div key="signin" {...pageTransition}>
<SignInPage onBackClick={() => setCurrentPage('home')} />
</motion.div>
);
case 'passes':
return (
<motion.div key="passes" {...pageTransition}>
<PassesPage onBackClick={() => setCurrentPage('home')} />
</motion.div>
);
case 'home':
default:
return (
<motion.div key="home" {...pageTransition}>
<HomePage
isMobile={isMobile}
onSignInClick={() => setCurrentPage('signin')}
onPassesClick={() => setCurrentPage('passes')}
currentPage={currentPage}
/>
</motion.div>
);
}
};
return (
<div className="min-h-screen bg-background relative">
{/* Global Animation Context Provider */}
<motion.div
className="relative z-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: easeOutCubic }}
>
<AnimatePresence mode="wait">
{renderPage()}
</AnimatePresence>
</motion.div>
{/* Sticky QR Code Widget */}
<div className="fixed bottom-6 right-6 z-50">
<AnimatePresence>
{!showQRCard && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: easeOutCubic }}
onClick={() => setShowQRCard(true)}
className="w-36 h-36 bg-black text-white shadow-2xl flex items-center justify-center border border-gray-800 rounded-[5px]"
aria-label="Show QR Code"
>
<div className="flex flex-col items-center gap-3">
<QrCode className="w-14 h-14" />
<div className="font-poppins text-sm font-medium opacity-80 text-center leading-tight">Scan for App</div>
</div>
{/* Static Border Glow */}
<div className="absolute inset-0 border border-warm-coral/20 opacity-50 rounded-[5px]"></div>
</motion.button>
)}
</AnimatePresence>
<AnimatePresence>
{showQRCard && (
<motion.div
initial={{ scale: 0, opacity: 0, x: 20, y: 20 }}
animate={{ scale: 1, opacity: 1, x: 0, y: 0 }}
exit={{ scale: 0, opacity: 0, x: 20, y: 20 }}
transition={{ duration: 0.4, ease: easeOutExpo }}
className="bg-black p-14 shadow-2xl border border-gray-800 backdrop-blur-sm relative rounded-[5px]"
>
{/* Close Button */}
<button
onClick={() => setShowQRCard(false)}
className="absolute top-6 right-6 w-12 h-12 bg-gray-700 hover:bg-gray-600 flex items-center justify-center transition-colors duration-200 rounded-[5px]"
aria-label="Close QR Code"
>
<X className="w-6 h-6 text-white" />
</button>
{/* QR Code - Much Larger */}
<div className="w-120 h-120 bg-white p-10 mb-10 rounded-[5px]">
<div className="w-full h-full bg-white relative overflow-hidden rounded-[3px]">
{/* QR Code Pattern */}
<div className="grid grid-cols-27 gap-0 w-full h-full p-4">
{qrPattern.map((filled, index) => (
<motion.div
key={index}
className={`aspect-square ${filled ? 'bg-black' : 'bg-transparent'} rounded-[0.5px]`}
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.01,
delay: index * 0.001,
ease: "easeOut"
}}
/>
))}
</div>
{/* Center Logo - Much Larger */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-20 h-20 bg-white shadow-2xl border-4 border-gray-200 flex items-center justify-center rounded-[5px]">
<img
src={cityCardsLogo}
alt="CityCards"
className="w-16 h-16 object-contain"
/>
</div>
</div>
</div>
</div>
{/* Text Content - Larger */}
<div className="text-center text-white">
<h3 className="font-poppins text-2xl md:text-3xl lg:text-4xl leading-tight font-semibold text-white mb-5">Scan QR for app</h3>
<p className="font-poppins text-xl leading-relaxed font-normal text-gray-300 max-w-lg mx-auto">
Scan to get the CityCards mobile app
</p>
</div>
{/* Static Border */}
<div className="absolute inset-0 border border-warm-coral/20 pointer-events-none rounded-[5px]"></div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Preload animation optimization */}
<motion.div
className="fixed inset-0 bg-background z-50 pointer-events-none"
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
onAnimationComplete={() => {
// Remove from DOM after animation
const element = document.querySelector('[data-preload]');
element?.remove();
}}
data-preload
/>
</div>
);
}
// Export animation variants for use in components
export {
heroVariants,
sectionVariants,
cardVariants,
staggerContainer,
fastStaggerContainer,
headingVariants,
textVariants,
ctaVariants,
iconVariants,
buttonHoverVariants,
cardHoverVariants,
easeOutQuart,
easeOutExpo,
easeOutCubic
};
export default App;

3
src/Attributions.md Normal file
View File

@@ -0,0 +1,3 @@
This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

View File

@@ -0,0 +1,79 @@
import { ImageWithFallback } from './figma/ImageWithFallback';
import { Button } from './ui/button';
interface ArticleCard {
id: string;
title: string;
image: string;
}
const articles: ArticleCard[] = [
{
id: '1',
title: 'Choosing a Travel Backpack: A Comprehensive Guide!',
image: 'https://images.unsplash.com/photo-1553062407-98eeb64c6a62?w=500&h=400&fit=crop&q=80'
},
{
id: '2',
title: 'Bucket List Trip - 52 Places To Visit In Your Life...',
image: 'https://images.unsplash.com/photo-1488646953014-85cb44e25828?w=500&h=400&fit=crop&q=80'
},
{
id: '3',
title: 'The FREE Way to Travel: No Money, No Problems!',
image: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=500&h=400&fit=crop&q=80'
}
];
export function ArticlesSection() {
return (
<section className="py-20 bg-white">
<div className="container mx-auto px-4">
{/* Header */}
<div className="text-center mb-16">
<h2 className="text-5xl font-bold text-gray-900 mb-6">
Our latest articles about travel
</h2>
<p className="text-xl text-gray-400 font-light">
Know the latest articles about travel
</p>
</div>
{/* Articles Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-7xl mx-auto mb-16">
{articles.map((article) => (
<div
key={article.id}
className="relative group cursor-pointer overflow-hidden rounded-3xl h-[400px] shadow-md hover:shadow-2xl transition-all duration-300 hover:-translate-y-2"
>
<ImageWithFallback
src={article.image}
alt={article.title}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
{/* Article title */}
<div className="absolute bottom-0 left-0 right-0 p-8">
<h3 className="text-white text-xl font-semibold leading-tight drop-shadow-lg">
{article.title}
</h3>
</div>
</div>
))}
</div>
{/* CTA Button */}
<div className="text-center">
<Button
className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white px-10 rounded-full font-semibold text-lg shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 h-14"
size="lg"
>
Explore more
</Button>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,344 @@
import { useState } from 'react';
import { ChevronLeft, ChevronRight, Clock, Users, Star, Zap, CheckCircle, MapPin, Volume2, Camera } from 'lucide-react';
import { ImageWithFallback } from './figma/ImageWithFallback';
import { motion } from 'motion/react';
const attractions = [
{
id: 1,
name: "Sydney Opera House",
city: "Sydney",
country: "Australia",
image: "https://images.unsplash.com/photo-1657622884558-cc7525f93638?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzeWRuZXklMjBvcGVyYSUyMGhvdXNlJTIwaGFyYm9yJTIwYnJpZGdlfGVufDF8fHx8MTc1NjExNDMwMHww&ixlib=rb-4.1.0&q=80&w=1080",
rating: 4.8,
reviews: "12,500+",
category: "Landmarks",
originalPrice: "$89",
includedValue: "$89",
perks: [
{ icon: Zap, label: "Skip-the-line", color: "text-green-600" },
{ icon: Volume2, label: "Audio guide", color: "text-blue-600" },
{ icon: Camera, label: "Photo spots", color: "text-purple-600" }
]
},
{
id: 2,
name: "Great Ocean Road",
city: "Melbourne",
country: "Australia",
image: "https://images.unsplash.com/photo-1557544780-585e99807b15?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxncmVhdCUyMG9jZWFuJTIwcm9hZCUyMHR3ZWx2ZSUyMGFwb3N0bGVzfGVufDF8fHx8MTc1NjExNDMwNHww&ixlib=rb-4.1.0&q=80&w=1080",
rating: 4.9,
reviews: "8,200+",
category: "Nature",
originalPrice: "$125",
includedValue: "$125",
perks: [
{ icon: Users, label: "Guided tour", color: "text-blue-600" },
{ icon: MapPin, label: "Transport", color: "text-green-600" },
{ icon: Camera, label: "Photo stops", color: "text-purple-600" }
]
},
{
id: 3,
name: "Lone Pine Koala Sanctuary",
city: "Brisbane",
country: "Australia",
image: "https://images.unsplash.com/photo-1625476038303-0d3022077d39?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxsb25lJTIwcGluZSUyMGtvYWxhJTIwc2FuY3R1YXJ5JTIwYnJpc2JhbmV8ZW58MXx8fHwxNzU2MTE0MzA3fDA&ixlib=rb-4.1.0&q=80&w=1080",
rating: 4.7,
reviews: "15,800+",
category: "Wildlife",
originalPrice: "$65",
includedValue: "$65",
perks: [
{ icon: Zap, label: "Skip-the-line", color: "text-green-600" },
{ icon: Users, label: "Animal encounters", color: "text-orange-600" },
{ icon: Camera, label: "Photo opportunities", color: "text-purple-600" }
]
},
{
id: 4,
name: "Kings Park",
city: "Perth",
country: "Australia",
image: "https://images.unsplash.com/photo-1667315682754-852d9e855207?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxraW5ncyUyMHBhcmslMjBwZXJ0aCUyMGJvdGFuaWNhbCUyMGdhcmRlbnxlbnwxfHx8fDE3NTYxMTQzMTJ8MA&ixlib=rb-4.1.0&q=80&w=1080",
rating: 4.6,
reviews: "9,400+",
category: "Parks",
originalPrice: "Free",
includedValue: "$35",
perks: [
{ icon: Users, label: "Walking tours", color: "text-blue-600" },
{ icon: Volume2, label: "Audio guide", color: "text-blue-600" },
{ icon: MapPin, label: "Trail maps", color: "text-green-600" }
]
},
{
id: 5,
name: "Barossa Valley",
city: "Adelaide",
country: "Australia",
image: "https://images.unsplash.com/photo-1578274821879-08e7f9050d83?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxiYXJvc3NhJTIwdmFsbGV5JTIwdmluZXlhcmQlMjB3aW5lcnl8ZW58MXx8fHwxNzU2MTE0MzE3fDA&ixlib=rb-4.1.0&q=80&w=1080",
rating: 4.8,
reviews: "6,700+",
category: "Wine Tours",
originalPrice: "$98",
includedValue: "$98",
perks: [
{ icon: Users, label: "Wine tastings", color: "text-purple-600" },
{ icon: MapPin, label: "Transport", color: "text-green-600" },
{ icon: Volume2, label: "Expert guide", color: "text-blue-600" }
]
}
];
const categories = ["All", "Landmarks", "Nature", "Wildlife", "Parks", "Wine Tours"];
export function BookAttractionSection() {
const [activeCategory, setActiveCategory] = useState("All");
const filteredAttractions = activeCategory === "All"
? attractions
: attractions.filter(attraction => attraction.category === activeCategory);
const AttractionCard = ({ attraction, index }: { attraction: typeof attractions[0], index: number }) => (
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
viewport={{ once: true }}
className="group cursor-pointer flex-shrink-0 w-[280px] md:w-auto md:flex-shrink h-96 flip-card-container"
>
{/* 3D Flip Container */}
<div className="flip-card-inner group-hover:[transform:rotateY(180deg)] relative w-full h-full">
{/* FRONT FACE */}
<div className="flip-card-face absolute inset-0 w-full h-full rounded-2xl overflow-hidden shadow-lg">
{/* Background Image */}
<ImageWithFallback
src={attraction.image}
alt={attraction.name}
className="w-full h-full object-cover"
/>
{/* Rating Badge */}
<div className="absolute top-4 right-4 bg-white/95 backdrop-blur-sm rounded-full px-3 py-1.5 flex items-center gap-1 shadow-lg z-10">
<div className="w-4 h-4 bg-yellow-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs"></span>
</div>
<span className="font-poppins text-sm font-medium text-foreground">{attraction.rating}</span>
</div>
{/* Front Content - Clean Title & Location */}
<div className="absolute bottom-0 left-0 right-0">
<div className="bg-black/50 p-6">
<h3 className="font-poppins text-lg md:text-xl leading-snug font-semibold text-white mb-1">{attraction.name}</h3>
<p className="font-poppins text-sm leading-relaxed font-normal text-white/90">
{attraction.city}, {attraction.country}
</p>
</div>
</div>
</div>
{/* BACK FACE */}
<div className="flip-card-face flip-card-back absolute inset-0 w-full h-full rounded-2xl overflow-hidden shadow-lg bg-gray-900">
{/* Back Content Container */}
<div className="relative w-full h-full p-6 flex flex-col justify-center text-white">
{/* Included Value Section */}
<div className="mb-4">
<div className="inline-flex items-center gap-2 bg-warm-coral text-white px-3 py-1.5 rounded-full text-sm font-medium mb-3">
<CheckCircle className="w-4 h-4" />
<span>Included Value</span>
</div>
<div className="text-2xl font-bold mb-1">{attraction.includedValue}</div>
<p className="text-white/80 text-sm">
{attraction.originalPrice === "Free"
? "Premium access included"
: "Save money with CityCard"}
</p>
</div>
{/* What's Included List */}
<div className="mb-4">
<h4 className="font-semibold text-sm mb-3">What's Included:</h4>
<div className="space-y-2">
{attraction.perks.slice(0, 3).map((perk, perkIndex) => (
<div key={perkIndex} className="flex items-center gap-3 text-white/90">
<div className="w-6 h-6 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center">
<perk.icon className="w-3 h-3 text-white" />
</div>
<span className="text-sm">{perk.label}</span>
</div>
))}
</div>
</div>
{/* Duration & Meta Info */}
<div className="mb-4">
<div className="flex items-center gap-4 text-white/80 text-sm">
<div className="flex items-center gap-1">
<Clock className="w-4 h-4" />
<span>2-3 hours</span>
</div>
<div className="flex items-center gap-1">
<Users className="w-4 h-4" />
<span>All ages</span>
</div>
</div>
</div>
{/* Footer Features */}
<div className="border-t border-white/20 pt-4">
<div className="flex items-center justify-between text-white/80 text-xs">
<div className="flex items-center gap-2">
<MapPin className="w-3 h-3" />
<span>Mobile ticket</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle className="w-3 h-3" />
<span>Instant confirmation</span>
</div>
</div>
</div>
{/* Decorative Elements */}
<div className="absolute top-4 right-4 w-16 h-16 bg-primary/20 rounded-full blur-xl"></div>
<div className="absolute bottom-4 left-4 w-12 h-12 bg-primary/15 rounded-full blur-lg"></div>
</div>
</div>
</div>
</motion.div>
);
return (
<section className="py-20 bg-gray-50 relative overflow-hidden">
<div className="container mx-auto px-4">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
className="text-center mb-16"
>
<div className="inline-flex items-center gap-2 bg-primary/10 px-4 py-2 rounded-full mb-6">
<div className="w-2 h-2 bg-primary rounded-full"></div>
<span className="font-poppins text-sm font-medium text-primary">
Must-See Destinations
</span>
</div>
<h2 className="font-poppins text-2xl md:text-3xl lg:text-4xl leading-tight text-foreground mb-4">
<span className="font-bold text-primary italic">
Top
</span>{' '}
<span className="font-normal">Attractions</span>
</h2>
<p className="font-poppins text-xl leading-relaxed font-normal text-gray-600 max-w-3xl mx-auto">
Discover Australia's most iconic attractions and hidden gems across Sydney, Melbourne, Brisbane, Perth, and Adelaide - all included with your CityCard
</p>
</motion.div>
{/* Category Tabs */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
viewport={{ once: true }}
className="flex flex-wrap justify-center gap-3 mb-12"
>
{categories.map((category, index) => (
<motion.button
key={category}
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
viewport={{ once: true }}
onClick={() => setActiveCategory(category)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className={`px-6 py-4 h-14 rounded-full font-medium transition-all duration-300 ${
activeCategory === category
? 'bg-warm-coral text-white shadow-xl shadow-warm-coral/25 ring-2 ring-warm-coral/20'
: 'bg-white/80 backdrop-blur-sm text-gray-700 hover:text-gray-900 hover:shadow-lg border border-gray-200/50 hover:border-warm-coral/20 hover:bg-white'
}`}
>
{category}
</motion.button>
))}
</motion.div>
{/* Mobile Horizontal Carousel */}
<div className="block md:hidden mb-8">
<div className="relative">
{/* Scroll Container */}
<div className="flex gap-6 overflow-x-auto scrollbar-hide pb-4 px-4 -mx-4">
{filteredAttractions.map((attraction, index) => (
<AttractionCard key={attraction.id} attraction={attraction} index={index} />
))}
</div>
{/* Scroll Indicators */}
<div className="flex justify-center mt-6 gap-2">
{Array.from({ length: Math.ceil(filteredAttractions.length / 2) }).map((_, index) => (
<div
key={index}
className="w-2 h-2 rounded-full bg-gray-300"
/>
))}
</div>
{/* Mobile Hint Text */}
<div className="text-center mt-4">
<p className="text-sm text-gray-500">
Swipe to explore more attractions
</p>
</div>
</div>
</div>
{/* Desktop Bento Grid */}
<div className="hidden md:block w-full">
{/* Top Row - 3 equal cards */}
<div className="grid grid-cols-3 gap-6">
{filteredAttractions.slice(0, 3).map((attraction, index) => (
<AttractionCard key={attraction.id} attraction={attraction} index={index} />
))}
</div>
{/* Consistent Vertical Spacing */}
<div className="h-6"></div>
{/* Bottom Row - 2 larger cards */}
<div className="grid grid-cols-2 gap-6">
{filteredAttractions.slice(3, 5).map((attraction, index) => (
<AttractionCard key={attraction.id} attraction={attraction} index={index + 3} />
))}
</div>
</div>
{/* Call to Action */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
viewport={{ once: true }}
className="text-center mt-12"
>
<motion.button
whileHover={{ scale: 1.05, boxShadow: "0 20px 40px rgba(249,95,98,0.3)" }}
whileTap={{ scale: 0.95 }}
className="relative bg-warm-coral text-white py-4 px-12 rounded-full text-lg shadow-xl transition-all duration-300 overflow-hidden group"
>
<span className="relative z-10">Get Your City Card</span>
{/* Shine animation */}
<div className="absolute inset-0 opacity-30">
<div className="h-full bg-white/30 animate-shine"></div>
</div>
</motion.button>
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,351 @@
import { useState } from 'react';
import { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { MapPin, Clock, Star, Users, Camera, Calendar, ChevronLeft } from 'lucide-react';
import { ImageWithFallback } from './figma/ImageWithFallback';
interface Attraction {
id: string;
name: string;
description: string;
image: string;
category: string;
duration: string;
rating: number;
reviews: number;
highlights: string[];
included: boolean;
}
const parisAttractions: Attraction[] = [
{
id: 'eiffel-tower',
name: 'Eiffel Tower',
description: 'Iconic iron lattice tower offering breathtaking panoramic views of Paris',
image: 'https://images.unsplash.com/photo-1511739001486-6bfe10ce785f?q=80&w=400&auto=format&fit=crop',
category: 'Landmark',
duration: '2-3 hours',
rating: 4.8,
reviews: 12584,
highlights: ['Skip-the-line access', 'Second floor included', 'Audio guide', 'City views'],
included: true
},
{
id: 'louvre',
name: 'Louvre Museum',
description: 'World\'s largest art museum housing the Mona Lisa and thousands of masterpieces',
image: 'https://images.unsplash.com/photo-1566139884366-0c8eb7a7e24f?q=80&w=400&auto=format&fit=crop',
category: 'Museum',
duration: '3-4 hours',
rating: 4.9,
reviews: 8942,
highlights: ['Priority entrance', 'Digital guide', 'Free Wi-Fi', 'Famous artworks'],
included: true
},
{
id: 'arc-triomphe',
name: 'Arc de Triomphe',
description: 'Monumental arch honoring those who fought for France, with rooftop viewing deck',
image: 'https://images.unsplash.com/photo-1502602898536-47ad22581b52?q=80&w=400&auto=format&fit=crop',
category: 'Monument',
duration: '1-2 hours',
rating: 4.7,
reviews: 5632,
highlights: ['Rooftop access', 'Historical exhibits', 'Champs-Élysées views', 'Photo opportunities'],
included: true
},
{
id: 'seine-cruise',
name: 'Seine River Cruise',
description: 'Relaxing boat cruise along the Seine with commentary and city landmarks',
image: 'https://images.unsplash.com/photo-1502602898536-47ad22581b52?q=80&w=400&auto=format&fit=crop',
category: 'Experience',
duration: '1 hour',
rating: 4.6,
reviews: 3421,
highlights: ['Commentary included', 'Multiple departure times', 'City from water', 'Relaxing experience'],
included: true
}
];
export function CityDetailPage() {
const [selectedPass, setSelectedPass] = useState<'selective' | 'unlimited' | 'premium'>('unlimited');
const passDetails = {
selective: {
name: 'Selective Pass',
price: '€49',
duration: '3 days',
attractions: '5 attractions',
savings: 'Save 46%'
},
unlimited: {
name: 'Unlimited Pass',
price: '€79',
duration: '7 days',
attractions: 'All 45 attractions',
savings: 'Save 47%'
},
premium: {
name: 'Premium Pass',
price: '€129',
duration: '10 days',
attractions: 'All attractions + VIP',
savings: 'Save 41%'
}
};
return (
<div className="min-h-screen bg-background">
{/* Hero Section */}
<section className="relative h-[60vh] overflow-hidden">
<ImageWithFallback
src="https://images.unsplash.com/photo-1502602898536-47ad22581b52?q=80&w=1200&auto=format&fit=crop"
alt="Paris cityscape"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
<div className="absolute inset-0 flex items-end">
<div className="container mx-auto px-4 pb-12">
<div className="max-w-2xl text-white space-y-4">
<Button variant="ghost" className="text-white hover:bg-white/20 mb-4">
<ChevronLeft className="w-4 h-4 mr-2" />
Back to Cities
</Button>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Badge className="bg-white/20 text-white border-white/30">
Most Popular
</Badge>
<div className="flex items-center space-x-1">
<Star className="w-4 h-4 text-yellow-400" />
<span>4.9 (15k+ reviews)</span>
</div>
</div>
<h1 className="text-4xl md:text-5xl font-bold">Paris, France</h1>
<p className="text-lg opacity-90">
The City of Light awaits with world-famous landmarks, incredible art, and timeless romance
</p>
<div className="flex items-center space-x-6 text-sm">
<div className="flex items-center space-x-1">
<MapPin className="w-4 h-4" />
<span>45 attractions included</span>
</div>
<div className="flex items-center space-x-1">
<Users className="w-4 h-4" />
<span>250k+ visitors last year</span>
</div>
<div className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span>3-7 days recommended</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Main Content */}
<div className="container mx-auto px-4 py-12">
<div className="grid lg:grid-cols-3 gap-8">
{/* Left Column - Content */}
<div className="lg:col-span-2 space-y-8">
<Tabs defaultValue="attractions" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="attractions">Attractions</TabsTrigger>
<TabsTrigger value="itinerary">Sample Itinerary</TabsTrigger>
<TabsTrigger value="map">Interactive Map</TabsTrigger>
</TabsList>
<TabsContent value="attractions" className="space-y-6">
<div className="space-y-4">
<h2 className="text-2xl font-bold">Top Attractions Included</h2>
<p className="text-muted-foreground">
Discover Paris's most iconic landmarks and hidden gems with priority access and expert insights.
</p>
</div>
<div className="grid gap-4">
{parisAttractions.map((attraction) => (
<Card key={attraction.id} className="overflow-hidden hover:shadow-lg transition-shadow">
<div className="md:flex">
<div className="md:w-48 md:flex-shrink-0">
<ImageWithFallback
src={attraction.image}
alt={attraction.name}
className="w-full h-48 md:h-full object-cover"
/>
</div>
<div className="p-6 flex-1">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="font-semibold text-lg">{attraction.name}</h3>
<Badge variant="secondary" className="mt-1">
{attraction.category}
</Badge>
</div>
<div className="text-right text-sm">
<div className="flex items-center space-x-1">
<Star className="w-4 h-4 text-yellow-500" />
<span>{attraction.rating}</span>
</div>
<div className="text-muted-foreground">
{attraction.reviews.toLocaleString()} reviews
</div>
</div>
</div>
<p className="text-muted-foreground mb-3">{attraction.description}</p>
<div className="flex items-center space-x-4 text-sm text-muted-foreground mb-3">
<div className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span>{attraction.duration}</span>
</div>
<div className="flex items-center space-x-1">
<Camera className="w-4 h-4" />
<span>Photo opportunities</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
{attraction.highlights.map((highlight, index) => (
<Badge key={index} variant="outline" className="text-xs">
{highlight}
</Badge>
))}
</div>
</div>
</div>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="itinerary" className="space-y-6">
<div className="space-y-4">
<h2 className="text-2xl font-bold">3-Day Paris Itinerary</h2>
<p className="text-muted-foreground">
Make the most of your time with our expert-crafted itinerary suggestion.
</p>
</div>
<div className="space-y-6">
{['Day 1: Classic Paris', 'Day 2: Art & Culture', 'Day 3: Hidden Gems'].map((day, index) => (
<Card key={index}>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Calendar className="w-5 h-5" />
<span>{day}</span>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Detailed itinerary content would be shown here with times, locations, and recommendations.
</p>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="map" className="space-y-6">
<div className="space-y-4">
<h2 className="text-2xl font-bold">Interactive Map</h2>
<p className="text-muted-foreground">
Explore all included attractions on our interactive map with real-time information.
</p>
</div>
<Card className="h-96 flex items-center justify-center bg-muted/20">
<div className="text-center space-y-2">
<MapPin className="w-12 h-12 text-muted-foreground mx-auto" />
<p className="text-muted-foreground">Interactive map would be embedded here</p>
</div>
</Card>
</TabsContent>
</Tabs>
</div>
{/* Right Column - Booking Card */}
<div className="space-y-6">
<Card className="sticky top-24">
<CardHeader>
<CardTitle>Choose Your Paris Pass</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Pass Selection */}
<div className="space-y-3">
{Object.entries(passDetails).map(([key, pass]) => (
<div
key={key}
onClick={() => setSelectedPass(key as any)}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedPass === key
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
<div className="flex justify-between items-start">
<div className="space-y-1">
<div className="font-medium">{pass.name}</div>
<div className="text-sm text-muted-foreground">{pass.attractions}</div>
<div className="text-sm text-muted-foreground">{pass.duration}</div>
</div>
<div className="text-right">
<div className="font-bold text-lg">{pass.price}</div>
<div className="text-sm text-green-600">{pass.savings}</div>
</div>
</div>
</div>
))}
</div>
{/* Selected Pass Summary */}
<div className="p-4 bg-muted/30 rounded-lg space-y-2">
<div className="font-medium">Selected: {passDetails[selectedPass].name}</div>
<div className="text-sm text-muted-foreground">
Valid for {passDetails[selectedPass].duration} • {passDetails[selectedPass].attractions}
</div>
<div className="text-lg font-bold">{passDetails[selectedPass].price}</div>
</div>
{/* Booking Button */}
<Button className="w-full bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90">
Book Now - {passDetails[selectedPass].price}
</Button>
{/* Trust Elements */}
<div className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<span>Free cancellation up to 24 hours</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<span>Instant mobile ticket delivery</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<span>24/7 customer support</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,267 @@
import { useState, useEffect } from 'react';
import { motion } from 'motion/react';
interface CitySubmenuProps {
onClose: () => void;
currentPage?: string;
onHomeClick?: () => void;
onMelbourneClick?: () => void;
onAttractionsClick?: () => void;
onPassesClick?: () => void;
onBlogsClick?: () => void;
onHowItWorksClick?: () => void;
}
interface SubmenuItem {
id: string;
label: string;
isCity?: boolean;
action?: () => void;
}
export function CitySubmenu({
onClose,
currentPage,
onHomeClick,
onMelbourneClick,
onAttractionsClick,
onPassesClick,
onBlogsClick,
onHowItWorksClick
}: CitySubmenuProps) {
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const [isScrolled, setIsScrolled] = useState(false);
// Handle scroll effects to match main navbar behavior
useEffect(() => {
const handleScroll = () => {
const scrolled = window.scrollY > 20;
setIsScrolled(scrolled);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const submenuItems: SubmenuItem[] = [
{
id: 'melbourne',
label: 'Melbourne',
isCity: true,
action: () => {
onMelbourneClick?.();
onClose();
}
},
{
id: 'sydney',
label: 'Sydney',
isCity: true,
action: () => {
// Add Sydney action when implemented
onClose();
}
},
{
id: 'brisbane',
label: 'Brisbane',
isCity: true,
action: () => {
// Add Brisbane action when implemented
onClose();
}
}
];
// Update selection based on current page
useEffect(() => {
if (currentPage === 'melbourne') {
setSelectedItem('melbourne');
} else if (currentPage === 'sydney') {
setSelectedItem('sydney');
} else if (currentPage === 'brisbane') {
setSelectedItem('brisbane');
} else {
setSelectedItem(null);
}
}, [currentPage]);
const handleItemClick = (item: SubmenuItem) => {
if (item.isCity) {
setSelectedItem(item.id);
}
item.action?.();
};
const isItemActive = (item: SubmenuItem) => {
return (item.isCity && currentPage === 'melbourne' && item.id === 'melbourne') ||
(item.isCity && currentPage === 'sydney' && item.id === 'sydney') ||
(item.isCity && currentPage === 'brisbane' && item.id === 'brisbane');
};
return (
<>
{/* Desktop Submenu - Fixed and Sticky */}
<motion.div
className="fixed top-[120px] left-1/2 transform -translate-x-1/2 hidden lg:block"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{
duration: 0.6,
ease: [0.25, 0.1, 0.25, 1],
delay: 0.4
}}
style={{ position: 'fixed', zIndex: 99998 }}
>
<motion.div
className="bg-white rounded-full px-2 py-2 shadow-lg border border-gray-200"
initial={{ scale: 0.9 }}
animate={{
scale: isScrolled ? 0.95 : 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] }}
>
<div className="flex items-center gap-1">
{submenuItems.map((item) => (
<motion.button
key={item.id}
onClick={() => handleItemClick(item)}
className={`relative px-4 py-2.5 text-sm font-medium transition-all duration-300 whitespace-nowrap rounded-full ${
isItemActive(item)
? 'bg-primary text-white shadow-md'
: 'text-gray-700 hover:text-white hover:bg-gray-800'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{item.label}
{/* Hover effect for non-active items */}
{!isItemActive(item) && (
<motion.div
className="absolute inset-0 bg-gray-800 rounded-full -z-10"
initial={{ opacity: 0, scale: 0.9 }}
whileHover={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
/>
)}
</motion.button>
))}
</div>
</motion.div>
</motion.div>
{/* Mobile Submenu - Fixed and Sticky */}
<motion.div
className="fixed top-[100px] left-4 right-4 md:hidden"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{
duration: 0.6,
ease: [0.25, 0.1, 0.25, 1],
delay: 0.4
}}
style={{ position: 'fixed', zIndex: 99998 }}
>
<motion.div
className="bg-white rounded-2xl px-3 py-3 shadow-lg border border-gray-200"
initial={{ scale: 0.9 }}
animate={{
scale: isScrolled ? 0.95 : 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] }}
>
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide">
{submenuItems.map((item) => (
<motion.button
key={item.id}
onClick={() => handleItemClick(item)}
className={`relative px-3 py-2 text-sm font-medium transition-all duration-300 whitespace-nowrap rounded-xl flex-shrink-0 ${
isItemActive(item)
? 'bg-primary text-white shadow-md'
: 'text-gray-700 hover:text-white hover:bg-gray-800'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{item.label}
{/* Hover effect for non-active items */}
{!isItemActive(item) && (
<motion.div
className="absolute inset-0 bg-gray-800 rounded-xl -z-10"
initial={{ opacity: 0, scale: 0.9 }}
whileHover={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
/>
)}
</motion.button>
))}
</div>
</motion.div>
</motion.div>
{/* Medium Screen Submenu - Fixed and Sticky */}
<motion.div
className="fixed top-[110px] left-1/2 transform -translate-x-1/2 hidden md:block lg:hidden"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{
duration: 0.6,
ease: [0.25, 0.1, 0.25, 1],
delay: 0.4
}}
style={{ position: 'fixed', zIndex: 99998 }}
>
<motion.div
className="bg-white rounded-full px-2 py-2 shadow-lg border border-gray-200"
initial={{ scale: 0.9 }}
animate={{
scale: isScrolled ? 0.95 : 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] }}
>
<div className="flex items-center gap-1">
{submenuItems.map((item) => (
<motion.button
key={item.id}
onClick={() => handleItemClick(item)}
className={`relative px-4 py-2.5 text-sm font-medium transition-all duration-300 whitespace-nowrap rounded-full ${
isItemActive(item)
? 'bg-primary text-white shadow-md'
: 'text-gray-700 hover:text-white hover:bg-gray-800'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{item.label}
{/* Hover effect for non-active items */}
{!isItemActive(item) && (
<motion.div
className="absolute inset-0 bg-gray-800 rounded-full -z-10"
initial={{ opacity: 0, scale: 0.9 }}
whileHover={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
/>
)}
</motion.button>
))}
</div>
</motion.div>
</motion.div>
</>
);
}

View File

@@ -0,0 +1,937 @@
import { useState, useRef, useEffect } from 'react';
import { Camera, ArrowRight, Edit3, Upload, Type, Calendar, Palette, Edit } from 'lucide-react';
import { Button } from './ui/button';
import { ImageWithFallback } from './figma/ImageWithFallback';
import { motion, useMotionValue, useSpring, useTransform, useInView } from 'motion/react';
import { HandwrittenText, useHandwrittenText } from './HandwrittenText';
interface EditableCardProps {
isEditing: boolean;
onEdit: () => void;
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
editIcon?: React.ReactNode;
}
export function CustomPostcards() {
const [editingCard, setEditingCard] = useState<string | null>(null);
const postcardRef = useRef<HTMLDivElement>(null);
const sectionRef = useRef<HTMLDivElement>(null);
// 3D tilt effect using mouse position
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
// Spring animations for smooth mouse following
const rotateX = useSpring(useTransform(mouseY, [-0.5, 0.5], [5, -5]), {
stiffness: 100,
damping: 15
});
const rotateY = useSpring(useTransform(mouseX, [-0.5, 0.5], [-5, 5]), {
stiffness: 100,
damping: 15
});
// Detect when section is in view to trigger handwriting
const isInView = useInView(sectionRef, {
once: true,
margin: "-100px",
amount: 0.3
});
// Handwritten text control
const handwrittenControl = useHandwrittenText(false);
// Handle mouse movement for 3D effect
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
if (!postcardRef.current) return;
const rect = postcardRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const x = (event.clientX - centerX) / (rect.width / 2);
const y = (event.clientY - centerY) / (rect.height / 2);
mouseX.set(x);
mouseY.set(y);
};
const handleMouseLeave = () => {
mouseX.set(0);
mouseY.set(0);
};
const [postcardData, setPostcardData] = useState({
photo: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=600&fit=crop&crop=center",
message: "Greetings from paradise!\\nThe beaches here are absolutely\\nbreathtaking. Wish you were\\nhere to enjoy this amazing\\nsunset with me.",
date: "July 2024",
addressLabel: "POSTCARD"
});
const handleCreatePostcard = () => {
console.log('Navigate to postcard creation page...');
};
// Start handwriting animation when section comes into view
useEffect(() => {
if (isInView && !editingCard) {
// Delay the start of handwriting to let the postcard animation settle
const timer = setTimeout(() => {
handwrittenControl.start();
}, 1200);
return () => clearTimeout(timer);
}
}, [isInView, editingCard, handwrittenControl]);
// Reset handwriting when editing
useEffect(() => {
if (editingCard === 'message') {
handwrittenControl.reset();
}
}, [editingCard, handwrittenControl]);
const EditableCard = ({ isEditing, onEdit, children, className = "", style = {}, editIcon }: EditableCardProps) => {
return (
<motion.div
className={`relative group cursor-pointer ${className}`}
style={style}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
whileHover={{
scale: 1.02,
transition: { duration: 0.2 }
}}
whileTap={{ scale: 0.98 }}
animate={isEditing ? {
boxShadow: "0 0 0 2px rgba(249, 95, 98, 0.5), 0 8px 16px rgba(249, 95, 98, 0.3)"
} : {}}
>
{children}
{/* Animated Edit overlay */}
<motion.div
className="absolute inset-0 bg-black rounded-md flex items-center justify-center"
initial={{ opacity: 0, backgroundColor: "rgba(0, 0, 0, 0)" }}
animate={isEditing ? {
opacity: 1,
backgroundColor: "rgba(0, 0, 0, 0.2)"
} : {
opacity: 0,
backgroundColor: "rgba(0, 0, 0, 0)"
}}
whileHover={!isEditing ? {
opacity: 1,
backgroundColor: "rgba(0, 0, 0, 0.1)"
} : {}}
transition={{ duration: 0.2 }}
>
<motion.div
className="bg-white bg-opacity-90 px-2 py-1 rounded-md shadow-lg border border-gray-200 flex items-center gap-1"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
whileHover={{ scale: 1.05 }}
transition={{ duration: 0.2 }}
>
<motion.div
animate={isEditing ? { rotate: [0, 10, -10, 0] } : {}}
transition={{ duration: 2, repeat: isEditing ? Infinity : 0 }}
>
{editIcon}
</motion.div>
<motion.span
className="text-xs text-gray-700 font-medium"
animate={isEditing ? {
color: ["#374151", "#F95F62", "#374151"]
} : {}}
transition={{ duration: 1, repeat: isEditing ? Infinity : 0 }}
>
{isEditing ? 'Editing...' : 'Edit'}
</motion.span>
</motion.div>
</motion.div>
</motion.div>
);
};
// Ultra-realistic vintage postcard with responsive scaling and animations
const PostcardFrame = () => {
return (
<motion.div
className="relative shadow-2xl transform rotate-[0.5deg] w-full h-full"
initial={{ opacity: 0, scale: 0.9, rotate: 0 }}
animate={{ opacity: 1, scale: 1, rotate: 0.5 }}
transition={{ duration: 0.8, ease: "easeOut" }}
style={{
background: `
radial-gradient(circle at 20% 80%, rgba(210, 180, 140, 0.15) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(160, 130, 100, 0.1) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(190, 160, 120, 0.08) 0%, transparent 50%),
linear-gradient(135deg, #f8f4e6 0%, #f0e7d3 25%, #ede2d0 50%, #e8dcc8 75%, #e3d5c2 100%)
`,
borderRadius: '10px',
boxShadow: `
inset 0px 1px 30px rgba(139, 115, 85, 0.2),
inset 0px -1px 20px rgba(160, 130, 100, 0.15),
0px 12px 40px rgba(0, 0, 0, 0.15),
0px 4px 12px rgba(0, 0, 0, 0.1)
`,
border: '1px solid rgba(139, 115, 85, 0.2)'
}}
>
{/* Realistic paper texture with variations */}
<div
className="absolute inset-0 opacity-40 rounded-[10px] pointer-events-none"
style={{
backgroundImage: `
url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23d4af9a' fill-opacity='0.08'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"),
radial-gradient(circle at 25% 75%, rgba(180, 150, 120, 0.1) 0%, transparent 40%),
radial-gradient(circle at 75% 25%, rgba(160, 130, 100, 0.08) 0%, transparent 35%)
`
}}
/>
{/* Age spots and stains */}
<div className="absolute inset-0 rounded-[10px] pointer-events-none opacity-30">
<div
className="absolute w-8 h-6 rounded-full"
style={{
background: 'radial-gradient(ellipse, rgba(160, 120, 80, 0.15) 0%, transparent 70%)',
top: '15%',
right: '20%',
transform: 'rotate(-15deg)'
}}
/>
<div
className="absolute w-6 h-8 rounded-full"
style={{
background: 'radial-gradient(ellipse, rgba(140, 110, 70, 0.12) 0%, transparent 60%)',
bottom: '25%',
left: '15%',
transform: 'rotate(25deg)'
}}
/>
<div
className="absolute w-4 h-4 rounded-full"
style={{
background: 'radial-gradient(circle, rgba(180, 140, 100, 0.2) 0%, transparent 50%)',
top: '60%',
right: '10%'
}}
/>
</div>
{/* Corner wear and creases */}
<div
className="absolute top-0 right-0 w-12 h-12 opacity-25"
style={{
background: 'radial-gradient(circle at top right, rgba(139, 115, 85, 0.3) 20%, rgba(160, 130, 100, 0.2) 40%, transparent 70%)',
borderTopRightRadius: '10px'
}}
/>
<div
className="absolute bottom-0 left-0 w-16 h-16 opacity-20"
style={{
background: 'radial-gradient(circle at bottom left, rgba(120, 95, 70, 0.25) 25%, rgba(140, 115, 85, 0.15) 50%, transparent 75%)',
borderBottomLeftRadius: '10px'
}}
/>
{/* Subtle crease lines */}
<div
className="absolute w-full h-px bg-gradient-to-r from-transparent via-amber-800/10 to-transparent opacity-40"
style={{
top: '35%',
transform: 'rotate(-0.5deg)'
}}
/>
{/* Animated Vintage Vector Logo - Top Right - Mobile Optimized */}
<motion.div
className="absolute opacity-60 pointer-events-none"
style={{
top: '4.3%',
right: '3.5%',
width: '7.5%',
height: '11.5%',
filter: 'sepia(30%) saturate(120%) hue-rotate(15deg) brightness(85%) contrast(110%)'
}}
animate={{
rotate: [5, 7, 3, 5],
scale: [1, 1.02, 0.98, 1]
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut"
}}
>
<ImageWithFallback
src="https://images.unsplash.com/photo-1702825328124-dab63d85490e?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHx2aW50YWdlJTIwbG9nbyUyMHZlY3RvciUyMHBvc3RhbCUyMHN0YW1wfGVufDF8fHx8MTc1ODk5MjExN3ww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral"
alt="Vintage logo"
className="w-full h-full object-contain"
style={{
mixBlendMode: 'multiply',
opacity: 0.8
}}
/>
{/* Subtle aging overlay for the vector */}
<div
className="absolute inset-0 rounded-full"
style={{
background: `
radial-gradient(circle at 30% 30%, rgba(160, 130, 90, 0.2) 0%, transparent 60%),
radial-gradient(circle at 70% 70%, rgba(140, 110, 70, 0.15) 0%, transparent 50%)
`,
mixBlendMode: 'multiply'
}}
/>
</motion.div>
{/* Editable Photo Card with realistic mounting - Responsive */}
<EditableCard
isEditing={editingCard === 'photo'}
onEdit={() => setEditingCard(editingCard === 'photo' ? null : 'photo')}
style={{
position: 'absolute',
width: '33.3%',
height: '72.3%',
left: '4.9%',
top: '7.4%',
transform: 'rotate(-0.8deg)'
}}
editIcon={<Upload className="w-3 h-3 text-gray-600" />}
>
<div
className="w-full h-full rounded-lg overflow-hidden shadow-lg relative"
style={{
border: '4px solid #ffffff',
boxShadow: `
0px 6px 25px rgba(0, 0, 0, 0.25),
inset 0px 1px 3px rgba(255, 255, 255, 0.8),
inset 0px -1px 2px rgba(160, 130, 100, 0.2)
`
}}
>
<ImageWithFallback
src={postcardData.photo}
alt="Beautiful tropical beach"
className="w-full h-full object-cover"
style={{
filter: 'sepia(8%) saturate(110%) contrast(102%) brightness(98%)'
}}
/>
{/* Photo aging overlay */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background: `
radial-gradient(circle at 20% 20%, rgba(255, 235, 205, 0.15) 0%, transparent 40%),
radial-gradient(circle at 80% 80%, rgba(200, 170, 130, 0.1) 0%, transparent 35%)
`,
mixBlendMode: 'multiply'
}}
/>
</div>
</EditableCard>
{/* Realistic vertical divider with ink bleeding - Responsive */}
<div
className="absolute"
style={{
top: '10.6%',
bottom: '10.6%',
left: '43.1%',
width: '2px',
background: `
linear-gradient(to bottom,
transparent 0%,
rgba(101, 84, 63, 0.4) 10%,
rgba(101, 84, 63, 0.6) 30%,
rgba(101, 84, 63, 0.8) 50%,
rgba(101, 84, 63, 0.6) 70%,
rgba(101, 84, 63, 0.4) 90%,
transparent 100%
)
`,
filter: 'blur(0.3px)'
}}
/>
{/* Ink bleed effect around divider - Responsive */}
<div
className="absolute opacity-20"
style={{
top: '10.6%',
bottom: '10.6%',
left: '42.8%',
width: '6px',
background: `
linear-gradient(to bottom,
transparent 0%,
rgba(101, 84, 63, 0.1) 20%,
rgba(101, 84, 63, 0.15) 50%,
rgba(101, 84, 63, 0.1) 80%,
transparent 100%
)
`,
filter: 'blur(1px)'
}}
/>
{/* Right side content area - Responsive */}
<div
className="absolute pointer-events-none"
style={{
top: '10.6%',
left: '47.2%',
width: '47.2%',
height: '78.7%'
}}
>
{/* Editable Address Label Card - Responsive */}
<EditableCard
isEditing={editingCard === 'label'}
onEdit={() => setEditingCard(editingCard === 'label' ? null : 'label')}
style={{
position: 'absolute',
top: '4.1%',
left: '0%',
pointerEvents: 'auto',
transform: 'rotate(-0.3deg)'
}}
editIcon={<Type className="w-3 h-3 text-gray-600" />}
>
<div
className="px-2 py-1 rounded text-xs md:text-sm"
style={{
fontWeight: '600',
letterSpacing: '2px',
textTransform: 'uppercase',
color: 'rgba(101, 84, 63, 0.8)',
textShadow: '0px 1px 2px rgba(0, 0, 0, 0.1)'
}}
>
{postcardData.addressLabel}
</div>
</EditableCard>
{/* Realistic horizontal ruled lines with ink bleeding - Responsive */}
<div
className="absolute pointer-events-none"
style={{
top: '14.9%',
left: '0%',
width: '94.1%',
height: '59.5%'
}}
>
{[...Array(9)].map((_, i) => (
<div key={i} className="relative" style={{ marginBottom: '6.5%' }}>
{/* Main line */}
<div
style={{
height: '1px',
width: i % 2 === 0 ? '100%' : '92%',
background: `linear-gradient(to right,
rgba(101, 84, 63, 0.3) 0%,
rgba(101, 84, 63, 0.5) 20%,
rgba(101, 84, 63, 0.6) 50%,
rgba(101, 84, 63, 0.4) 80%,
rgba(101, 84, 63, 0.2) 95%,
transparent 100%
)`,
transform: `rotate(${(Math.random() - 0.5) * 0.5}deg)`
}}
/>
{/* Ink bleed effect */}
<div
className="absolute top-0 left-0 opacity-30"
style={{
height: '3px',
width: i % 2 === 0 ? '98%' : '90%',
background: `linear-gradient(to right,
rgba(101, 84, 63, 0.1) 0%,
rgba(101, 84, 63, 0.2) 30%,
rgba(101, 84, 63, 0.15) 70%,
transparent 100%
)`,
filter: 'blur(1px)',
transform: 'translateY(-1px)'
}}
/>
</div>
))}
</div>
{/* Editable Message Card with Handwritten Animation - Responsive */}
<EditableCard
isEditing={editingCard === 'message'}
onEdit={() => setEditingCard(editingCard === 'message' ? null : 'message')}
style={{
position: 'absolute',
top: '17.6%',
left: '4.4%',
width: '88.2%',
pointerEvents: 'auto',
transform: 'rotate(-0.7deg)'
}}
editIcon={<Edit3 className="w-3 h-3 text-gray-600" />}
>
<div className="px-2 py-2 rounded leading-relaxed">
{editingCard === 'message' ? (
// Show static text when editing
<div
style={{
fontFamily: "'Dancing Script', 'Brush Script MT', cursive",
fontSize: 'clamp(14px, 3.6vw, 26px)',
lineHeight: '1.7',
color: 'rgba(85, 70, 50, 0.9)',
textShadow: `
1px 1px 2px rgba(0, 0, 0, 0.08),
0px 0px 3px rgba(101, 84, 63, 0.1)
`,
whiteSpace: 'pre-line',
filter: 'contrast(110%) brightness(98%)'
}}
>
{postcardData.message}
</div>
) : (
// Show animated handwritten text when not editing
<HandwrittenText
text={postcardData.message}
speed={6}
startDelay={0}
autoStart={false}
onComplete={handwrittenControl.onComplete}
style={{
fontFamily: "'Dancing Script', 'Brush Script MT', cursive",
fontSize: 'clamp(14px, 3.6vw, 26px)',
lineHeight: '1.7',
color: 'rgba(85, 70, 50, 0.9)',
textShadow: `
1px 1px 2px rgba(0, 0, 0, 0.08),
0px 0px 3px rgba(101, 84, 63, 0.1)
`,
filter: 'contrast(110%) brightness(98%)'
}}
/>
)}
</div>
</EditableCard>
{/* Editable Date Card - Responsive */}
<EditableCard
isEditing={editingCard === 'date'}
onEdit={() => setEditingCard(editingCard === 'date' ? null : 'date')}
style={{
position: 'absolute',
bottom: '16.2%',
right: '7.4%',
pointerEvents: 'auto',
transform: 'rotate(-1.5deg)'
}}
editIcon={<Calendar className="w-3 h-3 text-gray-600" />}
>
<div
className="px-2 py-1 rounded text-xs"
style={{
fontWeight: '500',
letterSpacing: '1px',
color: 'rgba(101, 84, 63, 0.7)',
textShadow: '0px 1px 1px rgba(0, 0, 0, 0.05)',
fontFamily: "'Poppins', sans-serif"
}}
>
{postcardData.date}
</div>
</EditableCard>
</div>
{/* Ultra-realistic stamp - Responsive */}
<EditableCard
isEditing={editingCard === 'stamp'}
onEdit={() => setEditingCard(editingCard === 'stamp' ? null : 'stamp')}
style={{
position: 'absolute',
width: '12.5%',
height: '19.1%',
right: '3.5%',
bottom: '5.3%',
transform: 'rotate(-12deg)'
}}
editIcon={<Edit3 className="w-3 h-3 text-gray-600" />}
>
<div
className="w-full h-full border-2 border-dashed rounded-full flex items-center justify-center relative"
style={{
borderColor: 'rgba(139, 115, 85, 0.8)',
background: `
radial-gradient(circle at 30% 30%, rgba(220, 190, 150, 0.95) 0%, rgba(200, 170, 130, 0.9) 40%, rgba(180, 150, 110, 0.85) 100%),
linear-gradient(135deg, rgba(240, 220, 180, 0.3) 0%, transparent 50%)
`,
boxShadow: `
inset 0px 2px 8px rgba(0, 0, 0, 0.25),
inset 0px -1px 4px rgba(255, 255, 255, 0.2),
0px 4px 12px rgba(0, 0, 0, 0.15)
`
}}
>
{/* Stamp aging and wear */}
<div
className="absolute inset-0 rounded-full pointer-events-none"
style={{
background: `
radial-gradient(circle at 70% 20%, rgba(160, 130, 90, 0.3) 0%, transparent 40%),
radial-gradient(circle at 20% 80%, rgba(140, 110, 70, 0.2) 0%, transparent 35%)
`,
mixBlendMode: 'multiply'
}}
/>
{/* Stamp inner details */}
<div className="absolute inset-3 border border-amber-800/50 rounded-full" />
<div className="absolute inset-5 border border-amber-800/30 rounded-full" />
{/* Stamp text */}
<div className="text-center leading-tight z-10" style={{ color: 'rgba(101, 84, 63, 0.9)' }}>
<div style={{ fontSize: 'clamp(6px, 1.25vw, 9px)', fontWeight: '700', letterSpacing: '1px' }}>
TRAVEL
</div>
<div style={{ fontSize: 'clamp(5px, 0.97vw, 7px)', fontWeight: '600', marginTop: '2px', letterSpacing: '0.5px' }}>
MEMORIES
</div>
<div style={{ fontSize: 'clamp(5px, 0.97vw, 7px)', fontWeight: '500', marginTop: '1px' }}>
2024
</div>
</div>
{/* Stamp perforations with realistic variations */}
{[...Array(20)].map((_, i) => (
<div
key={i}
className="absolute rounded-full"
style={{
width: '2px',
height: '2px',
backgroundColor: 'rgba(101, 84, 63, 0.4)',
top: '50%',
left: '50%',
transform: `translate(-50%, -50%) rotate(${i * 18}deg) translateY(-42px)`,
opacity: Math.random() > 0.1 ? 1 : 0.3 // Random missing perforations
}}
/>
))}
{/* Stamp smudge mark */}
<div
className="absolute w-3 h-2 opacity-25"
style={{
background: 'radial-gradient(ellipse, rgba(101, 84, 63, 0.4) 0%, transparent 70%)',
bottom: '15%',
right: '20%',
transform: 'rotate(20deg)',
filter: 'blur(0.5px)'
}}
/>
</div>
</EditableCard>
{/* Additional realistic aging effects */}
<div
className="absolute w-3 h-1 opacity-15"
style={{
background: 'linear-gradient(45deg, rgba(120, 100, 70, 0.3), transparent)',
top: '20%',
left: '60%',
transform: 'rotate(45deg)',
filter: 'blur(0.5px)'
}}
/>
</motion.div>
);
};
return (
<section ref={sectionRef} className="py-12 md:py-16 lg:py-20 bg-white relative overflow-hidden">
{/* Background decorations */}
<div className="absolute inset-0 opacity-10 overflow-hidden">
{/* Vintage Stamps */}
<div className="absolute top-20 left-20 w-16 h-20 bg-warm-coral/30 rounded-sm rotate-12 border-2 border-warm-coral/20"></div>
<div className="absolute top-40 right-32 w-12 h-16 bg-warm-coral/30 rounded-sm -rotate-6 border-2 border-warm-coral/20"></div>
<div className="absolute bottom-32 left-40 w-14 h-18 bg-warm-coral/20 rounded-sm rotate-45 border-2 border-warm-coral/15"></div>
{/* Paper Textures */}
<div className="absolute top-1/3 right-1/4 w-32 h-32 bg-warm-coral/10 rounded-full blur-2xl"></div>
<div className="absolute bottom-1/3 left-1/4 w-40 h-40 bg-warm-coral/10 rounded-full blur-2xl"></div>
{/* Ink Splatters */}
<div className="absolute top-1/2 left-1/2 w-8 h-8 bg-warm-coral/20 rounded-full blur-sm"></div>
<div className="absolute top-1/4 right-1/3 w-6 h-6 bg-warm-coral/20 rounded-full blur-sm"></div>
<div className="absolute bottom-1/4 left-1/3 w-4 h-4 bg-warm-coral/15 rounded-full blur-sm"></div>
</div>
<div className="container mx-auto px-4 relative z-10">
{/* Header */}
<div className="text-center mb-12 md:mb-16">
<div className="inline-flex items-center gap-2 bg-gray-50 px-4 py-2 rounded-full mb-4">
<Camera className="w-4 h-4 text-warm-coral" />
<span className="text-sm text-gray-700">Custom Memories</span>
</div>
<h2 className="text-3xl md:text-4xl lg:text-5xl xl:text-6xl text-gray-900 mb-6">
<span className="font-light">The Only Card That Sends Your</span>
<span className="block">
<span className="font-bold text-warm-coral italic">
Holiday
</span>{' '}
<span className="font-normal">Home.</span>
</span>
</h2>
<p className="text-lg md:text-xl text-gray-600 leading-relaxed max-w-3xl mx-auto">
Transform your travel memories into beautiful, personalized postcards that capture the essence of your adventures.
</p>
</div>
{/* Centered Postcard Preview - Enhanced with Animations */}
<div className="flex justify-center mb-12 px-4">
<motion.div
className="relative group w-full max-w-4xl"
initial={{ opacity: 0, y: 30, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.6,
ease: [0.25, 0.1, 0.25, 1],
delay: 0.2
}}
>
{/* Interactive 3D Container */}
<motion.div
ref={postcardRef}
className="relative w-full overflow-hidden rounded-xl cursor-pointer"
style={{
aspectRatio: '720 / 470',
maxWidth: '720px',
maxHeight: '470px',
minWidth: '280px',
minHeight: '183px',
rotateX,
rotateY,
transformStyle: "preserve-3d"
}}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
whileHover={{
scale: 1.02,
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(99, 102, 241, 0.1)",
transition: { duration: 0.3 }
}}
animate={{
y: [0, -8, 0],
}}
transition={{
y: {
duration: 4,
repeat: Infinity,
ease: "easeInOut"
}
}}
>
<PostcardFrame />
{/* Subtle glow effect */}
<motion.div
className="absolute inset-0 rounded-xl opacity-0 pointer-events-none"
style={{
background: "radial-gradient(circle at center, rgba(99, 102, 241, 0.1) 0%, transparent 70%)",
filter: "blur(20px)"
}}
whileHover={{ opacity: 0.5 }}
transition={{ duration: 0.3 }}
/>
</motion.div>
{/* Animated Edit Instructions */}
{editingCard && (
<motion.div
className="absolute -top-12 left-1/2 transform -translate-x-1/2 bg-primary text-white px-3 py-2 md:px-4 md:py-2 rounded-lg shadow-lg text-xs md:text-sm font-medium whitespace-nowrap z-20"
initial={{ opacity: 0, y: 10, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.9 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<motion.span
animate={{
color: ["#ffffff", "#e0e7ff", "#ffffff"]
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut"
}}
>
Click on any element to edit it
</motion.span>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-primary"></div>
</motion.div>
)}
</motion.div>
</div>
{/* Animated Editing Panel */}
{editingCard && (
<motion.div
className="max-w-md mx-auto mb-12 p-6 bg-gray-50 rounded-lg border"
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<h3 className="font-medium mb-4 capitalize">Edit {editingCard}</h3>
{editingCard === 'photo' && (
<div>
<label className="block text-sm font-medium mb-2">Photo URL</label>
<input
type="text"
value={postcardData.photo}
onChange={(e) => setPostcardData(prev => ({ ...prev, photo: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Enter image URL"
/>
</div>
)}
{editingCard === 'message' && (
<div>
<label className="block text-sm font-medium mb-2">Message</label>
<textarea
value={postcardData.message}
onChange={(e) => setPostcardData(prev => ({ ...prev, message: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary h-32 resize-none"
placeholder="Enter your message"
/>
</div>
)}
{editingCard === 'date' && (
<div>
<label className="block text-sm font-medium mb-2">Date Text</label>
<input
type="text"
value={postcardData.date}
onChange={(e) => setPostcardData(prev => ({ ...prev, date: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Enter date text"
/>
</div>
)}
{editingCard === 'label' && (
<div>
<label className="block text-sm font-medium mb-2">Address Label</label>
<input
type="text"
value={postcardData.addressLabel}
onChange={(e) => setPostcardData(prev => ({ ...prev, addressLabel: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Enter label text"
/>
</div>
)}
{editingCard === 'stamp' && (
<div>
<p className="text-sm text-gray-600">Stamp design features authentic vintage styling with realistic aging effects.</p>
</div>
)}
<div className="flex gap-2 mt-4">
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Button
onClick={() => setEditingCard(null)}
variant="outline"
size="sm"
className="flex-1"
>
Done
</Button>
</motion.div>
</div>
</motion.div>
)}
{/* Enhanced Call to Action */}
<div className="text-center">
<Button
onClick={handleCreatePostcard}
className="bg-warm-coral hover:bg-warm-coral/90 text-white px-8 py-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105 mx-auto"
>
Customize Your Postcard
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
<motion.p
className="text-gray-600"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.6 }}
>
Click on any postcard element above to edit it, or create a completely new design
</motion.p>
</div>
{/* Enhanced Features Section */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-5xl mx-auto mb-10 mt-2">
{[
{
icon: Palette,
title: "Authentic Design",
description: "Realistic vintage styling with aging effect, paper texture, and authentic details"
},
{
icon: Edit,
title: "Handwritten Style",
description: "Beautiful cursive fonts with realistic ink bleeding and natural imperfections"
},
{
icon: Camera,
title: "Easy Customization",
description: "Click any element to edit photos, messages, and details in real-time"
}
].map((feature, index) => (
<motion.div
key={index}
className="text-center group p-6 rounded-xl bg-white hover:bg-gray-50 transition-all duration-300 border border-gray-100 hover:border-warm-coral/20 hover:shadow-lg"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
whileHover={{
y: -5,
transition: { duration: 0.2 }
}}
>
<motion.div
className="mx-auto mb-4 p-3 rounded-full bg-warm-coral/10 group-hover:bg-warm-coral/20 transition-all duration-300 w-16 h-16 flex items-center justify-center"
whileHover={{
scale: 1.1,
rotate: 360,
transition: { duration: 0.5 }
}}
>
<feature.icon className="w-8 h-8 text-warm-coral" />
</motion.div>
<h3 className="mb-2 text-gray-900 group-hover:text-warm-coral transition-colors duration-300">
{feature.title}
</h3>
<p className="text-gray-600 text-sm leading-relaxed">
{feature.description}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1 @@
// This enhanced testimonials component (created for Melbourne page) has been removed

View File

@@ -0,0 +1,246 @@
import { useState } from 'react';
import { Button } from './ui/button';
import { Card, CardContent } from './ui/card';
import { Badge } from './ui/badge';
import { ChevronLeft, ChevronRight, MapPin, Star, Clock } from 'lucide-react';
import { ImageWithFallback } from './figma/ImageWithFallback';
interface City {
id: string;
name: string;
country: string;
image: string;
attractions: number;
rating: number;
duration: string;
startingPrice: number;
popular?: boolean;
description: string;
}
const cities: City[] = [
{
id: 'paris',
name: 'Paris',
country: 'France',
image: 'https://images.unsplash.com/photo-1431274172761-fca41d930114?q=80&w=600&auto=format&fit=crop',
attractions: 45,
rating: 4.9,
duration: '3-7 days',
startingPrice: 49,
popular: true,
description: 'City of Light with world-famous landmarks and art'
},
{
id: 'london',
name: 'London',
country: 'United Kingdom',
image: 'https://images.unsplash.com/photo-1513635269975-59663e0ac1ad?q=80&w=600&auto=format&fit=crop',
attractions: 38,
rating: 4.8,
duration: '3-7 days',
startingPrice: 55,
description: 'Historic capital with royal palaces and modern culture'
},
{
id: 'tokyo',
name: 'Tokyo',
country: 'Japan',
image: 'https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?q=80&w=600&auto=format&fit=crop',
attractions: 52,
rating: 4.9,
duration: '5-10 days',
startingPrice: 68,
popular: true,
description: 'Vibrant metropolis blending tradition with innovation'
},
{
id: 'barcelona',
name: 'Barcelona',
country: 'Spain',
image: 'https://images.unsplash.com/photo-1539037116277-4db20889f2d4?q=80&w=600&auto=format&fit=crop',
attractions: 29,
rating: 4.7,
duration: '3-5 days',
startingPrice: 42,
description: 'Architectural wonder with beaches and vibrant culture'
},
{
id: 'rome',
name: 'Rome',
country: 'Italy',
image: 'https://images.unsplash.com/photo-1552832230-c0197dd311b5?q=80&w=600&auto=format&fit=crop',
attractions: 34,
rating: 4.8,
duration: '3-6 days',
startingPrice: 45,
description: 'Eternal city with ancient history and timeless beauty'
},
{
id: 'new-york',
name: 'New York',
country: 'United States',
image: 'https://images.unsplash.com/photo-1496442226666-8d4d0e62e6e9?q=80&w=600&auto=format&fit=crop',
attractions: 67,
rating: 4.6,
duration: '4-8 days',
startingPrice: 75,
description: 'The city that never sleeps with iconic skyline'
}
];
export function FeaturedCities() {
const [currentSlide, setCurrentSlide] = useState(0);
const itemsPerSlide = 3;
const totalSlides = Math.ceil(cities.length / itemsPerSlide);
const nextSlide = () => {
setCurrentSlide((prev) => (prev + 1) % totalSlides);
};
const prevSlide = () => {
setCurrentSlide((prev) => (prev - 1 + totalSlides) % totalSlides);
};
const visibleCities = cities.slice(
currentSlide * itemsPerSlide,
(currentSlide + 1) * itemsPerSlide
);
return (
<section className="py-16 md:py-24">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between mb-12">
<div className="space-y-2">
<h2 className="font-poppins text-2xl md:text-3xl lg:text-4xl leading-tight font-semibold text-foreground">
Featured Destinations
</h2>
<p className="font-poppins text-base leading-relaxed font-normal text-gray-700">
Discover the world's most incredible cities with our curated passes
</p>
</div>
<div className="hidden md:flex items-center space-x-2">
<Button
variant="outline"
className="w-16 h-16"
onClick={prevSlide}
disabled={currentSlide === 0}
>
<ChevronLeft className="w-8 h-8" />
</Button>
<Button
variant="outline"
className="w-16 h-16"
onClick={nextSlide}
disabled={currentSlide === totalSlides - 1}
>
<ChevronRight className="w-8 h-8" />
</Button>
</div>
</div>
{/* Desktop Grid */}
<div className="hidden md:grid md:grid-cols-3 gap-6 mb-8">
{visibleCities.map((city) => (
<CityCard key={city.id} city={city} />
))}
</div>
{/* Mobile Carousel */}
<div className="md:hidden">
<div className="overflow-hidden">
<div
className="flex transition-transform duration-300 ease-in-out"
style={{ transform: `translateX(-${currentSlide * 100}%)` }}
>
{cities.map((city) => (
<div key={city.id} className="w-full flex-shrink-0 px-2">
<CityCard city={city} />
</div>
))}
</div>
</div>
{/* Mobile Navigation */}
<div className="flex items-center justify-center space-x-2 mt-6">
{Array.from({ length: cities.length }).map((_, index) => (
<button
key={index}
onClick={() => setCurrentSlide(index)}
className={`w-2 h-2 rounded-full transition-colors ${
index === currentSlide ? 'bg-primary' : 'bg-muted'
}`}
/>
))}
</div>
</div>
<div className="text-center mt-8">
<Button size="lg" variant="outline" className="h-14">
View All Cities
</Button>
</div>
</div>
</section>
);
}
function CityCard({ city }: { city: City }) {
return (
<Card className="group cursor-pointer transition-all duration-300 hover:shadow-xl hover:-translate-y-1">
<div className="relative overflow-hidden rounded-t-lg">
{city.popular && (
<Badge className="absolute top-3 left-3 z-10 bg-gradient-to-r from-primary to-secondary text-white">
Popular
</Badge>
)}
<ImageWithFallback
src={city.image}
alt={`${city.name}, ${city.country}`}
className="w-full h-64 object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
<div className="absolute bottom-3 left-3 text-white">
<h3 className="font-poppins text-lg font-medium leading-snug">{city.name}</h3>
<p className="font-poppins text-sm leading-relaxed font-normal opacity-90">{city.country}</p>
</div>
</div>
<CardContent className="p-4 space-y-3">
<p className="font-poppins text-sm leading-relaxed font-normal text-gray-500">{city.description}</p>
<div className="flex items-center justify-between text-sm font-poppins">
<div className="flex items-center space-x-1">
<MapPin className="w-4 h-4 text-muted-foreground" />
<span className="font-normal text-foreground">{city.attractions} attractions</span>
</div>
<div className="flex items-center space-x-1">
<Star className="w-4 h-4 text-yellow-500" />
<span className="font-normal text-foreground">{city.rating}</span>
</div>
</div>
<div className="flex items-center justify-between text-sm font-poppins">
<div className="flex items-center space-x-1">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="font-normal text-foreground">{city.duration}</span>
</div>
<div className="font-poppins font-semibold text-foreground">
From €{city.startingPrice}
</div>
</div>
<Button className="w-full h-14 font-poppins font-medium" variant="outline">
Explore {city.name}
</Button>
</CardContent>
</Card>
);
}

60
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,60 @@
import { ImageWithFallback } from './figma/ImageWithFallback';
import { NewsletterSection } from './NewsletterSection';
import { FooterBrand } from './FooterBrand';
import { FooterNavigation } from './FooterNavigation';
import { FooterBottom } from './FooterBottom';
export function Footer() {
return (
<>
{/* Newsletter Subscription Section */}
<NewsletterSection />
{/* Footer with Nature Background */}
<footer className="relative text-white overflow-hidden">
{/* Background Image */}
<div className="absolute inset-0 z-0">
<ImageWithFallback
src="https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=1920&h=1080&fit=crop&q=80"
alt="Beautiful mountain landscape with lake and forest - travel destination"
className="w-full h-full object-cover"
/>
{/* Enhanced White Gradient Overlay at Top */}
<div className="absolute top-0 left-0 right-0 h-48 bg-gradient-to-b from-white via-white/95 via-white/80 via-white/60 via-white/40 to-transparent z-10" />
{/* Additional Smooth Transition Layer */}
<div className="absolute top-0 left-0 right-0 h-24 bg-gradient-to-b from-white via-white/90 to-white/70 z-10" />
{/* Dark overlay for text readability */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-black/30 to-black/70 z-20" />
{/* Additional atmospheric effects */}
<div className="absolute inset-0 z-15">
<div className="absolute top-1/3 left-1/4 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-primary/10 rounded-full blur-3xl" />
</div>
</div>
{/* Content Overlay */}
<div className="relative z-30 py-24">
<div className="container mx-auto px-4">
{/* Footer Content Grid */}
<div className="w-full mt-48 bg-primary/10 backdrop-blur-lg rounded-[10px] border border-white/10 p-12">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-16">
{/* Logo and Description - Left Side */}
<FooterBrand />
{/* Navigation Links - Center to Right */}
<FooterNavigation />
</div>
{/* Bottom Section - Copyright, Legal Links, and Social Icons */}
<FooterBottom />
</div>
</div>
</div>
</footer>
</>
);
}

View File

@@ -0,0 +1,43 @@
import { motion } from 'motion/react';
import { Facebook, Twitter, Instagram, Youtube } from 'lucide-react';
export function FooterBottom() {
return (
<motion.div
className="border-t border-white/20 pt-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.5 }}
>
<div className="flex flex-col lg:flex-row justify-between items-center space-y-6 lg:space-y-0">
{/* Copyright */}
<p className="text-white/60 text-sm">
© 2024 CityCards. All rights reserved.
</p>
{/* Right Section - Legal Links and Social Icons */}
<div className="flex flex-col md:flex-row items-center space-y-4 md:space-y-0 md:space-x-8">
{/* Legal Links */}
{/* Social Icons - Horizontal Layout */}
<div className="flex space-x-3">
{[Facebook, Twitter, Instagram, Youtube].map((Icon, index) => (
<motion.a
key={index}
href="#"
className="text-white/70 hover:text-white transition-colors duration-200 p-2 rounded-lg hover:bg-white/10"
whileHover={{ scale: 1.1, y: -2 }}
whileTap={{ scale: 0.95 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
>
<Icon className="w-5 h-5" />
</motion.a>
))}
</div>
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,67 @@
import { motion } from 'motion/react';
import { Apple, Play } from 'lucide-react';
import { ImageWithFallback } from './figma/ImageWithFallback';
import cityCardsLogo from 'figma:asset/4d07c3035c8f965d162e4e0d20cb3910fd5fa6fe.png';
export function FooterBrand() {
return (
<motion.div
className="lg:col-span-4 space-y-4"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="flex items-center">
<img
src={cityCardsLogo}
alt="CityCards Logo"
className="h-12 w-auto"
/>
</div>
<p className="text-white/80 text-sm leading-relaxed max-w-xs">
Discover the best of every city with our curated experiences and attractions.
Your gateway to unforgettable urban adventures.
</p>
{/* Get Application Section */}
<div className="pt-4">
<div className="flex items-center space-x-4">
<p className="text-white/80 text-sm whitespace-nowrap">Get Application:</p>
<div className="flex space-x-3">
{/* iOS App Store Button */}
<motion.a
href="#"
className="flex items-center justify-center w-12 h-12 bg-white/15 rounded-full hover:bg-white/25 transition-all duration-300 border border-white/20 hover:border-white/40 relative group"
whileHover={{ scale: 1.1, y: -2 }}
whileTap={{ scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
title="Download for iOS"
>
<Apple className="w-6 h-6 text-white fill-current" />
<div className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-black/80 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">
iOS
</div>
</motion.a>
{/* Android App Store Button */}
<motion.a
href="#"
className="flex items-center justify-center w-12 h-12 bg-white/15 rounded-full hover:bg-white/25 transition-all duration-300 border border-white/20 hover:border-white/40 relative group"
whileHover={{ scale: 1.1, y: -2 }}
whileTap={{ scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
title="Download for Android"
>
<Play className="w-6 h-6 text-white fill-current" />
<div className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-black/80 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">
Android
</div>
</motion.a>
</div>
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,38 @@
import { motion } from 'motion/react';
import { footerSections } from '../utils/footerConstants';
export function FooterNavigation() {
return (
<div className="lg:col-span-8 grid grid-cols-2 md:grid-cols-4 gap-8">
{Object.entries(footerSections).map(([key, section]) => (
<motion.div
key={key}
className="space-y-4"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{
duration: 0.5,
delay: Object.keys(footerSections).indexOf(key) * 0.1 + 0.1
}}
>
<h4 className="font-semibold text-white">{section.title}</h4>
<ul className="space-y-3">
{section.links.map((link, index) => (
<li key={link}>
<motion.a
href="#"
className="text-white/80 hover:text-white transition-colors duration-200 text-sm"
whileHover={{ x: 4 }}
transition={{ duration: 0.2 }}
>
{link}
</motion.a>
</li>
))}
</ul>
</motion.div>
))}
</div>
);
}

View File

@@ -0,0 +1,227 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
interface HandwrittenTextProps {
text: string;
className?: string;
style?: React.CSSProperties;
speed?: number; // Characters per second
startDelay?: number; // Delay before animation starts (ms)
onComplete?: () => void;
autoStart?: boolean;
}
export function HandwrittenText({
text,
className = "",
style = {},
speed = 8,
startDelay = 0,
onComplete,
autoStart = true
}: HandwrittenTextProps) {
const [displayedText, setDisplayedText] = useState('');
const [currentIndex, setCurrentIndex] = useState(0);
const [isWriting, setIsWriting] = useState(false);
const [showCursor, setShowCursor] = useState(false);
// Split text into characters, preserving line breaks
const characters = text.split('');
useEffect(() => {
if (!autoStart) return;
const startTimer = setTimeout(() => {
setIsWriting(true);
setShowCursor(true);
}, startDelay);
return () => clearTimeout(startTimer);
}, [autoStart, startDelay]);
useEffect(() => {
if (!isWriting || currentIndex >= characters.length) {
if (currentIndex >= characters.length) {
// Hide cursor after a delay
const cursorTimer = setTimeout(() => {
setShowCursor(false);
setIsWriting(false);
onComplete?.();
}, 800);
return () => clearTimeout(cursorTimer);
}
return;
}
const char = characters[currentIndex];
const baseDelay = 1000 / speed;
// Variable delays for more natural writing
let delay = baseDelay;
if (char === ' ') {
delay = baseDelay * 0.5; // Spaces are quicker
} else if (char === '\n') {
delay = baseDelay * 2; // Line breaks take longer
} else if (['.', '!', '?'].includes(char)) {
delay = baseDelay * 1.5; // Punctuation takes a bit longer
} else if ([',', ';', ':'].includes(char)) {
delay = baseDelay * 1.2;
} else {
// Add some randomness to letter timing
delay = baseDelay * (0.8 + Math.random() * 0.4);
}
const timer = setTimeout(() => {
setDisplayedText(prev => prev + char);
setCurrentIndex(prev => prev + 1);
}, delay);
return () => clearTimeout(timer);
}, [isWriting, currentIndex, characters, speed, onComplete]);
// Reset function for external control
const reset = () => {
setDisplayedText('');
setCurrentIndex(0);
setIsWriting(false);
setShowCursor(false);
};
const start = () => {
reset();
setTimeout(() => {
setIsWriting(true);
setShowCursor(true);
}, startDelay);
};
// Expose control methods
useEffect(() => {
if (typeof window !== 'undefined') {
(window as any).handwrittenTextControls = { reset, start };
}
}, []);
return (
<div className={`relative ${className}`} style={style}>
{/* Main text with character-by-character animation */}
<motion.div
className="relative"
style={{ whiteSpace: 'pre-line' }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{displayedText.split('').map((char, index) => (
<motion.span
key={`${index}-${char}`}
initial={{
opacity: 0,
scale: 0.8,
y: 2
}}
animate={{
opacity: 1,
scale: 1,
y: 0
}}
transition={{
duration: 0.2,
ease: "easeOut",
delay: 0
}}
style={{
display: char === '\n' ? 'block' : 'inline',
width: char === '\n' ? '100%' : 'auto',
height: char === '\n' ? '0' : 'auto'
}}
>
{char === '\n' ? '' : char}
</motion.span>
))}
{/* Writing cursor/pen effect */}
<AnimatePresence>
{showCursor && (
<motion.span
className="inline-block"
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0.3, 1, 0.3],
scale: [0.8, 1.1, 0.8],
rotate: [0, 2, -2, 0]
}}
exit={{ opacity: 0, scale: 0 }}
transition={{
opacity: {
duration: 0.8,
repeat: Infinity,
ease: "easeInOut"
},
scale: {
duration: 0.8,
repeat: Infinity,
ease: "easeInOut"
},
rotate: {
duration: 1.2,
repeat: Infinity,
ease: "easeInOut"
}
}}
style={{
color: 'rgba(85, 70, 50, 0.6)',
marginLeft: '1px'
}}
>
|
</motion.span>
)}
</AnimatePresence>
</motion.div>
{/* Ink flow effect */}
<motion.div
className="absolute inset-0 pointer-events-none"
style={{
background: `
radial-gradient(ellipse at 20% 30%, rgba(85, 70, 50, 0.05) 0%, transparent 60%),
radial-gradient(ellipse at 80% 70%, rgba(101, 84, 63, 0.04) 0%, transparent 50%)
`,
filter: 'blur(2px)',
borderRadius: '4px'
}}
initial={{ opacity: 0 }}
animate={{ opacity: isWriting ? 0.3 : 0 }}
transition={{ duration: 0.5 }}
/>
</div>
);
}
// Hook for controlling the animation externally
export function useHandwrittenText(autoStart = true) {
const [isComplete, setIsComplete] = useState(false);
const reset = () => {
setIsComplete(false);
if (typeof window !== 'undefined' && (window as any).handwrittenTextControls) {
(window as any).handwrittenTextControls.reset();
}
};
const start = () => {
setIsComplete(false);
if (typeof window !== 'undefined' && (window as any).handwrittenTextControls) {
(window as any).handwrittenTextControls.start();
}
};
return {
isComplete,
reset,
start,
onComplete: () => setIsComplete(true)
};
}

134
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,134 @@
import { useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Sheet, SheetContent, SheetTrigger } from './ui/sheet';
import { Search, Menu, MapPin } from 'lucide-react';
interface HeaderProps {
activeCity?: string;
onCityChange?: (city: string) => void;
}
export function Header({ activeCity, onCityChange }: HeaderProps) {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const cities = [
'Paris', 'London', 'New York', 'Tokyo', 'Barcelona', 'Rome'
];
const filteredCities = cities.filter(city =>
city.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleCitySelect = (city: string) => {
onCityChange?.(city);
setSearchQuery('');
setIsSearchOpen(false);
};
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center justify-between px-4">
{/* Logo */}
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-gradient-to-r from-primary to-secondary rounded-lg flex items-center justify-center">
<MapPin className="w-5 h-5 text-white" />
</div>
<span className="text-lg font-semibold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
CityCards
</span>
</div>
</div>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-6">
<a href="#" className="hover:text-primary transition-colors">Cities</a>
<a href="#" className="hover:text-primary transition-colors">Passes</a>
<a href="#" className="hover:text-primary transition-colors">How it Works</a>
<a href="#" className="hover:text-primary transition-colors">Support</a>
</nav>
{/* Search and Actions */}
<div className="flex items-center space-x-3">
{/* City Search */}
<div className="relative hidden md:block">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="Search cities..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setIsSearchOpen(e.target.value.length > 0);
}}
onFocus={() => setIsSearchOpen(true)}
className="pl-10 w-64"
/>
</div>
{isSearchOpen && (
<div className="absolute top-full mt-1 w-full bg-popover border rounded-md shadow-lg z-50">
{filteredCities.length > 0 ? (
filteredCities.map((city) => (
<button
key={city}
onClick={() => handleCitySelect(city)}
className="w-full text-left px-4 py-2 hover:bg-accent transition-colors first:rounded-t-md last:rounded-b-md"
>
{city}
</button>
))
) : (
<div className="px-4 py-2 text-muted-foreground">No cities found</div>
)}
</div>
)}
</div>
<Button variant="outline" className="hidden md:inline-flex">
Sign In
</Button>
<Button className="hidden md:inline-flex bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90">
Get Started
</Button>
{/* Mobile Menu */}
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="md:hidden">
<Menu className="h-4 w-4" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80">
<div className="flex flex-col space-y-4 mt-6">
{/* Mobile Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input placeholder="Search cities..." className="pl-10" />
</div>
{/* Mobile Navigation */}
<nav className="flex flex-col space-y-3">
<a href="#" className="py-2 hover:text-primary transition-colors">Cities</a>
<a href="#" className="py-2 hover:text-primary transition-colors">Passes</a>
<a href="#" className="py-2 hover:text-primary transition-colors">How it Works</a>
<a href="#" className="py-2 hover:text-primary transition-colors">Support</a>
</nav>
<div className="flex flex-col space-y-2 pt-4 border-t">
<Button variant="outline">Sign In</Button>
<Button className="bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90">
Get Started
</Button>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,882 @@
import { useState, useEffect, useRef, forwardRef } from "react";
import {
Star,
Menu,
X,
ShoppingBag,
ChevronDown,
Globe,
} from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { Button } from "./ui/button";
import { CitySubmenu } from "./CitySubmenu";
import Frame1597884853 from '../imports/Frame1597884853';
import { ImageWithFallback } from './figma/ImageWithFallback';
import cityCardsLogo from "figma:asset/4d07c3035c8f965d162e4e0d20cb3910fd5fa6fe.png";
import heroBannerImage from 'figma:asset/d34005cfa14dc032f5b14c284f3ecd65df31444e.png';
import logoImage from 'figma:asset/4d07c3035c8f965d162e4e0d20cb3910fd5fa6fe.png';
import heroFriendsImage from 'figma:asset/85a189eb1e1976493720fa5345a5f387c5edcc1a.png';
interface DropdownItem {
id: string;
label: string;
icon?: React.ReactNode;
action?: () => void;
badge?: string | number;
}
interface CartItem {
id: string;
name: string;
price: string;
image?: string;
quantity: number;
}
interface DropdownProps {
isOpen: boolean;
onToggle: () => void;
items: DropdownItem[];
trigger: React.ReactNode;
title?: string;
className?: string;
}
// Dropdown component with proper ref forwarding
const Dropdown = forwardRef<HTMLDivElement, DropdownProps>(({
isOpen,
onToggle,
items,
trigger,
title,
className = ""
}, ref) => (
<div ref={ref} className={`relative ${className}`}>
<motion.button
onClick={onToggle}
className="relative"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{trigger}
</motion.button>
<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.2, ease: [0.25, 0.1, 0.25, 1] }}
className="absolute top-full right-0 mt-2 bg-white rounded-2xl shadow-xl border border-gray-100 min-w-[220px] overflow-hidden"
style={{ zIndex: 99999 }}
>
{title && (
<div className="px-5 py-4 border-b border-gray-100">
<h3 className="font-semibold text-foreground text-base font-poppins">{title}</h3>
</div>
)}
<div className="py-2">
{items.map((item, index) => (
<motion.button
key={item.id}
onClick={() => {
item.action?.();
onToggle();
}}
className="w-full flex items-center justify-between px-5 py-3 text-left hover:bg-gray-50 transition-colors duration-200"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
whileHover={{ x: 4 }}
>
<div className="flex items-center space-x-3">
{item.icon}
<span className="text-foreground text-base font-poppins">{item.label}</span>
</div>
{item.badge && (
<span className="bg-primary text-white text-xs px-2 py-1 rounded-full font-poppins">
{item.badge}
</span>
)}
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
));
// Set display name for debugging
Dropdown.displayName = 'Dropdown';
interface HeroSectionProps {
onSignInClick?: () => void;
onPassesClick?: () => void;
currentPage?: string;
}
export function HeroSection({
onSignInClick,
onPassesClick,
currentPage,
}: HeroSectionProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [activeLanguageDropdown, setActiveLanguageDropdown] = useState(false);
const [activeCartDropdown, setActiveCartDropdown] = useState(false);
const [showCitySubmenu, setShowCitySubmenu] = useState(true);
const languageRef = useRef<HTMLDivElement>(null);
const cartRef = useRef<HTMLDivElement>(null);
// Languages available
const languages: DropdownItem[] = [
{ id: 'en', label: 'English', icon: <span className="text-base">🇺🇸</span> },
{ id: 'es', label: 'Español', icon: <span className="text-base">🇪🇸</span> },
{ id: 'fr', label: 'Français', icon: <span className="text-base">🇫🇷</span> },
{ id: 'de', label: 'Deutsch', icon: <span className="text-base">🇩🇪</span> },
{ id: 'it', label: 'Italiano', icon: <span className="text-base">🇮🇹</span> },
];
// Mock cart items
const cartItems: CartItem[] = [
{ id: '1', name: 'Sydney 2-Day Pass', price: '$89', quantity: 1 },
{ id: '2', name: 'Melbourne Premium Pass', price: '$129', quantity: 1 },
];
// Section IDs for navigation
const sectionIds = [
'hero-section',
'why-choose-section',
'variety-adventures-section',
'how-it-works-section',
'magic-itinerary-section',
'book-attraction-section',
'custom-postcards-section',
'upcoming-cities-section',
'trust-section',
'mobile-app-section'
];
const scrollToSection = (index: number) => {
const sectionId = sectionIds[index];
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
setIsMobileMenuOpen(false);
};
const closeMobileMenu = () => {
setIsMobileMenuOpen(false);
};
// Create click handlers for the navbar elements
const handleNavClick = (section: string) => {
switch (section) {
case 'about':
scrollToSection(0);
break;
case 'products':
onPassesClick?.();
break;
case 'attractions':
console.log('Navigate to attractions');
break;
case 'offer':
scrollToSection(5);
break;
case 'card':
scrollToSection(9);
break;
default:
break;
}
};
// Check if navigation item is active (simplified - only based on current page)
const isNavItemActive = (action: string) => {
return currentPage === action;
};
// Calculate cart total
const cartTotal = cartItems.reduce((total, item) => {
const price = parseFloat(item.price.replace('$', ''));
return total + (price * item.quantity);
}, 0);
// Detect scroll for navbar styling
useEffect(() => {
const handleScroll = () => {
const scrolled = window.scrollY > 20;
setIsScrolled(scrolled);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// 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);
}, []);
// Close mobile menu on escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsMobileMenuOpen(false);
setActiveLanguageDropdown(false);
setActiveCartDropdown(false);
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, []);
return (
<div className="relative">
{/* Sticky Hero Background Container */}
<div
className="sticky top-0 min-h-screen bg-cover bg-center bg-no-repeat overflow-hidden"
style={{
backgroundImage: `linear-gradient(to bottom, rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.75)), url('${heroBannerImage}')`,
}}
>
{/* Enhanced Beach Vibes with Floating Elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{/* Floating geometric elements inspired by the beach theme */}
<motion.div
className="absolute top-1/4 right-1/4 w-32 h-32 bg-white/10 rounded-full blur-xl"
animate={{
y: [0, -20, 0],
x: [0, 10, 0],
scale: [1, 1.1, 1],
}}
transition={{
duration: 6,
repeat: Infinity,
ease: "easeInOut",
}}
/>
<motion.div
className="absolute bottom-1/3 left-1/4 w-24 h-24 bg-secondary/20 rounded-full blur-lg"
animate={{
y: [0, 15, 0],
x: [0, -15, 0],
scale: [1, 0.9, 1],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay: 2,
}}
/>
<motion.div
className="absolute top-2/3 right-1/3 w-16 h-16 bg-primary/15 rounded-full blur-md"
animate={{
y: [0, -25, 0],
scale: [1, 1.2, 1],
opacity: [0.3, 0.6, 0.3],
}}
transition={{
duration: 10,
repeat: Infinity,
ease: "easeInOut",
delay: 4,
}}
/>
</div>
{/* Desktop Navbar - Fixed and Sticky */}
<motion.nav
className="fixed top-6 left-0 right-0 hidden lg:block"
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 }}
style={{ position: 'fixed', zIndex: 99999 }}
>
<div className="container mx-auto px-4">
<motion.div
className="bg-white backdrop-blur-[20px] rounded-full px-8 py-4 shadow-lg shadow-black/5 border border-white/20 w-full"
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] }}
>
{/* Using justify-between layout like Figma reference */}
<div className="flex items-center justify-between w-full">
{/* Logo Section - Increased Size */}
<motion.div
className="flex items-center cursor-pointer flex-shrink-0"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => handleNavClick('about')}
>
<ImageWithFallback
src={logoImage}
alt="CityCards Logo"
className="h-10 w-auto"
/>
</motion.div>
{/* Navigation Links - No automatic highlighting */}
<div className="flex items-center gap-[51px] absolute left-1/2 -translate-x-1/2">
{[
{ label: 'About Us', action: 'about' },
{ label: 'Your Card', action: 'card' }
].map((item) => (
<motion.button
key={item.action}
onClick={() => handleNavClick(item.action)}
className={`relative px-0 py-2 text-base font-medium font-poppins transition-all duration-200 whitespace-nowrap group capitalize ${
isNavItemActive(item.action)
? 'text-primary'
: 'text-foreground hover:text-primary'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{item.label}
{/* Active indicator - only for current page */}
<motion.div
className="absolute bottom-0 left-0 h-0.5 bg-primary 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-gray-100 rounded-lg -z-10"
initial={{ scale: 0, opacity: 0 }}
whileHover={{ scale: 1, opacity: 0.5 }}
transition={{ duration: 0.2 }}
/>
</motion.button>
))}
{/* Our Products */}
<button
onClick={() => handleNavClick('products')}
className="flex items-center text-foreground hover:text-primary text-base font-medium font-poppins transition-colors duration-200 cursor-pointer rounded-lg hover:bg-gray-50 px-2 py-1"
>
<span>Our Products</span>
</button>
</div>
{/* Right Section - Increased Sizes */}
<div className="flex items-center gap-5">
{/* Language Dropdown - Increased Font */}
<Dropdown
ref={languageRef}
isOpen={activeLanguageDropdown}
onToggle={() => setActiveLanguageDropdown(!activeLanguageDropdown)}
items={languages}
title="Select Language"
trigger={
<div className="flex items-center space-x-2 text-foreground hover:text-primary px-0 py-2 text-base font-medium font-poppins transition-colors duration-200 cursor-pointer rounded-lg hover:bg-gray-50 uppercase">
<Globe className="w-5 h-5" />
<span>ENG</span>
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${activeLanguageDropdown ? 'rotate-180' : ''}`} />
</div>
}
/>
{/* Shopping Cart - Increased Size */}
<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-foreground hover:text-primary p-0 transition-colors duration-200 rounded-lg hover:bg-gray-50 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-white font-bold font-poppins">{cartItems.length}</span>
</motion.div>
</div>
}
/>
{/* CTA Button with Shine Effect */}
<Button
onClick={onSignInClick}
withShine={true}
size="lg"
className="min-w-[180px] font-poppins font-semibold rounded-full"
>
GET A CITY CARD
</Button>
</div>
</div>
</motion.div>
</div>
</motion.nav>
{/* Medium Screen Navbar - Fixed and Sticky */}
<motion.nav
className="fixed top-6 left-0 right-0 hidden md:block lg:hidden"
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 }}
style={{ position: 'fixed', zIndex: 99999 }}
>
<div className="container mx-auto px-4">
<motion.div
className="bg-white/95 backdrop-blur-md rounded-full px-6 py-3 shadow-lg shadow-black/5 border border-gray-100/50 w-full"
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] }}
>
<div className="flex items-center justify-between w-full">
{/* Logo - Increased Size */}
<motion.div
className="flex items-center cursor-pointer flex-shrink-0"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => handleNavClick('about')}
>
<ImageWithFallback
src={logoImage}
alt="CityCards Logo"
className="h-9 w-auto"
/>
</motion.div>
{/* Navigation - Increased Font */}
<div className="flex items-center gap-[40px]">
{[
{ label: 'About Us', action: 'about' },
{ label: 'Cities', action: 'products' },
{ 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-1.5 text-base font-poppins font-medium transition-all duration-200 whitespace-nowrap group capitalize ${
isNavItemActive(item.action)
? 'text-primary'
: 'text-foreground hover:text-primary'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{item.label}
<motion.div
className="absolute bottom-0 left-0 h-0.5 bg-primary 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 }}
/>
</motion.button>
))}
</div>
{/* Right Section - Increased Sizes */}
<div className="flex items-center gap-4">
<Dropdown
ref={languageRef}
isOpen={activeLanguageDropdown}
onToggle={() => setActiveLanguageDropdown(!activeLanguageDropdown)}
items={languages}
title="Select Language"
trigger={
<div className="flex items-center space-x-1.5 text-gray-700 hover:text-gray-900 text-base font-medium transition-colors duration-200 cursor-pointer rounded-lg hover:bg-gray-50 uppercase">
<Globe className="w-4 h-4" />
<span>ENG</span>
<ChevronDown className={`w-3.5 h-3.5 transition-transform duration-200 ${activeLanguageDropdown ? 'rotate-180' : ''}`} />
</div>
}
/>
<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-foreground hover:text-primary transition-colors duration-200 rounded-lg hover:bg-gray-50 cursor-pointer">
<ShoppingBag className="w-6 h-6" />
<motion.div
className="absolute -top-1 -right-1 w-5 h-5 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-white font-poppins font-bold">{cartItems.length}</span>
</motion.div>
</div>
}
/>
{/* CTA Button with Shine Effect */}
<Button
onClick={onSignInClick}
withShine={true}
className="h-[44px] min-w-[155px] px-5 py-3 rounded-full text-white font-medium"
>
GET A CITY CARD
</Button>
</div>
</div>
</motion.div>
</div>
</motion.nav>
{/* Mobile Navbar - Fixed and Sticky */}
<nav className="fixed top-0 w-full lg:hidden" style={{ position: 'fixed', zIndex: 99999 }}>
<div className="bg-white/95 backdrop-blur-lg border-b border-gray-100 shadow-sm">
<div className="container mx-auto px-4">
<div className="flex justify-between items-center h-16">
{/* Mobile Logo - Increased Size */}
<motion.div
className="flex items-center cursor-pointer"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleNavClick('about')}
>
<ImageWithFallback
src={logoImage}
alt="CityCards Logo"
className="h-10 w-auto"
/>
</motion.div>
{/* Mobile Actions - Increased Sizes */}
<div className="flex items-center space-x-2">
{/* Mobile Cart - Increased Size */}
<motion.button
className="relative text-foreground hover:text-primary p-2 transition-colors duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => setActiveCartDropdown(!activeCartDropdown)}
>
<ShoppingBag className="w-6 h-6" />
<div className="absolute -top-1 -right-1 w-5 h-5 bg-primary rounded-full flex items-center justify-center">
<span className="text-xs text-white font-poppins font-bold">{cartItems.length}</span>
</div>
</motion.button>
{/* Mobile menu button - Increased Size */}
<motion.button
onClick={() => setIsMobileMenuOpen(true)}
className="inline-flex items-center justify-center p-2 rounded-lg text-foreground hover:text-primary hover:bg-gray-100 transition-colors duration-200"
aria-label="Open menu"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Menu className="w-7 h-7" />
</motion.button>
</div>
</div>
</div>
</div>
</nav>
{/* Mobile Menu Overlay - Already without Arrow */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
className="fixed inset-0 lg:hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
style={{ zIndex: 100000 }}
>
{/* Backdrop */}
<motion.div
className="fixed inset-0 bg-black/50"
onClick={closeMobileMenu}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
{/* Menu Panel */}
<motion.div
className="fixed top-4 right-4 left-4 bottom-4 bg-white rounded-3xl shadow-xl flex flex-col border border-gray-100 max-h-[calc(100vh-32px)] overflow-hidden"
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
>
{/* Header - Increased Font */}
<div className="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
<div className="flex items-center">
<ImageWithFallback
src={logoImage}
alt="CityCards Logo"
className="h-10 w-auto"
/>
</div>
<motion.button
onClick={closeMobileMenu}
className="p-2 rounded-lg text-gray-700 hover:text-gray-900 hover:bg-gray-100 transition-colors duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<X className="w-7 h-7" />
</motion.button>
</div>
{/* Content - Increased Font Sizes */}
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-8">
{/* Navigation Links - Increased Font, No automatic highlighting */}
<div className="space-y-2">
<h3 className="text-base font-poppins font-semibold text-muted-foreground uppercase tracking-wider mb-4">Navigation</h3>
{[
{ label: 'About Us', action: 'about' },
{ label: 'Cities', action: 'products' },
{ label: 'Your Card', action: 'card' },
{ label: 'Deals', action: 'offer' }
].map((item, index) => (
<motion.button
key={item.action}
onClick={() => {
handleNavClick(item.action);
closeMobileMenu();
}}
className={`w-full text-left px-4 py-4 rounded-xl transition-all duration-200 text-xl font-poppins font-medium flex items-center justify-between group ${
isNavItemActive(item.action)
? 'bg-primary/10 text-primary border border-primary/20'
: 'text-foreground hover:text-primary hover:bg-gray-50'
}`}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02, x: 10 }}
whileTap={{ scale: 0.98 }}
>
<span>{item.label}</span>
{isNavItemActive(item.action) && (
<motion.div
className="w-3 h-3 bg-primary rounded-full"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2 }}
/>
)}
</motion.button>
))}
</div>
{/* Language Selection - Increased Font */}
<div className="space-y-2">
<h3 className="text-base font-poppins font-semibold text-muted-foreground uppercase tracking-wider mb-4">Language</h3>
<div className="grid grid-cols-2 gap-2">
{languages.slice(0, 4).map((lang, index) => (
<motion.button
key={lang.id}
className="flex items-center space-x-3 p-3 rounded-xl bg-gray-50 hover:bg-gray-100 transition-colors duration-200 text-base font-poppins"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.05 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{lang.icon}
<span className="text-foreground font-medium">{lang.label}</span>
</motion.button>
))}
</div>
</div>
{/* Shopping Cart Summary - Increased Font */}
<div className="space-y-2">
<h3 className="text-base font-poppins font-semibold text-muted-foreground uppercase tracking-wider mb-4">Cart ({cartItems.length})</h3>
<div className="space-y-2">
{cartItems.map((item, index) => (
<motion.div
key={item.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-xl text-base font-poppins"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
>
<span className="text-foreground font-medium">{item.name}</span>
<span className="text-primary font-bold">{item.price}</span>
</motion.div>
))}
<div className="border-t border-gray-200 pt-3 mt-3">
<div className="flex items-center justify-between text-lg font-poppins font-bold">
<span className="text-foreground">Total:</span>
<span className="text-primary">${cartTotal.toFixed(2)}</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Footer - CTA Button */}
<div className="p-6 border-t border-gray-100 flex-shrink-0">
<Button
onClick={() => {
onSignInClick?.();
closeMobileMenu();
}}
withShine={true}
className="w-full h-[56px] rounded-xl text-white font-medium text-lg"
>
GET A CITY CARD
</Button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* City Submenu */}
{showCitySubmenu && (
<CitySubmenu
onClose={() => setShowCitySubmenu(false)}
currentPage={currentPage}
onHomeClick={() => console.log('Home clicked')}
onMelbourneClick={() => console.log('Melbourne clicked')}
onAttractionsClick={() => console.log('Attractions clicked')}
onPassesClick={onPassesClick}
onBlogsClick={() => console.log('Blogs clicked')}
onHowItWorksClick={() => console.log('How It Works clicked')}
/>
)}
{/* Hero Content */}
<div
className="relative z-10 min-h-screen flex items-end justify-start pt-24 pb-16 bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: `url(${heroBannerImage})` }}
>
<div className="container mx-auto px-4">
<motion.div
className="max-w-4xl lg:max-w-3xl xl:max-w-4xl"
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],
}}
>
{/* Main Headline */}
<div className="mb-6 text-left">
<h1 className="font-poppins leading-tight text-white text-5xl md:text-6xl lg:text-7xl drop-shadow-lg">
<span className="block font-light">CityCards.</span>
<span className="block font-normal">See More, Spend Less.</span>
</h1>
</div>
{/* Subheading */}
<div className="mb-8 text-left">
<p className="font-poppins text-xl leading-relaxed font-normal text-white/90 drop-shadow-md">
Instant QR access to 40+ attractions,
<br className="hidden sm:block" />
exclusive perks, and savings up to 30%
</p>
</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],
}}
>
<Button
withShine={true}
size="xl"
className="bg-primary hover:bg-primary/90 py-4 rounded-full text-lg font-poppins font-semibold px-8 text-white shadow-lg hover:shadow-xl transition-all duration-300"
>
Explore Cities
</Button>
</motion.div>
</motion.div>
</div>
</div>
</div>
</div>
);
}

108
src/components/HomePage.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { motion, useScroll, useSpring, useTransform } from 'motion/react';
import { HeroSection } from './HeroSection';
import { WhyChooseCityCards } from './WhyChooseCityCards';
import { VarietyOfAdventures } from './VarietyOfAdventures';
import { MobileAppSection } from './MobileAppSection';
import { MagicItinerary } from './MagicItinerary';
import { ScrollAnimatedJourney } from './ScrollAnimatedJourney';
import { CustomPostcards } from './CustomPostcards';
import { BookAttractionSection } from './BookAttractionSection';
import { UpcomingCities } from './UpcomingCities';
import { TrustSection } from './TrustSection';
import { Footer } from './Footer';
import { SectionWrapper } from './SectionWrapper';
import { sectionsConfig } from '../utils/sections';
import {
heroVariants,
staggerContainer,
backgroundVariants
} from '../utils/animations';
interface HomePageProps {
isMobile: boolean;
onSignInClick?: () => void;
onPassesClick?: () => void;
currentPage?: string;
}
export function HomePage({ isMobile, onSignInClick, onPassesClick, currentPage }: HomePageProps) {
// Smooth scroll progress for global effects
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001
});
// Parallax effect for scroll progress
const progressOpacity = useTransform(scrollYProgress, [0, 0.1], [0, 1]);
const sectionComponents = [
WhyChooseCityCards,
VarietyOfAdventures,
ScrollAnimatedJourney,
MagicItinerary,
BookAttractionSection,
CustomPostcards,
UpcomingCities,
TrustSection,
MobileAppSection
];
return (
<>
{/* Scroll Progress Indicator */}
<motion.div
className="fixed top-0 left-0 right-0 h-1 bg-warm-coral origin-left z-40"
style={{
scaleX,
opacity: progressOpacity
}}
/>
{/* Main Content - No padding needed as capsule navbar floats */}
<main>
{/* 1. Hero Section - Immediate Load Animation */}
<motion.section
id="hero-section"
initial="hidden"
animate="visible"
variants={staggerContainer}
className="relative overflow-hidden"
>
<motion.div variants={backgroundVariants}>
<motion.div variants={heroVariants}>
<HeroSection
onSignInClick={onSignInClick}
onPassesClick={onPassesClick}
currentPage={currentPage}
/>
</motion.div>
</motion.div>
</motion.section>
{/* 2-10. All Other Sections */}
{sectionsConfig.map((config, index) => {
const Component = sectionComponents[index];
return (
<SectionWrapper
key={config.id}
id={config.id}
containerType={config.containerType}
backgroundGradient={config.backgroundGradient}
className={config.className}
variantType={config.variantType}
isMobile={isMobile}
>
<Component />
</SectionWrapper>
);
})}
</main>
{/* 11. Footer */}
<Footer />
</>
);
}

View File

@@ -0,0 +1,632 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Wand2, MapPin, Clock, DollarSign, Sparkles, Star, Navigation, Plane, Map } from 'lucide-react';
import { Button } from './ui/button';
import { ImageWithFallback } from './figma/ImageWithFallback';
interface ItineraryCard {
id: number;
city: string;
country: string;
days: number;
image: string;
activities: {
time: string;
name: string;
price: string;
}[];
totalCost: string;
highlights: string[];
}
const itineraryCards: ItineraryCard[] = [
{
id: 1,
city: 'Paris',
country: 'France',
days: 3,
image: 'https://images.unsplash.com/photo-1431274172761-fca41d930114?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxQYXJpcyUyMEVpZmZlbCUyMFRvd2VyfGVufDF8fHx8MTc1OTIzNTg5MXww&ixlib=rb-4.1.0&q=80&w=1080',
activities: [
{ time: '9:00 AM', name: 'Eiffel Tower', price: '$28' },
{ time: '1:00 PM', name: 'Louvre Museum', price: '$18' },
{ time: '6:00 PM', name: 'Seine River Cruise', price: '$22' },
],
totalCost: '$68',
highlights: ['Art & Culture', 'Historic Sites', 'Fine Dining'],
},
{
id: 2,
city: 'Tokyo',
country: 'Japan',
days: 4,
image: 'https://images.unsplash.com/photo-1717986439981-0c6a51130cfa?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxUb2t5byUyMGNpdHklMjBza3lsaW5lfGVufDF8fHx8MTc1OTI5Nzc3NHww&ixlib=rb-4.1.0&q=80&w=1080',
activities: [
{ time: '8:00 AM', name: 'Senso-ji Temple', price: 'Free' },
{ time: '12:00 PM', name: 'Tokyo Skytree', price: '$24' },
{ time: '5:00 PM', name: 'Shibuya Crossing', price: 'Free' },
],
totalCost: '$24',
highlights: ['Modern Culture', 'Temples', 'Street Food'],
},
{
id: 3,
city: 'New York',
country: 'USA',
days: 3,
image: 'https://images.unsplash.com/photo-1698066574628-3d1a68c2f204?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxOZXclMjBZb3JrJTIwQ2l0eSUyME1hbmhhdHRhbnxlbnwxfHx8fDE3NTkyOTc3NzR8MA&ixlib=rb-4.1.0&q=80&w=1080',
activities: [
{ time: '9:00 AM', name: 'Central Park', price: 'Free' },
{ time: '2:00 PM', name: 'Empire State Building', price: '$44' },
{ time: '7:00 PM', name: 'Times Square', price: 'Free' },
],
totalCost: '$44',
highlights: ['Urban Adventure', 'Skyscrapers', 'Broadway'],
},
{
id: 4,
city: 'London',
country: 'UK',
days: 4,
image: 'https://images.unsplash.com/photo-1745016176874-cd3ed3f5bfc6?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxMb25kb24lMjBCaWclMjBCZW58ZW58MXx8fHwxNzU5Mjk3Nzc1fDA&ixlib=rb-4.1.0&q=80&w=1080',
activities: [
{ time: '10:00 AM', name: 'Tower of London', price: '$35' },
{ time: '2:00 PM', name: 'British Museum', price: 'Free' },
{ time: '6:00 PM', name: 'London Eye', price: '$32' },
],
totalCost: '$67',
highlights: ['Royal Heritage', 'Museums', 'Theatre'],
},
{
id: 5,
city: 'Barcelona',
country: 'Spain',
days: 3,
image: 'https://images.unsplash.com/photo-1653677903266-1d814985b3cc?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxCYXJjZWxvbmElMjBhcmNoaXRlY3R1cmV8ZW58MXx8fHwxNzU5Mjk3Nzc2fDA&ixlib=rb-4.1.0&q=80&w=1080',
activities: [
{ time: '9:30 AM', name: 'Sagrada Familia', price: '$26' },
{ time: '1:00 PM', name: 'Park Güell', price: '$14' },
{ time: '5:00 PM', name: 'La Rambla', price: 'Free' },
],
totalCost: '$40',
highlights: ['Gaudí Architecture', 'Beach', 'Tapas'],
},
{
id: 6,
city: 'Dubai',
country: 'UAE',
days: 3,
image: 'https://images.unsplash.com/photo-1537132766573-55e8b870c5d6?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxEdWJhaSUyMGNpdHlzY2FwZXxlbnwxfHx8fDE3NTkyOTc3NzV8MA&ixlib=rb-4.1.0&q=80&w=1080',
activities: [
{ time: '10:00 AM', name: 'Burj Khalifa', price: '$45' },
{ time: '3:00 PM', name: 'Dubai Mall', price: 'Free' },
{ time: '7:00 PM', name: 'Desert Safari', price: '$75' },
],
totalCost: '$120',
highlights: ['Luxury', 'Desert Adventure', 'Shopping'],
},
];
export function MagicItinerary() {
const [currentCardIndex, setCurrentCardIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
const interval = setInterval(() => {
setIsAnimating(true);
setTimeout(() => {
setCurrentCardIndex((prev) => (prev + 1) % itineraryCards.length);
setIsAnimating(false);
}, 400); // Half of the animation duration
}, 4000);
return () => clearInterval(interval);
}, []);
const currentCard = itineraryCards[currentCardIndex];
const nextCard = itineraryCards[(currentCardIndex + 1) % itineraryCards.length];
const thirdCard = itineraryCards[(currentCardIndex + 2) % itineraryCards.length];
return (
<section className="relative py-20 lg:py-32 overflow-hidden -mt-20 pt-32 z-[100]">
{/* Dynamic City Background */}
<AnimatePresence mode="wait">
<motion.div
key={currentCard.id}
className="absolute inset-0 overflow-hidden pointer-events-none z-[5]"
initial={{ opacity: 0, scale: 1.05 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.05 }}
transition={{ duration: 0.8, ease: "easeInOut" }}
>
{/* City Background Image */}
<div className="absolute inset-0">
<ImageWithFallback
src={currentCard.image}
alt={`${currentCard.city} background`}
className="w-full h-full object-cover"
/>
</div>
{/* Lightened Multi-layer Gradient Overlay - Reduced opacity to show city */}
<div className="absolute inset-0 bg-gradient-to-br from-rose-50/50 via-orange-50/40 to-amber-50/50" />
<div className="absolute inset-0 backdrop-blur-lg" />
<div className="absolute inset-0 bg-gradient-to-t from-white/30 via-transparent to-white/30" />
</motion.div>
</AnimatePresence>
{/* White Readability Overlay - 42% Opacity */}
<div className="absolute inset-0 bg-white/42 pointer-events-none z-[10]" />
{/* Simplified Decorative Elements - Optimized */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{/* Single Coral Gradient Blob - Optimized */}
<motion.div
className="absolute top-20 -left-20 w-[500px] h-[500px] bg-gradient-to-br from-warm-coral/20 via-orange-400/10 to-transparent rounded-full blur-3xl will-change-transform"
animate={{
scale: [1, 1.15, 1],
x: [0, 30, 0],
}}
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
/>
{/* Simplified Floating Icons - Reduced from 8 to 4 */}
{[...Array(4)].map((_, i) => (
<motion.div
key={i}
className="absolute will-change-transform"
style={{
top: `${25 + (i * 20)}%`,
left: `${15 + (i * 20)}%`,
}}
animate={{
y: [0, -20, 0],
opacity: [0.15, 0.35, 0.15],
}}
transition={{
duration: 6 + i * 2,
repeat: Infinity,
ease: "easeInOut",
delay: i * 0.8,
}}
>
{i % 2 === 0 ? (
<Plane className="w-6 h-6 text-warm-coral" />
) : (
<MapPin className="w-6 h-6 text-warm-coral" />
)}
</motion.div>
))}
</div>
<div className="container mx-auto px-4 relative z-[101] flex flex-col items-center">
{/* Header */}
<div className="text-center mb-20 max-w-5xl w-full">
<motion.div
className="inline-flex items-center gap-3 bg-gradient-to-r from-warm-coral/10 to-orange-100/50 backdrop-blur-sm px-6 py-3 rounded-full border-2 border-warm-coral/30 shadow-xl mb-8"
initial={{ opacity: 0, scale: 0.8, y: 20 }}
whileInView={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.7, ease: [0.34, 1.56, 0.64, 1] }}
viewport={{ once: true }}
>
<motion.div
animate={{
rotate: [0, 20, -20, 0],
scale: [1, 1.2, 1.2, 1],
}}
transition={{ duration: 2, repeat: Infinity }}
>
<Wand2 className="w-6 h-6 text-warm-coral drop-shadow-lg" />
</motion.div>
<span className="font-semibold text-gray-800">AI-Powered Magic Itinerary</span>
<motion.div
className="w-2 h-2 bg-warm-coral rounded-full"
animate={{
scale: [1, 1.5, 1],
opacity: [1, 0.5, 1],
}}
transition={{ duration: 1.5, repeat: Infinity }}
/>
</motion.div>
<motion.h2
className="text-4xl md:text-5xl lg:text-6xl mb-8 leading-tight"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.1, ease: [0.34, 1.56, 0.64, 1] }}
viewport={{ once: true }}
>
<span className="font-light">Plan Your</span>{' '}
<span className="font-bold italic bg-gradient-to-r from-warm-coral via-orange-500 to-rose-500 bg-clip-text text-transparent drop-shadow-lg">
Dream Journey
</span>
<br />
<span className="font-normal">in Just</span>{' '}
<span className="font-bold text-warm-coral">3 Seconds</span>
<motion.span
className="inline-block ml-2"
animate={{
rotate: [0, 10, -10, 0],
y: [0, -5, 0],
}}
transition={{ duration: 2, repeat: Infinity, delay: 0.5 }}
>
</motion.span>
</motion.h2>
<motion.p
className="text-xl md:text-2xl text-gray-700 leading-relaxed max-w-3xl mx-auto"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
viewport={{ once: true }}
>
Our AI creates <span className="font-semibold text-warm-coral">personalized itineraries</span> with
perfectly timed activities, optimized routes, and <span className="font-semibold text-warm-coral">curated experiences</span> tailored
just for you.
</motion.p>
</div>
{/* Card Stack Display */}
<div className="max-w-6xl w-full">
<div className="relative flex justify-center items-center min-h-[600px] lg:min-h-[650px] perspective-1000">
{/* Card Stack Container */}
<div className="relative w-full max-w-md lg:max-w-lg" style={{ transformStyle: 'preserve-3d' }}>
{/* Visible Card Stack - Third Card */}
<motion.div
className="absolute inset-0 bg-white rounded-3xl shadow-lg overflow-hidden"
style={{
zIndex: 1,
transformStyle: 'preserve-3d'
}}
animate={{
scale: 0.88,
y: 24,
opacity: 0.4,
}}
transition={{ duration: 0.3 }}
>
<div className="relative h-64 lg:h-80 overflow-hidden">
<ImageWithFallback
src={thirdCard.image}
alt={`${thirdCard.city}, ${thirdCard.country}`}
className="w-full h-full object-cover opacity-60"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent" />
<div className="absolute bottom-6 left-6 right-6">
<h3 className="text-2xl lg:text-3xl font-bold text-white opacity-70">
{thirdCard.city}
</h3>
</div>
</div>
</motion.div>
{/* Visible Card Stack - Second Card */}
<motion.div
className="absolute inset-0 bg-white rounded-3xl shadow-xl overflow-hidden"
style={{
zIndex: 2,
transformStyle: 'preserve-3d'
}}
animate={{
scale: 0.94,
y: 12,
opacity: 0.7,
}}
transition={{ duration: 0.3 }}
>
<div className="relative h-64 lg:h-80 overflow-hidden">
<ImageWithFallback
src={nextCard.image}
alt={`${nextCard.city}, ${nextCard.country}`}
className="w-full h-full object-cover opacity-80"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent" />
<div className="absolute bottom-6 left-6 right-6">
<h3 className="text-2xl lg:text-3xl font-bold text-white opacity-80">
{nextCard.city}
</h3>
</div>
</div>
</motion.div>
{/* Animated Front Card */}
<AnimatePresence mode="popLayout">
<motion.div
key={currentCard.id}
className="relative bg-white rounded-3xl shadow-2xl overflow-hidden"
style={{
zIndex: 3,
transformStyle: 'preserve-3d'
}}
initial={{
scale: 0.94,
y: 12,
opacity: 0.7,
}}
animate={{
scale: 1,
opacity: 1,
y: 0,
}}
exit={{
scale: 1.05,
opacity: 0,
x: -100,
rotateZ: -5,
}}
transition={{
duration: 0.6,
ease: [0.34, 1.56, 0.64, 1], // Bounce easing
}}
>
{/* Card Image */}
<div className="relative h-64 lg:h-80 overflow-hidden">
<ImageWithFallback
src={currentCard.image}
alt={`${currentCard.city}, ${currentCard.country}`}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent" />
{/* City Info Overlay */}
<motion.div
className="absolute bottom-6 left-6 right-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.5, ease: [0.34, 1.56, 0.64, 1] }}
>
<h3 className="text-3xl lg:text-4xl font-bold text-white mb-2">
{currentCard.city}
</h3>
<p className="text-white/90 text-lg flex items-center gap-2">
<MapPin className="w-5 h-5" />
{currentCard.country}
</p>
</motion.div>
{/* Duration Badge */}
<motion.div
className="absolute top-6 right-6 bg-gradient-to-r from-warm-coral to-orange-500 px-4 py-2 rounded-full shadow-xl"
initial={{ scale: 0, opacity: 0, rotate: -180 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{ delay: 0.3, duration: 0.6, type: "spring", stiffness: 200, damping: 12 }}
>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-white" />
<span className="font-bold text-white">{currentCard.days} Days</span>
</div>
</motion.div>
{/* Top Left Journey Icon */}
<motion.div
className="absolute top-6 left-6 bg-white/95 backdrop-blur-sm p-3 rounded-full shadow-lg"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2, duration: 0.5, type: "spring" }}
>
<Plane className="w-5 h-5 text-warm-coral" />
</motion.div>
</div>
{/* Card Content */}
<motion.div
className="p-4 lg:p-5"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3, duration: 0.5 }}
>
{/* Highlights - Compact */}
<motion.div
className="flex flex-wrap gap-1.5 mb-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.4 }}
>
{currentCard.highlights.map((highlight, idx) => (
<span
key={idx}
className="px-3 py-1 bg-gradient-to-r from-warm-coral/15 to-orange-100 text-warm-coral border border-warm-coral/20 rounded-full text-xs font-semibold shadow-sm transition-transform hover:scale-105"
>
{highlight}
</span>
))}
</motion.div>
{/* Day 1 Header */}
<motion.div
className="flex items-center gap-2 mb-2.5"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.35, duration: 0.4 }}
>
<div className="flex items-center gap-1.5 px-3 py-1 bg-gradient-to-r from-warm-coral to-orange-500 rounded-full shadow-md">
<Clock className="w-3 h-3 text-white" />
<span className="text-xs font-bold text-white">Day 1</span>
</div>
<div className="flex-1 h-px bg-gradient-to-r from-warm-coral/30 to-transparent" />
</motion.div>
{/* Day 1 Activities - Compact */}
<motion.div
className="space-y-2 mb-3"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.4 }}
>
{currentCard.activities.slice(0, 2).map((activity, idx) => (
<div
key={idx}
className="relative flex items-center gap-2.5 p-2.5 bg-gradient-to-r from-white to-orange-50/30 rounded-xl border border-warm-coral/10 hover:border-warm-coral/30 hover:shadow-md transition-all group hover:translate-x-0.5"
>
{/* Route Line Connector */}
{idx < 1 && (
<div className="absolute left-4 top-full h-2 w-0.5 bg-gradient-to-b from-warm-coral/50 to-transparent" />
)}
<div className="relative flex-shrink-0">
<div className="w-7 h-7 bg-gradient-to-br from-warm-coral to-orange-500 rounded-full flex items-center justify-center shadow-md">
<span className="text-white font-bold text-xs">{idx + 1}</span>
</div>
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-green-400 rounded-full border border-white" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-semibold text-gray-900 group-hover:text-warm-coral transition-colors leading-tight mb-0.5">{activity.name}</p>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Clock className="w-2.5 h-2.5" />
<span>{activity.time}</span>
</div>
</div>
</div>
))}
</motion.div>
{/* Day 2 Header */}
<motion.div
className="flex items-center gap-2 mb-2.5"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.45, duration: 0.4 }}
>
<div className="flex items-center gap-1.5 px-3 py-1 bg-gradient-to-r from-orange-500 to-rose-500 rounded-full shadow-md">
<Clock className="w-3 h-3 text-white" />
<span className="text-xs font-bold text-white">Day 2</span>
</div>
<div className="flex-1 h-px bg-gradient-to-r from-orange-500/30 to-transparent" />
</motion.div>
{/* Day 2 Activities - Compact */}
<motion.div
className="space-y-2"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5, duration: 0.4 }}
>
{currentCard.activities.slice(2, 4).map((activity, idx) => (
<div
key={idx + 2}
className="relative flex items-center gap-2.5 p-2.5 bg-gradient-to-r from-white to-rose-50/30 rounded-xl border border-orange-500/10 hover:border-orange-500/30 hover:shadow-md transition-all group hover:translate-x-0.5"
>
{/* Route Line Connector */}
{idx < 1 && (
<div className="absolute left-4 top-full h-2 w-0.5 bg-gradient-to-b from-orange-500/50 to-transparent" />
)}
<div className="relative flex-shrink-0">
<div className="w-7 h-7 bg-gradient-to-br from-orange-500 to-rose-500 rounded-full flex items-center justify-center shadow-md">
<span className="text-white font-bold text-xs">{idx + 1}</span>
</div>
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-green-400 rounded-full border border-white" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-semibold text-gray-900 group-hover:text-orange-500 transition-colors leading-tight mb-0.5">{activity.name}</p>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Clock className="w-2.5 h-2.5" />
<span>{activity.time}</span>
</div>
</div>
</div>
))}
</motion.div>
</motion.div>
</motion.div>
</AnimatePresence>
{/* Optimized Floating Sparkles - Reduced from 8 to 3 */}
{[...Array(3)].map((_, i) => (
<motion.div
key={i}
className="absolute pointer-events-none will-change-transform"
style={{
top: `${20 + i * 30}%`,
left: `${10 + i * 40}%`,
}}
animate={{
y: [0, -20, 0],
opacity: [0, 0.5, 0],
scale: [0, 1, 0],
}}
transition={{
duration: 3,
repeat: Infinity,
delay: i * 0.8,
ease: "easeInOut",
}}
>
<Sparkles className="w-5 h-5 text-warm-coral" />
</motion.div>
))}
</div>
</div>
{/* Card Indicators with City Names */}
<motion.div
className="flex flex-wrap justify-center gap-3 mt-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 1 }}
viewport={{ once: true }}
>
{itineraryCards.map((card, idx) => (
<motion.button
key={card.id}
onClick={() => {
setIsAnimating(true);
setTimeout(() => {
setCurrentCardIndex(idx);
setIsAnimating(false);
}, 400);
}}
className={`group relative transition-all duration-300 px-4 py-2 rounded-full font-medium ${
idx === currentCardIndex
? 'bg-gradient-to-r from-warm-coral to-orange-500 text-white shadow-lg scale-110'
: 'bg-white/80 backdrop-blur-sm text-gray-600 hover:text-warm-coral hover:bg-white border border-gray-200 hover:border-warm-coral/30 hover:scale-105'
}`}
whileHover={{ y: -2 }}
whileTap={{ scale: 0.95 }}
>
{card.city}
{idx === currentCardIndex && (
<motion.div
className="absolute -bottom-1 left-1/2 w-1.5 h-1.5 bg-white rounded-full"
layoutId="activeIndicator"
initial={false}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
style={{ x: '-50%' }}
/>
)}
</motion.button>
))}
</motion.div>
{/* CTA Button - Optimized */}
<motion.div
className="flex flex-col items-center gap-6 mt-16"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
viewport={{ once: true }}
>
<Button
withShine={true}
className="py-7 px-16 rounded-full text-xl font-bold bg-gradient-to-r from-warm-coral via-orange-500 to-rose-500 hover:from-warm-coral/90 hover:via-orange-500/90 hover:to-rose-500/90 shadow-2xl hover:shadow-warm-coral/50 transition-all hover:scale-105 hover:-translate-y-1"
>
<span className="flex items-center gap-3">
<Wand2 className="w-6 h-6" />
Create My Perfect Itinerary
</span>
</Button>
<p className="text-gray-600 text-sm flex items-center gap-2">
<Sparkles className="w-4 h-4 text-warm-coral" />
<span>Free to use No credit card required</span>
<Sparkles className="w-4 h-4 text-warm-coral" />
</p>
</motion.div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1 @@
// This Melbourne attractions component has been removed

View File

@@ -0,0 +1 @@
// This Melbourne blogs component has been removed

View File

@@ -0,0 +1 @@
// This Melbourne card comparison component has been removed

View File

@@ -0,0 +1 @@
// This Melbourne FAQ component has been removed

View File

@@ -0,0 +1 @@
// This Melbourne page component has been removed

View File

@@ -0,0 +1 @@
// This Melbourne tour overview component has been removed

View File

@@ -0,0 +1 @@
// This mobile app promotion component (created for Melbourne page) has been removed

View File

@@ -0,0 +1,642 @@
import { ArrowRight, Smartphone, MapPin, Star, Clock, Users, Heart, Share2, Filter, Search } from 'lucide-react';
import { motion } from 'motion/react';
import imgFrame1597884939 from "figma:asset/5da1b0444c0d21bc7ee776c49e36e2a8ea4d3e12.png";
export function MobileAppSection() {
// Generate a realistic QR code pattern
const generateQRPattern = () => {
const size = 21; // Standard QR code size
const pattern = [];
for (let i = 0; i < size * size; i++) {
// Create corner detection patterns
const row = Math.floor(i / size);
const col = i % size;
// Corner squares (7x7)
const isCornerSquare =
(row < 7 && col < 7) || // Top-left
(row < 7 && col >= 14) || // Top-right
(row >= 14 && col < 7); // Bottom-left
// Finder patterns within corner squares
const isFinderPattern = isCornerSquare && (
(row === 0 || row === 6 || col === 0 || col === 6) ||
(row >= 2 && row <= 4 && col >= 2 && col <= 4)
);
// Timing patterns
const isTimingPattern = (row === 6 && col >= 8 && col <= 12) || (col === 6 && row >= 8 && row <= 12);
// Random data pattern for other areas
const isDataPattern = !isCornerSquare && !isTimingPattern && Math.random() > 0.45;
pattern.push(isFinderPattern || isTimingPattern || isDataPattern);
}
return pattern;
};
const qrPattern = generateQRPattern();
return (
<section className="py-20 lg:py-32 bg-muted/30 relative overflow-hidden">
{/* Subtle Background Elements */}
<div className="absolute inset-0">
<div className="absolute top-1/3 left-1/6 w-64 h-64 bg-warm-coral/3 rounded-full blur-3xl"></div>
<div className="absolute bottom-1/2 right-1/6 w-48 h-48 bg-warm-coral/3 rounded-full blur-3xl"></div>
</div>
<div className="container mx-auto px-4 relative z-10">
{/* Figma Layout Implementation */}
<motion.div
className="flex flex-col gap-16 lg:gap-20"
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, ease: "easeOut" }}
>
{/* Header Section - Following Figma Layout */}
<div className="flex flex-col lg:flex-row items-start justify-between gap-12 lg:gap-16">
{/* Left Side - Main Heading */}
<motion.div
className="flex-1 max-w-2xl"
initial={{ opacity: 0, x: -40 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.7, ease: "easeOut" }}
>
<h1 className="text-4xl lg:text-5xl xl:text-6xl leading-tight text-foreground">
<span className="font-normal">Your</span>{' '}
<span className="text-warm-coral font-bold italic">
Melbourne
</span>
<br />
<span className="font-normal">City Card in Your</span>{' '}
<span className="font-semibold">Pocket.</span>
</h1>
</motion.div>
{/* Right Side - Description and Buttons */}
<motion.div
className="flex flex-col gap-8 w-full lg:w-[400px] xl:w-[450px]"
initial={{ opacity: 0, x: 40 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.7, ease: "easeOut", delay: 0.1 }}
>
{/* Description Text */}
<p className="text-lg text-muted-foreground leading-relaxed">
Download our mobile app and unlock instant access to premium city experiences across Australia.
</p>
{/* Download Buttons - Following Figma Layout */}
<div className="flex flex-col sm:flex-row gap-6">
{/* Android Download Button */}
<motion.button
className="interactive-button relative flex items-center gap-3 bg-foreground text-background px-6 py-4 rounded-xl font-medium text-base hover:bg-foreground/90 transition-all duration-300 shadow-lg hover:shadow-xl flex-1 overflow-hidden group"
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
{/* Continuous Shine Effect */}
<div className="absolute inset-0 -top-px opacity-100">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/25 to-transparent transform -skew-x-12 animate-shine"></div>
</div>
{/* Google Play Logo */}
<svg className="w-6 h-6 relative z-10" viewBox="0 0 24 24" fill="currentColor">
<path d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.53,12.9 20.18,13.18L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z"/>
</svg>
<div className="text-left relative z-10">
<div className="text-xs opacity-80">Get it on</div>
<div className="font-semibold">Google Play</div>
</div>
</motion.button>
{/* iOS Download Button */}
<motion.button
className="interactive-button relative flex items-center gap-3 bg-foreground text-background px-6 py-4 rounded-xl font-medium text-base hover:bg-foreground/90 transition-all duration-300 shadow-lg hover:shadow-xl flex-1 overflow-hidden group"
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
{/* Continuous Shine Effect */}
<div className="absolute inset-0 -top-px opacity-100">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/25 to-transparent transform -skew-x-12 animate-shine"></div>
</div>
{/* Apple Logo */}
<svg className="w-6 h-6 relative z-10" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg>
<div className="text-left relative z-10">
<div className="text-xs opacity-80">Download on the</div>
<div className="font-semibold">App Store</div>
</div>
</motion.button>
</div>
</motion.div>
</div>
{/* Mobile Mockups Section - Ultra-Realistic and Larger */}
<motion.div
className="relative w-full h-[400px] lg:h-[500px] rounded-3xl overflow-hidden shadow-2xl"
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, ease: "easeOut", delay: 0.2 }}
style={{
background: `url(${imgFrame1597884939}) center/cover`
}}
>
{/* Mobile Mockup 1 - Enhanced Main App Screen */}
<div className="absolute top-[-6rem] lg:top-[-7rem] right-[calc(50%-100px-8.25rem)] lg:right-[calc(50%-100px-10.25rem)]">
<motion.div
className="w-60 lg:w-64 xl:w-72 h-[420px] lg:h-[480px] xl:h-[540px] bg-white rounded-[2.5rem] shadow-2xl border-4 lg:border-8 border-gray-900 relative overflow-hidden"
initial={{ opacity: 0, y: -30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.4 }}
whileHover={{ scale: 1.02, rotateY: 2 }}
>
{/* iPhone Status Bar */}
<div className="flex justify-between items-center px-6 py-3 bg-white">
<div className="flex items-center gap-1">
<span className="text-sm font-semibold text-black">9:41</span>
</div>
<div className="flex items-center gap-1">
{/* Signal bars */}
<div className="flex gap-0.5">
{[1, 2, 3, 4].map((bar) => (
<div key={bar} className={`w-1 bg-black rounded-full ${bar === 1 ? 'h-1' : bar === 2 ? 'h-2' : bar === 3 ? 'h-3' : 'h-4'}`}></div>
))}
</div>
{/* WiFi */}
<svg className="w-4 h-4 ml-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.07 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/>
</svg>
{/* Battery */}
<div className="w-6 h-3 border border-black rounded-sm ml-1">
<div className="w-4 h-1.5 bg-green-500 rounded-sm m-0.5"></div>
</div>
<div className="w-0.5 h-2 bg-black rounded-r-sm"></div>
</div>
</div>
{/* App Header */}
<div className="px-5 py-4 bg-warm-coral">
<div className="flex items-center justify-between text-white mb-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-white/20 rounded-xl flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<div>
<h3 className="text-lg font-bold">CityCards</h3>
<div className="text-xs text-white/70">Premium Active</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="w-5 h-5 text-white/80" />
<div className="absolute -top-1 -right-1 w-2 h-2 bg-orange-400 rounded-full"></div>
</div>
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
<div className="w-4 h-4 bg-white rounded-full"></div>
</div>
</div>
</div>
{/* Location */}
<div className="flex items-center justify-between text-white/90">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
<span className="text-sm font-medium">Sydney, NSW</span>
<div className="w-1 h-1 bg-white/60 rounded-full"></div>
<span className="text-xs">22°C </span>
</div>
<div className="flex items-center gap-2 text-xs bg-white/20 px-3 py-1.5 rounded-full">
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></span>
<span>3 active</span>
</div>
</div>
</div>
{/* App Content */}
<div className="flex-1 overflow-hidden">
{/* Today's Deal Banner */}
<div className="mx-5 my-4 bg-gradient-to-r from-purple-100 via-blue-100 to-indigo-100 rounded-xl p-4 border border-purple-200">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-purple-700 bg-purple-200 px-2 py-1 rounded-full">TODAY ONLY</span>
<div className="flex items-center gap-1 text-xs text-purple-600">
<Clock className="w-3 h-3" />
<span>8h left</span>
</div>
</div>
<Heart className="w-4 h-4 text-red-400" />
</div>
<h4 className="font-bold text-gray-900 text-sm mb-1">Sydney Explorer Pass</h4>
<p className="text-xs text-gray-700 mb-3">Visit 3+ attractions & get 40% off</p>
<div className="flex gap-2">
<button className="bg-purple-600 text-white px-3 py-1.5 rounded-lg text-xs font-semibold">
Activate
</button>
<button className="border border-purple-300 text-purple-700 px-3 py-1.5 rounded-lg text-xs font-medium">
Details
</button>
</div>
</div>
{/* Stats Bar */}
<div className="mx-5 mb-4">
<div className="bg-gray-50 rounded-xl p-3 border border-gray-100">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div className="w-5 h-5 bg-green-100 rounded-full flex items-center justify-center">
<span className="text-xs font-bold text-green-700">3</span>
</div>
<span className="text-gray-700 text-xs">Passes used today</span>
</div>
<div className="text-right">
<div className="font-bold text-green-600 text-sm">$127</div>
<div className="text-xs text-gray-500">saved</div>
</div>
</div>
</div>
</div>
{/* Attraction Cards */}
<div className="mx-5 space-y-3">
{/* Opera House */}
<div className="bg-white border border-gray-200 rounded-xl p-3 shadow-sm relative">
<div className="absolute top-2 right-2 bg-yellow-100 text-yellow-600 text-xs font-bold px-2 py-1 rounded-full">
PREMIUM
</div>
<div className="flex gap-3 mb-2">
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.94-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
</div>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<h4 className="font-bold text-gray-900 text-sm">Sydney Opera House</h4>
<div className="flex items-center gap-1 text-xs text-gray-600 mt-0.5">
<Star className="w-3 h-3 text-yellow-400 fill-current" />
<span className="font-medium">4.8</span>
<span className="text-gray-400">(2.4k)</span>
<span className="mx-1"></span>
<MapPin className="w-3 h-3 text-blue-500" />
<span>2.3km</span>
</div>
</div>
<div className="flex gap-1">
<Heart className="w-3 h-3 text-gray-400" />
<Share2 className="w-3 h-3 text-gray-400" />
</div>
</div>
<div className="flex items-center gap-1.5 mt-2 text-xs">
<div className="bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full font-medium">
Architecture
</div>
<div className="bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full font-medium">
Cultural
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<div className="flex items-center gap-2">
<div>
<div className="flex items-center gap-1">
<span className="text-base font-bold text-gray-900">$45</span>
<span className="text-xs text-gray-500 line-through">$75</span>
</div>
<div className="text-xs text-green-600 font-medium">Save $30</div>
</div>
<div className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full border border-green-200">
40% OFF
</div>
</div>
<button className="bg-warm-coral text-white px-3 py-1.5 rounded-lg font-semibold text-xs">
Use Pass
</button>
</div>
</div>
{/* Harbour Bridge */}
<div className="bg-white border border-gray-200 rounded-xl p-3 shadow-sm relative">
<div className="absolute top-2 right-2 bg-blue-100 text-blue-600 text-xs font-bold px-2 py-1 rounded-full">
POPULAR
</div>
<div className="flex gap-3 mb-2">
<div className="w-12 h-12 bg-gradient-to-br from-emerald-500 to-green-600 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</div>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<h4 className="font-bold text-gray-900 text-sm">Harbour Bridge Climb</h4>
<div className="flex items-center gap-1 text-xs text-gray-600 mt-0.5">
<Star className="w-3 h-3 text-yellow-400 fill-current" />
<span className="font-medium">4.9</span>
<span className="text-gray-400">(1.8k)</span>
<span className="mx-1"></span>
<MapPin className="w-3 h-3 text-blue-500" />
<span>1.8km</span>
</div>
</div>
<div className="flex gap-1">
<Heart className="w-3 h-3 text-red-400 fill-current" />
<Share2 className="w-3 h-3 text-gray-400" />
</div>
</div>
<div className="flex items-center gap-1.5 mt-2 text-xs">
<div className="bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-medium">
Adventure
</div>
<div className="bg-red-100 text-red-700 px-2 py-0.5 rounded-full font-medium">
Iconic
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<div className="flex items-center gap-2">
<div>
<div className="flex items-center gap-1">
<span className="text-base font-bold text-gray-900">$159</span>
<span className="text-xs text-gray-500 line-through">$289</span>
</div>
<div className="text-xs text-green-600 font-medium">Save $130</div>
</div>
<div className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full border border-blue-200">
45% OFF
</div>
</div>
<button className="bg-primary text-white px-3 py-1.5 rounded-lg font-semibold text-xs">
Use Pass
</button>
</div>
</div>
{/* Botanic Gardens */}
<div className="bg-white border border-gray-200 rounded-xl p-3 shadow-sm relative">
<div className="absolute top-2 right-2 bg-emerald-100 text-emerald-600 text-xs font-bold px-2 py-1 rounded-full">
FREE
</div>
<div className="flex gap-3 mb-2">
<div className="w-12 h-12 bg-gradient-to-br from-green-400 to-emerald-500 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M17,8C8,10 5.9,16.17 3.82,21.34L5.71,22L6.66,19.7C7.14,19.87 7.64,20 8,20C19,20 22,3 22,3C21,5 14,5.25 9,6.25C4,7.25 2,11.5 2,13.5C2,15.5 3.75,17.25 3.75,17.25C7,8 17,8 17,8Z"/>
</svg>
</div>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<h4 className="font-bold text-gray-900 text-sm">Royal Botanic Gardens</h4>
<div className="flex items-center gap-1 text-xs text-gray-600 mt-0.5">
<Star className="w-3 h-3 text-yellow-400 fill-current" />
<span className="font-medium">4.7</span>
<span className="text-gray-400">(956)</span>
<span className="mx-1"></span>
<MapPin className="w-3 h-3 text-blue-500" />
<span>1.2km</span>
</div>
</div>
<div className="flex gap-1">
<Heart className="w-3 h-3 text-gray-400" />
<Share2 className="w-3 h-3 text-gray-400" />
</div>
</div>
<div className="flex items-center gap-1.5 mt-2 text-xs">
<div className="bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-medium">
Nature
</div>
<div className="bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full font-medium">
Family
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<div className="flex items-center gap-2">
<div>
<span className="text-base font-bold text-green-600">FREE</span>
<div className="text-xs text-green-600 font-medium">Included</div>
</div>
</div>
<button className="bg-green-600 text-white px-3 py-1.5 rounded-lg font-semibold text-xs">
Visit Now
</button>
</div>
</div>
</div>
</div>
{/* Bottom Navigation */}
<div className="border-t border-gray-100 bg-white/95 backdrop-blur-sm">
<div className="flex items-center justify-around py-3">
{[
{
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
),
label: "Home",
active: true,
badge: null
},
{
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
),
label: "Map",
active: false,
badge: "3"
},
{
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M22 10v6c0 1.11-.89 2-2 2H4c-1.11 0-2-.89-2-2v-8c0-1.11.89-2 2-2h14l2-2v6zm-9 4.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>
</svg>
),
label: "Passes",
active: false,
badge: null
},
{
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
</svg>
),
label: "Activity",
active: false,
badge: null
},
{
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
),
label: "Profile",
active: false,
badge: null
}
].map((item, index) => (
<div key={index} className={`flex flex-col items-center justify-center gap-1 py-1 relative ${item.active ? 'text-primary' : 'text-gray-500'}`}>
<div className="relative">
{item.icon}
{item.badge && (
<div className="absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full min-w-[16px] h-4 flex items-center justify-center px-1">
{item.badge}
</div>
)}
</div>
<span className={`text-xs ${item.active ? 'font-semibold' : 'font-medium'}`}>
{item.label}
</span>
{item.active && (
<div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-4 h-0.5 bg-primary rounded-full"></div>
)}
</div>
))}
</div>
</div>
</motion.div>
</div>
{/* Mobile Mockup 2 - Map View */}
<div className="absolute bottom-[-6rem] lg:bottom-[-7rem] left-[calc(50%-100px-8.25rem)] lg:left-[calc(50%-100px-10.25rem)]">
<motion.div
className="w-56 lg:w-60 xl:w-64 h-[380px] lg:h-[440px] xl:h-[480px] bg-white rounded-[2.5rem] shadow-2xl border-4 lg:border-8 border-gray-900 relative overflow-hidden"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.6 }}
whileHover={{ scale: 1.02, rotateY: -2 }}
>
{/* Status Bar */}
<div className="flex justify-between items-center px-6 py-3 bg-white">
<div className="flex items-center gap-1">
<span className="text-sm font-semibold text-black">9:41</span>
</div>
<div className="flex items-center gap-1">
<div className="flex gap-0.5">
{[1, 2, 3, 4].map((bar) => (
<div key={bar} className={`w-1 bg-black rounded-full ${bar === 1 ? 'h-1' : bar === 2 ? 'h-2' : bar === 3 ? 'h-3' : 'h-4'}`}></div>
))}
</div>
<svg className="w-4 h-4 ml-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.07 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/>
</svg>
<div className="w-6 h-3 border border-black rounded-sm ml-1">
<div className="w-4 h-1.5 bg-green-500 rounded-sm m-0.5"></div>
</div>
<div className="w-0.5 h-2 bg-black rounded-r-sm"></div>
</div>
</div>
{/* Map Header */}
<div className="px-5 py-3 bg-white border-b border-gray-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center">
<svg className="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
<h3 className="font-bold text-gray-900">Explore Sydney</h3>
<div className="text-xs text-gray-500">3 nearby attractions</div>
</div>
</div>
<button className="w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
<Filter className="w-4 h-4 text-primary" />
</button>
</div>
</div>
{/* Map Content */}
<div className="flex-1 bg-gradient-to-br from-green-100 to-blue-100 relative overflow-hidden">
{/* Simplified Map Pattern */}
<div className="absolute inset-0 opacity-20">
<div className="w-full h-full bg-gradient-to-br from-blue-200 via-green-200 to-yellow-200"></div>
{/* Street lines */}
<div className="absolute top-1/4 left-0 right-0 h-px bg-gray-400 opacity-30"></div>
<div className="absolute top-1/2 left-0 right-0 h-px bg-gray-400 opacity-40"></div>
<div className="absolute top-3/4 left-0 right-0 h-px bg-gray-400 opacity-30"></div>
<div className="absolute left-1/4 top-0 bottom-0 w-px bg-gray-400 opacity-30"></div>
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-gray-400 opacity-40"></div>
<div className="absolute left-3/4 top-0 bottom-0 w-px bg-gray-400 opacity-30"></div>
</div>
{/* Location Pins */}
<div className="absolute top-8 left-8 w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
<div className="absolute top-16 right-12 w-3 h-3 bg-blue-500 rounded-full animate-pulse"></div>
<div className="absolute bottom-20 left-12 w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
<div className="absolute bottom-12 right-8 w-3 h-3 bg-purple-500 rounded-full animate-pulse"></div>
{/* Current Location */}
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<div className="relative">
<div className="w-6 h-6 bg-blue-600 rounded-full border-4 border-white shadow-lg animate-pulse"></div>
<div className="absolute inset-0 w-6 h-6 bg-blue-600 rounded-full animate-ping opacity-20"></div>
</div>
</div>
{/* Route Line */}
<svg className="absolute inset-0 w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
<path
d="M20,30 Q40,20 60,40 T80,70"
stroke="#6366f1"
strokeWidth="2"
fill="none"
strokeDasharray="5,5"
className="animate-pulse"
/>
</svg>
</div>
{/* Bottom Card */}
<div className="absolute bottom-4 left-4 right-4 bg-white rounded-xl p-3 shadow-lg border border-gray-100">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.94-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
</div>
<div className="flex-1">
<h4 className="font-bold text-gray-900 text-sm">Sydney Opera House</h4>
<div className="flex items-center gap-1 text-xs text-gray-600">
<MapPin className="w-3 h-3 text-blue-500" />
<span>2.3km away 5 min walk</span>
</div>
</div>
<button className="bg-primary text-white px-3 py-1.5 rounded-lg font-semibold text-xs">
Go
</button>
</div>
</div>
</motion.div>
</div>
</motion.div>
</motion.div>
</div>
</section>
);
}

736
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,736 @@
import { useState, useEffect, useRef, forwardRef } from 'react';
import { Menu, X, ShoppingBag, ChevronDown, Globe } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import Frame1597884853 from '../imports/Frame1597884853';
import { Button } from './ui/button';
import { ImageWithFallback } from './figma/ImageWithFallback';
import logoImage from 'figma:asset/e96a0ba8c1e8ee053e3eb462a3b4552a8657e7b6.png';
interface NavbarProps {
activeCity: string;
onCityChange: (city: string) => void;
onSignInClick: () => void;
onPassesClick: () => void;
currentPage?: 'home' | 'signin' | 'passes';
}
interface DropdownItem {
id: string;
label: string;
icon?: React.ReactNode;
action?: () => void;
badge?: string | number;
}
interface CartItem {
id: string;
name: string;
price: string;
image?: string;
quantity: number;
}
interface DropdownProps {
isOpen: boolean;
onToggle: () => void;
items: DropdownItem[];
trigger: React.ReactNode;
title?: string;
className?: string;
}
export default function Navbar({
activeCity,
onCityChange,
onSignInClick,
onPassesClick,
currentPage = 'home'
}: NavbarProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [activeLanguageDropdown, setActiveLanguageDropdown] = useState(false);
const [activeCartDropdown, setActiveCartDropdown] = useState(false);
const languageRef = useRef<HTMLDivElement>(null);
const cartRef = useRef<HTMLDivElement>(null);
// Languages available
const languages: DropdownItem[] = [
{ id: 'en', label: 'English', icon: <span className="text-base">🇺🇸</span> },
{ id: 'es', label: 'Español', icon: <span className="text-base">🇪🇸</span> },
{ id: 'fr', label: 'Français', icon: <span className="text-base">🇫🇷</span> },
{ id: 'de', label: 'Deutsch', icon: <span className="text-base">🇩🇪</span> },
{ id: 'it', label: 'Italiano', icon: <span className="text-base">🇮🇹</span> },
];
// Mock cart items
const cartItems: CartItem[] = [
{ id: '1', name: 'Sydney 2-Day Pass', price: '$89', quantity: 1 },
{ id: '2', name: 'Melbourne Premium Pass', price: '$129', quantity: 1 },
];
// Section IDs for navigation
const sectionIds = [
'hero-section',
'why-choose-section',
'variety-adventures-section',
'how-it-works-section',
'magic-itinerary-section',
'book-attraction-section',
'custom-postcards-section',
'upcoming-cities-section',
'trust-section',
'mobile-app-section'
];
const scrollToSection = (index: number) => {
const sectionId = sectionIds[index];
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
setIsMobileMenuOpen(false);
};
const closeMobileMenu = () => {
setIsMobileMenuOpen(false);
};
// Detect scroll for navbar styling
useEffect(() => {
const handleScroll = () => {
const scrolled = window.scrollY > 20;
setIsScrolled(scrolled);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// 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);
}, []);
// Close mobile menu on escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsMobileMenuOpen(false);
setActiveLanguageDropdown(false);
setActiveCartDropdown(false);
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, []);
// Create click handlers for the navbar elements
const handleNavClick = (section: string) => {
switch (section) {
case 'about':
scrollToSection(0);
break;
case 'products':
onPassesClick();
break;
case 'offer':
scrollToSection(5);
break;
case 'card':
scrollToSection(9);
break;
default:
break;
}
};
// Check if navigation item is active (simplified - only based on current page)
const isNavItemActive = (action: string) => {
return currentPage === action;
};
// Calculate cart total
const cartTotal = cartItems.reduce((total, item) => {
const price = parseFloat(item.price.replace('$', ''));
return total + (price * item.quantity);
}, 0);
// Dropdown component with proper ref forwarding
const Dropdown = forwardRef<HTMLDivElement, DropdownProps>(({
isOpen,
onToggle,
items,
trigger,
title,
className = ""
}, ref) => (
<div ref={ref} className={`relative ${className}`}>
<motion.button
onClick={onToggle}
className="relative"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{trigger}
</motion.button>
<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.2, ease: [0.25, 0.1, 0.25, 1] }}
className="absolute top-full right-0 mt-2 bg-white rounded-2xl shadow-xl border border-gray-100 min-w-[220px] overflow-hidden z-50"
>
{title && (
<div className="px-5 py-4 border-b border-gray-100">
<h3 className="font-semibold text-gray-900 text-base">{title}</h3>
</div>
)}
<div className="py-2">
{items.map((item, index) => (
<motion.button
key={item.id}
onClick={() => {
item.action?.();
onToggle();
}}
className="w-full flex items-center justify-between px-5 py-3 text-left hover:bg-gray-50 transition-colors duration-200"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
whileHover={{ x: 4 }}
>
<div className="flex items-center space-x-3">
{item.icon}
<span className="text-gray-700 text-base">{item.label}</span>
</div>
{item.badge && (
<span className="bg-primary text-primary-foreground text-xs px-2 py-1 rounded-full">
{item.badge}
</span>
)}
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
));
// Set display name for debugging
Dropdown.displayName = 'Dropdown';
return (
<>
{/* Desktop Navbar - Extended to Gutter Width */}
<motion.nav
className="fixed top-6 left-0 right-0 z-50 hidden lg:block"
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 }}
>
<div className="container mx-auto px-4">
<motion.div
className="bg-white/70 backdrop-blur-[20px] rounded-full px-8 py-4 shadow-lg shadow-black/5 border border-white/20 w-full"
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] }}
>
{/* Using justify-between layout like Figma reference */}
<div className="flex items-center justify-between w-full">
{/* Logo Section - Increased Size */}
<motion.div
className="flex items-center cursor-pointer flex-shrink-0"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => handleNavClick('about')}
>
<ImageWithFallback
src={logoImage}
alt="CityCards Logo"
className="h-10 w-auto"
/>
</motion.div>
{/* Navigation Links - No automatic highlighting */}
<div className="flex items-center gap-[51px]">
{[
{ label: 'About Us', action: 'about' },
{ label: 'Cities', action: 'products' },
{ 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-primary'
: 'text-gray-700 hover:text-gray-900'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{item.label}
{/* Active indicator - only for current page */}
<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-gray-100 rounded-lg -z-10"
initial={{ scale: 0, opacity: 0 }}
whileHover={{ scale: 1, opacity: 0.5 }}
transition={{ duration: 0.2 }}
/>
</motion.button>
))}
</div>
{/* Right Section - Increased Sizes */}
<div className="flex items-center gap-5">
{/* Language Dropdown - Increased Font */}
<Dropdown
ref={languageRef}
isOpen={activeLanguageDropdown}
onToggle={() => setActiveLanguageDropdown(!activeLanguageDropdown)}
items={languages}
title="Select Language"
trigger={
<div className="flex items-center space-x-2 text-gray-700 hover:text-gray-900 px-0 py-2 text-base font-medium transition-colors duration-200 cursor-pointer rounded-lg hover:bg-gray-50 uppercase">
<Globe className="w-5 h-5" />
<span>ENG</span>
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${activeLanguageDropdown ? 'rotate-180' : ''}`} />
</div>
}
/>
{/* Shopping Cart - Increased Size */}
<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-gray-700 hover:text-gray-900 p-0 transition-colors duration-200 rounded-lg hover:bg-gray-50 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>
}
/>
{/* CTA Button with Shine Effect */}
<Button
onClick={onSignInClick}
withShine={true}
className="h-[52px] min-w-[180px] px-6 py-4 rounded-full text-white font-medium"
>
GET A CITY CARD
</Button>
</div>
</div>
</motion.div>
</div>
</motion.nav>
{/* Medium Screen Navbar - Extended to Gutter Width */}
<motion.nav
className="fixed top-6 left-0 right-0 z-50 hidden md:block lg:hidden"
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 }}
>
<div className="container mx-auto px-4">
<motion.div
className="bg-white/95 backdrop-blur-md rounded-full px-6 py-3 shadow-lg shadow-black/5 border border-gray-100/50 w-full"
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] }}
>
<div className="flex items-center justify-between w-full">
{/* Logo - Increased Size */}
<motion.div
className="flex items-center cursor-pointer flex-shrink-0"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => handleNavClick('about')}
>
<ImageWithFallback
src={logoImage}
alt="CityCards Logo"
className="h-9 w-auto"
/>
</motion.div>
{/* Navigation - Increased Font */}
<div className="flex items-center gap-[40px]">
{[
{ label: 'About Us', action: 'about' },
{ label: 'Cities', action: 'products' },
{ 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-1.5 text-base font-medium transition-all duration-200 whitespace-nowrap group capitalize ${
isNavItemActive(item.action)
? 'text-primary'
: 'text-gray-700 hover:text-gray-900'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{item.label}
<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 }}
/>
</motion.button>
))}
</div>
{/* Right Section - Increased Sizes */}
<div className="flex items-center gap-4">
<Dropdown
ref={languageRef}
isOpen={activeLanguageDropdown}
onToggle={() => setActiveLanguageDropdown(!activeLanguageDropdown)}
items={languages}
title="Select Language"
trigger={
<div className="flex items-center space-x-1.5 text-gray-700 hover:text-gray-900 text-base font-medium transition-colors duration-200 cursor-pointer rounded-lg hover:bg-gray-50 uppercase">
<Globe className="w-4 h-4" />
<span>ENG</span>
<ChevronDown className={`w-3.5 h-3.5 transition-transform duration-200 ${activeLanguageDropdown ? 'rotate-180' : ''}`} />
</div>
}
/>
<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-gray-700 hover:text-gray-900 transition-colors duration-200 rounded-lg hover:bg-gray-50 cursor-pointer">
<ShoppingBag className="w-6 h-6" />
<motion.div
className="absolute -top-1 -right-1 w-5 h-5 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>
}
/>
{/* CTA Button with Shine Effect */}
<Button
onClick={onSignInClick}
withShine={true}
className="h-[44px] min-w-[155px] px-5 py-3 rounded-full text-white font-medium"
>
GET A CITY CARD
</Button>
</div>
</div>
</motion.div>
</div>
</motion.nav>
{/* Mobile Navbar - Already Full Width */}
<nav className="fixed top-0 w-full z-50 lg:hidden">
<div className="bg-white/95 backdrop-blur-lg border-b border-gray-100 shadow-sm">
<div className="container mx-auto px-4">
<div className="flex justify-between items-center h-16">
{/* Mobile Logo - Increased Size */}
<motion.div
className="flex items-center cursor-pointer"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleNavClick('about')}
>
<ImageWithFallback
src={logoImage}
alt="CityCards Logo"
className="h-10 w-auto"
/>
</motion.div>
{/* Mobile Actions - Increased Sizes */}
<div className="flex items-center space-x-2">
{/* Mobile Cart - Increased Size */}
<motion.button
className="relative text-gray-700 hover:text-gray-900 p-2 transition-colors duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => setActiveCartDropdown(!activeCartDropdown)}
>
<ShoppingBag className="w-6 h-6" />
<div className="absolute -top-1 -right-1 w-5 h-5 bg-primary rounded-full flex items-center justify-center">
<span className="text-xs text-primary-foreground font-bold">{cartItems.length}</span>
</div>
</motion.button>
{/* Mobile menu button - Increased Size */}
<motion.button
onClick={() => setIsMobileMenuOpen(true)}
className="inline-flex items-center justify-center p-2 rounded-lg text-gray-700 hover:text-gray-900 hover:bg-gray-100 transition-colors duration-200"
aria-label="Open menu"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Menu className="w-7 h-7" />
</motion.button>
</div>
</div>
</div>
</div>
</nav>
{/* Mobile Menu Overlay - Already without Arrow */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
className="fixed inset-0 z-[100] lg:hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
{/* Backdrop */}
<motion.div
className="fixed inset-0 bg-black/50"
onClick={closeMobileMenu}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
{/* Menu Panel */}
<motion.div
className="fixed top-4 right-4 left-4 bottom-4 bg-white rounded-3xl shadow-xl flex flex-col border border-gray-100 max-h-[calc(100vh-32px)] overflow-hidden"
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
>
{/* Header - Increased Font */}
<div className="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
<div className="flex items-center">
<ImageWithFallback
src={logoImage}
alt="CityCards Logo"
className="h-10 w-auto"
/>
</div>
<motion.button
onClick={closeMobileMenu}
className="p-2 rounded-lg text-gray-700 hover:text-gray-900 hover:bg-gray-100 transition-colors duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<X className="w-7 h-7" />
</motion.button>
</div>
{/* Content - Increased Font Sizes */}
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-8">
{/* Navigation Links - Increased Font, No automatic highlighting */}
<div className="space-y-2">
<h3 className="text-base font-semibold text-gray-500 uppercase tracking-wider mb-4">Navigation</h3>
{[
{ label: 'About Us', action: 'about' },
{ label: 'Cities', action: 'products' },
{ label: 'Your Card', action: 'card' },
{ label: 'Deals', action: 'offer' }
].map((item, index) => (
<motion.button
key={item.action}
onClick={() => {
handleNavClick(item.action);
closeMobileMenu();
}}
className={`w-full text-left px-4 py-4 rounded-xl transition-all duration-200 text-xl font-medium flex items-center justify-between group ${
isNavItemActive(item.action)
? 'bg-primary/10 text-primary border border-primary/20'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-50'
}`}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02, x: 10 }}
whileTap={{ scale: 0.98 }}
>
<span>{item.label}</span>
{isNavItemActive(item.action) && (
<motion.div
className="w-3 h-3 bg-primary rounded-full"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2 }}
/>
)}
</motion.button>
))}
</div>
{/* Language Selection - Increased Font */}
<div className="space-y-2">
<h3 className="text-base font-semibold text-gray-500 uppercase tracking-wider mb-4">Language</h3>
<div className="grid grid-cols-2 gap-2">
{languages.slice(0, 4).map((lang, index) => (
<motion.button
key={lang.id}
className="flex items-center space-x-3 p-3 rounded-xl bg-gray-50 hover:bg-gray-100 transition-colors duration-200 text-base"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.05 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{lang.icon}
<span className="text-gray-700 font-medium">{lang.label}</span>
</motion.button>
))}
</div>
</div>
{/* Shopping Cart Summary - Increased Font */}
<div className="space-y-2">
<h3 className="text-base font-semibold text-gray-500 uppercase tracking-wider mb-4">Cart ({cartItems.length})</h3>
<div className="space-y-2">
{cartItems.map((item, index) => (
<motion.div
key={item.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-xl text-base"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
>
<span className="text-gray-700 font-medium">{item.name}</span>
<span className="text-primary font-bold">{item.price}</span>
</motion.div>
))}
<div className="border-t border-gray-200 pt-3 mt-3">
<div className="flex items-center justify-between text-lg font-bold">
<span>Total:</span>
<span className="text-primary">${cartTotal.toFixed(2)}</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Footer - CTA Button */}
<div className="p-6 border-t border-gray-100 flex-shrink-0">
<Button
onClick={() => {
onSignInClick();
closeMobileMenu();
}}
withShine={true}
className="w-full h-[56px] rounded-xl text-white font-medium text-lg"
>
GET A CITY CARD
</Button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,181 @@
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Mail } from 'lucide-react';
import { motion } from 'motion/react';
import { ImageWithFallback } from './figma/ImageWithFallback';
export function NewsletterSection() {
return (
<section className="relative min-h-screen overflow-hidden bg-gradient-to-br from-primary/5 via-white to-secondary/5">
{/* Background Pattern */}
<div className="absolute inset-0 opacity-30">
<div className="absolute top-20 left-20 w-32 h-32 bg-primary/10 rounded-full blur-3xl animate-float"></div>
<div className="absolute bottom-32 right-16 w-40 h-40 bg-secondary/10 rounded-full blur-3xl animate-float animate-delay-1000"></div>
<div className="absolute top-1/2 left-1/4 w-24 h-24 bg-primary/5 rounded-full blur-2xl"></div>
<div className="absolute bottom-1/4 right-1/3 w-36 h-36 bg-secondary/5 rounded-full blur-3xl"></div>
</div>
{/* Subtle Grid Pattern */}
<div className="absolute inset-0 opacity-5">
<div className="w-full h-full" style={{
backgroundImage: `
linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px)
`,
backgroundSize: '50px 50px'
}}></div>
</div>
{/* Floating Email Icons */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<motion.div
className="absolute top-1/4 left-10 text-primary/20"
animate={{
y: [0, -20, 0],
rotate: [0, 5, 0]
}}
transition={{
duration: 6,
repeat: Infinity,
ease: "easeInOut"
}}
>
<Mail className="w-8 h-8" />
</motion.div>
<motion.div
className="absolute top-1/3 right-16 text-secondary/20"
animate={{
y: [0, 15, 0],
rotate: [0, -5, 0]
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay: 2
}}
>
<Mail className="w-6 h-6" />
</motion.div>
<motion.div
className="absolute bottom-1/3 left-1/4 text-primary/15"
animate={{
y: [0, -10, 0],
rotate: [0, 3, 0]
}}
transition={{
duration: 7,
repeat: Infinity,
ease: "easeInOut",
delay: 4
}}
>
<Mail className="w-5 h-5" />
</motion.div>
</div>
{/* Main Content */}
<div className="relative z-10 py-20 lg:py-28 text-center space-y-8 px-4">
{/* Main Heading with Typography Guidelines */}
<motion.div
className="space-y-4"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
>
<h2 className="font-poppins text-5xl md:text-6xl lg:text-7xl leading-tight">
<div className="font-light">Get</div>
<div>
<span className="font-bold text-primary italic">
travel tips
</span>
<span className="font-light"> &</span>
</div>
<div className="font-semibold">exclusive offers.</div>
</h2>
<motion.p
className="font-poppins text-xl leading-relaxed font-normal text-gray-600 max-w-2xl mx-auto"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
We recommend you to subscribe, drop your email below to get daily update about us
</motion.p>
</motion.div>
{/* Newsletter Subscription Interface */}
<motion.div
className="max-w-2xl mx-auto space-y-6"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
>
{/* Email Subscription Bar */}
<div className="relative">
<div className="flex items-center space-x-3 bg-white rounded-full p-2 shadow-xl border border-gray-100/50">
<div className="flex-1 relative">
<Mail className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<Input
type="email"
placeholder="Enter your email address"
className="font-poppins pl-12 pr-4 h-14 text-base border-0 focus:ring-0 focus:border-0 bg-transparent"
/>
</div>
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Button className="font-poppins font-semibold bg-primary hover:bg-primary/90 py-6 px-12 rounded-full text-lg shadow-lg text-white">
Subscribe Now
</Button>
</motion.div>
</div>
</div>
{/* Trust Indicators */}
<motion.div
className="font-poppins flex flex-wrap justify-center items-center gap-4 text-sm font-normal text-gray-500"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.5 }}
>
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>No spam, unsubscribe anytime</span>
</div>
<div className="hidden md:block"></div>
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span>Read our Privacy Policy</span>
</div>
<div className="hidden md:block"></div>
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-primary rounded-full"></div>
<span>Join 10,000+ subscribers</span>
</div>
</motion.div>
{/* Additional Info */}
<motion.p
className="text-sm text-gray-500 mt-4"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: 0.8 }}
>
Get exclusive travel tips, destination guides, and special offers delivered to your inbox
</motion.p>
</motion.div>
</div>
{/* Bottom Decorative Elements */}
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-white/50 to-transparent pointer-events-none"></div>
</section>
);
}

View File

@@ -0,0 +1,193 @@
import { ArrowRight, Star, Clock, MapPin } from 'lucide-react';
import { ImageWithFallback } from './figma/ImageWithFallback';
import { Button } from './ui/button';
import { motion } from 'motion/react';
const otherCities = [
{
id: 1,
name: 'London',
country: 'United Kingdom',
rating: 4.8,
experiences: 180,
duration: '2-3 days',
highlight: 'Historic Landmarks',
image: 'https://images.unsplash.com/photo-1559788591-f5ea2371b915?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxsb25kb24lMjBicmlkZ2UlMjBjaXR5c2NhcGV8ZW58MXx8fHwxNzU2MTIzNTYyfDA&ixlib=rb-4.1.0&q=80&w=1080',
description: 'Discover centuries of history, royal palaces, and world-class museums in England\'s capital city.'
},
{
id: 2,
name: 'Hong Kong',
country: 'China',
rating: 4.7,
experiences: 125,
duration: '3-4 days',
highlight: 'Urban Adventure',
image: 'https://images.unsplash.com/photo-1698416286339-7edbf0922953?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxob25nJTIwa29uZyUyMHNreWxpbmUlMjBuaWdodHxlbnwxfHx8fDE3NTYxMjM1NjZ8MA&ixlib=rb-4.1.0&q=80&w=1080',
description: 'Experience the perfect blend of East meets West with stunning skylines and incredible cuisine.'
},
{
id: 3,
name: 'Istanbul',
country: 'Turkey',
rating: 4.6,
experiences: 95,
duration: '3-5 days',
highlight: 'Cultural Heritage',
image: 'https://images.unsplash.com/photo-1669117403979-be8e9448d9b3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpc3RhbmJ1bCUyMGJvc3Bob3J1cyUyMG1vc3F1ZXxlbnwxfHx8fDE3NTYxMjM1NzB8MA&ixlib=rb-4.1.0&q=80&w=1080',
description: 'Immerse yourself in the crossroads of Europe and Asia with stunning architecture and rich history.'
},
{
id: 4,
name: 'Cairo',
country: 'Egypt',
rating: 4.5,
experiences: 78,
duration: '4-5 days',
highlight: 'Ancient Wonders',
image: 'https://images.unsplash.com/photo-1705874930271-88eeb8f533dc?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxjYWlybyUyMHB5cmFtaWRzJTIwYW5jaWVudHxlbnwxfHx8fDE3NTYxMjM1NzR8MA&ixlib=rb-4.1.0&q=80&w=1080',
description: 'Journey through millennia of civilization with the Great Pyramids and the treasures of the Nile.'
},
{
id: 5,
name: 'Vancouver',
country: 'Canada',
rating: 4.8,
experiences: 110,
duration: '2-3 days',
highlight: 'Nature & City',
image: 'https://images.unsplash.com/photo-1730661906876-18bfc6e95f2f?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHx2YW5jb3V2ZXIlMjBtb3VudGFpbnMlMjBjaXR5c2NhcGV8ZW58MXx8fHwxNzU2MTIzNTc4fDA&ixlib=rb-4.1.0&q=80&w=1080',
description: 'Where urban sophistication meets breathtaking natural beauty, from mountains to ocean.'
},
{
id: 6,
name: 'Miami',
country: 'United States',
rating: 4.6,
experiences: 135,
duration: '3-4 days',
highlight: 'Beach Culture',
image: 'https://images.unsplash.com/photo-1735825713164-192ff57874bf?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtaWFtaSUyMGJlYWNoJTIwYXJ0JTIwZGVjb3xlbnwxfHx8fDE3NTYxMjM1ODR8MA&ixlib=rb-4.1.0&q=80&w=1080',
description: 'Dive into vibrant Art Deco architecture, world-famous beaches, and electric nightlife.'
}
];
export function OtherCities() {
return (
<section className="py-20 bg-white">
<div className="container mx-auto px-4">
{/* Header */}
<motion.div
className="text-center mb-16"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
>
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
<span className="font-light">Explore</span>{' '}
<span className="bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Other Cities
</span>
</h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
Discover incredible destinations around the world with our comprehensive city guides and curated experiences.
</p>
</motion.div>
{/* Cities Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{otherCities.map((city, index) => (
<motion.div
key={city.id}
className="group relative bg-white rounded-3xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-500"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: index * 0.1 }}
viewport={{ once: true }}
whileHover={{ y: -8 }}
>
{/* Image */}
<div className="relative h-64 overflow-hidden">
<ImageWithFallback
src={city.image}
alt={city.name}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
/>
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
{/* Rating Badge */}
<div className="absolute top-4 left-4 bg-white/95 backdrop-blur-sm px-3 py-1 rounded-full flex items-center gap-1">
<Star className="w-4 h-4 text-yellow-500 fill-current" />
<span className="text-sm font-semibold text-gray-900">{city.rating}</span>
</div>
{/* Highlight Badge */}
<div className="absolute top-4 right-4 bg-primary/90 backdrop-blur-sm px-3 py-1 rounded-full">
<span className="text-sm font-medium text-white">{city.highlight}</span>
</div>
</div>
{/* Content */}
<div className="p-6">
{/* City Info */}
<div className="mb-4">
<h3 className="text-2xl font-bold text-gray-900 mb-1">{city.name}</h3>
<div className="flex items-center text-gray-600 mb-3">
<MapPin className="w-4 h-4 mr-1" />
<span className="text-sm">{city.country}</span>
</div>
</div>
{/* Stats */}
<div className="flex items-center justify-between mb-4 text-sm text-gray-600">
<div className="flex items-center gap-1">
<span className="font-semibold text-primary">{city.experiences}</span>
<span>experiences</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-4 h-4" />
<span>{city.duration}</span>
</div>
</div>
{/* Description */}
<p className="text-gray-600 mb-6 line-clamp-3">
{city.description}
</p>
{/* CTA Button */}
<Button
className="w-full bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90 text-white font-medium py-3 rounded-2xl transition-all duration-300 group/btn"
>
<span>Explore {city.name}</span>
<ArrowRight className="w-4 h-4 ml-2 group-hover/btn:translate-x-1 transition-transform duration-300" />
</Button>
</div>
</motion.div>
))}
</div>
{/* Bottom CTA */}
<motion.div
className="text-center mt-16"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
>
<Button
size="lg"
variant="outline"
className="px-8 py-4 text-lg border-2 border-primary text-primary hover:bg-primary hover:text-white transition-all duration-300"
>
<span>View All Cities</span>
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,138 @@
import { Button } from './ui/button';
import { Check } from 'lucide-react';
interface Pass {
id: string;
name: string;
description: string;
price: string;
period: string;
originalPrice?: string;
popular?: boolean;
features: string[];
forText: string;
}
const passes: Pass[] = [
{
id: 'selective',
name: 'Selective Card',
description: 'Ideal for first-time visitors. Enjoy access to a curated selection of attractions and basic customization options to get your journey started.',
price: '$39',
period: '/24 hours',
originalPrice: '$65',
forText: 'For first-time visitors',
features: [
'Access to 5+ top attractions',
'Skip-the-line at selected venues',
'Valid for 24 consecutive hours',
'Mobile ticket delivery',
'Free cancellation up to 24h',
'Basic audio guide access'
]
},
{
id: 'unlimited',
name: 'Unlimited Card',
description: 'Perfect for adventure seekers, providing unlimited access to all attractions, an extensive library of experiences and integration with popular travel tools.',
price: '$79',
period: '/48 hours',
originalPrice: '$150',
popular: true,
forText: 'For adventure seekers',
features: [
'Unlimited access to all attractions',
'Skip-the-line at every venue',
'Valid for 48 consecutive hours',
'Free public transport included',
'Priority customer support',
'Enhanced regular updates',
'Perfect for exploring teams',
'Integration with 25+ travel tool including Maps, Reviews, and Local Guides'
]
}
];
export function PassComparison() {
return (
<section className="py-16 md:py-24 bg-gray-50">
<div className="container mx-auto px-4">
<div className="text-center space-y-4 mb-12">
<p className="text-purple-600 font-medium">Pricing Plans</p>
<h2 className="text-3xl md:text-4xl font-bold text-gray-900">
Choose the perfect plan for your needs
</h2>
</div>
<div className="grid md:grid-cols-2 gap-6 max-w-5xl mx-auto">
{passes.map((pass) => (
<div
key={pass.id}
className={`relative bg-white rounded-3xl p-8 transition-all duration-300 ${
pass.popular
? 'border-2 border-purple-500 shadow-xl transform scale-105'
: 'border border-gray-200 shadow-lg hover:shadow-xl'
}`}
>
{pass.popular && (
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
<span className="bg-yellow-400 text-black px-4 py-1 rounded-full text-sm font-medium">
Most Popular
</span>
</div>
)}
<div className="text-center mb-8">
<h3 className="text-2xl font-bold text-gray-900 mb-3">{pass.name}</h3>
<p className="text-gray-600 text-sm mb-6 leading-relaxed">
{pass.description}
</p>
<div className="mb-2">
<span className="text-4xl font-bold text-gray-900">{pass.price}</span>
<span className="text-lg text-gray-500">{pass.period}</span>
</div>
{pass.originalPrice && (
<div className="text-sm text-gray-500">
<span className="line-through">{pass.originalPrice}</span> billed annually
</div>
)}
<Button
className="w-full mt-6"
>
Choose plan
</Button>
</div>
<div className="space-y-6">
<div>
<h4 className="font-semibold text-gray-900 mb-4">{pass.forText}</h4>
<ul className="space-y-3">
{pass.features.map((feature, index) => (
<li key={index} className="flex items-start space-x-3">
<div className="mt-0.5">
<Check className="w-5 h-5 text-gray-400" />
</div>
<span className="text-sm text-gray-700 leading-relaxed">{feature}</span>
</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
<div className="text-center mt-12">
<p className="text-gray-600 mb-4">
Not sure which pass is right for you?
</p>
<Button variant="outline" size="lg" className="h-12 px-8">
Compare All Features
</Button>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,221 @@
import { Check, X, ArrowLeft } from 'lucide-react';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import cityCardsLogo from 'figma:asset/4d07c3035c8f965d162e4e0d20cb3910fd5fa6fe.png';
interface PassesPageProps {
onBackClick?: () => void;
}
export function PassesPage({ onBackClick }: PassesPageProps) {
const passTypes = [
{
name: 'Selective Card',
description: 'Perfect for first-time visitors',
price: '$39',
period: '24 hours',
color: 'from-green-500 to-emerald-600',
features: {
'Access to attractions': true,
'Entry to attractions': true,
'Access to experiences': true,
'Entry to sites': true,
'Access to venues': false,
'Entry to events': 'Selective Card',
'Access to locations': 'Selective Card',
'Entry to activities': false,
'Access to exhibits': true,
'Entry to top attractions': true,
}
},
{
name: 'Unlimited Card',
description: 'Most popular choice',
price: '$79',
period: '48 hours',
color: 'from-purple-600 to-blue-600',
popular: true,
features: {
'Access to attractions': true,
'Entry to attractions': true,
'Access to experiences': true,
'Entry to sites': true,
'Access to venues': true,
'Entry to events': 'Unlimited Card',
'Access to locations': 'Unlimited Card',
'Entry to activities': true,
'Access to exhibits': true,
'Entry to top attractions': true,
}
}
];
const allFeatures = [
'Access to attractions',
'Entry to attractions',
'Access to experiences',
'Entry to sites',
'Access to venues',
'Entry to events',
'Access to locations',
'Entry to activities',
'Access to exhibits',
'Entry to top attractions'
];
const renderFeatureValue = (value: boolean | string) => {
if (typeof value === 'boolean') {
return value ? (
<Check className="w-5 h-5 text-green-600 mx-auto" />
) : (
<X className="w-5 h-5 text-gray-400 mx-auto" />
);
}
return (
<span className="text-sm text-center text-gray-700 font-medium">{value}</span>
);
};
return (
<div className="min-h-screen bg-gray-50 relative overflow-hidden">
{/* Animated Grid Background */}
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0 bg-gradient-to-br from-purple-600/20 to-blue-600/20"></div>
<div
className="absolute inset-0 animate-pulse"
style={{
backgroundImage: `
linear-gradient(rgba(147, 51, 234, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(147, 51, 234, 0.1) 1px, transparent 1px)
`,
backgroundSize: '50px 50px',
}}
></div>
</div>
{/* Header */}
<div className="relative bg-white/80 backdrop-blur-sm border-b border-gray-200">
<div className="container mx-auto px-4 py-12">
{onBackClick && (
<button
onClick={onBackClick}
className="flex items-center space-x-2 text-gray-600 hover:text-gray-900 transition-colors mb-6"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm">Back to Home</span>
</button>
)}
<div className="text-center">
{/* CityCards Logo */}
<div className="flex justify-center mb-8">
<img
src={cityCardsLogo}
alt="CityCards Logo"
className="h-16 w-auto"
/>
</div>
<h1 className="font-poppins text-5xl md:text-6xl lg:text-7xl leading-tight font-semibold mb-4 text-foreground">Choose Your Perfect Pass</h1>
<p className="font-poppins text-xl leading-relaxed font-normal text-gray-600 max-w-2xl mx-auto">
Compare our two pass options and find the perfect fit for your travel style and budget
</p>
</div>
</div>
</div>
{/* Pass Cards Overview */}
<div className="container mx-auto px-4 py-12 relative z-10">
<div className="grid md:grid-cols-2 gap-8 mb-12 max-w-4xl mx-auto">
{passTypes.map((pass, index) => (
<Card key={index} className={`relative ${pass.popular ? 'ring-2 ring-purple-500 scale-105' : ''}`}>
{pass.popular && (
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
<span className="bg-gradient-to-r from-purple-600 to-blue-600 text-white px-4 py-1 rounded-full text-sm font-medium">
Most Popular
</span>
</div>
)}
<CardHeader className="text-center pb-4">
<CardTitle className="font-poppins text-xl md:text-2xl leading-snug font-semibold">{pass.name}</CardTitle>
<CardDescription className="font-poppins text-base font-normal text-gray-600">{pass.description}</CardDescription>
<div className="mt-4">
<span className="font-poppins text-2xl md:text-3xl lg:text-4xl leading-tight font-bold text-foreground">{pass.price}</span>
<span className="font-poppins text-base font-normal text-gray-600 ml-1">/ {pass.period}</span>
</div>
</CardHeader>
<CardContent className="pt-4">
<Button
className={`font-poppins font-semibold w-full py-4 px-12 rounded-lg text-lg bg-gradient-to-r ${pass.color} hover:opacity-90 text-white`}
>
Select {pass.name}
</Button>
</CardContent>
</Card>
))}
</div>
{/* Detailed Comparison Table */}
<Card className="overflow-hidden relative z-10 bg-white/95 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-2xl font-bold text-center">Detailed Feature Comparison</CardTitle>
<CardDescription className="text-center">
See exactly what's included with each pass type
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left p-4 font-semibold text-gray-900 min-w-[200px]">Features</th>
{passTypes.map((pass, index) => (
<th key={index} className="text-center p-4 font-semibold text-gray-900 min-w-[180px]">
{pass.name}
</th>
))}
</tr>
</thead>
<tbody>
{allFeatures.map((feature, featureIndex) => (
<tr key={featureIndex} className={`border-b border-gray-100 ${featureIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'}`}>
<td className="p-4 font-medium text-gray-900">
<div className="flex items-center">
<span className="w-2 h-2 bg-purple-500 rounded-full mr-3"></span>
{feature}
</div>
</td>
{passTypes.map((pass, passIndex) => (
<td key={passIndex} className="p-4 text-center">
{renderFeatureValue(pass.features[feature as keyof typeof pass.features])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Bottom CTA */}
<div className="text-center mt-12 relative z-10">
<h3 className="text-2xl font-bold text-gray-900 mb-4">Ready to explore?</h3>
<p className="text-gray-600 mb-6 max-w-md mx-auto">
Choose your pass and start discovering amazing attractions with skip-the-line access
</p>
<Button
size="lg"
className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white py-4 px-12 rounded-lg text-lg"
>
Get Started Today
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { useState, useEffect, useRef } from 'react';
import { motion, useScroll, useTransform, useInView } from 'motion/react';
import { ImageWithFallback } from './figma/ImageWithFallback';
// Stack cards content data
const stackCardsContent = [
{
index: 1,
mainHeading: "Your Journey Starts Here",
subtitle: "Follow these simple steps to unlock amazing experiences in your chosen city",
stepNumber: "1",
leftTitle: "Choose Your City - Left",
rightTitle: "Choose Your City - Right",
title: "Choose Your City",
subtitleShort: "Select Your Destination",
description: "Browse our collection of amazing cities and pick the perfect destination for your next adventure. Each city offers unique experiences and attractions.",
image: "https://images.unsplash.com/photo-1611021317203-37e3da396a29?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHx8fDE3NTc0OTI0NzJ8MA&ixlib=rb-4.1.0&q=80&w=1080"
},
{
index: 2,
stepNumber: "2",
leftTitle: "Select Your Pass - Left",
rightTitle: "Select Your Pass - Right",
title: "Select Your Pass",
subtitleShort: "Pick Your Perfect Plan",
description: "Choose from our flexible pass options that match your travel style and duration. From 1-day express to 7-day explorer passes.",
image: "https://images.unsplash.com/photo-1748459864963-c07abe863a1a?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHx8fDE3NTc0OTI0ODN8MA&ixlib=rb-4.1.0&q=80&w=1080"
},
{
index: 3,
stepNumber: "3",
leftTitle: "Plan Your Itinerary - Left",
rightTitle: "Plan Your Itinerary - Right",
title: "Plan Your Itinerary",
subtitleShort: "Create Your Schedule",
description: "Use our smart planning tools to create the perfect itinerary with skip-the-line access to your favorite attractions and experiences.",
image: "https://images.unsplash.com/photo-1435527173128-983b87201f4d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHx8fDE3NTc0OTI0OTJ8MA&ixlib=rb-4.1.0&q=80&w=1080"
},
{
index: 4,
stepNumber: "4",
leftTitle: "Download the App - Left",
rightTitle: "Download the App - Right",
title: "Download the App",
subtitleShort: "Get Mobile Access",
description: "Download our mobile app to have instant access to your digital pass, maps, and real-time updates wherever you go.",
image: "https://images.unsplash.com/photo-1752392185223-c1d5d5f1d7af?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHx8fDE3NTc0OTI0NzZ8MA&ixlib=rb-4.1.0&q=80&w=1080"
},
{
index: 5,
stepNumber: "5",
leftTitle: "Enjoy Your Adventure - Left",
rightTitle: "Enjoy Your Adventure - Right",
title: "Enjoy Your Adventure",
subtitleShort: "Explore & Discover",
description: "Show your digital pass and enjoy instant access to attractions, tours, and experiences. No more waiting in long ticket lines.",
image: "https://images.unsplash.com/photo-1745121485729-37b38a72f62d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHx8fDE3NTc0OTI0ODV8MA&ixlib=rb-4.1.0&q=80&w=1080"
},
{
index: 6,
stepNumber: "6",
leftTitle: "Create Memories - Left",
rightTitle: "Create Memories - Right",
title: "Create Memories",
subtitleShort: "Share Your Journey",
description: "Capture amazing moments and share your experiences with friends. Rate attractions and help future travelers discover the best spots.",
image: "https://images.unsplash.com/photo-1594269732608-e9897da9baab?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHx8fDE3NTc0OTI0ODh8MA&ixlib=rb-4.1.0&q=80&w=1080"
}
];
// StackCard component for overlapping cards
interface StackCardProps {
step: typeof stackCardsContent[0];
index: number;
isTablet?: boolean;
isMobile?: boolean;
}
function StackCard({ step, index, isTablet = false, isMobile = false }: StackCardProps) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true, margin: "-50px" });
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"]
});
// Parallax transform for the image
const imageY = useTransform(scrollYProgress, [0, 1], [50, -50]);
// Dynamic z-index based on scroll position and index
const zIndex = isInView ? 50 - index : 10 - index;
return (
<motion.div
ref={ref}
className={`relative w-full ${
isMobile ? 'h-80' : isTablet ? 'h-96' : 'h-[500px]'
} rounded-2xl overflow-hidden shadow-2xl bg-white`}
style={{ zIndex }}
initial={{ opacity: 0, y: 100, rotateX: 15 }}
animate={isInView ? {
opacity: 1,
y: 0,
rotateX: 0,
scale: isInView ? 1 : 0.95
} : {
opacity: 0,
y: 100,
rotateX: 15,
scale: 0.95
}}
transition={{
duration: 0.8,
delay: index * 0.15,
ease: [0.25, 0.1, 0.25, 1]
}}
whileHover={{
y: -10,
scale: 1.02,
rotateX: -2,
transition: { duration: 0.3 }
}}
>
{/* Background Image with Parallax */}
<div className="absolute inset-0 overflow-hidden">
<motion.div
className="relative w-full h-full"
style={{ y: imageY }}
>
<ImageWithFallback
src={step.image}
alt={step.title}
className="w-full h-[120%] object-cover"
/>
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-r from-primary/10 to-secondary/10" />
</motion.div>
</div>
{/* Content Container */}
<div className="relative h-full flex flex-col justify-between p-6 lg:p-8 text-white z-10">
{/* Top Section - Step Number */}
<div className="flex justify-between items-start">
<motion.div
className="w-12 h-12 lg:w-16 lg:h-16 bg-gradient-to-r from-primary to-secondary rounded-full flex items-center justify-center shadow-lg"
initial={{ scale: 0, rotate: -180 }}
animate={isInView ? { scale: 1, rotate: 0 } : { scale: 0, rotate: -180 }}
transition={{ duration: 0.6, delay: index * 0.1 + 0.3 }}
>
<span className="text-white font-bold text-lg lg:text-xl">{step.stepNumber}</span>
</motion.div>
{!isMobile && (
<motion.div
className="text-right opacity-80"
initial={{ opacity: 0, x: 20 }}
animate={isInView ? { opacity: 0.8, x: 0 } : { opacity: 0, x: 20 }}
transition={{ duration: 0.6, delay: index * 0.1 + 0.5 }}
>
<div className="text-xs lg:text-sm font-medium">Step {step.stepNumber} of 6</div>
</motion.div>
)}
</div>
{/* Bottom Section - Main Content */}
<div className="space-y-3 lg:space-y-4">
{/* Left Title (Smaller text) */}
<motion.div
className="text-sm lg:text-base opacity-80 font-medium"
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 0.8, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.6, delay: index * 0.1 + 0.4 }}
>
{step.leftTitle}
</motion.div>
{/* Main Title */}
<motion.h3
className={`font-bold ${
isMobile ? 'text-2xl' : isTablet ? 'text-3xl' : 'text-4xl lg:text-5xl'
} leading-tight`}
initial={{ opacity: 0, y: 30 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
transition={{ duration: 0.6, delay: index * 0.1 + 0.5 }}
>
<span className="font-light">{step.title.split(' ').slice(0, -1).join(' ')}</span>{' '}
<span className="font-bold italic bg-gradient-to-r from-white to-gray-200 bg-clip-text text-transparent">
{step.title.split(' ').slice(-1)[0]}
</span>
</motion.h3>
{/* Subtitle */}
<motion.h4
className={`font-semibold ${
isMobile ? 'text-lg' : 'text-xl lg:text-2xl'
} text-primary-foreground opacity-90`}
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 0.9, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.6, delay: index * 0.1 + 0.6 }}
>
{step.subtitleShort}
</motion.h4>
{/* Right Title (Smaller text) */}
<motion.div
className="text-sm lg:text-base opacity-80 font-medium"
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 0.8, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.6, delay: index * 0.1 + 0.7 }}
>
{step.rightTitle}
</motion.div>
</div>
</div>
{/* Interactive Shine Effect */}
<motion.div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent opacity-0"
initial={{ x: "-100%", opacity: 0 }}
animate={isInView ? { x: "100%", opacity: [0, 1, 0] } : { x: "-100%", opacity: 0 }}
transition={{
duration: 1.5,
delay: index * 0.1 + 1,
ease: "easeInOut"
}}
/>
</motion.div>
);
}
// Main ScrollAnimatedJourney Component
export function ScrollAnimatedJourney() {
return (
<section className="relative bg-gradient-to-b from-gray-50/50 to-white overflow-hidden">
{/* Journey Section Wrapper - Creates scroll height for 6 steps */}
<div className="relative">
{/* Sticky Section Container */}
{/* Your Journey */}
</div>
{/* Background Effects */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<motion.div
className="absolute top-1/4 -left-32 w-64 h-64 bg-gradient-to-r from-primary/10 to-secondary/10 rounded-full blur-3xl"
animate={{
y: [0, -30, 0],
x: [0, 20, 0],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut"
}}
/>
<motion.div
className="absolute bottom-1/4 -right-32 w-80 h-80 bg-gradient-to-l from-secondary/8 to-primary/8 rounded-full blur-3xl"
animate={{
y: [0, 40, 0],
x: [0, -15, 0],
}}
transition={{
duration: 10,
repeat: Infinity,
ease: "easeInOut",
delay: 1
}}
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,55 @@
import { motion } from 'motion/react';
import { getVariants, getViewportSettings } from '../utils/helpers';
import {
staggerContainer,
fastStaggerContainer,
backgroundVariants
} from '../utils/animations';
interface SectionWrapperProps {
id: string;
children: React.ReactNode;
containerType?: 'stagger' | 'fastStagger';
backgroundGradient?: string;
className?: string;
isMobile?: boolean;
variantType?: 'section' | 'card' | 'footer';
}
export function SectionWrapper({
id,
children,
containerType = 'stagger',
backgroundGradient,
className = '',
isMobile = false,
variantType = 'section'
}: SectionWrapperProps) {
const containerVariants = containerType === 'fastStagger' ? fastStaggerContainer : staggerContainer;
const viewportType = variantType === 'footer' ? 'footer' : variantType;
return (
<motion.section
id={id}
className={`relative ${className}`}
initial="hidden"
whileInView="visible"
viewport={getViewportSettings(viewportType)}
variants={containerVariants}
>
{backgroundGradient && (
<motion.div
className={`absolute inset-0 ${backgroundGradient}`}
variants={backgroundVariants}
/>
)}
<motion.div
className="relative z-10"
variants={getVariants(variantType, isMobile)}
>
{children}
</motion.div>
</motion.section>
);
}

View File

@@ -0,0 +1,341 @@
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Facebook, Apple, ArrowLeft, ChevronLeft, ChevronRight, Lock, Gift } from 'lucide-react';
import { ImageWithFallback } from './figma/ImageWithFallback';
import cityCardsLogo from 'figma:asset/4d07c3035c8f965d162e4e0d20cb3910fd5fa6fe.png';
interface SignInPageProps {
onBackClick?: () => void;
}
export function SignInPage({ onBackClick }: SignInPageProps) {
const [mode, setMode] = useState<'login' | 'signup'>('signup');
const [formData, setFormData] = useState({
email: '',
password: '',
referralCode: ''
});
const [currentSlide, setCurrentSlide] = useState(0);
const carouselImages = [
{
src: 'https://images.unsplash.com/photo-1549144511-f099e773c147',
alt: 'Eiffel Tower at sunset - iconic Paris landmark',
title: 'Iconic Landmarks',
description: 'Skip the lines at world-famous attractions'
},
{
src: 'https://images.unsplash.com/photo-1554907984-15263bfd63bd',
alt: 'Grand museum interior with classical architecture',
title: 'World-Class Museums',
description: 'Explore culture and history with unlimited access'
},
{
src: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e',
alt: 'Tour group exploring scenic city overlook with guide',
title: 'City Tours & Experiences',
description: 'Discover hidden gems with guided adventures'
},
{
src: 'https://images.unsplash.com/photo-1520637836862-4d197d17c0a8',
alt: 'Sagrada Familia Barcelona - stunning architectural masterpiece',
title: 'Architectural Wonders',
description: 'Access exclusive sites and architectural marvels'
}
];
// Auto-rotate carousel every 5 seconds
useEffect(() => {
const timer = setInterval(() => {
setCurrentSlide((prev) => (prev + 1) % carouselImages.length);
}, 5000);
return () => clearInterval(timer);
}, [carouselImages.length]);
const nextSlide = () => {
setCurrentSlide((prev) => (prev + 1) % carouselImages.length);
};
const prevSlide = () => {
setCurrentSlide((prev) => (prev - 1 + carouselImages.length) % carouselImages.length);
};
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('Form submitted:', { mode, ...formData });
};
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Left Side - Form */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="w-full max-w-md space-y-8">
{/* Back Button */}
{onBackClick && (
<button
onClick={onBackClick}
className="flex items-center space-x-2 text-gray-600 hover:text-gray-900 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm">Back to CityCards</span>
</button>
)}
{/* Header */}
<div className="text-center space-y-4">
{/* CityCards Logo */}
<div className="flex justify-center mb-6">
<img
src={cityCardsLogo}
alt="CityCards Logo"
className="h-16 w-auto"
/>
</div>
<h1 className="text-3xl font-bold text-gray-900">
{mode === 'signup' ? 'Create Your Account' : 'Welcome Back'}
</h1>
<p className="text-gray-600 text-sm">
{mode === 'signup'
? 'Setting up an account takes less than one minute.'
: 'Sign in to access your account and continue your journey.'
}
</p>
</div>
{/* Toggle Buttons */}
<div className="flex bg-gray-200 rounded-full p-1">
<button
onClick={() => setMode('login')}
className={`flex-1 py-2 px-4 rounded-full text-sm font-medium transition-all duration-200 ${
mode === 'login'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
Login
</button>
<button
onClick={() => setMode('signup')}
className={`flex-1 py-2 px-4 rounded-full text-sm font-medium transition-all duration-200 ${
mode === 'signup'
? 'bg-gradient-to-r from-purple-600 to-blue-600 text-white shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
Sign Up
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Email Field */}
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium text-gray-700">
Email Address
</Label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span className="text-gray-500 text-sm">@</span>
</div>
<Input
id="email"
type="email"
placeholder="Email Address"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
className="pl-8 h-12 border-gray-300 rounded-lg focus:border-purple-500 focus:ring-purple-500"
required
/>
</div>
</div>
{/* Password Field */}
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium text-gray-700">
Password
</Label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="w-4 h-4 text-gray-500" />
</div>
<Input
id="password"
type="password"
placeholder="Password"
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
className="pl-8 h-12 border-gray-300 rounded-lg focus:border-purple-500 focus:ring-purple-500"
required
/>
</div>
</div>
{/* Referral Code Field (only for signup) */}
{mode === 'signup' && (
<div className="space-y-2">
<Label htmlFor="referralCode" className="text-sm font-medium text-gray-700">
Referral Code (Optional)
</Label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Gift className="w-4 h-4 text-gray-500" />
</div>
<Input
id="referralCode"
type="text"
placeholder="Referral Code (Optional)"
value={formData.referralCode}
onChange={(e) => handleInputChange('referralCode', e.target.value)}
className="pl-8 h-12 border-gray-300 rounded-lg focus:border-purple-500 focus:ring-purple-500"
/>
</div>
</div>
)}
{/* Continue Button */}
<Button
type="submit"
className="w-full py-4 px-12 rounded-lg text-lg bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white flex items-center justify-center space-x-2"
>
<span>Continue</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Button>
</form>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gray-50 text-gray-500">Or Continue with</span>
</div>
</div>
{/* Social Login Buttons */}
<div className="flex space-x-3">
<Button
variant="outline"
className="flex-1 py-4 border-gray-300 hover:bg-blue-50 hover:border-blue-300"
>
<Facebook className="w-5 h-5 text-blue-600" />
</Button>
<Button
variant="outline"
className="flex-1 py-4 border-gray-300 hover:bg-gray-50"
>
<Apple className="w-5 h-5 text-gray-900" />
</Button>
<Button
variant="outline"
className="flex-1 py-4 border-gray-300 hover:bg-red-50 hover:border-red-300"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
</Button>
</div>
</div>
</div>
{/* Right Side - CityCards Attraction Carousel */}
<div className="hidden lg:flex flex-1 bg-gradient-to-br from-purple-50 to-blue-50 relative overflow-hidden">
{/* Carousel Container */}
<div className="relative w-full h-full">
{/* Images */}
<div className="relative w-full h-full">
{carouselImages.map((image, index) => (
<div
key={index}
className={`absolute inset-0 transition-all duration-700 ease-in-out ${
index === currentSlide
? 'opacity-100 transform translate-x-0'
: index < currentSlide
? 'opacity-0 transform -translate-x-full'
: 'opacity-0 transform translate-x-full'
}`}
>
<ImageWithFallback
src={image.src}
alt={image.alt}
className="w-full h-full object-cover"
/>
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent" />
{/* Content Overlay */}
<div className="absolute bottom-0 left-0 right-0 p-8 text-white">
<div className="max-w-md">
<h3 className="text-2xl font-bold mb-2">{image.title}</h3>
<p className="text-lg opacity-90 mb-4">{image.description}</p>
<div className="flex items-center space-x-2 text-sm opacity-75">
<div className="w-2 h-2 bg-white rounded-full"></div>
<span>Included with CityCards Pass</span>
</div>
</div>
</div>
</div>
))}
</div>
{/* Navigation Buttons */}
<button
onClick={prevSlide}
className="absolute left-6 top-1/2 -translate-y-1/2 w-12 h-12 bg-white/20 backdrop-blur-sm border border-white/30 rounded-full flex items-center justify-center text-white hover:bg-white/30 transition-all duration-200 z-10"
>
<ChevronLeft className="w-6 h-6" />
</button>
<button
onClick={nextSlide}
className="absolute right-6 top-1/2 -translate-y-1/2 w-12 h-12 bg-white/20 backdrop-blur-sm border border-white/30 rounded-full flex items-center justify-center text-white hover:bg-white/30 transition-all duration-200 z-10"
>
<ChevronRight className="w-6 h-6" />
</button>
{/* Dot Indicators */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex space-x-3 z-10">
{carouselImages.map((_, index) => (
<button
key={index}
onClick={() => setCurrentSlide(index)}
className={`w-3 h-3 rounded-full transition-all duration-300 ${
index === currentSlide
? 'bg-white scale-110'
: 'bg-white/50 hover:bg-white/75'
}`}
/>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,432 @@
import { useState, useRef, useEffect } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { motion, AnimatePresence, useMotionValue, useTransform, PanInfo } from 'motion/react';
const testimonials = [
{
id: 1,
name: 'Sarah Mitchell',
role: 'Travel Blogger',
company: 'Wanderlust Adventures',
avatar: 'https://images.unsplash.com/photo-1494790108755-2616b612b1ac?q=80&w=150&auto=format&fit=crop&ixlib=rb-4.0.3',
quote: 'CityCards completely transformed our Australian city-hopping adventure. The curated attraction passes saved us 60% on costs and the skip-the-line access was invaluable. Every city felt like a personalized experience tailored just for us.',
signature: 'Sarah M.'
},
{
id: 2,
name: 'Michael Chen',
role: 'Travel Photographer',
company: 'Urban Lens Studio',
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?q=80&w=150&auto=format&fit=crop&ixlib=rb-4.0.3',
quote: 'As someone who captures cities worldwide, CityCards gave me access to unique perspectives and hidden gems across Australia. The mobile city guide made discovering photo spots seamless, from Sydney\'s harbor to Melbourne\'s laneways.',
signature: 'Michael C.'
},
{
id: 3,
name: 'Emma Rodriguez',
role: 'Adventure Seeker',
company: 'Solo Travel Co.',
avatar: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?q=80&w=150&auto=format&fit=crop&ixlib=rb-4.0.3',
quote: 'Solo traveling became effortless with CityCards. From instant bookings to local recommendations, I felt confident exploring Australian cities. The comprehensive city guides unlocked experiences I never knew existed.',
signature: 'Emma R.'
},
{
id: 4,
name: 'David Park',
role: 'Family Traveler',
company: 'Adventure Families',
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?q=80&w=150&auto=format&fit=crop&ixlib=rb-4.0.3',
quote: 'Planning family trips used to be overwhelming, but CityCards simplified everything. The kids loved the interactive city experiences and we saved hours with skip-the-line access at every single attraction we visited.',
signature: 'David P.'
},
{
id: 5,
name: 'Lisa Thompson',
role: 'Business Traveler',
company: 'Global Solutions Inc.',
avatar: 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?q=80&w=150&auto=format&fit=crop&ixlib=rb-4.0.3',
quote: 'Between business meetings, CityCards helped me maximize my limited free time in every city. Quick access to top attractions without the hassle of traditional booking made every business trip memorable and productive.',
signature: 'Lisa T.'
},
{
id: 6,
name: 'James Wilson',
role: 'Cultural Explorer',
company: 'Heritage Travels',
avatar: 'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?q=80&w=150&auto=format&fit=crop&ixlib=rb-4.0.3',
quote: 'CityCards opened doors to authentic cultural experiences I would have missed otherwise. The curated recommendations led to discoveries that became the absolute highlights of our entire cultural journey through Australia\'s diverse cities.',
signature: 'James W.'
}
];
// Custom SVG quotation marks
const QuoteStart = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 8C10 9.3 9.3 10 8 10C6.7 10 6 9.3 6 8C6 6.7 6.7 6 8 6C9.3 6 10 6.7 10 8ZM18 8C18 9.3 17.3 10 16 10C14.7 10 14 9.3 14 8C14 6.7 14.7 6 16 6C17.3 6 18 6.7 18 8ZM8 12L6 18H10L8 12ZM16 12L14 18H18L16 12Z" fill="currentColor"/>
</svg>
);
const QuoteEnd = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 16C14 14.7 14.7 14 16 14C17.3 14 18 14.7 18 16C18 17.3 17.3 18 16 18C14.7 18 14 17.3 14 16ZM6 16C6 14.7 6.7 14 8 14C9.3 14 10 14.7 10 16C10 17.3 9.3 18 8 18C6.7 18 6 17.3 6 16ZM16 12L18 6H14L16 12ZM8 12L10 6H6L8 12Z" fill="currentColor"/>
</svg>
);
export function TrustSection() {
const [currentIndex, setCurrentIndex] = useState(0);
const [hoveredCard, setHoveredCard] = useState<number | null>(null);
const [dragConstraints, setDragConstraints] = useState({ left: 0, right: 0 });
const [showNameOnProgress, setShowNameOnProgress] = useState(false);
const carouselRef = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
// Calculate how many cards to show based on screen size
const [cardsPerView, setCardsPerView] = useState(1);
useEffect(() => {
const updateCardsPerView = () => {
if (window.innerWidth >= 1200) {
setCardsPerView(2);
} else {
setCardsPerView(1);
}
};
updateCardsPerView();
window.addEventListener('resize', updateCardsPerView);
return () => window.removeEventListener('resize', updateCardsPerView);
}, []);
const totalSlides = Math.ceil(testimonials.length / cardsPerView);
const maxIndex = totalSlides - 1;
useEffect(() => {
if (carouselRef.current) {
const cardWidth = carouselRef.current.offsetWidth;
const maxDrag = -(cardWidth * maxIndex);
setDragConstraints({ left: maxDrag, right: 0 });
}
}, [maxIndex, cardsPerView]);
const handlePrevious = () => {
setCurrentIndex(prev => Math.max(0, prev - 1));
};
const handleNext = () => {
setCurrentIndex(prev => Math.min(maxIndex, prev + 1));
};
const handleDragEnd = (event: any, info: PanInfo) => {
const offset = info.offset.x;
const velocity = info.velocity.x;
if (Math.abs(offset) > 100 || Math.abs(velocity) > 500) {
if (offset > 0 && currentIndex > 0) {
setCurrentIndex(prev => prev - 1);
} else if (offset < 0 && currentIndex < maxIndex) {
setCurrentIndex(prev => prev + 1);
}
}
};
const progress = totalSlides > 1 ? (currentIndex / maxIndex) * 100 : 0;
const getCurrentTestimonialNames = () => {
const startIndex = currentIndex * cardsPerView;
const endIndex = Math.min(startIndex + cardsPerView, testimonials.length);
return testimonials.slice(startIndex, endIndex).map(t => t.name).join(', ');
};
return (
<section className="py-16 md:py-24 bg-background relative overflow-hidden">
{/* Subtle background texture */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23000' fill-opacity='0.03'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3z'/%3E%3C/g%3E%3C/svg%3E")`
}}
/>
<div className="container mx-auto px-4" style={{ overflow: 'visible' }}>
{/* Header */}
<div className="text-center mb-16">
<h2 className="font-poppins text-2xl md:text-3xl lg:text-4xl leading-tight mb-6 text-foreground">
<span className="font-light">What Our</span>{' '}
<span className="font-bold italic text-primary">
Travelers
</span>{' '}
<span className="font-light">Say</span>
</h2>
<p className="font-poppins text-xl leading-relaxed font-normal text-gray-700 max-w-2xl mx-auto">
Real stories from real travelers who've discovered amazing cities with CityCards
</p>
</div>
{/* Carousel Container */}
<div className="relative max-w-7xl mx-auto" style={{ overflow: 'visible' }}>
{/* Carousel */}
<div className="mx-8 py-6">
<motion.div
ref={carouselRef}
className="flex"
animate={{ x: `${-currentIndex * 100}%` }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
drag="x"
dragConstraints={dragConstraints}
onDragEnd={handleDragEnd}
style={{ cursor: 'grab', overflow: 'visible' }}
whileDrag={{ cursor: 'grabbing' }}
>
{testimonials.map((testimonial, index) => {
const cardRotation = (index % 3 - 1) * 1.5 + (Math.random() - 0.5) * 1;
const cardOffset = (index % 2) * 10;
return (
<motion.div
key={testimonial.id}
className="flex-shrink-0 px-8 py-4"
style={{
width: cardsPerView === 2 ? '50%' : '100%',
}}
whileHover={{
scale: 1.02,
y: -5,
transition: { duration: 0.2 }
}}
onHoverStart={() => setHoveredCard(testimonial.id)}
onHoverEnd={() => setHoveredCard(null)}
>
{/* Paper Card with enhanced realism */}
<div
className="relative bg-white rounded-lg p-8"
style={{
transform: `rotate(${cardRotation}deg) translateY(${cardOffset}px)`,
transformOrigin: 'center center',
minHeight: '480px',
background: `
radial-gradient(circle at 20% 80%, rgba(255, 248, 235, 0.8) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(250, 245, 230, 0.6) 0%, transparent 50%),
linear-gradient(145deg, #ffffff 0%, #fefefe 25%, #fdfdfd 50%, #fcfcfc 75%, #fbfbfb 100%)
`,
boxShadow: `
0 8px 32px rgba(0, 0, 0, 0.12),
0 4px 16px rgba(0, 0, 0, 0.08),
0 2px 8px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.8),
inset 0 -1px 0 rgba(0, 0, 0, 0.02)
`,
border: '1px solid rgba(0, 0, 0, 0.04)',
filter: hoveredCard === testimonial.id ? 'brightness(1.02)' : 'brightness(1)',
transition: 'all 0.3s ease'
}}
>
{/* Enhanced paper texture */}
<div
className="absolute inset-0 rounded-lg opacity-40 pointer-events-none"
style={{
backgroundImage: `
url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23f5f0e8' fill-opacity='0.4'%3E%3Cpath d='M10 10h1v1h-1zM20 15h1v1h-1zM30 25h1v1h-1zM40 30h1v1h-1zM50 40h1v1h-1zM15 50h1v1h-1z'/%3E%3C/g%3E%3C/svg%3E"),
radial-gradient(circle at 25% 75%, rgba(245, 240, 232, 0.3) 0%, transparent 40%),
radial-gradient(circle at 75% 25%, rgba(250, 245, 235, 0.2) 0%, transparent 35%)
`,
mixBlendMode: 'multiply'
}}
/>
{/* Paper creases and folds */}
<div
className="absolute inset-0 rounded-lg opacity-20 pointer-events-none"
style={{
background: `
linear-gradient(135deg, transparent 40%, rgba(0,0,0,0.02) 45%, rgba(0,0,0,0.01) 55%, transparent 60%),
linear-gradient(45deg, transparent 30%, rgba(0,0,0,0.015) 35%, transparent 40%)
`
}}
/>
{/* Corner fold effect */}
<div
className="absolute top-0 right-0 w-12 h-12 opacity-15 pointer-events-none"
style={{
background: `
linear-gradient(-45deg,
transparent 40%,
rgba(0,0,0,0.08) 45%,
rgba(0,0,0,0.12) 50%,
rgba(0,0,0,0.08) 55%,
transparent 60%
)
`,
borderTopRightRadius: '8px',
clipPath: 'polygon(60% 0%, 100% 0%, 100% 60%)'
}}
/>
{/* Paperclip decoration */}
<div
className="absolute -top-2 -right-2 w-8 h-12 opacity-60 pointer-events-none z-10"
style={{
background: `
linear-gradient(145deg, #e0e0e0 0%, #d0d0d0 50%, #c8c8c8 100%)
`,
borderRadius: '2px 2px 4px 4px',
boxShadow: `
0 2px 4px rgba(0,0,0,0.1),
inset 0 1px 0 rgba(255,255,255,0.5),
inset 0 -1px 0 rgba(0,0,0,0.1)
`,
transform: 'rotate(8deg)'
}}
>
<div
className="absolute inset-1 border border-gray-400 rounded-sm"
style={{
background: 'transparent',
borderStyle: 'solid',
borderWidth: '1px'
}}
/>
</div>
{/* Tape effect on left edge */}
<div
className="absolute -left-1 top-16 w-4 h-16 opacity-30 pointer-events-none"
style={{
background: `
linear-gradient(90deg,
rgba(255,255,220,0.8) 0%,
rgba(255,255,220,0.6) 50%,
rgba(255,255,220,0.4) 100%
)
`,
borderRadius: '2px',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.1)',
transform: 'rotate(-2deg)'
}}
/>
{/* Enhanced quotation marks */}
<div className="mb-6">
<QuoteStart className="w-8 h-8 text-amber-700/30 mb-4" />
<p
className="font-poppins text-base leading-relaxed font-normal text-foreground relative z-10"
>
{testimonial.quote}
</p>
<div className="flex justify-end mt-2">
<QuoteEnd className="w-6 h-6 text-amber-700/30" />
</div>
</div>
{/* Enhanced Profile Section with sticker effect */}
<div className="mb-8 relative z-10">
<div className="font-poppins text-lg leading-snug font-semibold text-foreground">
{testimonial.name}
</div>
<div className="font-poppins text-sm leading-relaxed font-normal text-gray-600">
{testimonial.company}
</div>
</div>
{/* Enhanced signature with writing animation */}
<div className="flex justify-end relative z-10">
<motion.div
className="text-right transform -rotate-1"
initial={{ pathLength: 0, opacity: 0 }}
animate={{
pathLength: 1,
opacity: 1,
transition: {
pathLength: { duration: 2, delay: index * 0.1 },
opacity: { duration: 0.5, delay: index * 0.1 }
}
}}
style={{
fontFamily: "'Dancing Script', 'Brush Script MT', cursive",
fontSize: '32px',
color: 'rgba(101, 84, 63, 0.8)',
textShadow: `
1px 1px 2px rgba(0, 0, 0, 0.1),
0 0 4px rgba(101, 84, 63, 0.2)
`,
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.1))'
}}
>
{testimonial.signature}
</motion.div>
</div>
{/* Subtle aging spots */}
<div
className="absolute w-3 h-3 rounded-full opacity-8 pointer-events-none"
style={{
background: 'radial-gradient(circle, rgba(160, 120, 80, 0.15) 0%, transparent 70%)',
top: '15%',
right: '20%'
}}
/>
<div
className="absolute w-2 h-2 rounded-full opacity-8 pointer-events-none"
style={{
background: 'radial-gradient(circle, rgba(140, 110, 70, 0.12) 0%, transparent 70%)',
bottom: '25%',
left: '15%'
}}
/>
{/* Pin shadow effect */}
{index % 3 === 0 && (
<div
className="absolute w-2 h-2 rounded-full opacity-20 pointer-events-none"
style={{
background: 'radial-gradient(circle, rgba(0,0,0,0.3) 0%, transparent 70%)',
top: '8px',
left: '50%',
transform: 'translateX(-50%)',
filter: 'blur(1px)'
}}
/>
)}
</div>
</motion.div>
);
})}
</motion.div>
</div>
{/* Enhanced Progress Bar with hover names */}
<div className="mt-10 max-w-md mx-auto">
{/* Enhanced slide indicators */}
<div className="flex justify-center space-x-3">
{Array.from({ length: totalSlides }).map((_, index) => (
<button
key={index}
onClick={() => setCurrentIndex(index)}
className={`w-4 h-4 rounded-full transition-all duration-300 transform ${
index === currentIndex
? 'bg-warm-coral scale-110 shadow-lg'
: 'bg-gray-300 hover:bg-gray-400 hover:scale-105'
}`}
style={{
boxShadow: index === currentIndex
? '0 4px 8px rgba(249, 95, 98, 0.3), 0 2px 4px rgba(249, 95, 98, 0.2)'
: '0 2px 4px rgba(0,0,0,0.1)'
}}
/>
))}
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,327 @@
import { ArrowRight } from 'lucide-react';
import { ImageWithFallback } from './figma/ImageWithFallback';
import { Button } from './ui/button';
import { useRef, useState, useEffect } from 'react';
import Image592Traced from '../imports/Image592Traced-5025-559';
const upcomingCities = [
{
id: 1,
name: 'Boston',
country: 'USA',
launchDate: 'Spring 2025',
attractions: 65,
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.',
image: 'https://images.unsplash.com/photo-1568271667303-14b2a1a36da1?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: true
},
{
id: 2,
name: 'Rome',
country: 'Italy',
launchDate: 'Summer 2025',
attractions: 80,
image: 'https://images.unsplash.com/photo-1552832230-c0197dd311b5?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false
},
{
id: 3,
name: 'Paris',
country: 'France',
launchDate: 'Fall 2025',
attractions: 95,
image: 'https://images.unsplash.com/photo-1502602898536-47ad22581b52?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false
},
{
id: 4,
name: 'Dubai',
country: 'UAE',
launchDate: 'Winter 2025',
attractions: 70,
image: 'https://images.unsplash.com/photo-1512453979798-5ea266f8880c?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false,
badge: 'New'
},
{
id: 5,
name: 'Tokyo',
country: 'Japan',
launchDate: 'Early 2026',
attractions: 120,
image: 'https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false
},
{
id: 6,
name: 'Sydney',
country: 'Australia',
launchDate: 'Spring 2026',
attractions: 85,
image: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false
},
{
id: 7,
name: 'New York',
country: 'USA',
launchDate: 'Summer 2026',
attractions: 150,
image: 'https://images.unsplash.com/photo-1496442226666-8d4d0e62e6e9?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false,
badge: 'Most Requested'
},
{
id: 8,
name: 'Singapore',
country: 'Singapore',
launchDate: 'Fall 2026',
attractions: 75,
image: 'https://images.unsplash.com/photo-1525625293386-3f8f99389edd?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false
},
{
id: 9,
name: 'Amsterdam',
country: 'Netherlands',
launchDate: 'Winter 2026',
attractions: 90,
image: 'https://images.unsplash.com/photo-1534351590666-13e3e96b5017?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false
},
{
id: 10,
name: 'Barcelona',
country: 'Spain',
launchDate: 'Early 2027',
attractions: 110,
image: 'https://images.unsplash.com/photo-1583422409516-2895a77efded?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false
}
];
export function UpcomingCities() {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const [showDragHint, setShowDragHint] = useState(false);
const handleMouseDown = (e: React.MouseEvent) => {
if (!scrollContainerRef.current) return;
// Only start dragging if not clicking on a button or interactive element
const target = e.target as HTMLElement;
if (target.closest('button') || target.closest('[role="button"]')) {
return;
}
setIsDragging(true);
setStartX(e.pageX - scrollContainerRef.current.offsetLeft);
setScrollLeft(scrollContainerRef.current.scrollLeft);
setShowDragHint(false);
};
const handleMouseLeave = () => {
setIsDragging(false);
setShowDragHint(false);
};
const handleMouseUp = () => {
setIsDragging(false);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging || !scrollContainerRef.current) return;
e.preventDefault();
const x = e.pageX - scrollContainerRef.current.offsetLeft;
const walk = (x - startX) * 1.5; // Reduced multiplier for smoother movement
scrollContainerRef.current.scrollLeft = scrollLeft - walk;
};
const handleMouseEnter = () => {
if (!isDragging) {
setShowDragHint(true);
}
};
useEffect(() => {
const handleGlobalMouseUp = () => setIsDragging(false);
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
}, []);
return (
<section className="py-20 bg-gray-50">
{/* Header - Contained and aligned */}
<div className="container mx-auto px-4">
<div className="mb-16">
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-4">
Upcoming Cities
</h2>
<p className="text-lg text-gray-600 max-w-2xl">
Here are lots of interesting destinations to visit, but don't be confused—they're already grouped by category.
</p>
</div>
</div>
{/* Cities Carousel - Aligned with header, extending to screen edge */}
<div className="relative">
{/* Drag Hint Pill */}
{showDragHint && (
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-20 bg-black/80 text-white px-4 py-2 rounded-full text-sm font-medium backdrop-blur-sm transition-all duration-300 pointer-events-none">
Drag to scroll
</div>
)}
<div
ref={scrollContainerRef}
className={`flex gap-6 overflow-x-auto scrollbar-hide pb-2 ${isDragging ? 'cursor-grabbing dragging select-none' : 'cursor-grab'}`}
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
scrollBehavior: isDragging ? 'auto' : 'smooth',
paddingLeft: 'max(1rem, calc((100vw - 1280px) / 2 + 1rem))',
paddingRight: '1rem'
}}
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onMouseEnter={handleMouseEnter}
>
{upcomingCities.map((city) => (
<div
key={city.id}
className="flex-shrink-0 w-72 md:w-80 group relative h-[420px] rounded-3xl overflow-hidden shadow-lg hover:shadow-xl transition-all duration-500"
>
{/* Background - Either solid color or image */}
{city.showHoverState ? (
// Boston card with image background and same layout as other cards
<>
<ImageWithFallback
src={city.image!}
alt={city.name}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
/>
{/* Dark overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent group-hover:from-black/80 transition-all duration-500" />
{/* City name overlay - matching Rome card layout */}
<div className="absolute bottom-6 left-6 right-6 text-white">
<h3 className="text-2xl font-bold mb-2">{city.name}</h3>
<div className="flex items-center justify-between text-sm text-white/80">
<span>{city.country}</span>
<span>{city.launchDate}</span>
</div>
</div>
{/* Hover state overlay - same as other cards */}
<div className="absolute inset-0 bg-warm-coral/90 opacity-0 group-hover:opacity-100 transition-all duration-500 flex items-center justify-center">
<div className="text-center text-white">
<h3 className="text-2xl font-bold mb-2">{city.name}</h3>
<p className="text-white/90 mb-4">{city.attractions}+ attractions</p>
<p className="text-sm text-white/80 mb-6">Coming {city.launchDate}</p>
<Button
variant="secondary"
className="bg-white/20 hover:bg-white/30 text-white border-white/30 hover:border-white/50 backdrop-blur-sm"
onMouseDown={(e) => {
e.stopPropagation();
setIsDragging(false);
}}
onClick={(e) => {
e.stopPropagation();
console.log('Notify Me button clicked');
}}
>
Notify Me
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</>
) : (
// Image background for other cards
<>
<ImageWithFallback
src={city.image!}
alt={city.name}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
/>
{/* Dark overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent group-hover:from-black/80 transition-all duration-500" />
{/* Badge (if present) */}
{city.badge && (
<div className="absolute top-4 right-4 bg-white text-gray-900 px-3 py-1 rounded-full text-sm font-medium shadow-lg">
{city.badge}
</div>
)}
{/* City name overlay */}
<div className="absolute bottom-6 left-6 right-6 text-white">
<h3 className="text-2xl font-bold mb-2">{city.name}</h3>
<div className="flex items-center justify-between text-sm text-white/80">
<span>{city.country}</span>
<span>{city.launchDate}</span>
</div>
</div>
{/* Hover state overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/90 to-secondary/90 opacity-0 group-hover:opacity-100 transition-all duration-500 flex items-center justify-center">
<div className="text-center text-white">
<h3 className="text-2xl font-bold mb-2">{city.name}</h3>
<p className="text-white/90 mb-4">{city.attractions}+ attractions</p>
<p className="text-sm text-white/80 mb-6">Coming {city.launchDate}</p>
<Button
variant="secondary"
className="bg-white/20 hover:bg-white/30 text-white border-white/30 hover:border-white/50 backdrop-blur-sm"
onMouseDown={(e) => {
e.stopPropagation();
setIsDragging(false);
}}
onClick={(e) => {
e.stopPropagation();
console.log('Notify Me button clicked');
}}
>
Notify Me
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</>
)}
</div>
))}
</div>
</div>
{/* Bottom CTA - Contained */}
<div className="container mx-auto px-4">
</div>
{/* Global Offices Section */}
<div className="container mx-auto px-4 mt-20">
{/* Global Presence Section - Exact Screenshot Recreation */}
</div>
<style dangerouslySetInnerHTML={{
__html: `
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
.scrollbar-hide.dragging {
scroll-behavior: auto;
}
`
}} />
</section>
);
}

View File

@@ -0,0 +1,251 @@
import { Calendar, Users, Compass, Bell, ArrowRight, Sparkles } from 'lucide-react';
import { ImageWithFallback } from './figma/ImageWithFallback';
import { Button } from './ui/button';
import { motion } from 'motion/react';
const upcomingDestinations = [
{
id: 1,
name: 'Reykjavik',
country: 'Iceland',
launchDate: 'March 2025',
season: 'Spring',
expectedAttractions: 45,
specialFeature: 'Northern Lights',
status: 'launching-soon',
image: 'https://images.unsplash.com/photo-1666003415555-1634ff3cd022?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxyZXlramF2aWslMjBpY2VsYW5kJTIwbm9ydGhlcm4lMjBsaWdodHN8ZW58MXx8fHwxNzU2MTIzNjI1fDA&ixlib=rb-4.1.0&q=80&w=1080',
description: 'Experience the magic of Iceland with geothermal wonders, dramatic landscapes, and the enchanting Northern Lights.',
earlyBirdDiscount: '25%'
},
{
id: 2,
name: 'Cape Town',
country: 'South Africa',
launchDate: 'June 2025',
season: 'Winter',
expectedAttractions: 85,
specialFeature: 'Wildlife Safari',
status: 'in-development',
image: 'https://images.unsplash.com/photo-1635260980154-315c57168b12?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxjYXBlJTIwdG93biUyMHRhYmxlJTIwbW91bnRhaW58ZW58MXx8fHwxNzU2MTIzNjI5fDA&ixlib=rb-4.1.0&q=80&w=1080',
description: 'Discover the Mother City with Table Mountain adventures, wine tours, and incredible wildlife experiences.',
earlyBirdDiscount: '20%'
},
{
id: 3,
name: 'Oslo',
country: 'Norway',
launchDate: 'September 2025',
season: 'Autumn',
expectedAttractions: 65,
specialFeature: 'Fjord Tours',
status: 'planning',
image: 'https://images.unsplash.com/photo-1725993486972-f569d8038915?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxvc2xvJTIwZmpvcmQlMjBub3J3YXl8ZW58MXx8fHwxNzU2MTIzNjMzfDA&ixlib=rb-4.1.0&q=80&w=1080',
description: 'Explore Norwegian culture, stunning fjords, and modern Scandinavian design in this beautiful Nordic capital.',
earlyBirdDiscount: '15%'
},
{
id: 4,
name: 'Marrakech',
country: 'Morocco',
launchDate: 'December 2025',
season: 'Winter',
expectedAttractions: 70,
specialFeature: 'Desert Adventure',
status: 'coming-soon',
image: 'https://images.unsplash.com/photo-1589008207338-a9c80f8a2d23?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtYXJyYWtlY2glMjBtb3JvY2NvJTIwbWVkaW5hfGVufDF8fHx8MTc1NjEyMzYzNnww&ixlib=rb-4.1.0&q=80&w=1080',
description: 'Immerse yourself in the imperial city\'s vibrant souks, stunning palaces, and Sahara desert adventures.',
earlyBirdDiscount: '30%'
}
];
const getStatusColor = (status: string) => {
switch (status) {
case 'launching-soon':
return 'bg-green-100 text-green-800 border-green-200';
case 'in-development':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'planning':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'coming-soon':
return 'bg-purple-100 text-purple-800 border-purple-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'launching-soon':
return 'Launching Soon';
case 'in-development':
return 'In Development';
case 'planning':
return 'Planning Stage';
case 'coming-soon':
return 'Coming Soon';
default:
return 'Upcoming';
}
};
export function UpcomingDestinations() {
return (
<section className="py-20 bg-gradient-to-br from-gray-50 to-blue-50/30">
<div className="container mx-auto px-4">
{/* Header */}
<motion.div
className="text-center mb-16"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
>
<div className="inline-flex items-center gap-2 bg-primary/10 text-primary px-4 py-2 rounded-full mb-6">
<Sparkles className="w-4 h-4" />
<span className="text-sm font-medium">New Destinations Coming</span>
</div>
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
<span className="font-light">Upcoming</span>{' '}
<span className="bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent italic">
Destinations
</span>
</h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
Be the first to explore these incredible new destinations. Get early access and exclusive discounts when they launch.
</p>
</motion.div>
{/* Destinations Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-16">
{upcomingDestinations.map((destination, index) => (
<motion.div
key={destination.id}
className="group relative bg-white rounded-3xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-500"
initial={{ opacity: 0, x: index % 2 === 0 ? -30 : 30 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: index * 0.2 }}
viewport={{ once: true }}
whileHover={{ y: -5 }}
>
<div className="flex flex-col lg:flex-row">
{/* Image Section */}
<div className="relative lg:w-1/2 h-64 lg:h-auto overflow-hidden">
<ImageWithFallback
src={destination.image}
alt={destination.name}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
/>
{/* Overlay */}
<div className="absolute inset-0 bg-gradient-to-t lg:bg-gradient-to-r from-black/40 via-transparent to-transparent" />
{/* Status Badge */}
<div className={`absolute top-4 left-4 px-3 py-1 rounded-full border text-sm font-medium ${getStatusColor(destination.status)}`}>
{getStatusText(destination.status)}
</div>
{/* Discount Badge */}
{destination.earlyBirdDiscount && (
<div className="absolute top-4 right-4 bg-red-500 text-white px-3 py-1 rounded-full text-sm font-bold">
-{destination.earlyBirdDiscount} Early Bird
</div>
)}
</div>
{/* Content Section */}
<div className="lg:w-1/2 p-8 flex flex-col">
{/* Header */}
<div className="mb-6">
<h3 className="text-2xl font-bold text-gray-900 mb-2">{destination.name}</h3>
<p className="text-gray-600 mb-4">{destination.country}</p>
<p className="text-gray-700 text-sm leading-relaxed">
{destination.description}
</p>
</div>
{/* Stats */}
<div className="space-y-3 mb-6">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-gray-600">
<Calendar className="w-4 h-4" />
<span>Launch Date</span>
</div>
<span className="font-semibold text-primary">{destination.launchDate}</span>
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-gray-600">
<Compass className="w-4 h-4" />
<span>Attractions</span>
</div>
<span className="font-semibold">{destination.expectedAttractions}+ planned</span>
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-gray-600">
<Sparkles className="w-4 h-4" />
<span>Special Feature</span>
</div>
<span className="font-semibold text-secondary">{destination.specialFeature}</span>
</div>
</div>
{/* CTA Buttons */}
<div className="mt-auto space-y-3">
<Button
className="w-full bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90 text-white font-medium py-3 rounded-2xl transition-all duration-300 group/btn"
>
<Bell className="w-4 h-4 mr-2" />
<span>Notify Me</span>
<ArrowRight className="w-4 h-4 ml-2 group-hover/btn:translate-x-1 transition-transform duration-300" />
</Button>
<Button
variant="outline"
className="w-full border-primary text-primary hover:bg-primary hover:text-white py-3 rounded-2xl transition-all duration-300"
>
<Users className="w-4 h-4 mr-2" />
<span>Join Waitlist</span>
</Button>
</div>
</div>
</div>
</motion.div>
))}
</div>
{/* Newsletter Signup */}
<motion.div
className="bg-gradient-to-r from-primary to-secondary rounded-3xl p-8 lg:p-12 text-center text-white"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
>
<h3 className="text-3xl font-bold mb-4">Be the First to Explore</h3>
<p className="text-xl text-white/90 mb-8 max-w-2xl mx-auto">
Subscribe to get exclusive early access, special discounts, and be notified the moment new destinations launch.
</p>
<div className="flex flex-col sm:flex-row gap-4 max-w-md mx-auto">
<input
type="email"
placeholder="Enter your email"
className="flex-1 px-4 py-3 rounded-2xl text-gray-900 placeholder-gray-500 border-0 focus:ring-2 focus:ring-white/50 focus:outline-none"
/>
<Button
className="bg-white text-primary hover:bg-gray-100 font-semibold px-8 py-3 rounded-2xl transition-all duration-300"
>
Subscribe
</Button>
</div>
<p className="text-sm text-white/70 mt-4">
Join 50,000+ travelers who get first access to new destinations
</p>
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,358 @@
import { Coffee, Palette, Trees, UtensilsCrossed, Music, Building2, Ship, ShoppingBag } from 'lucide-react';
import { Button } from './ui/button';
import { motion, AnimatePresence } from 'motion/react';
import { useState } from 'react';
import { ImageWithFallback } from './figma/ImageWithFallback';
export function VarietyOfAdventures() {
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
const melbourneCategories = [
{
id: 'street-art',
title: 'Street Art & Laneways',
tourCount: '12+ tours',
icon: Palette,
image: 'https://images.unsplash.com/photo-1613910774524-0651750373ec?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBzdHJlZXQlMjBhcnQlMjBncmFmZml0aSUyMGxhbmV3YXlzfGVufDF8fHx8MTc1NjEwNTYwOHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
attractions: [
{
name: 'Hosier Lane',
image: 'https://images.unsplash.com/photo-1582076197789-5c2af0bb51fd?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxob3NpZXIlMjBsYW5lJTIwbWVsYm91cm5lJTIwc3RyZWV0JTIwYXJ0fGVufDF8fHx8MTc1NjEwNjExMnww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
},
{
name: 'AC/DC Lane',
image: 'https://images.unsplash.com/photo-1735704197205-823cfd615e13?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxhYyUyMGRjJTIwbGFuZSUyMG1lbGJvdXJuZSUyMGdyYWZmaXRpfGVufDF8fHx8MTc1NjEwNjExN3ww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
}
]
},
{
id: 'coffee-culture',
title: 'Coffee Culture',
tourCount: '8+ experiences',
icon: Coffee,
image: 'https://images.unsplash.com/photo-1681745623555-efc392301d6d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBjb2ZmZWUlMjBjdWx0dXJlJTIwY2FmZSUyMGJhcmlzdGF8ZW58MXx8fHwxNzU2MTA1NjEyfDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
attractions: [
{
name: 'Degraves Street',
image: 'https://images.unsplash.com/photo-1686052183140-088f1bd9a49b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxkZWdyYXZlcyUyMHN0cmVldCUyMG1lbGJvdXJuZSUyMGNhZmV8ZW58MXx8fHwxNzU2MTA2MTIzfDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
},
{
name: 'Centre Place',
image: 'https://images.unsplash.com/photo-1583569695977-1e5758f793d1?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxjZW50cmUlMjBwbGFjZSUyMG1lbGJvdXJuZSUyMGNvZmZlZSUyMGxhbmV3YXl8ZW58MXx8fHwxNzU2MTA2MTI3fDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
}
]
},
{
id: 'gardens-parks',
title: 'Gardens & Parks',
tourCount: '6+ nature spots',
icon: Trees,
image: 'https://images.unsplash.com/photo-1639481326289-1efe7cbfbfe5?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjByb3lhbCUyMGJvdGFuaWMlMjBnYXJkZW5zJTIwbmF0dXJlfGVufDF8fHx8MTc1NjEwNTYxNnww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
attractions: [
{
name: 'Royal Botanic Gardens',
image: 'https://images.unsplash.com/photo-1670027537688-77def132d556?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxyb3lhbCUyMGJvdGFuaWMlMjBnYXJkZW5zJTIwbWVsYm91cm5lJTIwbGFrZXxlbnwxfHx8fDE3NTYxMDYxMzN8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
},
{
name: 'Fitzroy Gardens',
image: 'https://images.unsplash.com/photo-1735605918618-0193db0a30af?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxmaXR6cm95JTIwZ2FyZGVucyUyMG1lbGJvdXJuZSUyMGNvbnNlcnZhdG9yeXxlbnwxfHx8fDE3NTYxMDYxMzl8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
}
]
},
{
id: 'food-markets',
title: 'Food & Markets',
tourCount: '10+ food spots',
icon: UtensilsCrossed,
image: 'https://images.unsplash.com/photo-1656177796132-3dab16b30652?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBxdWVlbiUyMHZpY3RvcmlhJTIwbWFya2V0JTIwZm9vZHxlbnwxfHx8fDE3NTYxMDU2MTl8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
attractions: [
{
name: 'Queen Victoria Market',
image: 'https://images.unsplash.com/photo-1708903965305-f8439248cebd?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxxdWVlbiUyMHZpY3RvcmlhJTIwbWFya2V0JTIwbWVsYm91cm5lJTIwZm9vZCUyMHN0YWxsc3xlbnwxfHx8fDE3NTYxMDYxNDV8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
},
{
name: 'South Melbourne Market',
image: 'https://images.unsplash.com/photo-1749229964993-f802d5203142?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzb3V0aCUyMG1lbGJvdXJuZSUyMG1hcmtldCUyMGZvb2QlMjB2ZW5kb3JzfGVufDF8fHx8MTc1NjEwNjE1MXww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
}
]
},
{
id: 'music-entertainment',
title: 'Music & Entertainment',
tourCount: '15+ venues',
icon: Music,
image: 'https://images.unsplash.com/photo-1684679106461-dae134df8da6?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBtdXNpYyUyMGxpdmUlMjBjb25jZXJ0JTIwdmVudWV8ZW58MXx8fHwxNzU2MTA1NjI1fDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
attractions: [
{
name: 'Princess Theatre',
image: 'https://images.unsplash.com/photo-1709063370226-1369f8d26069?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxwcmluY2VzcyUyMHRoZWF0cmUlMjBtZWxib3VybmUlMjBoaXN0b3JpYyUyMHZlbnVlfGVufDF8fHx8MTc1NjEwNjE1N3ww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
},
{
name: 'Rod Laver Arena',
image: 'https://images.unsplash.com/photo-1684679106461-dae134df8da6?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBtdXNpYyUyMGxpdmUlMjBjb25jZXJ0JTIwdmVudWV8ZW58MXx8fHwxNzU2MTA1NjI1fDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
}
]
},
{
id: 'architecture',
title: 'Historic Architecture',
tourCount: '9+ heritage sites',
icon: Building2,
image: 'https://images.unsplash.com/photo-1719447001523-7474ec81ef80?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBhcmNoaXRlY3R1cmUlMjBoaXN0b3JpYyUyMGJ1aWxkaW5nc3xlbnwxfHx8fDE3NTYxMDU2Mjl8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
attractions: [
{
name: 'Block Arcade',
image: 'https://images.unsplash.com/photo-1695657678988-5bd451215eb4?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxibG9jayUyMGFyY2FkZSUyMG1lbGJvdXJuZSUyMGhlcml0YWdlJTIwc2hvcHBpbmd8ZW58MXx8fHwxNzU2MTA2MTYxfDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
},
{
name: 'St Paul\'s Cathedral',
image: 'https://images.unsplash.com/photo-1719447001523-7474ec81ef80?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBhcmNoaXRlY3R1cmUlMjBoaXN0b3JpYyUyMGJ1aWxkaW5nc3xlbnwxfHx8fDE3NTYxMDU2Mjl8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
}
]
},
{
id: 'river-cruises',
title: 'River & Cruises',
tourCount: '5+ water experiences',
icon: Ship,
image: 'https://images.unsplash.com/photo-1722943661451-1a439d092ff2?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjByaXZlcnNpZGUlMjB5YXJyYSUyMHJpdmVyJTIwY3J1aXNlfGVufDF8fHx8MTc1NjEwNTYzM3ww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
attractions: [
{
name: 'Yarra River Cruise',
image: 'https://images.unsplash.com/photo-1668376212180-d4c25f929ce4?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHx5YXJyYSUyMHJpdmVyJTIwbWVsYm91cm5lJTIwY3J1aXNlJTIwYm9hdHN8ZW58MXx8fHwxNzU2MTA2MTY1fDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
},
{
name: 'Southbank Promenade',
image: 'https://images.unsplash.com/photo-1722943661451-1a439d092ff2?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjByaXZlcnNpZGUlMjB5YXJyYSUyMHJpdmVyJTIwY3J1aXNlfGVufDF8fHx8MTc1NjEwNTYzM3ww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
}
]
},
{
id: 'shopping',
title: 'Shopping & Style',
tourCount: '7+ shopping areas',
icon: ShoppingBag,
image: 'https://images.unsplash.com/photo-1744357725934-12140170ebf3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBzaG9wcGluZyUyMGNvbGxpbnMlMjBzdHJlZXQlMjBib3V0aXF1ZXxlbnwxfHx8fDE3NTYxMDU2NDV8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
attractions: [
{
name: 'Collins Street',
image: 'https://images.unsplash.com/photo-1583569695977-1e5758f793d1?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxjb2xsaW5zJTIwc3RyZWV0JTIwbWVsYm91cm5lJTIwYm91dGlxdWUlMjBzaG9wcGluZ3xlbnwxfHx8fDE3NTYxMDYxNjl8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
},
{
name: 'Chapel Street',
image: 'https://images.unsplash.com/photo-1744357725934-12140170ebf3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBzaG9wcGluZyUyMGNvbGxpbnMlMjBzdHJlZXQlMjBib3V0aXF1ZXxlbnwxfHx8fDE3NTYxMDU2NDV8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
}
]
}
];
// Create extended array for seamless infinite scroll
const extendedCategories = [...melbourneCategories, ...melbourneCategories, ...melbourneCategories];
return (
<section className="py-20 lg:py-28 bg-white overflow-hidden">
<div className="container mx-auto px-4">
{/* Header */}
<div className="text-center mb-16 max-w-4xl mx-auto">
<motion.h2
className="font-poppins text-2xl md:text-3xl lg:text-4xl leading-tight text-foreground mb-6"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
>
<span className="font-light">A Melbourne</span>{' '}
<span className="font-bold text-primary italic">Experience</span>{' '}
<span className="font-light">for Every Traveller</span>
</motion.h2>
<motion.p
className="font-poppins text-xl leading-relaxed font-normal text-gray-600"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
>
From iconic laneways and world-class coffee to stunning gardens and vibrant markets,
discover Melbourne's unique character through curated experiences that showcase the city's soul.
</motion.p>
</div>
{/* Full-Width Horizontal Scrolling Carousel */}
<div className="mb-16 relative">
{/* Carousel Container - Full Width */}
<div className="relative w-full overflow-hidden">
{/* Scrolling Track */}
<motion.div
className="horizontal-scroll-track flex items-center gap-8 py-8"
style={{
width: 'max-content',
animation: 'scrollHorizontal 80s linear infinite'
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 0.4 }}
>
{/* Adventure Category Cards */}
{extendedCategories.map((category, index) => (
<motion.div
key={`${category.id}-${index}`}
className="flex-shrink-0 w-80 h-96 group cursor-pointer relative"
onMouseEnter={() => setHoveredCategory(`${category.id}-${index}`)}
onMouseLeave={() => setHoveredCategory(null)}
whileHover={{ scale: 1.02, y: -4 }}
transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
>
{/* Card Content - New Design */}
<div className="relative rounded-3xl overflow-hidden h-full shadow-lg hover:shadow-2xl transition-all duration-500">
{/* Full Background Image */}
<div className="absolute inset-0">
<ImageWithFallback
src={category.image}
alt={category.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
{/* Dark Overlay */}
<div className="absolute inset-0 bg-black/30" />
</div>
{/* Bottom Content Card */}
<div className="absolute bottom-0 left-0 right-0 p-6">
<motion.div
className="bg-white/95 backdrop-blur-sm rounded-2xl p-4 border border-white/20"
whileHover={{ y: -2 }}
transition={{ duration: 0.2 }}
>
<div className="flex items-center justify-between">
{/* Text Content */}
<div>
<h3 className="text-xl font-bold text-gray-900 mb-1">
{category.title}
</h3>
<p className="text-base font-medium text-gray-600">
{category.tourCount}
</p>
</div>
{/* Icon */}
<motion.div
className="w-12 h-12 bg-warm-coral rounded-xl flex items-center justify-center flex-shrink-0"
whileHover={{ scale: 1.1, rotate: 5 }}
transition={{ duration: 0.2 }}
>
<category.icon className="w-6 h-6 text-white" />
</motion.div>
</div>
</motion.div>
</div>
{/* Subtle Animation Effect */}
<div className="absolute -bottom-2 -right-2 w-16 h-16 bg-warm-coral/10 rounded-full blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</div>
{/* Popup Card - Attractions */}
<AnimatePresence>
{hoveredCategory === `${category.id}-${index}` && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1],
layout: { duration: 0.2 }
}}
className="absolute top-0 left-0 w-full h-full z-20 pointer-events-none"
>
{/* Popup Content */}
<div className="relative w-full h-full bg-white/95 backdrop-blur-lg rounded-3xl border border-white/60 shadow-2xl overflow-hidden">
{/* Header */}
<div className="p-6 border-b border-white/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-warm-coral/20 rounded-xl flex items-center justify-center">
<category.icon className="w-5 h-5 text-warm-coral" />
</div>
<div>
<h4 className="font-bold text-gray-900">{category.title}</h4>
<p className="text-sm text-warm-coral/80">Featured Attractions</p>
</div>
</div>
</div>
{/* Attractions Grid */}
<div className="p-6 space-y-4">
{category.attractions.map((attraction, idx) => (
<motion.div
key={attraction.name}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: 0.3,
delay: idx * 0.1,
ease: [0.25, 0.1, 0.25, 1]
}}
className="flex items-center gap-4 p-3 rounded-2xl bg-white/60 backdrop-blur-sm border border-white/40 hover:bg-white/80 transition-all duration-300"
>
{/* Attraction Image */}
<div className="w-16 h-16 flex-shrink-0 rounded-xl overflow-hidden">
<ImageWithFallback
src={attraction.image}
alt={attraction.name}
className="w-full h-full object-cover"
/>
</div>
{/* Attraction Info */}
<div className="flex-1">
<h5 className="font-semibold text-gray-900 mb-1">
{attraction.name}
</h5>
<div className="flex items-center gap-1">
<div className="w-1.5 h-1.5 bg-warm-coral rounded-full" />
<span className="text-sm text-warm-coral/80">
Included in Pass
</span>
</div>
</div>
</motion.div>
))}
</div>
{/* Footer */}
<div className="p-6 pt-0">
<div className="text-center">
<p className="text-sm text-gray-600">
+ Many more attractions included
</p>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</motion.div>
{/* Gradient Fade Edges */}
<div className="absolute left-0 top-0 bottom-0 w-32 bg-white/80 pointer-events-none z-10" />
<div className="absolute right-0 top-0 bottom-0 w-32 bg-white/80 pointer-events-none z-10" />
</div>
</div>
{/* CTA Button */}
<motion.div
className="text-center"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
>
<Button
withShine={true}
size="xl"
className="h-16 rounded-full text-lg px-8"
>
Get A City Card And Start Exploring
</Button>
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,173 @@
import { motion } from 'motion/react';
import { ImageWithFallback } from './figma/ImageWithFallback';
export function WhyChooseCityCards() {
return (
<section className="py-20 bg-gradient-to-b from-white to-gray-50/30 overflow-hidden">
<div className="container mx-auto px-4">
{/* Header */}
<div className="text-center mb-16">
<div className="inline-flex items-center gap-2 bg-primary/10 px-4 py-2 rounded-full mb-6">
<div className="w-2 h-2 bg-primary rounded-full"></div>
<span className="font-poppins text-sm font-medium text-primary">
Smart Savings
</span>
</div>
<h2 className="font-poppins text-2xl md:text-3xl lg:text-4xl leading-tight text-foreground mb-4">
<span className="font-light">Save</span>{' '}
<span className="font-bold text-primary italic">Big</span>{' '}
<span className="font-normal">on attractions</span>
</h2>
<p className="font-poppins text-xl leading-relaxed font-normal text-gray-700 max-w-3xl mx-auto">
Why pay $350+ buying tickets individually when Melbourne City Card gives you 40+ attractions for as little as $199?
</p>
</div>
{/* Main Content */}
<div className="max-w-5xl mx-auto">
{/* Polaroid Cards Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
{/* Melbourne Card */}
<motion.div
className="relative"
initial={{ opacity: 0, y: 30, rotate: -2 }}
whileInView={{ opacity: 1, y: 0, rotate: -1 }}
transition={{ duration: 0.6, ease: "easeOut" }}
viewport={{ once: true }}
>
<div className="bg-white p-4 shadow-xl transform rotate-1 hover:rotate-0 transition-transform duration-300">
{/* Image */}
<div className="aspect-[4/3] overflow-hidden mb-4">
<ImageWithFallback
src="https://images.unsplash.com/photo-1514395462725-fb4566210144?w=600&h=400&fit=crop"
alt="Melbourne cityscape"
className="w-full h-full object-cover"
/>
</div>
{/* Polaroid Caption */}
<div className="text-center">
<h3 className="font-poppins text-xl md:text-2xl leading-snug font-semibold text-foreground mb-2">Melbourne</h3>
<div className="space-y-2 font-poppins">
<div className="flex justify-between items-center">
<span className="text-base font-normal text-gray-700">Individual tickets:</span>
<span className="text-base font-normal text-foreground line-through">$350+</span>
</div>
<div className="flex justify-between items-center">
<span className="text-base font-normal text-gray-700">City Card:</span>
<span className="text-2xl font-bold text-primary">$199</span>
</div>
<div className="text-center pt-2">
<span className="bg-green-100 text-green-800 px-3 py-1 rounded-full text-sm font-poppins font-medium">
Save $151+
</span>
</div>
</div>
</div>
</div>
{/* Decorative elements */}
<div className="absolute -top-2 -right-2 w-6 h-6 bg-yellow-400 rounded-full shadow-lg transform rotate-12"></div>
<div className="absolute -bottom-3 -left-3 w-4 h-4 bg-blue-400 rounded-full shadow-lg"></div>
</motion.div>
{/* Sydney Card */}
<motion.div
className="relative"
initial={{ opacity: 0, y: 30, rotate: 2 }}
whileInView={{ opacity: 1, y: 0, rotate: 1 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
viewport={{ once: true }}
>
<div className="bg-white p-4 shadow-xl transform -rotate-1 hover:rotate-0 transition-transform duration-300">
{/* Image */}
<div className="aspect-[4/3] overflow-hidden mb-4">
<ImageWithFallback
src="https://images.unsplash.com/photo-1624138784614-87fd1b6528f8?w=600&h=400&fit=crop"
alt="Sydney Opera House and Harbour Bridge"
className="w-full h-full object-cover"
/>
</div>
{/* Polaroid Caption */}
<div className="text-center">
<h3 className="font-poppins text-xl md:text-2xl leading-snug font-semibold text-foreground mb-2">Sydney</h3>
<div className="space-y-2 font-poppins">
<div className="flex justify-between items-center">
<span className="text-base font-normal text-gray-700">Individual tickets:</span>
<span className="text-base font-normal text-foreground line-through">$420+</span>
</div>
<div className="flex justify-between items-center">
<span className="text-base font-normal text-gray-700">City Card:</span>
<span className="text-2xl font-bold text-primary">$249</span>
</div>
<div className="text-center pt-2">
<span className="bg-green-100 text-green-800 px-3 py-1 rounded-full text-sm font-poppins font-medium">
Save $171+
</span>
</div>
</div>
</div>
</div>
{/* Decorative elements */}
<div className="absolute -top-3 -left-2 w-5 h-5 bg-pink-400 rounded-full shadow-lg transform -rotate-12"></div>
<div className="absolute -bottom-2 -right-3 w-3 h-3 bg-green-400 rounded-full shadow-lg"></div>
</motion.div>
</div>
{/* Bottom Features */}
<motion.div
className="mt-16 text-center"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
viewport={{ once: true }}
>
<div className="bg-primary/5 rounded-3xl p-8 border border-gray-100">
<h3 className="font-poppins text-xl md:text-2xl leading-snug font-semibold text-foreground mb-6">
<span className="font-normal">Why</span>{' '}
<span className="text-primary">CityCards</span>?
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<div className="w-16 h-16 bg-gradient-to-br from-green-100 to-green-50 rounded-xl flex items-center justify-center mx-auto mb-3 shadow-lg border border-green-200/50">
<span className="text-3xl filter drop-shadow-sm">💰</span>
</div>
<h4 className="font-poppins text-lg leading-snug font-medium text-foreground mb-2">Massive Savings</h4>
<p className="font-poppins text-sm leading-relaxed font-normal text-gray-500">Save up to 50% compared to individual attraction tickets</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-gradient-to-br from-yellow-100 to-orange-50 rounded-xl flex items-center justify-center mx-auto mb-3 shadow-lg border border-yellow-200/50">
<span className="text-3xl filter drop-shadow-sm"></span>
</div>
<h4 className="font-poppins text-lg leading-snug font-medium text-foreground mb-2">Skip the Lines</h4>
<p className="font-poppins text-sm leading-relaxed font-normal text-gray-500">Fast-track entry to popular attractions and experiences</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-gradient-to-br from-blue-100 to-indigo-50 rounded-xl flex items-center justify-center mx-auto mb-3 shadow-lg border border-blue-200/50">
<span className="text-3xl filter drop-shadow-sm">📱</span>
</div>
<h4 className="font-poppins text-lg leading-snug font-medium text-foreground mb-2">Digital Convenience</h4>
<p className="font-poppins text-sm leading-relaxed font-normal text-gray-500">Everything on your phone - no physical tickets needed</p>
</div>
</div>
<motion.button
className="mt-8 bg-primary text-white py-4 px-12 rounded-full font-poppins font-semibold text-base hover:shadow-lg hover:shadow-primary/25 transition-all duration-200"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Start Saving Today
</motion.button>
</div>
</motion.div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,27 @@
import React, { useState } from 'react'
const ERROR_IMG_SRC =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
const [didError, setDidError] = useState(false)
const handleError = () => {
setDidError(true)
}
const { src, alt, style, className, ...rest } = props
return didError ? (
<div
className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
style={style}
>
<div className="flex items-center justify-center w-full h-full">
<img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src} />
</div>
</div>
) : (
<img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError} />
)
}

View File

@@ -0,0 +1 @@
Placeholder file to test deletion

View File

@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion@1.2.3";
import { ChevronDownIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,157 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog@1.1.6";
import { cn } from "./utils";
import { buttonVariants } from "./button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
import { cn } from "./utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,11 @@
"use client";
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio@1.1.2";
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio };

View File

@@ -0,0 +1,53 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar@1.1.3";
import { cn } from "./utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot@1.1.2";
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
import { cn } from "./utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,109 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot@1.1.2";
import { ChevronRight, MoreHorizontal } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,72 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot@1.1.2";
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
import { cn } from "./utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl font-poppins font-semibold transition-all duration-300 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 focus-visible:ring-4 focus-visible:ring-primary/10",
{
variants: {
variant: {
default: "bg-gradient-to-r from-primary to-secondary text-white hover:from-primary/90 hover:to-secondary/90 shadow-lg hover:shadow-xl",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 shadow-lg hover:shadow-xl",
outline:
"border border-primary bg-transparent text-primary hover:bg-primary hover:text-white font-medium",
secondary:
"bg-gray-900 text-white hover:bg-primary font-medium",
ghost:
"hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline font-medium",
shine: "relative bg-gradient-to-r from-primary to-secondary text-white font-semibold shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105 overflow-hidden group [&>span]:text-white [&>span]:font-semibold",
},
size: {
default: "px-8 py-4 text-base",
sm: "px-6 py-3 text-sm rounded-lg",
lg: "px-8 py-4 text-lg",
xl: "px-10 py-5 text-lg",
icon: "size-10 rounded-lg",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
withShine?: boolean;
}
>(({ className, variant, size, asChild = false, withShine = false, children, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const buttonContent = withShine ? (
<>
<span className="relative z-10 text-white font-medium">{children}</span>
{/* Shine Wave Animation */}
<div className="absolute inset-0 opacity-30">
<div className="wave-line-infinity h-full bg-gradient-to-r from-transparent via-white to-transparent animate-wave"></div>
</div>
</>
) : children;
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant: withShine ? "shine" : variant, size, className }))}
ref={ref}
{...props}
>
{buttonContent}
</Comp>
);
});
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,75 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react@0.487.0";
import { DayPicker } from "react-day-picker@8.10.1";
import { cn } from "./utils";
import { buttonVariants } from "./button";
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
caption: "flex justify-center pt-1 relative items-center w-full",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-x-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md",
),
day: cn(
buttonVariants({ variant: "ghost" }),
"size-8 p-0 font-normal aria-selected:opacity-100",
),
day_range_start:
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
day_range_end:
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("size-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("size-4", className)} {...props} />
),
}}
{...props}
/>
);
}
export { Calendar };

View File

@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "./utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<h4
data-slot="card-title"
className={cn("leading-none", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<p
data-slot="card-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6 [&:last-child]:pb-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,241 @@
"use client";
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react@8.6.0";
import { ArrowLeft, ArrowRight } from "lucide-react@0.487.0";
import { cn } from "./utils";
import { Button } from "./button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

353
src/components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,353 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts@2.15.2";
import { cn } from "./utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox@1.1.4";
import { CheckIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border bg-input-background dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -0,0 +1,33 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible@1.1.3";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,177 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk@1.1.1";
import { SearchIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,252 @@
"use client";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu@2.2.6";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -0,0 +1,135 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog@1.1.6";
import { XIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,132 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul@1.1.2";
import { cn } from "./utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,257 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu@2.1.6";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

168
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,168 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label@2.1.2";
import { Slot } from "@radix-ui/react-slot@1.1.2";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form@7.55.0";
import { cn } from "./utils";
import { Label } from "./label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -0,0 +1,44 @@
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card@1.1.6";
import { cn } from "./utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -0,0 +1,77 @@
"use client";
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp@1.4.2";
import { MinusIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center gap-1", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm bg-input-background transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "./utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label@2.1.2";
import { cn } from "./utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,276 @@
"use client";
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar@1.1.6";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className,
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className,
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};

Some files were not shown because too many files have changed in this diff Show More