295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import { QrCode, X } from 'lucide-react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import cityCardsLogo from './assets/cit-logo.png';
|
|
import imgRectangle18609 from "./assets/84c8cfb942f3c96e9f5fbb29459a9861138f61d8.png";
|
|
|
|
import { AppRouter } from './AppRouter';
|
|
import {
|
|
pageTransition,
|
|
easeOutExpo,
|
|
easeOutCubic
|
|
} from './utils/animations';
|
|
import { AuthProvider } from './context/AuthContext';
|
|
|
|
// User type definition
|
|
interface User {
|
|
email: string;
|
|
name: string;
|
|
}
|
|
|
|
function App() {
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [showQRCard, setShowQRCard] = useState(false);
|
|
const [offersSource, setOffersSource] = useState<'products' | 'passes'>('products');
|
|
const [stickyCardType, setStickyCardType] = useState<'unlimited' | 'selective'>('unlimited');
|
|
|
|
// ✅ Authentication state management
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
|
|
|
// ✅ City state management
|
|
const [activeCity, setActiveCity] = useState('');
|
|
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
|
|
// ✅ Login handlers
|
|
const handleLoginSuccess = (userData: User) => {
|
|
setUser(userData);
|
|
setShowLoginModal(false);
|
|
console.log('User logged in successfully:', userData);
|
|
};
|
|
|
|
const handleSignOut = () => {
|
|
setUser(null);
|
|
navigate('/');
|
|
console.log('User signed out');
|
|
};
|
|
|
|
const handleCloseLoginModal = () => {
|
|
setShowLoginModal(false);
|
|
};
|
|
|
|
const handleSignInClick = () => {
|
|
setShowLoginModal(true);
|
|
};
|
|
|
|
// ✅ Handle city change
|
|
const handleCityChange = (city: string) => {
|
|
console.log('City changed to:', city);
|
|
setActiveCity(city);
|
|
};
|
|
|
|
// ✅ Handle checkout (you can expand this later)
|
|
const handleCheckoutClick = () => {
|
|
console.log('Proceeding to checkout for user:', user);
|
|
// Add your checkout logic here
|
|
navigate('/checkout');
|
|
};
|
|
|
|
// 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;
|
|
const pattern = [];
|
|
|
|
for (let i = 0; i < size * size; i++) {
|
|
const row = Math.floor(i / size);
|
|
const col = i % size;
|
|
|
|
const isCornerSquare =
|
|
(row < 7 && col < 7) ||
|
|
(row < 7 && col >= 20) ||
|
|
(row >= 20 && col < 7);
|
|
|
|
const isFinderPattern = isCornerSquare && (
|
|
(row === 0 || row === 6 || col === 0 || col === 6) ||
|
|
(row >= 2 && row <= 4 && col >= 2 && col <= 4)
|
|
);
|
|
|
|
const isTimingPattern = (row === 6 && col >= 8 && col <= 18) || (col === 6 && row >= 8 && row <= 18);
|
|
const isDataPattern = !isCornerSquare && !isTimingPattern && Math.random() > 0.38;
|
|
|
|
pattern.push(isFinderPattern || isTimingPattern || isDataPattern);
|
|
}
|
|
|
|
return pattern;
|
|
};
|
|
|
|
const qrPattern = generateQRPattern();
|
|
|
|
const handleStickyWidgetClick = () => {
|
|
if (location.pathname === '/attractions') {
|
|
navigate('/checkout');
|
|
} else {
|
|
setShowQRCard(true);
|
|
}
|
|
};
|
|
|
|
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 }}
|
|
>
|
|
<AuthProvider>
|
|
<AppRouter
|
|
user={user}
|
|
activeCity={activeCity}
|
|
onCityChange={handleCityChange}
|
|
showLoginModal={showLoginModal}
|
|
onSignInClick={handleSignInClick}
|
|
onSignOutClick={handleSignOut}
|
|
onLoginSuccess={handleLoginSuccess}
|
|
onCloseLoginModal={handleCloseLoginModal}
|
|
onCheckoutClick={handleCheckoutClick} // ✅ Pass checkout handler
|
|
offersSource={offersSource}
|
|
/>
|
|
</AuthProvider>
|
|
</motion.div>
|
|
|
|
{/* Sticky 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: easeOutExpo }}
|
|
whileHover={{ scale: 1.05, y: -2 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={handleStickyWidgetClick}
|
|
className={`relative shadow-2xl flex items-center justify-center rounded-2xl transition-all duration-300 overflow-hidden group ${location.pathname === '/attractions'
|
|
? 'w-[244px] h-36'
|
|
: 'w-36 h-36 bg-black text-white'
|
|
}`}
|
|
aria-label={location.pathname === '/attractions' ? 'Get CityCard' : 'Show QR Code'}
|
|
>
|
|
{location.pathname === '/attractions' ? (
|
|
<div className="bg-black relative rounded-[12px] size-full">
|
|
<div className="overflow-clip relative size-full">
|
|
{/* Header Image */}
|
|
<div className="absolute h-[49px] left-0 rounded-tl-[12px] rounded-tr-[12px] top-0 w-full">
|
|
<div aria-hidden="true" className="absolute inset-0 pointer-events-none rounded-tl-[12px] rounded-tr-[12px]">
|
|
<img alt="" className="absolute max-w-none object-cover rounded-tl-[12px] rounded-tr-[12px] size-full" style={{ objectPosition: '50% 50%' }} src={imgRectangle18609} />
|
|
<div className="absolute bg-[rgba(0,0,0,0.42)] inset-0 rounded-tl-[12px] rounded-tr-[12px]" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* GET NOW Text */}
|
|
<p className="absolute font-poppins font-semibold leading-[16px] left-[50%] -translate-x-1/2 not-italic text-[12px] text-nowrap text-white top-[17px] whitespace-pre">GET NOW</p>
|
|
|
|
{/* Dashed Line Separator */}
|
|
<div className="absolute h-0 left-0 top-[49px] w-full">
|
|
<div className="absolute bottom-0 left-0 right-0 top-[-1px]">
|
|
<svg className="block size-full" fill="none" preserveAspectRatio="none" viewBox="0 0 244 1">
|
|
<line stroke="#FFB23F" strokeDasharray="6 6" x2="244" y1="0.5" y2="0.5" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card Title in Orange */}
|
|
<p className="absolute font-appoppins font-medium leading-[1.3] left-[50%] text-[#ffb23f] text-[24px] text-center top-[65px] tracking-[-0.96px] translate-x-[-50%] w-[202px]" style={{ fontVariationSettings: "'wdth' 100" }}>
|
|
{stickyCardType === 'unlimited' ? (
|
|
<>Melbourne Unlimited Card</>
|
|
) : (
|
|
<>Get Selective Card</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Orange Border */}
|
|
<div aria-hidden="true" className="absolute border-2 border-[#ffb23f] border-solid inset-0 pointer-events-none rounded-[12px]" />
|
|
</div>
|
|
) : (
|
|
<div className="relative flex flex-col items-center gap-2.5 z-10">
|
|
<QrCode className="w-14 h-14" />
|
|
<div className="text-center">
|
|
<div className="font-poppins font-semibold tracking-wide">
|
|
Scan QR
|
|
</div>
|
|
<div className="font-poppins text-xs opacity-70 leading-tight">
|
|
Download App
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-white/5 pointer-events-none rounded-2xl"></div>
|
|
</motion.button>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<AnimatePresence>
|
|
{showQRCard && location.pathname !== '/attractions' && (
|
|
<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]"
|
|
>
|
|
<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>
|
|
|
|
<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]">
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<div className="text-center text-white">
|
|
<h3 className="text-4xl font-semibold text-white mb-5">Scan QR for app</h3>
|
|
<p className="text-xl text-gray-300 leading-relaxed max-w-lg mx-auto">
|
|
Scan to get the Melbourne CityCards mobile app
|
|
</p>
|
|
</div>
|
|
|
|
<div className="absolute inset-0 border border-primary/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={() => {
|
|
const element = document.querySelector('[data-preload]');
|
|
element?.remove();
|
|
}}
|
|
data-preload
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App; |