This commit is contained in:
Hemant Vishwakarma
2026-03-20 14:43:19 +05:30
24 changed files with 1139 additions and 823 deletions

112
package-lock.json generated
View File

@@ -34,6 +34,7 @@
"@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/postcss": "^4.1.13", "@tailwindcss/postcss": "^4.1.13",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -49,6 +50,7 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"recharts": "^2.15.2", "recharts": "^2.15.2",
@@ -1917,6 +1919,32 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2197,6 +2225,18 @@
"win32" "win32"
] ]
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/core": { "node_modules/@swc/core": {
"version": "1.13.5", "version": "1.13.5",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz",
@@ -3033,6 +3073,7 @@
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==", "integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -3043,6 +3084,7 @@
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -3053,10 +3095,17 @@
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react-swc": { "node_modules/@vitejs/plugin-react-swc": {
"version": "3.11.0", "version": "3.11.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz",
@@ -3311,7 +3360,8 @@
"version": "8.6.0", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/embla-carousel-react": { "node_modules/embla-carousel-react": {
"version": "8.6.0", "version": "8.6.0",
@@ -3477,6 +3527,16 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/input-otp": { "node_modules/input-otp": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
@@ -3885,6 +3945,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3942,6 +4003,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -3968,6 +4030,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -3998,6 +4061,30 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.1", "version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
@@ -4178,6 +4265,28 @@
"decimal.js-light": "^2.4.1" "decimal.js-light": "^2.4.1"
} }
}, },
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.50.1", "version": "4.50.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz",
@@ -4424,6 +4533,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",

View File

@@ -29,6 +29,7 @@
"@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/postcss": "^4.1.13", "@tailwindcss/postcss": "^4.1.13",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -44,6 +45,7 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"recharts": "^2.15.2", "recharts": "^2.15.2",

View File

@@ -11,6 +11,7 @@ import {
easeOutExpo, easeOutExpo,
easeOutCubic easeOutCubic
} from './utils/animations'; } from './utils/animations';
import { AuthProvider } from './context/AuthContext';
// User type definition // User type definition
interface User { interface User {
@@ -23,11 +24,11 @@ function App() {
const [showQRCard, setShowQRCard] = useState(false); const [showQRCard, setShowQRCard] = useState(false);
const [offersSource, setOffersSource] = useState<'products' | 'passes'>('products'); const [offersSource, setOffersSource] = useState<'products' | 'passes'>('products');
const [stickyCardType, setStickyCardType] = useState<'unlimited' | 'selective'>('unlimited'); const [stickyCardType, setStickyCardType] = useState<'unlimited' | 'selective'>('unlimited');
// ✅ Authentication state management // ✅ Authentication state management
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [showLoginModal, setShowLoginModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false);
// ✅ City state management // ✅ City state management
const [activeCity, setActiveCity] = useState(''); const [activeCity, setActiveCity] = useState('');
@@ -73,7 +74,7 @@ function App() {
const checkMobile = () => { const checkMobile = () => {
setIsMobile(window.innerWidth < 768); setIsMobile(window.innerWidth < 768);
}; };
checkMobile(); checkMobile();
window.addEventListener('resize', checkMobile); window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile); return () => window.removeEventListener('resize', checkMobile);
@@ -83,27 +84,27 @@ function App() {
const generateQRPattern = () => { const generateQRPattern = () => {
const size = 27; const size = 27;
const pattern = []; const pattern = [];
for (let i = 0; i < size * size; i++) { for (let i = 0; i < size * size; i++) {
const row = Math.floor(i / size); const row = Math.floor(i / size);
const col = i % size; const col = i % size;
const isCornerSquare = const isCornerSquare =
(row < 7 && col < 7) || (row < 7 && col < 7) ||
(row < 7 && col >= 20) || (row < 7 && col >= 20) ||
(row >= 20 && col < 7); (row >= 20 && col < 7);
const isFinderPattern = isCornerSquare && ( const isFinderPattern = isCornerSquare && (
(row === 0 || row === 6 || col === 0 || col === 6) || (row === 0 || row === 6 || col === 0 || col === 6) ||
(row >= 2 && row <= 4 && col >= 2 && col <= 4) (row >= 2 && row <= 4 && col >= 2 && col <= 4)
); );
const isTimingPattern = (row === 6 && col >= 8 && col <= 18) || (col === 6 && row >= 8 && row <= 18); const isTimingPattern = (row === 6 && col >= 8 && col <= 18) || (col === 6 && row >= 8 && row <= 18);
const isDataPattern = !isCornerSquare && !isTimingPattern && Math.random() > 0.38; const isDataPattern = !isCornerSquare && !isTimingPattern && Math.random() > 0.38;
pattern.push(isFinderPattern || isTimingPattern || isDataPattern); pattern.push(isFinderPattern || isTimingPattern || isDataPattern);
} }
return pattern; return pattern;
}; };
@@ -120,24 +121,26 @@ function App() {
return ( return (
<div className="min-h-screen bg-background relative"> <div className="min-h-screen bg-background relative">
{/* Global Animation Context Provider */} {/* Global Animation Context Provider */}
<motion.div <motion.div
className="relative z-10" className="relative z-10"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: easeOutCubic }} transition={{ duration: 0.3, ease: easeOutCubic }}
> >
<AppRouter <AuthProvider>
user={user} <AppRouter
activeCity={activeCity} user={user}
onCityChange={handleCityChange} activeCity={activeCity}
showLoginModal={showLoginModal} onCityChange={handleCityChange}
onSignInClick={handleSignInClick} showLoginModal={showLoginModal}
onSignOutClick={handleSignOut} onSignInClick={handleSignInClick}
onLoginSuccess={handleLoginSuccess} onSignOutClick={handleSignOut}
onCloseLoginModal={handleCloseLoginModal} onLoginSuccess={handleLoginSuccess}
onCheckoutClick={handleCheckoutClick} // ✅ Pass checkout handler onCloseLoginModal={handleCloseLoginModal}
offersSource={offersSource} onCheckoutClick={handleCheckoutClick} // ✅ Pass checkout handler
/> offersSource={offersSource}
/>
</AuthProvider>
</motion.div> </motion.div>
{/* Sticky Widget */} {/* Sticky Widget */}
@@ -152,11 +155,10 @@ function App() {
whileHover={{ scale: 1.05, y: -2 }} whileHover={{ scale: 1.05, y: -2 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
onClick={handleStickyWidgetClick} onClick={handleStickyWidgetClick}
className={`relative shadow-2xl flex items-center justify-center rounded-2xl transition-all duration-300 overflow-hidden group ${ className={`relative shadow-2xl flex items-center justify-center rounded-2xl transition-all duration-300 overflow-hidden group ${location.pathname === '/attractions'
location.pathname === '/attractions'
? 'w-[244px] h-36' ? 'w-[244px] h-36'
: 'w-36 h-36 bg-black text-white' : 'w-36 h-36 bg-black text-white'
}`} }`}
aria-label={location.pathname === '/attractions' ? 'Get CityCard' : 'Show QR Code'} aria-label={location.pathname === '/attractions' ? 'Get CityCard' : 'Show QR Code'}
> >
{location.pathname === '/attractions' ? ( {location.pathname === '/attractions' ? (
@@ -169,10 +171,10 @@ function App() {
<div className="absolute bg-[rgba(0,0,0,0.42)] inset-0 rounded-tl-[12px] rounded-tr-[12px]" /> <div className="absolute bg-[rgba(0,0,0,0.42)] inset-0 rounded-tl-[12px] rounded-tr-[12px]" />
</div> </div>
</div> </div>
{/* GET NOW Text */} {/* 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> <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 */} {/* Dashed Line Separator */}
<div className="absolute h-0 left-0 top-[49px] w-full"> <div className="absolute h-0 left-0 top-[49px] w-full">
<div className="absolute bottom-0 left-0 right-0 top-[-1px]"> <div className="absolute bottom-0 left-0 right-0 top-[-1px]">
@@ -181,7 +183,7 @@ function App() {
</svg> </svg>
</div> </div>
</div> </div>
{/* Card Title in Orange */} {/* Card Title in Orange */}
<p className="absolute font-poppins 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" }}> <p className="absolute font-poppins 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' ? ( {stickyCardType === 'unlimited' ? (
@@ -191,7 +193,7 @@ function App() {
)} )}
</p> </p>
</div> </div>
{/* Orange Border */} {/* Orange Border */}
<div aria-hidden="true" className="absolute border-2 border-[#ffb23f] border-solid inset-0 pointer-events-none rounded-[12px]" /> <div aria-hidden="true" className="absolute border-2 border-[#ffb23f] border-solid inset-0 pointer-events-none rounded-[12px]" />
</div> </div>
@@ -240,8 +242,8 @@ function App() {
className={`aspect-square ${filled ? 'bg-black' : 'bg-transparent'} rounded-[0.5px]`} className={`aspect-square ${filled ? 'bg-black' : 'bg-transparent'} rounded-[0.5px]`}
initial={{ opacity: 0, scale: 0 }} initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ transition={{
duration: 0.01, duration: 0.01,
delay: index * 0.001, delay: index * 0.001,
ease: "easeOut" ease: "easeOut"
}} }}
@@ -251,9 +253,9 @@ function App() {
<div className="absolute inset-0 flex items-center justify-center"> <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]"> <div className="w-20 h-20 bg-white shadow-2xl border-4 border-gray-200 flex items-center justify-center rounded-[5px]">
<img <img
src={cityCardsLogo} src={cityCardsLogo}
alt="CityCards" alt="CityCards"
className="w-16 h-16 object-contain" className="w-16 h-16 object-contain"
/> />
</div> </div>

View File

@@ -117,7 +117,7 @@ export function AppRouter({
<Route path="/attractions/:attractionId" element={ <Route path="/attractions/:attractionId" element={
<motion.div key="attraction-details" {...pageTransition}> <motion.div key="attraction-details" {...pageTransition}>
<AttractionDetailsPage <AttractionDetailsPage
attractionId={attractionId || ''} // attractionId={attractionId || ''}
{...commonNavHandlers} {...commonNavHandlers}
onBackClick={() => navigate(-1)} onBackClick={() => navigate(-1)}
onCheckoutClick={() => navigate('/checkout')} onCheckoutClick={() => navigate('/checkout')}
@@ -274,12 +274,6 @@ export function AppRouter({
} /> } />
</Routes> </Routes>
</AnimatePresence> </AnimatePresence>
<LoginModal
isOpen={showLoginModal}
onClose={onCloseLoginModal}
onLoginSuccess={onLoginSuccess}
/>
</> </>
); );
} }

24
src/Redux/Store.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { configureStore } from "@reduxjs/toolkit";
import { fakeApi } from "./services/fakeApi.service";
import { attractionsApi } from "./services/attractions.service";
import { citiesApi } from "./services/cities.service";
export const store = configureStore({
reducer: {
[fakeApi.reducerPath]:fakeApi.reducer,
[attractionsApi.reducerPath]:attractionsApi.reducer,
[citiesApi.reducerPath]:citiesApi.reducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(
fakeApi.middleware,
attractionsApi.middleware,
citiesApi.middleware
),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

17
src/Redux/baseQuery.ts Normal file
View File

@@ -0,0 +1,17 @@
// src/store/baseQuery.ts
import { fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const baseQuery = fetchBaseQuery({
baseUrl: import.meta.env.VITE_BASE_URL,
// credentials: "include",
prepareHeaders: (headers) => {
const token = localStorage.getItem("accessToken");
if (token) {
headers.set("Authorization", `Bearer ${token}`);
// headers.set("access-token", token);
}
// headers.set("Content-Type", "application/json");
return headers;
},
});

View File

@@ -0,0 +1,41 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { baseQuery } from "../baseQuery";
export const attractionsApi = createApi({
reducerPath: 'attractionsApi',
// baseQuery: fetchBaseQuery({
// baseUrl: 'https://testingapi.citycards.betadelivery.com',
// }),
baseQuery,
endpoints: (builder) => ({
getAttractionFilters: builder.query({
// cityId is passed as the query param
query: (cityId) => `/attractions/customer/filters?cityXid=${cityId}`,
}),
getCustomerAttractions: builder.query({
// cityId is required, others optional
query: ({ cityId, categoryId, isBookingRequired, cardType, search }) => {
const params = new URLSearchParams();
// required
params.append('cityXid', cityId);
// optional
if (categoryId) params.append('categoryXid', categoryId);
if (isBookingRequired !== undefined) params.append('isBookingRequired', isBookingRequired);
if (cardType) params.append('cardType', cardType);
if (search) params.append('search', search);
return `/attractions/customer/customer-attractions?${params.toString()}`;
},
}),
getAttractionDetailsById: builder.query({
query: (id: number) => `/attractions/customer/${id}`,
}),
}),
});
export const { useGetAttractionFiltersQuery,useGetCustomerAttractionsQuery,useGetAttractionDetailsByIdQuery } = attractionsApi;

View File

@@ -0,0 +1,30 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { baseQuery } from "../baseQuery";
export const citiesApi = createApi({
reducerPath: 'citiesApi',
// baseQuery: fetchBaseQuery({
// baseUrl: 'https://testingapi.citycards.betadelivery.com',
// }),
baseQuery,
endpoints: (builder) => ({
getCityListWithBanner: builder.query({
query: ({ search }) => {
const params = new URLSearchParams();
if (search) params.append('search', search);
return `/cities/list/customer/cities?${params.toString()}`
}
}),
getUpcomingCities: builder.query({
query: (listType) => `/cities/list/all?listType=${listType}`,
})
}),
});
export const { useGetCityListWithBannerQuery,useGetUpcomingCitiesQuery } = citiesApi;

View File

@@ -0,0 +1,19 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const fakeApi = createApi({
reducerPath: 'fakeApi',
baseQuery: fetchBaseQuery({
baseUrl: " https://fakestoreapi.com",
}),
endpoints: (builder) => ({
getProducts: builder.query<any, void>({
query: () => ({
url: 'products',
method: 'GET',
}),
}),
}),
})
export const { useGetProductsQuery} = fakeApi

View File

@@ -6,6 +6,8 @@ import { Badge } from './ui/badge';
import { Card, } from './ui/card'; import { Card, } from './ui/card';
import { ImageWithFallback } from './figma/ImageWithFallback'; import { ImageWithFallback } from './figma/ImageWithFallback';
import { Layout } from '../Layout'; import { Layout } from '../Layout';
import { useParams } from 'react-router-dom';
import { useGetAttractionDetailsByIdQuery } from '../Redux/services/attractions.service';
interface AttractionDetailsPageProps { interface AttractionDetailsPageProps {
onBackClick: () => void; onBackClick: () => void;
@@ -13,7 +15,7 @@ interface AttractionDetailsPageProps {
onSignInClick: () => void; onSignInClick: () => void;
onSignOutClick?: () => void; onSignOutClick?: () => void;
user?: { email: string; name: string } | null; user?: { email: string; name: string } | null;
attractionId: string; // attractionId: string;
} }
export function AttractionDetailsPage({ export function AttractionDetailsPage({
@@ -23,74 +25,33 @@ export function AttractionDetailsPage({
onSignOutClick, onSignOutClick,
user, user,
}: AttractionDetailsPageProps) { }: AttractionDetailsPageProps) {
const [date, setDate] = useState<Date | undefined>(new Date());
// Featured attraction for the main display const { attractionId } = useParams()
const featuredAttraction = {
id: 'phi-phi', const { data: attraction, isLoading } = useGetAttractionDetailsByIdQuery(Number(attractionId));
name: 'Phi Phi Islands Adventure Day Trip with Seaview Lunch by V. Marine Tour',
badges: ['Bestseller', 'Free cancellation', 'Reservation Required'], if (isLoading) {
images: { return <div>loading...</div>
main: 'https://images.unsplash.com/photo-1559827260-dc66d52bef19?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzbm9ya2VsaW5nJTIwdHVydGxlJTIwYWR2ZW50dXJlfGVufDF8fHx8MTc1ODEwNDkwMHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral', }
gallery: [
'https://images.unsplash.com/photo-1559827260-dc66d52bef19?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzbm9ya2VsaW5nJTIwdHVydGxlJTIwYWR2ZW50dXJlfGVufDF8fHx8MTc1ODEwNDkwMHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpc2xhbmQlMjB0b3VyJTIwYWRvJTIwdHJvcGljYWx8ZW58MXx8fHwxNzU4MTA0OTEwfDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxncmVhdCUyMG9jZWFuJTIwcm9hZCUyMGF1c3RyYWxpYXxlbnwxfHx8fDE3NTgxMDQ5Mzd8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
'https://images.unsplash.com/photo-1682687220742-aba13b6e50ba?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxhdHYlMjBkZXNlcnQlMjB0b3VyfGVufDF8fHx8MTc1ODEwNDg5Nnww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral'
]
},
overview: {
duration: '3 days',
groupSize: '10 people',
ages: '18-99 yrs',
languages: 'English, Japanese'
},
description: 'The Phi Phi archipelago is a must-visit while in Phuket, and this speedboat trip whisks you around the islands in one day. Swim over the coral reefs of Pileh Lagoon, have lunch at Phi Phi Leh, snorkel at Bamboo Island, and visit Monkey Beach and Maya Bay, immortalized in "The Beach." Boat transfers, snacks, buffet lunch, snorkeling equipment, and Phuket hotel pickup and drop-off all included.',
highlights: [
'Experience the thrill of a speedboat to the stunning Phi Phi Islands',
'Be amazed by the variety of marine life in the archepelago',
'Enjoy relaxing in paradise with white sand beaches and azure turquoise water',
'Feel the comfort of a tour limited to 35 passengers',
'Catch a glimpse of the wild monkeys around Monkey Beach'
],
included: [
'Beverages, drinking water, morning tea and buffet lunch',
'Local taxes',
'Hotel pickup and drop-off by air-conditioned minivan',
'Insurance Transfer to a private pier',
'Soft drinks',
'Tour Guide'
],
notIncluded: [
'Towel',
'Tips',
'Alcoholic Beverages'
],
bookingOptions: [
'By Calling on 022 2645675',
'Email your details at islands.booking@mail.com',
'Via CityCards Portal'
]
};
return ( return (
<Layout <Layout
activeCity="" activeCity=""
onSignInClick={onSignInClick} onSignInClick={onSignInClick}
onSignOutClick={onSignOutClick} onSignOutClick={onSignOutClick}
user={user} user={user}
showCitySubmenu={false} // showCitySubmenu={false}
> >
<div className="container mx-auto px-4 pt-40 pb-16 max-w-6xl"> <div className="container mx-auto px-4 pt-40 pb-16 max-w-6xl">
{/* Back Button */} {/* Back Button */}
<motion.div <motion.div
className="mb-8" className="mb-8"
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
> >
<Button <Button
variant="ghost" variant="ghost"
onClick={onBackClick} onClick={onBackClick}
className="font-poppins font-medium text-base text-gray-600 hover:text-primary transition-colors duration-200" className="font-poppins font-medium text-base text-gray-600 hover:text-primary transition-colors duration-200"
> >
@@ -102,27 +63,26 @@ export function AttractionDetailsPage({
{/* Title and Badges Section */} {/* Title and Badges Section */}
<div className="mb-8"> <div className="mb-8">
<div className="flex flex-wrap gap-3 mb-6"> <div className="flex flex-wrap gap-3 mb-6">
{featuredAttraction.badges.map((badge, index) => ( {attraction.attractionBadges.map((badge: any, index: number) => (
<Badge <Badge
key={index} key={badge.badgeXid}
variant={index === 0 ? "default" : "secondary"} variant={index === 0 ? "default" : "secondary"}
className={`px-6 py-2 rounded-full text-sm transition-all duration-200 ${ className={`px-6 py-2 rounded-full text-sm transition-all duration-200 ${index === 0
index === 0 ? 'bg-primary text-white shadow-lg'
? 'bg-primary text-white shadow-lg' : 'bg-primary/10 text-primary border border-primary/20'
: 'bg-primary/10 text-primary border border-primary/20' }`}
}`}
> >
{badge} {badge.badge.badgeName}
</Badge> </Badge>
))} ))}
</div> </div>
<h1 className="text-4xl font-bold text-[#2d3134] leading-tight"> <h1 className="text-4xl font-bold text-[#2d3134] leading-tight">
<span className="bg-gradient-to-r from-primary to-primary/80 bg-clip-text text-transparent"> <span className="bg-gradient-to-r from-primary to-primary/80 bg-clip-text text-transparent">
Phi Phi Islands Adventure {attraction.title}
</span>{' '} </span>{' '}
<span className="text-[#2d3134]"> <span className="text-[#2d3134]">
Day Trip with Seaview Lunch by V. Marine Tour Day Trip by {attraction.partner.businessName}
</span> </span>
</h1> </h1>
</div> </div>
@@ -132,18 +92,18 @@ export function AttractionDetailsPage({
{/* Main large image */} {/* Main large image */}
<div className="col-span-2 row-span-2"> <div className="col-span-2 row-span-2">
<ImageWithFallback <ImageWithFallback
src={featuredAttraction.images.main} src={attraction.attractionGalleries[0].filePathUrl}
alt="Main attraction image" alt="Main attraction image"
className="w-full h-full object-cover rounded-lg" className="w-full h-full object-cover rounded-lg"
/> />
</div> </div>
{/* Gallery images */} {/* Gallery images */}
{featuredAttraction.images.gallery.slice(0, 4).map((image, index) => ( {attraction.attractionGalleries.slice().map((image:any) => (
<div key={index} className="col-span-1 row-span-1"> <div key={image.id} className="col-span-1 row-span-1">
<ImageWithFallback <ImageWithFallback
src={image} src={image.filePathUrl}
alt={`Gallery image ${index + 1}`} alt={`Gallery image ${image.id}`}
className="w-full h-full object-cover rounded-lg" className="w-full h-full object-cover rounded-lg"
/> />
</div> </div>
@@ -156,20 +116,43 @@ export function AttractionDetailsPage({
<div className="lg:col-span-2 space-y-12"> <div className="lg:col-span-2 space-y-12">
{/* Overview Cards */} {/* Overview Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6"> <div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{Object.entries(featuredAttraction.overview).map(([key, value]) => ( {/* Duration */}
<Card key={key} className="p-4 text-center bg-white border border-primary/10 hover:border-primary/20 transition-all duration-200 hover:shadow-lg group"> <Card className="p-4 text-center bg-white border border-primary/10 hover:border-primary/20 transition-all duration-200 hover:shadow-lg group">
<div className="w-12 h-12 bg-primary/10 rounded-xl mx-auto mb-3 flex items-center justify-center group-hover:bg-primary/20 transition-colors duration-200"> <div className="w-12 h-12 bg-primary/10 rounded-xl mx-auto mb-3 flex items-center justify-center group-hover:bg-primary/20 transition-colors duration-200">
{key === 'duration' && <Clock className="w-6 h-6 text-primary" />} <Clock className="w-6 h-6 text-primary" />
{key === 'groupSize' && <Users className="w-6 h-6 text-primary" />} </div>
{key === 'ages' && <Users className="w-6 h-6 text-primary" />} <h3 className="font-normal text-primary capitalize mb-1">Duration</h3>
{key === 'languages' && <MapPin className="w-6 h-6 text-primary" />} <p className="text-sm text-[#717171] font-light">{attraction.durations} mins</p>
</div> </Card>
<h3 className="font-normal text-primary capitalize mb-1">
{key === 'groupSize' ? 'Group Size' : key} {/* Group Size */}
</h3> <Card className="p-4 text-center bg-white border border-primary/10 hover:border-primary/20 transition-all duration-200 hover:shadow-lg group">
<p className="text-sm text-[#717171] font-light">{value}</p> <div className="w-12 h-12 bg-primary/10 rounded-xl mx-auto mb-3 flex items-center justify-center group-hover:bg-primary/20 transition-colors duration-200">
</Card> <Users className="w-6 h-6 text-primary" />
))} </div>
<h3 className="font-normal text-primary capitalize mb-1">Group Size</h3>
<p className="text-sm text-[#717171] font-light">{attraction.groupSize}</p>
</Card>
{/* Age Range */}
<Card className="p-4 text-center bg-white border border-primary/10 hover:border-primary/20 transition-all duration-200 hover:shadow-lg group">
<div className="w-12 h-12 bg-primary/10 rounded-xl mx-auto mb-3 flex items-center justify-center group-hover:bg-primary/20 transition-colors duration-200">
<Users className="w-6 h-6 text-primary" />
</div>
<h3 className="font-normal text-primary capitalize mb-1">Age Range</h3>
<p className="text-sm text-[#717171] font-light">{attraction.ageRange}</p>
</Card>
{/* Languages */}
<Card className="p-4 text-center bg-white border border-primary/10 hover:border-primary/20 transition-all duration-200 hover:shadow-lg group">
<div className="w-12 h-12 bg-primary/10 rounded-xl mx-auto mb-3 flex items-center justify-center group-hover:bg-primary/20 transition-colors duration-200">
<MapPin className="w-6 h-6 text-primary" />
</div>
<h3 className="font-normal text-primary capitalize mb-1">Languages</h3>
<p className="text-sm text-[#717171] font-light">
{attraction.attractionLanguages.map((lang: any) => lang.language.name).join(", ")}
</p>
</Card>
</div> </div>
{/* Tour Overview */} {/* Tour Overview */}
@@ -181,7 +164,7 @@ export function AttractionDetailsPage({
</h2> </h2>
</div> </div>
<p className="text-[#2d3134] leading-relaxed text-lg font-light"> <p className="text-[#2d3134] leading-relaxed text-lg font-light">
{featuredAttraction.description} {attraction.description}
</p> </p>
</div> </div>
@@ -194,12 +177,12 @@ export function AttractionDetailsPage({
</h3> </h3>
</div> </div>
<ul className="space-y-4"> <ul className="space-y-4">
{featuredAttraction.highlights.map((highlight, index) => ( {attraction.attractionHighlights.map((highlight: any) => (
<li key={index} className="flex items-start gap-3 group"> <li key={highlight.id} className="flex items-start gap-3 group">
<div className="w-6 h-6 bg-primary/10 rounded-full mt-1 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-200"> <div className="w-6 h-6 bg-primary/10 rounded-full mt-1 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-200">
<div className="w-2 h-2 bg-primary rounded-full"></div> <div className="w-2 h-2 bg-primary rounded-full"></div>
</div> </div>
<span className="text-[#2d3134] leading-relaxed font-light">{highlight}</span> <span className="text-[#2d3134] leading-relaxed font-light">{highlight.title}</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -220,30 +203,32 @@ export function AttractionDetailsPage({
<Check className="w-5 h-5" /> <Check className="w-5 h-5" />
Included Included
</h4> </h4>
{featuredAttraction.included.map((item, index) => ( {attraction.attractionInclusions.filter((inclusion: any) => inclusion.isInclusion === true)
<div key={index} className="flex items-start gap-3 group"> .map((inclusion: any) => (
<div className="w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0 mt-1 group-hover:bg-primary/20 transition-colors duration-200"> <div key={inclusion.id} className="flex items-start gap-3 group">
<Check className="w-3 h-3 text-primary" /> <div className="w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0 mt-1 group-hover:bg-primary/20 transition-colors duration-200">
<Check className="w-3 h-3 text-primary" />
</div>
<span className="text-[#2d3134] font-light">{inclusion.title}</span>
</div> </div>
<span className="text-[#2d3134] font-light">{item}</span> ))}
</div>
))}
</div> </div>
{/* Not Included */} {/* Not Included */}
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-medium text-gray-600 mb-4 flex items-center gap-2"> <h4 className="font-medium text-gray-600 mb-4 flex items-center gap-2">
<X className="w-5 h-5" /> <X className="w-5 h-5" />
Not Included Not Included
</h4> </h4>
{featuredAttraction.notIncluded.map((item, index) => ( {attraction.attractionInclusions.filter((inclusion: any) => inclusion.isInclusion === false)
<div key={index} className="flex items-start gap-3 group"> .map((inclusion: any) => (
<div className="w-6 h-6 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0 mt-1 group-hover:bg-gray-200 transition-colors duration-200"> <div key={inclusion.id} className="flex items-start gap-3 group">
<X className="w-3 h-3 text-gray-500" /> <div className="w-6 h-6 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0 mt-1 group-hover:bg-gray-200 transition-colors duration-200">
<X className="w-3 h-3 text-gray-500" />
</div>
<span className="text-[#2d3134] font-light">{inclusion.title}</span>
</div> </div>
<span className="text-[#2d3134] font-light">{item}</span> ))}
</div>
))}
</div> </div>
</div> </div>
</div> </div>
@@ -262,7 +247,8 @@ export function AttractionDetailsPage({
<MapPin className="w-8 h-8 text-primary" /> <MapPin className="w-8 h-8 text-primary" />
</div> </div>
<p className="text-lg font-medium text-primary mb-2">Interactive Map</p> <p className="text-lg font-medium text-primary mb-2">Interactive Map</p>
<p className="text-sm text-gray-600 font-light">Phi Phi Islands, Thailand</p> <p className="text-sm text-gray-600 font-light">{attraction.title}</p>
<p className="text-sm text-gray-600 font-light">{attraction.address} </p>
</div> </div>
</div> </div>
</div> </div>
@@ -276,7 +262,7 @@ export function AttractionDetailsPage({
<h3 className="text-xl font-bold text-primary mb-1">Select Date</h3> <h3 className="text-xl font-bold text-primary mb-1">Select Date</h3>
<p className="text-sm text-gray-600">Choose your preferred visit date</p> <p className="text-sm text-gray-600">Choose your preferred visit date</p>
</div> </div>
{/* Custom Calendar Design */} {/* Custom Calendar Design */}
<div className="space-y-4"> <div className="space-y-4">
{/* Calendar Header */} {/* Calendar Header */}
@@ -305,7 +291,7 @@ export function AttractionDetailsPage({
<div className="grid grid-cols-7 gap-1"> <div className="grid grid-cols-7 gap-1">
{/* Previous month */} {/* Previous month */}
<button className="h-10 w-10 text-sm text-gray-300 hover:bg-gray-50 rounded">31</button> <button className="h-10 w-10 text-sm text-gray-300 hover:bg-gray-50 rounded">31</button>
{/* Current month */} {/* Current month */}
{Array.from({ length: 30 }, (_, i) => { {Array.from({ length: 30 }, (_, i) => {
const day = i + 1; const day = i + 1;
@@ -314,13 +300,12 @@ export function AttractionDetailsPage({
return ( return (
<button <button
key={day} key={day}
className={`h-10 w-10 text-sm rounded font-medium transition-all duration-200 ${ className={`h-10 w-10 text-sm rounded font-medium transition-all duration-200 ${isSelected
isSelected ? 'bg-primary text-white shadow-lg scale-105'
? 'bg-primary text-white shadow-lg scale-105' : isToday
: isToday
? 'bg-primary/10 text-primary border border-primary/20' ? 'bg-primary/10 text-primary border border-primary/20'
: 'text-gray-700 hover:bg-primary/5 hover:text-primary' : 'text-gray-700 hover:bg-primary/5 hover:text-primary'
}`} }`}
> >
{day} {day}
</button> </button>
@@ -356,7 +341,7 @@ export function AttractionDetailsPage({
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-gray-600">Adult Ticket</span> <span className="text-gray-600">Adult Ticket</span>
<span className="font-bold text-xl text-primary">$89</span> <span className="font-bold text-xl text-primary">{attraction.ticketPriceAdult}</span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-gray-600">Service Fee</span> <span className="text-gray-600">Service Fee</span>
@@ -365,14 +350,14 @@ export function AttractionDetailsPage({
<div className="border-t border-primary/20 pt-4"> <div className="border-t border-primary/20 pt-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-semibold text-gray-900">Total</span> <span className="font-semibold text-gray-900">Total</span>
<span className="font-bold text-2xl text-primary">$94</span> <span className="font-bold text-2xl text-primary">${attraction.ticketPriceAdult + 5}</span>
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
{/* Confirm Booking Button */} {/* Confirm Booking Button */}
<Button <Button
className="w-full bg-primary text-white hover:bg-primary/90 py-6 text-lg rounded-xl font-semibold transition-all duration-200 shadow-lg hover:shadow-xl hover:scale-[1.02] relative overflow-hidden group" className="w-full bg-primary text-white hover:bg-primary/90 py-6 text-lg rounded-xl font-semibold transition-all duration-200 shadow-lg hover:shadow-xl hover:scale-[1.02] relative overflow-hidden group"
onClick={() => onCheckoutClick()} onClick={() => onCheckoutClick()}
> >
@@ -398,7 +383,7 @@ export function AttractionDetailsPage({
</div> </div>
</div> </div>
</Layout> </Layout>
); );
} }

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Search, Star, Clock } from 'lucide-react'; import { Search, Star, Clock } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Card, CardContent } from './ui/card'; import { Card, CardContent } from './ui/card';
@@ -9,12 +9,11 @@ import { Badge } from './ui/badge';
import { Checkbox } from './ui/checkbox'; import { Checkbox } from './ui/checkbox';
import { ImageWithFallback } from './figma/ImageWithFallback'; import { ImageWithFallback } from './figma/ImageWithFallback';
import { Layout } from '../Layout'; import { Layout } from '../Layout';
import { useGetAttractionFiltersQuery, useGetCustomerAttractionsQuery } from '../Redux/services/attractions.service';
interface User { interface User {
email: string; email: string;
name: string; name: string;
} }
interface Attraction { interface Attraction {
id: string; id: string;
name: string; name: string;
@@ -30,191 +29,187 @@ interface Attraction {
passType: string; passType: string;
} }
const attractions: Attraction[] = [ // {
{ // id: '1',
id: '1', // name: 'Centipede Tour - Guided Arizona Desert Tour by ATV',
name: 'Centipede Tour - Guided Arizona Desert Tour by ATV', // description: 'Experience the thrill of off-road adventure through the stunning Arizona desert landscape',
description: 'Experience the thrill of off-road adventure through the stunning Arizona desert landscape', // image: 'https://images.unsplash.com/photo-1682687220742-aba13b6e50ba?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxhdHYlMjBkZXNlcnQlMjB0b3VyfGVufDF8fHx8MTc1ODEwNDg5Nnww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
image: 'https://images.unsplash.com/photo-1682687220742-aba13b6e50ba?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxhdHYlMjBkZXNlcnQlMjB0b3VyfGVufDF8fHx8MTc1ODEwNDg5Nnww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral', // location: 'Paris, France',
location: 'Paris, France', // duration: '4 days',
duration: '4 days', // rating: 4.8,
rating: 4.8, // price: 189.25,
price: 189.25, // category: 'adventure',
category: 'adventure', // hasReservation: true,
hasReservation: true, // reviewCount: 243,
reviewCount: 243, // passType: 'unlimited'
passType: 'unlimited' // },
}, // {
{ // id: '2',
id: '2', // name: 'Molokini and Turtle Town Snorkeling Adventure Aboard',
name: 'Molokini and Turtle Town Snorkeling Adventure Aboard', // description: 'Snorkel in crystal-clear waters and swim alongside sea turtles in this unforgettable marine adventure',
description: 'Snorkel in crystal-clear waters and swim alongside sea turtles in this unforgettable marine adventure', // image: 'https://images.unsplash.com/photo-1559827260-dc66d52bef19?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzbm9ya2VsaW5nJTIwdHVydGxlJTIwYWR2ZW50dXJlfGVufDF8fHx8MTc1ODEwNDkwMHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
image: 'https://images.unsplash.com/photo-1559827260-dc66d52bef19?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzbm9ya2VsaW5nJTIwdHVydGxlJTIwYWR2ZW50dXJlfGVufDF8fHx8MTc1ODEwNDkwMHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral', // location: 'New York, USA',
location: 'New York, USA', // duration: '4 days',
duration: '4 days', // rating: 4.8,
rating: 4.8, // price: 225,
price: 225, // category: 'adventure',
category: 'adventure', // hasReservation: false,
hasReservation: false, // reviewCount: 167,
reviewCount: 167, // passType: 'selective'
passType: 'selective' // },
}, // {
{ // id: '3',
id: '3', // name: 'Westminster Walking Tour & Westminster Abbey Entry',
name: 'Westminster Walking Tour & Westminster Abbey Entry', // description: 'Explore the heart of London with guided tours of historic Westminster and the famous Abbey',
description: 'Explore the heart of London with guided tours of historic Westminster and the famous Abbey', // image: 'https://images.unsplash.com/photo-1533929736458-ca588d08c8be?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHx3ZXN0bWluc3RlciUyMGFiYmV5JTIwbG9uZG9ufGVufDF8fHx8MTc1ODEwNDkwNnww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
image: 'https://images.unsplash.com/photo-1533929736458-ca588d08c8be?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHx3ZXN0bWluc3RlciUyMGFiYmV5JTIwbG9uZG9ufGVufDF8fHx8MTc1ODEwNDkwNnww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral', // location: 'London, UK',
location: 'London, UK', // duration: '4 days',
duration: '4 days', // rating: 4.8,
rating: 4.8, // price: 343,
price: 343, // category: 'culture',
category: 'culture', // hasReservation: true,
hasReservation: true, // reviewCount: 343,
reviewCount: 343, // passType: 'unlimited'
passType: 'unlimited' // },
}, // {
{ // id: '4',
id: '4', // name: 'All Inclusive Ultimate Circle Island Day Tour with Lunch',
name: 'All Inclusive Ultimate Circle Island Day Tour with Lunch', // description: 'Comprehensive island tour including all major attractions, lunch, and transportation',
description: 'Comprehensive island tour including all major attractions, lunch, and transportation', // image: 'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpc2xhbmQlMjB0b3VyJTIwYWRvJTIwdHJvcGljYWx8ZW58MXx8fHwxNzU4MTA0OTEwfDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
image: 'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpc2xhbmQlMjB0b3VyJTIwYWRvJTIwdHJvcGljYWx8ZW58MXx8fHwxNzU4MTA0OTEwfDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral', // location: 'New York, USA',
location: 'New York, USA', // duration: '4 days',
duration: '4 days', // rating: 4.8,
rating: 4.8, // price: 225,
price: 225, // category: 'adventure',
category: 'adventure', // hasReservation: false,
hasReservation: false, // reviewCount: 243,
reviewCount: 243, // passType: 'unlimited'
passType: 'unlimited' // },
}, // {
{ // id: '5',
id: '5', // name: 'Space Center Houston Admission Ticket',
name: 'Space Center Houston Admission Ticket', // description: 'Explore NASA\'s Johnson Space Center and discover the wonders of space exploration',
description: 'Explore NASA\'s Johnson Space Center and discover the wonders of space exploration', // image: 'https://images.unsplash.com/photo-1446776653964-20c1d3a81b06?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzcGFjZSUyMGNlbnRlciUyMG5hc2ElMjBob3VzdG9ufGVufDF8fHx8MTc1ODEwNDkxM3ww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
image: 'https://images.unsplash.com/photo-1446776653964-20c1d3a81b06?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzcGFjZSUyMGNlbnRlciUyMG5hc2ElMjBob3VzdG9ufGVufDF8fHx8MTc1ODEwNDkxM3ww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral', // location: 'Paris, France',
location: 'Paris, France', // duration: '4 days',
duration: '4 days', // rating: 4.8,
rating: 4.8, // price: 225,
price: 225, // category: 'family',
category: 'family', // hasReservation: true,
hasReservation: true, // reviewCount: 243,
reviewCount: 243, // passType: 'selective'
passType: 'selective' // },
}, // {
{ // id: '6',
id: '6', // name: 'Melbourne Skydeck Observatory',
name: 'Melbourne Skydeck Observatory', // description: 'Experience breathtaking 360-degree views from the Southern Hemisphere\'s highest viewing platform',
description: 'Experience breathtaking 360-degree views from the Southern Hemisphere\'s highest viewing platform', // image: 'https://images.unsplash.com/photo-1677200922658-d0df5b2ac91e?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBhdHRyYWN0aW9ucyUyMGZhbW91cyUyMGxhbmRtYXJrc3xlbnwxfHx8fDE3NTc0MDEwODV8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
image: 'https://images.unsplash.com/photo-1677200922658-d0df5b2ac91e?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBhdHRyYWN0aW9ucyUyMGZhbW91cyUyMGxhbmRtYXJrc3xlbnwxfHx8fDE3NTc0MDEwODV8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral', // location: 'Melbourne CBD',
location: 'Melbourne CBD', // duration: '2 hours',
duration: '2 hours', // rating: 4.5,
rating: 4.5, // price: 25,
price: 25, // category: 'adventure',
category: 'adventure', // hasReservation: true,
hasReservation: true, // reviewCount: 892,
reviewCount: 892, // passType: 'selective'
passType: 'selective' // },
}, // {
{ // id: '7',
id: '7', // name: 'Royal Botanic Gardens Melbourne',
name: 'Royal Botanic Gardens Melbourne', // description: 'Explore 38 hectares of stunning gardens featuring over 8,500 species of plants',
description: 'Explore 38 hectares of stunning gardens featuring over 8,500 species of plants', // image: 'https://images.unsplash.com/photo-1721272962395-a848331ce92d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjByb3lhbCUyMGJvdGFuaWMlMjBnYXJkZW5zfGVufDF8fHx8MTc1NzMzNzc4OXww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
image: 'https://images.unsplash.com/photo-1721272962395-a848331ce92d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjByb3lhbCUyMGJvdGFuaWMlMjBnYXJkZW5zfGVufDF8fHx8MTc1NzMzNzc4OXww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral', // location: 'South Yarra',
location: 'South Yarra', // duration: '3 hours',
duration: '3 hours', // rating: 4.7,
rating: 4.7, // price: 0,
price: 0, // category: 'nature',
category: 'nature', // hasReservation: false,
hasReservation: false, // reviewCount: 1245,
reviewCount: 1245, // passType: 'selective'
passType: 'selective' // },
}, // {
{ // id: '8',
id: '8', // name: 'Federation Square Cultural Precinct',
name: 'Federation Square Cultural Precinct', // description: 'Melbourne\'s cultural precinct featuring galleries, museums, and unique architecture',
description: 'Melbourne\'s cultural precinct featuring galleries, museums, and unique architecture', // image: 'https://images.unsplash.com/photo-1580688027085-8220709e3d84?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxmZWRlcmF0aW9uJTIwc3F1YXJlJTIwbWVsYm91cm5lfGVufDF8fHx8MTc1NzQwMTA5Mnww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
image: 'https://images.unsplash.com/photo-1580688027085-8220709e3d84?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxmZWRlcmF0aW9uJTIwc3F1YXJlJTIwbWVsYm91cm5lfGVufDF8fHx8MTc1NzQwMTA5Mnww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral', // location: 'Melbourne CBD',
location: 'Melbourne CBD', // duration: '3 hours',
duration: '3 hours', // rating: 4.3,
rating: 4.3, // price: 0,
price: 0, // category: 'culture',
category: 'culture', // hasReservation: true,
hasReservation: true, // reviewCount: 672,
reviewCount: 672, // passType: 'unlimited'
passType: 'unlimited' // },
}, // {
{ // id: '9',
id: '9', // name: 'St Kilda Pier & Little Penguins',
name: 'St Kilda Pier & Little Penguins', // description: 'Watch little penguins return home at sunset while enjoying the scenic pier',
description: 'Watch little penguins return home at sunset while enjoying the scenic pier', // image: 'https://images.unsplash.com/photo-1597889790884-2bb63cfbd4f6?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzdCUyMGtpbGRhJTIwcGllciUyMG1lbGJvdXJuZXxlbnwxfHx8fDE3NTc0MDEwOTV8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
image: 'https://images.unsplash.com/photo-1597889790884-2bb63cfbd4f6?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzdCUyMGtpbGRhJTIwcGllciUyMG1lbGJvdXJuZXxlbnwxfHx8fDE3NTc0MDEwOTV8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral', // location: 'St Kilda',
location: 'St Kilda', // duration: '2 hours',
duration: '2 hours', // rating: 4.4,
rating: 4.4, // price: 0,
price: 0, // category: 'nature',
category: 'nature', // hasReservation: false,
hasReservation: false, // reviewCount: 543,
reviewCount: 543, // passType: 'unlimited'
passType: 'unlimited' // },
}, // {
{ // id: '10',
id: '10', // name: 'Queen Victoria Market Experience',
name: 'Queen Victoria Market Experience', // description: 'Historic market offering fresh produce, gourmet foods, and unique souvenirs',
description: 'Historic market offering fresh produce, gourmet foods, and unique souvenirs', // image: 'https://images.unsplash.com/photo-1676454953709-e0be46f62490?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxxdWVlbiUyMHZpY3RvcmlhJTIwbWFya2V0JTIwbWVsYm91cm5lfGVufDF8fHx8MTc1NzQwMTA5OHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
image: 'https://images.unsplash.com/photo-1676454953709-e0be46f62490?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxxdWVlbiUyMHZpY3RvcmlhJTIwbWFya2V0JTIwbWVsYm91cm5lfGVufDF8fHx8MTc1NzQwMTA5OHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral', // location: 'Melbourne CBD',
location: 'Melbourne CBD', // duration: '2 hours',
duration: '2 hours', // rating: 4.6,
rating: 4.6, // price: 0,
price: 0, // category: 'culture',
category: 'culture', // hasReservation: true,
hasReservation: true, // reviewCount: 987,
reviewCount: 987, // passType: 'selective'
passType: 'selective' // },
}, // {
{ // id: '11',
id: '11', // name: 'Melbourne Zoo Adventure',
name: 'Melbourne Zoo Adventure', // description: 'Meet over 320 animal species from around the world in naturalistic habitats',
description: 'Meet over 320 animal species from around the world in naturalistic habitats', // image: 'https://images.unsplash.com/photo-1681429477985-30dc7b88dd5b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjB6b28lMjBhbmltYWxzfGVufDF8fHx8MTc1NzMzNzgxMHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
image: 'https://images.unsplash.com/photo-1681429477985-30dc7b88dd5b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjB6b28lMjBhbmltYWxzfGVufDF8fHx8MTc1NzMzNzgxMHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral', // location: 'Parkville',
location: 'Parkville', // duration: '4 hours',
duration: '4 hours', // rating: 4.5,
rating: 4.5, // price: 40,
price: 40, // category: 'family',
category: 'family', // hasReservation: false,
hasReservation: false, // reviewCount: 1156,
reviewCount: 1156, // passType: 'selective'
passType: 'selective' // },
}, // {
{ // id: '12',
id: '12', // name: 'Great Ocean Road Day Tour',
name: 'Great Ocean Road Day Tour', // description: 'Scenic coastal drive featuring the famous Twelve Apostles and stunning ocean views',
description: 'Scenic coastal drive featuring the famous Twelve Apostles and stunning ocean views', // image: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxncmVhdCUyMG9jZWFuJTIwcm9hZCUyMGF1c3RyYWxpYXxlbnwxfHx8fDE3NTgxMDQ5Mzd8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
image: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxncmVhdCUyMG9jZWFuJTIwcm9hZCUyMGF1c3RyYWxpYXxlbnwxfHx8fDE3NTgxMDQ5Mzd8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral', // location: 'Great Ocean Road',
location: 'Great Ocean Road', // duration: '12 hours',
duration: '12 hours', // rating: 4.9,
rating: 4.9, // price: 85,
price: 85, // category: 'adventure',
category: 'adventure', // hasReservation: true,
hasReservation: true, // reviewCount: 678,
reviewCount: 678, // passType: 'unlimited'
passType: 'unlimited' // }
} // ];
]; // const filterCategories = [
// { value: 'with-reservation', label: 'With Reservation', count: 3 },
const filterCategories = [ // { value: 'without-reservation', label: 'Without Reservation', count: 3 },
{ value: 'with-reservation', label: 'With Reservation', count: 3 }, // { value: 'beach', label: 'Beach', count: 3 },
{ value: 'without-reservation', label: 'Without Reservation', count: 3 }, // { value: 'adventure', label: 'Adventure', count: 3 },
{ value: 'beach', label: 'Beach', count: 3 }, // { value: 'mountains', label: 'Mountains', count: 3 },
{ value: 'adventure', label: 'Adventure', count: 3 }, // { value: 'family', label: 'Family Friendly', count: 3 }
{ value: 'mountains', label: 'Mountains', count: 3 }, // ];
{ value: 'family', label: 'Family Friendly', count: 3 } // const passTypeCategories = [
]; // { value: 'selective', label: 'Flexi Pass', count: 6 },
// { value: 'unlimited', label: 'Unlimited Pass', count: 6 }
const passTypeCategories = [ // ];
{ value: 'selective', label: 'Flexi Pass', count: 6 },
{ value: 'unlimited', label: 'Unlimited Pass', count: 6 }
];
interface AttractionsPageProps { interface AttractionsPageProps {
onSignInClick: () => void; onSignInClick: () => void;
onSignOutClick?: () => void; onSignOutClick?: () => void;
@@ -226,55 +221,73 @@ export function AttractionsPage({
onSignOutClick, onSignOutClick,
user user
}: AttractionsPageProps) { }: AttractionsPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedPassTypes, setSelectedPassTypes] = useState<string[]>([]);
const filteredAttractions = attractions.filter(attraction => { const [search, setSearch] = useState("");
const matchesSearch = attraction.name.toLowerCase().includes(searchQuery.toLowerCase()) || const [isBookingRequired, setIsBookingRequired] = useState<boolean | undefined>(undefined)
attraction.description.toLowerCase().includes(searchQuery.toLowerCase()); const [selectedCategory, setSelectedCategory] = useState<number | null>(null);
const [selectedPassType, setSelectedPassType] = useState<string | null>(null);
const matchesCategory = selectedCategories.length === 0 || const cityId = 1
selectedCategories.some(cat => {
if (cat === 'with-reservation') return attraction.hasReservation; const { data: filterData, isLoading } = useGetAttractionFiltersQuery(cityId)
if (cat === 'without-reservation') return !attraction.hasReservation; const { data: attractions } = useGetCustomerAttractionsQuery({
return attraction.category === cat; cityId, // required
}); categoryId: selectedCategory, // optional
isBookingRequired, // optional
const matchesPassType = selectedPassTypes.length === 0 || cardType: selectedPassType, // optional
selectedPassTypes.includes(attraction.passType); search, // optional
return matchesSearch && matchesCategory && matchesPassType;
}); });
const toggleCategory = (category: string) => { if (isLoading) {
setSelectedCategories(prev => return <div>Loading...</div>
prev.includes(category) }
? prev.filter(c => c !== category)
: [...prev, category]
);
};
const togglePassType = (passType: string) => {
setSelectedPassTypes(prev =>
prev.includes(passType)
? prev.filter(p => p !== passType)
: [...prev, passType]
);
};
const handleAttractionClick = (attractionId: string) => { const handleAttractionClick = (attractionId: string) => {
navigate(`/attractions/${attractionId}`); navigate(`/attractions/${attractionId}`);
}; };
const handleCheckoutClick = () => { const handleCheckoutClick = () => {
navigate('/checkout'); navigate('/checkout');
}; };
const showingFrom = 1; const showingFrom = 1;
const showingTo = Math.min(12, filteredAttractions.length); const showingTo = Math.min(12, attractions?.length);
const totalItems = filteredAttractions.length; const totalItems = attractions?.length;
function handlePassTypeSelection(key: string, checked: boolean) {
if (checked) {
setSelectedPassType(key); // only keep the newly selected one
} else {
setSelectedPassType(null); // reset if unchecked
}
}
function handleCategorySelection(id: number, checked: boolean) {
if (checked) {
if (id === 50) {
setIsBookingRequired(true);
setSelectedCategory(null); // clear normal category
} else if (id === 51) {
setIsBookingRequired(false);
setSelectedCategory(null); // clear normal category
} else {
setSelectedCategory(id);
setIsBookingRequired(undefined); // clear booking filter
}
} else {
// reset if unchecked
if (id === 50 || id === 51) {
setIsBookingRequired(undefined);
} else {
setSelectedCategory(null);
}
}
}
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value)
}
return ( return (
<Layout <Layout
@@ -296,7 +309,6 @@ export function AttractionsPage({
Skip the lines and explore Melbourne's most iconic destinations with your CityCard pass Skip the lines and explore Melbourne's most iconic destinations with your CityCard pass
</p> </p>
</div> </div>
{/* City Card Promotional Banner */} {/* City Card Promotional Banner */}
<div className="mb-8"> <div className="mb-8">
<Card className="bg-gradient-to-r from-primary to-primary/80 text-white p-8 rounded-xl border-none shadow-lg overflow-hidden relative"> <Card className="bg-gradient-to-r from-primary to-primary/80 text-white p-8 rounded-xl border-none shadow-lg overflow-hidden relative">
@@ -304,20 +316,18 @@ export function AttractionsPage({
<h2 className="font-merchant text-2xl leading-tight font-bold text-white"> <h2 className="font-merchant text-2xl leading-tight font-bold text-white">
Find Your Perfect Adventure Find Your Perfect Adventure
</h2> </h2>
{/* Search Bar and Button Container */} {/* Search Bar and Button Container */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center"> <div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
{/* Search Bar */} {/* Search Bar */}
<div className="relative flex-1 max-w-lg"> <div className="relative flex-1 max-w-lg">
<Input <Input
placeholder="Search An Attraction" placeholder="Search An Attraction"
value={searchQuery} value={search}
onChange={(e) => setSearchQuery(e.target.value)} onChange={handleSearchChange}
className="pl-4 pr-12 h-[44px] bg-white/95 backdrop-blur-sm border-0 rounded-lg text-gray-800 placeholder:text-gray-500 font-poppins shadow-lg" className="pl-4 pr-12 h-[44px] bg-white/95 backdrop-blur-sm border-0 rounded-lg text-gray-800 placeholder:text-gray-500 font-poppins shadow-lg"
/> />
<Search className="absolute right-4 top-1/2 transform -translate-y-1/2 text-gray-500 w-5 h-5" /> <Search className="absolute right-4 top-1/2 transform -translate-y-1/2 text-gray-500 w-5 h-5" />
</div> </div>
{/* Call-to-Action Button */} {/* Call-to-Action Button */}
<Button <Button
className="bg-white/90 hover:bg-white text-primary border-2 border-primary hover:border-primary/80 px-8 h-[44px] rounded-lg font-semibold transition-all duration-200 hover:scale-105 font-poppins shadow-lg" className="bg-white/90 hover:bg-white text-primary border-2 border-primary hover:border-primary/80 px-8 h-[44px] rounded-lg font-semibold transition-all duration-200 hover:scale-105 font-poppins shadow-lg"
@@ -327,13 +337,11 @@ export function AttractionsPage({
</Button> </Button>
</div> </div>
</div> </div>
{/* Decorative background elements */} {/* Decorative background elements */}
<div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -translate-y-16 translate-x-16"></div> <div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -translate-y-16 translate-x-16"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-white/5 rounded-full translate-y-12 -translate-x-12"></div> <div className="absolute bottom-0 left-0 w-24 h-24 bg-white/5 rounded-full translate-y-12 -translate-x-12"></div>
</Card> </Card>
</div> </div>
<div className="flex gap-8"> <div className="flex gap-8">
{/* Left Sidebar */} {/* Left Sidebar */}
<div className="w-64 flex-shrink-0"> <div className="w-64 flex-shrink-0">
@@ -344,22 +352,29 @@ export function AttractionsPage({
<div className="h-0 w-6 border-t-[3px] border-[#2d2d2d] rotate-90"></div> <div className="h-0 w-6 border-t-[3px] border-[#2d2d2d] rotate-90"></div>
<h3 className="font-merchant text-[22px] text-[#2d2d2d] font-normal">Search by</h3> <h3 className="font-merchant text-[22px] text-[#2d2d2d] font-normal">Search by</h3>
</div> </div>
{/* Filter categories */} {/* Filter categories */}
<div className="space-y-4"> <div className="space-y-4">
{filterCategories.map(category => ( {filterData && filterData.categories.map((category: any) => (
<div key={category.value} className="flex items-center gap-3"> <div key={category.id} className="flex items-center gap-3">
<Checkbox <Checkbox
id={category.value} id={String(category.id)}
checked={selectedCategories.includes(category.value)} checked={
onCheckedChange={() => toggleCategory(category.value)} category.id === 50
? isBookingRequired === true
: category.id === 51
? isBookingRequired === false
: selectedCategory === category.id
}
onCheckedChange={(checked: boolean) =>
handleCategorySelection(category.id, checked)
}
className="border-[#bebebe]" className="border-[#bebebe]"
/> />
<label <label
htmlFor={category.value} htmlFor={String(category.id)}
className="font-poppins text-sm text-[#414141] cursor-pointer flex-1 font-normal" className="font-poppins text-sm text-[#414141] cursor-pointer flex-1 font-normal"
> >
{category.label} ({category.count}) {category.categoryName} ({category.count})
</label> </label>
</div> </div>
))} ))}
@@ -367,51 +382,49 @@ export function AttractionsPage({
{/* Divider */} {/* Divider */}
<div className="border-t border-[#e5e5e5]"></div> <div className="border-t border-[#e5e5e5]"></div>
{/* Pass Type header */} {/* Pass Type header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="h-0 w-6 border-t-[3px] border-[#2d2d2d] rotate-90"></div> <div className="h-0 w-6 border-t-[3px] border-[#2d2d2d] rotate-90"></div>
<h3 className="font-merchant text-[22px] text-[#2d2d2d] font-normal">Pass Type</h3> <h3 className="font-merchant text-[22px] text-[#2d2d2d] font-normal">Pass Type</h3>
</div> </div>
{/* Pass Type filters */} {/* Pass Type filters */}
<div className="space-y-4"> <div className="space-y-4">
{passTypeCategories.map(passType => ( {filterData && Object.entries(filterData.passType).map(([key, count]) => (
<div key={passType.value} className="flex items-center gap-3"> <div key={key} className="flex items-center gap-3">
<Checkbox <Checkbox
id={passType.value} id={key}
checked={selectedPassTypes.includes(passType.value)} checked={selectedPassType === key}
onCheckedChange={() => togglePassType(passType.value)} onCheckedChange={(checked: boolean) =>
handlePassTypeSelection(key, checked as boolean)
}
className="border-[#bebebe]" className="border-[#bebebe]"
/> />
<label <label
htmlFor={passType.value} htmlFor={key}
className="font-poppins text-sm text-[#414141] cursor-pointer flex-1 font-normal" className="font-poppins text-sm text-[#414141] cursor-pointer flex-1 font-normal"
> >
{passType.label} ({passType.count}) {key==="selective_pass" ?"Selective":"Unlimited"} ({count as number})
</label> </label>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</Card> </Card>
</div> </div>
{/* Main Content */} {/* Main Content */}
<div className="flex-1"> <div className="flex-1">
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-[48px] font-medium text-[#2d2d2d] mb-6">Attractions in Melbourne</h1> <h1 className="text-[48px] font-medium text-[#2d2d2d] mb-6">Attractions in Melbourne</h1>
{/* Results count */} {/* Results count */}
<p className="text-[16px] text-[#414141] mb-2"> <p className="text-[16px] text-[#414141] mb-2">
Showing {showingFrom}-{showingTo} of {totalItems} Item(s) Showing {showingFrom}-{showingTo} of {totalItems} Item(s)
</p> </p>
</div> </div>
{/* Attractions Grid */} {/* Attractions Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 auto-rows-fr"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 auto-rows-fr">
{filteredAttractions.slice(0, 12).map((attraction) => ( {attractions && attractions.map((attraction: any) => (
<motion.div <motion.div
key={attraction.id} key={attraction.id}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@@ -435,70 +448,52 @@ export function AttractionsPage({
<Badge className="bg-primary text-white px-3 py-1 font-poppins font-semibold shadow-lg"> <Badge className="bg-primary text-white px-3 py-1 font-poppins font-semibold shadow-lg">
FREE FREE
</Badge> </Badge>
) : attraction.passType === 'unlimited' ? ( ) : attraction.cards[0].cardType.cardTypeDisplayName === "Flexi card" ? (
<Badge className="bg-gradient-to-r from-amber-500 to-orange-500 text-white px-3 py-1 font-poppins font-semibold shadow-lg border-0">
Unlimited Pass Exclusive
</Badge>
) : (
<Badge className="bg-gradient-to-r from-blue-500 to-cyan-500 text-white px-3 py-1 font-poppins font-semibold shadow-lg border-0"> <Badge className="bg-gradient-to-r from-blue-500 to-cyan-500 text-white px-3 py-1 font-poppins font-semibold shadow-lg border-0">
Flexi Pass Flexi Pass
</Badge> </Badge>
) : (
<Badge className="bg-gradient-to-r from-amber-500 to-orange-500 text-white px-3 py-1 font-poppins font-semibold shadow-lg border-0">
Unlimited Pass Exclusive
</Badge>
)} )}
</div> </div>
</div> </div>
<CardContent className="p-4 flex-1 flex flex-col"> <CardContent className="p-4 flex-1 flex flex-col">
<div className="text-sm text-muted-foreground mb-2 font-medium font-poppins"> {/* <div className="text-sm text-muted-foreground mb-2 font-medium font-poppins">
{attraction.location} {attraction.location}
</div> </div> */}
<h3 className="font-semibold text-foreground mb-3 line-clamp-2 leading-tight min-h-[2.5rem] font-poppins"> <h3 className="font-semibold text-foreground mb-3 line-clamp-2 leading-tight min-h-[2.5rem] font-poppins">
{attraction.name} {attraction.title}
</h3> </h3>
<div className="flex items-center gap-2 mb-3">
<div className="flex items-center gap-1">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-4 h-4 ${i < Math.floor(attraction.rating)
? 'fill-primary text-primary'
: 'text-gray-300'
}`}
/>
))}
<span className="text-sm font-medium ml-1 text-gray-700 font-poppins">
{attraction.rating} ({attraction.reviewCount})
</span>
</div>
</div>
{/* Pricing and Pass Info */} {/* Pricing and Pass Info */}
<div className="mt-auto pt-2 space-y-3"> <div className="mt-auto pt-2 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-sm text-muted-foreground"> <div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="w-4 h-4 text-primary" /> <Clock className="w-4 h-4 text-primary" />
<span className="font-poppins">{attraction.duration}</span> <span className="font-poppins">{attraction.durations} minutes</span>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-xs text-muted-foreground font-poppins font-normal">Normal visit price</div> <div className="text-xs text-muted-foreground font-poppins font-normal">Normal visit price</div>
<div className="text-lg font-bold text-gray-400 line-through font-poppins"> <div className="text-lg font-bold text-gray-400 line-through font-poppins">
${attraction.price} ${attraction.ticketPriceAdult}
</div> </div>
</div> </div>
</div> </div>
{/* Included with Pass CTA */} {/* Included with Pass CTA */}
<div className="bg-gradient-to-r from-primary/10 to-secondary/10 border border-primary/20 rounded-lg p-2.5"> <div className="bg-gradient-to-r from-primary/10 to-secondary/10 border border-primary/20 rounded-lg p-2.5">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex-1"> <div className="flex-1">
<p className="text-xs font-poppins font-semibold text-primary uppercase"> <p className="text-xs font-poppins font-semibold text-primary uppercase">
Included with {attraction.passType === 'unlimited' ? 'Unlimited' : 'Selective'} Pass Included with {attraction.cards[0].cardType.cardTypeDisplayName === "Flexi card" ? 'Flexi' : 'Unlimited'} Pass
</p> </p>
<p className="text-xs font-poppins font-normal text-gray-600 mt-0.5"> <p className="text-xs font-poppins font-normal text-gray-600 mt-0.5">
Save ${attraction.price} Save ${attraction.cards[0].adultPrice}
</p> </p>
</div> </div>
<Button <Button
className="bg-primary hover:bg-primary/90 text-white font-poppins font-semibold text-xs px-4 min-h-[44px] min-w-[44px] h-[44px] whitespace-nowrap" className="bg-primary hover:bg-primary/90 text-white font-poppins font-semibold text-xs px-4 min-h-[44px] min-w-[44px] h-[44px] whitespace-nowrap"
onClick={(e) => { onClick={(e:React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
handleCheckoutClick(); handleCheckoutClick();
}} }}

View File

@@ -79,7 +79,7 @@ export function CTAButton({ user, onClick, className = "" }: CTAButtonProps) {
<motion.div <motion.div
key={user ? user.email : 'logged-out'} key={user ? user.email : 'logged-out'}
className="w-full h-full" className="w-full h-full"
initial={{ opacity: 0, scale: 0.9 }} // initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
> >

View File

@@ -1,16 +1,17 @@
// CitySelectionDialog.tsx // CitySelectionDialog.tsx
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog';
import { ArrowLeft, Search } from 'lucide-react'; import { ArrowLeft, Search } from 'lucide-react';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { ImageWithFallback } from './figma/ImageWithFallback'; import { ImageWithFallback } from './figma/ImageWithFallback';
import { useGetCityListWithBannerQuery } from '../Redux/services/cities.service';
interface City { interface City {
id: string; id: number;
name: string; cityName: string;
imageUrl: string; bannerImage: string;
} }
interface CitySelectionDialogProps { interface CitySelectionDialogProps {
@@ -19,43 +20,39 @@ interface CitySelectionDialogProps {
onCitySelect?: (cityId: string) => void; // ✅ Updated to pass cityId onCitySelect?: (cityId: string) => void; // ✅ Updated to pass cityId
} }
const cities: City[] = [ export function CitySelectionDialog({
{ id: 'melbourne', name: 'Melbourne', imageUrl: 'https://images.unsplash.com/photo-1624341373902-70e3a8dc9acc?...' }, isOpen,
{ id: 'new-york', name: 'New York', imageUrl: 'https://images.unsplash.com/photo-1514565131-fce0801e5785?...' }, onClose,
{ id: 'abu-dhabi', name: 'Abu Dhabi', imageUrl: 'https://images.unsplash.com/photo-1584551246679-0daf3d275d0f?...' }, onCitySelect
{ id: 'dubai', name: 'Dubai', imageUrl: 'https://images.unsplash.com/photo-1518684079-3c830dcef090?...' },
{ id: 'tokyo', name: 'Tokyo', imageUrl: 'https://images.unsplash.com/photo-1613487897980-50cc440ce118?...' },
{ id: 'ontario', name: 'Ontario', imageUrl: 'https://images.unsplash.com/photo-1542704792-e30dac463c90?...' },
{ id: 'mumbai', name: 'Mumbai', imageUrl: 'https://images.unsplash.com/photo-1600867161422-79f8f6e08c84?...' },
{ id: 'louisiana', name: 'Louisiana', imageUrl: 'https://images.unsplash.com/photo-1646508262200-455d62c22182?...' },
];
export function CitySelectionDialog({
isOpen,
onClose,
onCitySelect
}: CitySelectionDialogProps) { }: CitySelectionDialogProps) {
const [searchQuery, setSearchQuery] = useState(''); const [search, setSearch] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const filteredCities = cities.filter(city => const { data: cities, isLoading } = useGetCityListWithBannerQuery({ search })
city.name.toLowerCase().includes(searchQuery.toLowerCase())
); if (isLoading) {
return <div>Loading...</div>
}
const handleCityClick = (city: City) => { const handleCityClick = (city: City) => {
console.log('Selected city:', city.name); console.log('Selected city:', city.cityName);
// ✅ Call the onCitySelect callback if provided (passing cityId) // ✅ Call the onCitySelect callback if provided (passing cityId)
if (onCitySelect) { if (onCitySelect) {
onCitySelect(city.id); onCitySelect(String(city.id));
} else { } else {
// ✅ Default behavior: navigate to passes page // ✅ Default behavior: navigate to passes page
navigate(`/passes?city=${encodeURIComponent(city.name)}`); navigate(`/passes?city=${encodeURIComponent(city.cityName)}`);
} }
onClose(); onClose();
}; };
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value)
}
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md w-full p-0 gap-0 font-poppins"> <DialogContent className="max-w-md w-full p-0 gap-0 font-poppins">
@@ -83,8 +80,8 @@ export function CitySelectionDialog({
<Input <Input
type="text" type="text"
placeholder="Search Cities" placeholder="Search Cities"
value={searchQuery} value={search}
onChange={(e) => setSearchQuery(e.target.value)} onChange={handleSearchChange}
className="pl-10 bg-input border-0 rounded-lg h-11 font-poppins placeholder:text-gray-400" className="pl-10 bg-input border-0 rounded-lg h-11 font-poppins placeholder:text-gray-400"
/> />
</div> </div>
@@ -94,27 +91,26 @@ export function CitySelectionDialog({
<div className="px-6 pb-6 max-h-[60vh] overflow-y-auto"> <div className="px-6 pb-6 max-h-[60vh] overflow-y-auto">
<AnimatePresence> <AnimatePresence>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{filteredCities.map((city, index) => ( {cities && cities.map((city: City) => (
<motion.button <motion.button
key={city.id} key={city.id}
onClick={() => handleCityClick(city)} onClick={() => handleCityClick(city)}
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0, scale: 0.9 }} transition={{ duration: 0.2 }}
transition={{ delay: index * 0.05 }}
whileHover={{ scale: 1.03 }} whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
className="relative h-28 rounded-2xl overflow-hidden group cursor-pointer" className="relative h-28 rounded-2xl overflow-hidden group cursor-pointer"
> >
<ImageWithFallback <ImageWithFallback
src={city.imageUrl} src={city.bannerImage}
alt={city.name} alt={city.cityName}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110" className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
<div className="absolute bottom-3 left-3 right-3"> <div className="absolute bottom-3 left-3 right-3">
<h3 className="font-poppins font-semibold text-white text-left"> <h3 className="font-poppins font-semibold text-white text-left">
{city.name} {city.cityName}
</h3> </h3>
</div> </div>
</motion.button> </motion.button>
@@ -122,10 +118,10 @@ export function CitySelectionDialog({
</div> </div>
</AnimatePresence> </AnimatePresence>
{filteredCities.length === 0 && ( {cities?.length === 0 && (
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-gray-500 font-poppins"> <p className="text-gray-500 font-poppins">
No cities found matching "{searchQuery}" No cities found matching "{search}"
</p> </p>
</div> </div>
)} )}

View File

@@ -174,7 +174,7 @@ export function DiscoverPage({
return ( return (
<Layout <Layout
activeCity="shared" activeCity={sessionStorage.getItem("lastKnownCity") ||"shared"}
onSignInClick={onSignInClick} onSignInClick={onSignInClick}
onSignOutClick={onSignOutClick} onSignOutClick={onSignOutClick}
user={user} user={user}

View File

@@ -3,102 +3,103 @@ import { ImageWithFallback } from './figma/ImageWithFallback';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { useRef, useState, useEffect } from 'react'; import { useRef, useState, useEffect } from 'react';
import Image592Traced from '../imports/Image592Traced-5025-559'; import Image592Traced from '../imports/Image592Traced-5025-559';
import { useGetUpcomingCitiesQuery } from '../Redux/services/cities.service';
const upcomingCities = [ // const upcomingCities = [
{ // {
id: 1, // id: 1,
name: 'Boston', // name: 'Boston',
country: 'USA', // country: 'USA',
launchDate: 'Spring 2025', // launchDate: 'Spring 2025',
attractions: 65, // 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.', // 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', // image: 'https://images.unsplash.com/photo-1568271667303-14b2a1a36da1?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: true // showHoverState: true
}, // },
{ // {
id: 2, // id: 2,
name: 'Rome', // name: 'Rome',
country: 'Italy', // country: 'Italy',
launchDate: 'Summer 2025', // launchDate: 'Summer 2025',
attractions: 80, // attractions: 80,
image: 'https://images.unsplash.com/photo-1552832230-c0197dd311b5?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80', // image: 'https://images.unsplash.com/photo-1552832230-c0197dd311b5?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false // showHoverState: false
}, // },
{ // {
id: 3, // id: 3,
name: 'Paris', // name: 'Paris',
country: 'France', // country: 'France',
launchDate: 'Fall 2025', // launchDate: 'Fall 2025',
attractions: 95, // attractions: 95,
image: 'https://images.unsplash.com/photo-1502602898536-47ad22581b52?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80', // image: 'https://images.unsplash.com/photo-1502602898536-47ad22581b52?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false // showHoverState: false
}, // },
{ // {
id: 4, // id: 4,
name: 'Dubai', // name: 'Dubai',
country: 'UAE', // country: 'UAE',
launchDate: 'Winter 2025', // launchDate: 'Winter 2025',
attractions: 70, // attractions: 70,
image: 'https://images.unsplash.com/photo-1512453979798-5ea266f8880c?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80', // image: 'https://images.unsplash.com/photo-1512453979798-5ea266f8880c?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false, // showHoverState: false,
badge: 'New' // badge: 'New'
}, // },
{ // {
id: 5, // id: 5,
name: 'Tokyo', // name: 'Tokyo',
country: 'Japan', // country: 'Japan',
launchDate: 'Early 2026', // launchDate: 'Early 2026',
attractions: 120, // attractions: 120,
image: 'https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80', // image: 'https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false // showHoverState: false
}, // },
{ // {
id: 6, // id: 6,
name: 'Sydney', // name: 'Sydney',
country: 'Australia', // country: 'Australia',
launchDate: 'Spring 2026', // launchDate: 'Spring 2026',
attractions: 85, // attractions: 85,
image: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80', // image: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false // showHoverState: false
}, // },
{ // {
id: 7, // id: 7,
name: 'New York', // name: 'New York',
country: 'USA', // country: 'USA',
launchDate: 'Summer 2026', // launchDate: 'Summer 2026',
attractions: 150, // attractions: 150,
image: 'https://images.unsplash.com/photo-1496442226666-8d4d0e62e6e9?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80', // image: 'https://images.unsplash.com/photo-1496442226666-8d4d0e62e6e9?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false, // showHoverState: false,
badge: 'Most Requested' // badge: 'Most Requested'
}, // },
{ // {
id: 8, // id: 8,
name: 'Singapore', // name: 'Singapore',
country: 'Singapore', // country: 'Singapore',
launchDate: 'Fall 2026', // launchDate: 'Fall 2026',
attractions: 75, // attractions: 75,
image: 'https://images.unsplash.com/photo-1525625293386-3f8f99389edd?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80', // image: 'https://images.unsplash.com/photo-1525625293386-3f8f99389edd?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false // showHoverState: false
}, // },
{ // {
id: 9, // id: 9,
name: 'Amsterdam', // name: 'Amsterdam',
country: 'Netherlands', // country: 'Netherlands',
launchDate: 'Winter 2026', // launchDate: 'Winter 2026',
attractions: 90, // attractions: 90,
image: 'https://images.unsplash.com/photo-1534351590666-13e3e96b5017?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80', // image: 'https://images.unsplash.com/photo-1534351590666-13e3e96b5017?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false // showHoverState: false
}, // },
{ // {
id: 10, // id: 10,
name: 'Barcelona', // name: 'Barcelona',
country: 'Spain', // country: 'Spain',
launchDate: 'Early 2027', // launchDate: 'Early 2027',
attractions: 110, // attractions: 110,
image: 'https://images.unsplash.com/photo-1583422409516-2895a77efded?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80', // image: 'https://images.unsplash.com/photo-1583422409516-2895a77efded?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
showHoverState: false // showHoverState: false
} // }
]; // ];
export function LandingUpcomingCities() { export function LandingUpcomingCities() {
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -107,6 +108,15 @@ export function LandingUpcomingCities() {
const [scrollLeft, setScrollLeft] = useState(0); const [scrollLeft, setScrollLeft] = useState(0);
const [showDragHint, setShowDragHint] = useState(false); const [showDragHint, setShowDragHint] = useState(false);
const listType = "upcomingCity"
// const[listType,setListType]=useState("upcomingCity")
const { data, isLoading } = useGetUpcomingCitiesQuery(listType)
if(isLoading){
return <div>Loading...</div>
}
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
if (!scrollContainerRef.current) return; if (!scrollContainerRef.current) return;
// Only start dragging if not clicking on a button or interactive element // Only start dragging if not clicking on a button or interactive element
@@ -143,11 +153,11 @@ export function LandingUpcomingCities() {
} }
}; };
useEffect(() => { // useEffect(() => {
const handleGlobalMouseUp = () => setIsDragging(false); // const handleGlobalMouseUp = () => setIsDragging(false);
document.addEventListener('mouseup', handleGlobalMouseUp); // document.addEventListener('mouseup', handleGlobalMouseUp);
return () => document.removeEventListener('mouseup', handleGlobalMouseUp); // return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
}, []); // }, []);
return ( return (
<section className="py-20 bg-gray-50"> <section className="py-20 bg-gray-50">
@@ -172,11 +182,11 @@ export function LandingUpcomingCities() {
</div> </div>
)} )}
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
className={`flex gap-6 overflow-x-auto scrollbar-hide pb-2 ${isDragging ? 'cursor-grabbing dragging select-none' : 'cursor-grab'}`} className={`flex gap-6 overflow-x-auto scrollbar-hide pb-2 ${isDragging ? 'cursor-grabbing dragging select-none' : 'cursor-grab'}`}
style={{ style={{
scrollbarWidth: 'none', scrollbarWidth: 'none',
msOverflowStyle: 'none', msOverflowStyle: 'none',
scrollBehavior: isDragging ? 'auto' : 'smooth', scrollBehavior: isDragging ? 'auto' : 'smooth',
paddingLeft: 'max(1rem, calc((100vw - 1280px) / 2 + 1rem))', paddingLeft: 'max(1rem, calc((100vw - 1280px) / 2 + 1rem))',
@@ -188,112 +198,112 @@ export function LandingUpcomingCities() {
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
> >
{upcomingCities.map((city) => ( {data && data?.upcomingCities?.map((city: any) => (
<div <div
key={city.id} 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" 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 */} {/* Background - Either solid color or image */}
{city.showHoverState ? ( {true ? (
// Boston card with image background and same layout as other cards // Boston card with image background and same layout as other cards
<> <>
<ImageWithFallback <ImageWithFallback
src={city.image!} src={city.imgPathName!}
alt={city.name} alt={city.cityName}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700" 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 */} {/* Dark overlay */}
<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="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent group-hover:from-black/80 transition-all duration-500" />
<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 */} {/* City name overlay - matching Rome card layout */}
<div className="absolute bottom-6 left-6 right-6 text-white"> <div className="absolute bottom-6 left-6 right-6 text-white">
<h3 className="text-2xl font-bold mb-2">{city.name}</h3> <h3 className="text-2xl font-bold mb-2">{city.cityName}</h3>
<div className="flex items-center justify-between text-sm text-white/80"> <div className="flex items-center justify-between text-sm text-white/80">
<span>{city.country}</span> {/* <span>{city.country}</span>
<span>{city.launchDate}</span> <span>{city.launchDate}</span> */}
</div>
</div> </div>
</div>
{/* Hover state overlay */} {/* Hover state overlay - same as other cards */}
<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="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"> <div className="text-center text-white">
<h3 className="text-2xl font-bold mb-2">{city.name}</h3> <h3 className="text-2xl font-bold mb-2">{city.cityName}</h3>
<p className="text-white/90 mb-4">{city.attractions}+ attractions</p> {/* <p className="text-white/90 mb-4">{city.attractions}+ attractions</p>
<p className="text-sm text-white/80 mb-6">Coming {city.launchDate}</p> <p className="text-sm text-white/80 mb-6">Coming {city.launchDate}</p> */}
<Button <Button
variant="secondary" variant="secondary"
className="bg-white/20 hover:bg-white/30 text-white border-white/30 hover:border-white/50 backdrop-blur-sm" className="bg-white/20 hover:bg-white/30 text-white border-white/30 hover:border-white/50 backdrop-blur-sm"
onMouseDown={(e) => { onMouseDown={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
setIsDragging(false); setIsDragging(false);
}} }}
onClick={(e) => { onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
console.log('Notify Me button clicked'); console.log('Notify Me button clicked');
}} }}
> >
Notify Me Notify Me
<ArrowRight className="w-4 h-4 ml-2" /> <ArrowRight className="w-4 h-4 ml-2" />
</Button> </Button>
</div>
</div> </div>
</> </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.cityName}</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: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setIsDragging(false);
}}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
console.log('Notify Me button clicked');
}}
>
Notify Me
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</>
)}
</div>
))}
</div> </div>
</div> </div>

View File

@@ -4,14 +4,16 @@ import { X } from 'lucide-react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Label } from './ui/label'; import { Label } from './ui/label';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
interface LoginModalProps { interface LoginModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onLoginSuccess: (userData: { email: string; name: string }) => void; // onLoginSuccess: (userData: { email: string; name: string }) => void;
} }
export function LoginModal({ isOpen, onClose, onLoginSuccess }: LoginModalProps) { export function LoginModal({ isOpen, onClose, }: LoginModalProps) {
const [step, setStep] = useState<'email' | 'otp'>('email'); const [step, setStep] = useState<'email' | 'otp'>('email');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [otp, setOtp] = useState(['', '', '', '', '', '']); const [otp, setOtp] = useState(['', '', '', '', '', '']);
@@ -19,6 +21,8 @@ export function LoginModal({ isOpen, onClose, onLoginSuccess }: LoginModalProps)
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [helperText, setHelperText] = useState(''); const [helperText, setHelperText] = useState('');
const { login } = useAuth(); // from AuthContext
// Reset modal state when closed // Reset modal state when closed
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@@ -46,7 +50,7 @@ export function LoginModal({ isOpen, onClose, onLoginSuccess }: LoginModalProps)
setIsLoading(true); setIsLoading(true);
setHelperText(''); setHelperText('');
// Simulate API call // Simulate API call
setTimeout(() => { setTimeout(() => {
setStep('otp'); setStep('otp');
@@ -58,7 +62,7 @@ export function LoginModal({ isOpen, onClose, onLoginSuccess }: LoginModalProps)
const handleOTPChange = (index: number, value: string) => { const handleOTPChange = (index: number, value: string) => {
if (value.length > 1) return; // Only allow single digit if (value.length > 1) return; // Only allow single digit
const newOtp = [...otp]; const newOtp = [...otp];
newOtp[index] = value; newOtp[index] = value;
setOtp(newOtp); setOtp(newOtp);
@@ -92,12 +96,11 @@ export function LoginModal({ isOpen, onClose, onLoginSuccess }: LoginModalProps)
// Generate name from email for demo // Generate name from email for demo
const emailParts = email.split('@')[0]; const emailParts = email.split('@')[0];
const name = emailParts.charAt(0).toUpperCase() + emailParts.slice(1); const name = emailParts.charAt(0).toUpperCase() + emailParts.slice(1);
onLoginSuccess({ login({ email, name })
email,
name: name.length > 8 ? name.substring(0, 8) : name
});
setIsLoading(false); setIsLoading(false);
// navigate("/melbourne")
onClose(); onClose();
}, 1500); }, 1500);
}; };
@@ -139,7 +142,7 @@ export function LoginModal({ isOpen, onClose, onLoginSuccess }: LoginModalProps)
> >
<X className="w-4 h-4 text-gray-600" /> <X className="w-4 h-4 text-gray-600" />
</button> </button>
<h2 className="font-merchant text-2xl font-semibold text-gray-900 mb-2"> <h2 className="font-merchant text-2xl font-semibold text-gray-900 mb-2">
Login Login
</h2> </h2>
@@ -231,7 +234,7 @@ export function LoginModal({ isOpen, onClose, onLoginSuccess }: LoginModalProps)
/> />
))} ))}
</div> </div>
{/* Countdown */} {/* Countdown */}
{countdown > 0 && ( {countdown > 0 && (
<p className="font-poppins text-xs text-gray-500 text-center"> <p className="font-poppins text-xs text-gray-500 text-center">

View File

@@ -13,7 +13,7 @@ import { EnhancedTestimonials } from './EnhancedTestimonials';
import { MobileAppPromotion } from './MobileAppPromotion'; import { MobileAppPromotion } from './MobileAppPromotion';
import { MelbourneFAQ } from './MelbourneFAQ'; import { MelbourneFAQ } from './MelbourneFAQ';
import { Footer } from './Footer'; import { Footer } from './Footer';
import { MinimalHeroBanner } from './MinimalHeroBanner'; // import { MinimalHeroBanner } from './MinimalHeroBanner';
import { Layout } from '../Layout'; import { Layout } from '../Layout';
import { HeroBannerCarousel } from './HeroBannerCarousel'; import { HeroBannerCarousel } from './HeroBannerCarousel';
import { HotelEsimOffers } from './HotelEsimOffers'; import { HotelEsimOffers } from './HotelEsimOffers';
@@ -254,12 +254,12 @@ export function MelbournePage({
{/* Attractions Section */} {/* Attractions Section */}
<div id="attractions" className="scroll-mt-32"> <div id="attractions" className="scroll-mt-32">
<MelbourneAttractions onAttractionClick={() => { }} /> <MelbourneAttractions />
</div> </div>
{/* Pass Comparison */} {/* Pass Comparison */}
<div id="passes" className="scroll-mt-32"> <div id="passes" className="scroll-mt-32">
<MelbourneCardComparison onSelectPass={() => { }} /> <MelbourneCardComparison />
</div> </div>
{/* Tour Overview */} {/* Tour Overview */}
@@ -277,7 +277,7 @@ export function MelbournePage({
{/* Blogs */} {/* Blogs */}
<div id="blogs" className="scroll-mt-32"> <div id="blogs" className="scroll-mt-32">
<MelbourneBlogs onBlogClick={() => { }} /> <MelbourneBlogs />
</div> </div>
{/* Custom Postcards */} {/* Custom Postcards */}

View File

@@ -9,6 +9,8 @@ import { CTAButton } from './CTAButton';
import logoImage from '../assets/cit-logo.png'; import logoImage from '../assets/cit-logo.png';
import melbourneLogo from '../assets/melbourne-logo.png'; import melbourneLogo from '../assets/melbourne-logo.png';
import { CitySelectionDialog } from './CitySelectionDialog'; import { CitySelectionDialog } from './CitySelectionDialog';
import { useAuth } from '../context/AuthContext';
import { LoginModal } from './LoginModal';
interface NavbarProps { interface NavbarProps {
activeCity: string; activeCity: string;
@@ -63,7 +65,7 @@ export default function Navbar({
onSignInClick, onSignInClick,
onSignOutClick, onSignOutClick,
isUserSignedIn = false, isUserSignedIn = false,
user // user
}: NavbarProps) { }: NavbarProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
@@ -85,6 +87,22 @@ export default function Navbar({
const navigate = useNavigate(); const navigate = useNavigate();
const [lastKnownCity, setLastKnownCity] = useState<'landing' | 'melbourne'>('landing'); const [lastKnownCity, setLastKnownCity] = useState<'landing' | 'melbourne'>('landing');
const [isLoginOpen, setLoginOpen] = useState(false);
const { user, login, logout } = useAuth(); // from AuthContext
const protectedPaths = ["/passes", "/whats-included", "/", "/melbourne"];
const handleOpenLoginModal = () => {
if (!user && protectedPaths.includes(location.pathname)) {
setLoginOpen(true);
}
else if (!user) {
setIsCityDialogOpen(true); // normal city selection
} else if (user) {
setActiveUserDropdown(true)
}
};
// More flexible navigation configuration // More flexible navigation configuration
@@ -271,7 +289,7 @@ export default function Navbar({
console.log('City selected from navbar:', cityId); console.log('City selected from navbar:', cityId);
onCityChange(cityId); onCityChange(cityId);
if (cityId.toLowerCase() === 'melbourne') { if (cityId.toLowerCase() === '1') {
setNavigationSource('melbourne'); setNavigationSource('melbourne');
navigate('/melbourne'); navigate('/melbourne');
} else { } else {
@@ -454,7 +472,7 @@ export default function Navbar({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{title && ( {title && (
<div className="px-5 py-4 border-b border-gray-100/50"> <div className="px-5 py-4 border-b border-gray-200/50">
<h3 className="font-merchant font-semibold text-gray-900 text-base">{title}</h3> <h3 className="font-merchant font-semibold text-gray-900 text-base">{title}</h3>
</div> </div>
)} )}
@@ -642,7 +660,7 @@ export default function Navbar({
{/* Enhanced City Card Button with Source Tracking */} {/* Enhanced City Card Button with Source Tracking */}
<div className="flex items-center gap-3 pl-2"> <div className="flex items-center gap-3 pl-2">
<div className="relative"> <div className="relative">
{isUserSignedIn && user ? ( {user ? (
<Dropdown <Dropdown
ref={userRef} ref={userRef}
isOpen={activeUserDropdown} isOpen={activeUserDropdown}
@@ -671,9 +689,7 @@ export default function Navbar({
label: 'Sign Out', label: 'Sign Out',
icon: <LogOut className="w-4 h-4" />, icon: <LogOut className="w-4 h-4" />,
action: () => { action: () => {
if (onSignOutClick) { logout()
onSignOutClick();
}
setActiveUserDropdown(false); setActiveUserDropdown(false);
} }
} }
@@ -698,10 +714,10 @@ export default function Navbar({
) : ( ) : (
<div <div
className="cursor-pointer" className="cursor-pointer"
onClick={handleOpenCityDialogFromCTA} onClick={handleOpenLoginModal}
> >
<CTAButton <CTAButton
user={null} user={user}
onClick={() => { }} onClick={() => { }}
className="hover:scale-105 transition-transform duration-200" className="hover:scale-105 transition-transform duration-200"
/> />
@@ -887,6 +903,13 @@ export default function Navbar({
onClose={handleCloseCityDialog} onClose={handleCloseCityDialog}
onCitySelect={handleCitySelect} onCitySelect={handleCitySelect}
/> />
<LoginModal
isOpen={isLoginOpen}
onClose={() => {
setLoginOpen(false);
}}
/>
</> </>
); );
} }

View File

@@ -11,6 +11,7 @@ import { ReviewsSection } from './ReviewsSection';
import { Layout } from '../Layout'; import { Layout } from '../Layout';
import { LoginModal } from './LoginModal'; import { LoginModal } from './LoginModal';
import { ImageWithFallback } from './figma/ImageWithFallback'; import { ImageWithFallback } from './figma/ImageWithFallback';
import { useAuth } from '../context/AuthContext';
interface PassesPageProps { interface PassesPageProps {
onCheckoutClick?: () => void; onCheckoutClick?: () => void;
@@ -149,16 +150,18 @@ export function PassesPage({
onCheckoutClick, onCheckoutClick,
onSignInClick, onSignInClick,
onSignOutClick, onSignOutClick,
user, // user,
onLoginSuccess onLoginSuccess
}: PassesPageProps) { }: PassesPageProps) {
const [selectedPass, setSelectedPass] = useState<string>('unlimited'); const [selectedPass, setSelectedPass] = useState<string>('unlimited');
const [isLoginOpen, setIsLoginOpen] = useState(false); const [isLoginOpen, setIsLoginOpen] = useState(false);
const [userData, setUserData] = useState<{ email: string; name: string } | null>(user || null); // const [userData, setUserData] = useState<{ email: string; name: string } | null>(user || null);
const { user } = useAuth(); // from AuthContext
// ✅ Handle purchase button click // ✅ Handle purchase button click
const handlePurchaseClick = () => { const handlePurchaseClick = () => {
if (!userData) { if (!user) {
// User not logged in - show login modal // User not logged in - show login modal
setIsLoginOpen(true); setIsLoginOpen(true);
} else { } else {
@@ -169,7 +172,7 @@ export function PassesPage({
// ✅ Handle successful login // ✅ Handle successful login
const handleLoginSuccess = (data: { email: string; name: string }) => { const handleLoginSuccess = (data: { email: string; name: string }) => {
setUserData(data); // setUserData(data);
setIsLoginOpen(false); setIsLoginOpen(false);
console.log('Logged in user:', data); console.log('Logged in user:', data);
@@ -192,10 +195,10 @@ export function PassesPage({
return ( return (
<Layout <Layout
activeCity="shared" activeCity={sessionStorage.getItem("lastKnownCity")||"shared"}
onSignInClick={onSignInClick} onSignInClick={onSignInClick}
onSignOutClick={onSignOutClick} onSignOutClick={onSignOutClick}
user={userData} // ✅ Pass the updated user data user={user} // ✅ Pass the updated user data
> >
<div className="container mx-auto px-4 pt-52 pb-12 relative z-10"> <div className="container mx-auto px-4 pt-52 pb-12 relative z-10">
{/* Page Header */} {/* Page Header */}
@@ -759,12 +762,6 @@ export function PassesPage({
</div> </div>
</div> </div>
<LoginModal
isOpen={isLoginOpen}
onClose={() => setIsLoginOpen(false)}
onLoginSuccess={handleLoginSuccess}
/>
</Layout> </Layout>
); );
} }

View File

@@ -0,0 +1,51 @@
import React, { createContext, useContext, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom';
interface User {
email: string;
name: string
}
interface AuthContextType {
user: User | null;
login: (userData: User) => void;
logout: () => void
}
const AuthContext = createContext<AuthContextType | null>(null)
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<User | null>(null)
const navigate = useNavigate()
useEffect(() => {
const storedUser = localStorage.getItem("user")
if (storedUser) {
setUser(JSON.parse(storedUser))
}
}, [])
const login = (userData: User) => {
setUser(userData)
localStorage.setItem("user", JSON.stringify(userData))
}
const logout = () => {
setUser(null)
localStorage.removeItem("user")
navigate("/")
}
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error("useAuth must be used inside AuthProvider")
return ctx
}

View File

@@ -2,9 +2,13 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
import { Provider } from "react-redux";
import { store } from "./Redux/Store";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<BrowserRouter> <Provider store={store}>
<App /> <BrowserRouter>
</BrowserRouter> <App />
</BrowserRouter>
</Provider>
); );

View File

@@ -19,95 +19,100 @@ import { LandingNewsletterSection } from '../components/LandingNewsletterSection
import { CustomPostcards } from '../components/CustomPostcards'; import { CustomPostcards } from '../components/CustomPostcards';
import { Layout } from '../Layout'; import { Layout } from '../Layout';
import { getAutoNavigationSource } from '../utils/getAutoNavigationSource'; import { getAutoNavigationSource } from '../utils/getAutoNavigationSource';
import { useGetProductsQuery } from '../Redux/services/fakeApi.service';
const melbourneImage = const melbourneImage =
"https://images.unsplash.com/photo-1551836022-d5d88e9218df?auto=format&fit=crop&w=1920&q=80"; // Melbourne "https://images.unsplash.com/photo-1551836022-d5d88e9218df?auto=format&fit=crop&w=1920&q=80"; // Melbourne
const sydneyImage = const sydneyImage =
"https://images.unsplash.com/photo-1506976785307-8732e854ad03?auto=format&fit=crop&w=1920&q=80"; // Sydney Opera House "https://images.unsplash.com/photo-1506976785307-8732e854ad03?auto=format&fit=crop&w=1920&q=80"; // Sydney Opera House
const brisbaneImage = const brisbaneImage =
"https://images.unsplash.com/photo-1604644363101-03f3d7cbecb6?auto=format&fit=crop&w=1920&q=80"; // Brisbane skyline "https://images.unsplash.com/photo-1604644363101-03f3d7cbecb6?auto=format&fit=crop&w=1920&q=80"; // Brisbane skyline
interface User { interface User {
email: string; email: string;
name: string; name: string;
} }
interface LandingPageProps { interface LandingPageProps {
onSignInClick: () => void; onSignInClick: () => void;
onSignOutClick?: () => void; onSignOutClick?: () => void;
user?: User | null; user?: User | null;
} }
export function LandingPage({ onSignInClick, export function LandingPage({ onSignInClick,
onSignOutClick, onSignOutClick,
user }: LandingPageProps) { user }: LandingPageProps) {
const [currentCityIndex, setCurrentCityIndex] = useState(0); const [currentCityIndex, setCurrentCityIndex] = useState(0);
const location = useLocation(); const [isCityDialogOpen, setIsCityDialogOpen] = useState(Boolean)
const activeCity = getAutoNavigationSource(location); const location = useLocation();
const activeCity = getAutoNavigationSource(location);
const cities = [ // const { data } = useGetProductsQuery()
{ // console.log(data)
id: 'melbourne',
name: 'Melbourne',
description: 'Cultural capital with world-class attractions',
image: melbourneImage,
attractions: 45,
savings: '30%',
path: '/melbourne'
},
{
id: 'sydney',
name: 'Sydney',
description: 'Iconic landmarks and harbor views',
image: sydneyImage,
attractions: 38,
savings: '25%',
path: '/sydney'
},
{
id: 'brisbane',
name: 'Brisbane',
description: 'Sunshine, riverside dining, and adventure',
image: brisbaneImage,
attractions: 32,
savings: '28%',
path: '/brisbane'
}
];
// Auto-rotate cities const cities = [
useEffect(() => { {
const interval = setInterval(() => { id: 'melbourne',
setCurrentCityIndex((prev) => (prev + 1) % cities.length); name: 'Melbourne',
}, 4000); description: 'Cultural capital with world-class attractions',
return () => clearInterval(interval); image: melbourneImage,
}, []); attractions: 45,
savings: '30%',
path: '/melbourne'
},
{
id: 'sydney',
name: 'Sydney',
description: 'Iconic landmarks and harbor views',
image: sydneyImage,
attractions: 38,
savings: '25%',
path: '/sydney'
},
{
id: 'brisbane',
name: 'Brisbane',
description: 'Sunshine, riverside dining, and adventure',
image: brisbaneImage,
attractions: 32,
savings: '28%',
path: '/brisbane'
}
];
const scrollToCities = () => { // Auto-rotate cities
document.getElementById('cities-section')?.scrollIntoView({ useEffect(() => {
behavior: 'smooth' const interval = setInterval(() => {
}); setCurrentCityIndex((prev) => (prev + 1) % cities.length);
}; }, 4000);
return () => clearInterval(interval);
}, []);
return ( const scrollToCities = () => {
<div className="min-h-screen bg-white"> document.getElementById('cities-section')?.scrollIntoView({
{/* Navbar */} behavior: 'smooth'
<Layout });
activeCity={activeCity} };
onSignInClick={onSignInClick}
onSignOutClick={onSignOutClick}
user={user} // ✅ Pass the updated user data
>
{/* City Submenu */} return (
{/* <CitySubmenu <div className="min-h-screen bg-white">
{/* Navbar */}
<Layout
activeCity={activeCity}
onSignInClick={onSignInClick}
onSignOutClick={onSignOutClick}
user={user} // ✅ Pass the updated user data
>
{/* City Submenu */}
{/* <CitySubmenu
onClose={() => { }} onClose={() => { }}
/> */} /> */}
{/* Hero Section */} {/* Hero Section */}
<div <div
className="relative z-10 min-h-[90vh] flex items-end justify-start pt-24 pb-16 bg-cover bg-[center_35%] bg-no-repeat" className="relative z-10 min-h-[90vh] flex items-end justify-start pt-24 pb-16 bg-cover bg-[center_35%] bg-no-repeat"
style={{ backgroundImage: `url(${heroBannerImage})` }} style={{ backgroundImage: `url(${heroBannerImage})` }}
> >
@@ -162,34 +167,34 @@ export function LandingPage({ onSignInClick,
</div> </div>
</div> </div>
{/* Features Section */} {/* Features Section */}
<LandingWhyChooseCityCards /> <LandingWhyChooseCityCards />
{/* LandingVarietyOfAdventures Section */} {/* LandingVarietyOfAdventures Section */}
<LandingVarietyOfAdventures /> <LandingVarietyOfAdventures />
{/* MagicItinerary Section */} {/* MagicItinerary Section */}
<LandingMagicItinerary /> <LandingMagicItinerary />
{/* BookAttractionSection Section */} {/* BookAttractionSection Section */}
<LandingBookAttractionSection /> <LandingBookAttractionSection />
{/* CustomPostcards Section */} {/* CustomPostcards Section */}
<CustomPostcards/> <CustomPostcards />
{/* UpcomingCities Section */} {/* UpcomingCities Section */}
<LandingUpcomingCities /> <LandingUpcomingCities />
{/* TrustSection Section */} {/* TrustSection Section */}
<LandingTrustSection /> <LandingTrustSection />
{/* MobileAppSection Section */} {/* MobileAppSection Section */}
<LandingMobileAppSection /> <LandingMobileAppSection />
{/* Newsletter Section */} {/* Newsletter Section */}
<LandingNewsletterSection /> <LandingNewsletterSection />
</Layout> </Layout>
</div> </div>
); );
} }

8
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
interface ImportMetaEnv {
readonly VITE_BASE_URL: string
readonly VITE_GOOGLE_MAP: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -84,7 +84,7 @@ import * as path from 'path';
outDir: 'build', outDir: 'build',
}, },
server: { server: {
port: 4007, port: 4008,
open: true, open: true,
}, },
}); });