first commit
32
.gitignore
vendored
Normal 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
@@ -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
64
package.json
Normal 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
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
249
src/App.tsx
Normal 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
@@ -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).
|
||||||
BIN
src/assets/03b75e940c0ed36f1fbd27725d52152720984f86.png
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
src/assets/090c0a16f783254412cc67914fb0ed3cd9edfad3.png
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
src/assets/183ba346669a0a079762ab3f9535b7d1469ed4d9.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/assets/1e3bcb27a765c642a88aed36ef1b72f08a715dc6.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
src/assets/394719dd82255b7b60801f8ae7c81a0e3514bd13.png
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
src/assets/43f3bc1f9c8cc5b8f60f3f6be0bc1ad29eded0d7.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
src/assets/48312b6a6d62d8c34cca1cf135001fea23d6e195.png
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
src/assets/4a96daf4aee4ef525cd3be5298de82d1697d6961.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
src/assets/4d07c3035c8f965d162e4e0d20cb3910fd5fa6fe.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src/assets/52f02fe505fbc40e6338ebec9cbd919bca7657db.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
src/assets/5566b83072321980379e10ac9007fb546b9d5437.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
src/assets/574712bba8c579e168f68da6e27f45e0a691dc54.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
src/assets/5c46ee69599a58ad1a03823f5a76362b6aa14852.png
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
src/assets/5da1b0444c0d21bc7ee776c49e36e2a8ea4d3e12.png
Normal file
|
After Width: | Height: | Size: 401 KiB |
BIN
src/assets/6c1d7aa4854f71023510e35ac1f4eb623441cb4a.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
src/assets/76883b8919dd779e35f1ab093b16410f6a465b3d.png
Normal file
|
After Width: | Height: | Size: 471 KiB |
BIN
src/assets/79434fd2c5fcae2b17fa09533dfcdb8584ce39c1.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
src/assets/7b71a56a728fd21bca72228b78511c0072a1505f.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
src/assets/85a189eb1e1976493720fa5345a5f387c5edcc1a.png
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
src/assets/8b0be768b2331d5dcd1154cf3d9e4dc61e3d3afb.png
Normal file
|
After Width: | Height: | Size: 512 KiB |
BIN
src/assets/916a2ac02159f19d4e05b6fbbad76f315c8dfb66.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
src/assets/b508859d8646554ff359c4dadcedccdfca7e7bfb.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
src/assets/cd153b02eb88c70a1ae2ef3b926d2e44cbcb8dd9.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
src/assets/d34005cfa14dc032f5b14c284f3ecd65df31444e.png
Normal file
|
After Width: | Height: | Size: 8.5 MiB |
BIN
src/assets/da41b2748c2a938ba04e1663f300d2ad340eef5f.png
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
BIN
src/assets/e1947d1a05cd5bbd448c1ae6e6e9f2abd5d4ca10.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
src/assets/e6f059f48d3002a2b93fc24b73fa49b61b8cd3ab.png
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
src/assets/e96a0ba8c1e8ee053e3eb462a3b4552a8657e7b6.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
src/assets/f744b0d7006fc6835cbbb274a3da305f5e2da694.png
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
79
src/components/ArticlesSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
344
src/components/BookAttractionSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
351
src/components/CityDetailPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
src/components/CitySubmenu.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
937
src/components/CustomPostcards.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/EnhancedTestimonials.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// This enhanced testimonials component (created for Melbourne page) has been removed
|
||||||
246
src/components/FeaturedCities.tsx
Normal 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
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/FooterBottom.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/FooterBrand.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/FooterNavigation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
src/components/HandwrittenText.tsx
Normal 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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
882
src/components/HeroSection.tsx
Normal 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
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
632
src/components/MagicItinerary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/MelbourneAttractions.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// This Melbourne attractions component has been removed
|
||||||
1
src/components/MelbourneBlogs.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// This Melbourne blogs component has been removed
|
||||||
1
src/components/MelbourneCardComparison.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// This Melbourne card comparison component has been removed
|
||||||
1
src/components/MelbourneFAQ.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// This Melbourne FAQ component has been removed
|
||||||
1
src/components/MelbournePage.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// This Melbourne page component has been removed
|
||||||
1
src/components/MelbourneTourOverview.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// This Melbourne tour overview component has been removed
|
||||||
1
src/components/MobileAppPromotion.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// This mobile app promotion component (created for Melbourne page) has been removed
|
||||||
642
src/components/MobileAppSection.tsx
Normal 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
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
src/components/NewsletterSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
src/components/OtherCities.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
src/components/PassComparison.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
src/components/PassesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
src/components/ScrollAnimatedJourney.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/SectionWrapper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
341
src/components/SignInPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
432
src/components/TrustSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
327
src/components/UpcomingCities.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
src/components/UpcomingDestinations.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
358
src/components/VarietyOfAdventures.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
src/components/WhyChooseCityCards.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/figma/ImageWithFallback.tsx
Normal 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} />
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/components/temp_delete_melbourne.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Placeholder file to test deletion
|
||||||
66
src/components/ui/accordion.tsx
Normal 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 };
|
||||||
157
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
66
src/components/ui/alert.tsx
Normal 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 };
|
||||||
11
src/components/ui/aspect-ratio.tsx
Normal 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 };
|
||||||
53
src/components/ui/avatar.tsx
Normal 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 };
|
||||||
46
src/components/ui/badge.tsx
Normal 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 };
|
||||||
109
src/components/ui/breadcrumb.tsx
Normal 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,
|
||||||
|
};
|
||||||
72
src/components/ui/button.tsx
Normal 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 };
|
||||||
75
src/components/ui/calendar.tsx
Normal 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 };
|
||||||
92
src/components/ui/card.tsx
Normal 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,
|
||||||
|
};
|
||||||
241
src/components/ui/carousel.tsx
Normal 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
@@ -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,
|
||||||
|
};
|
||||||
32
src/components/ui/checkbox.tsx
Normal 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 };
|
||||||
33
src/components/ui/collapsible.tsx
Normal 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 };
|
||||||
177
src/components/ui/command.tsx
Normal 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,
|
||||||
|
};
|
||||||
252
src/components/ui/context-menu.tsx
Normal 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,
|
||||||
|
};
|
||||||
135
src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
132
src/components/ui/drawer.tsx
Normal 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,
|
||||||
|
};
|
||||||
257
src/components/ui/dropdown-menu.tsx
Normal 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
@@ -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,
|
||||||
|
};
|
||||||
44
src/components/ui/hover-card.tsx
Normal 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 };
|
||||||
77
src/components/ui/input-otp.tsx
Normal 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 };
|
||||||
21
src/components/ui/input.tsx
Normal 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 };
|
||||||
24
src/components/ui/label.tsx
Normal 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 };
|
||||||
276
src/components/ui/menubar.tsx
Normal 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,
|
||||||
|
};
|
||||||