main #31

Merged
Rajendra.Reddy merged 113 commits from main into uat-beta 2026-04-24 14:43:26 +00:00
24 changed files with 1139 additions and 823 deletions
Showing only changes of commit d51ed87b5f - Show all commits

112
package-lock.json generated
View File

@@ -34,6 +34,7 @@
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/postcss": "^4.1.13",
"@tailwindcss/vite": "^4.1.14",
"class-variance-authority": "^0.7.1",
@@ -49,6 +50,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.55.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "^7.9.4",
"recharts": "^2.15.2",
@@ -1917,6 +1919,32 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"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": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2197,6 +2225,18 @@
"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": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz",
@@ -3033,6 +3073,7 @@
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3043,6 +3084,7 @@
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -3053,10 +3095,17 @@
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@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": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz",
@@ -3311,7 +3360,8 @@
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
@@ -3477,6 +3527,16 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"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": {
"version": "1.4.2",
"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",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3942,6 +4003,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -3968,6 +4030,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -3998,6 +4061,30 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"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": {
"version": "2.7.1",
"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"
}
},
"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": {
"version": "4.50.1",
"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",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

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

View File

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

View File

@@ -117,7 +117,7 @@ export function AppRouter({
<Route path="/attractions/:attractionId" element={
<motion.div key="attraction-details" {...pageTransition}>
<AttractionDetailsPage
attractionId={attractionId || ''}
// attractionId={attractionId || ''}
{...commonNavHandlers}
onBackClick={() => navigate(-1)}
onCheckoutClick={() => navigate('/checkout')}
@@ -274,12 +274,6 @@ export function AppRouter({
} />
</Routes>
</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 { ImageWithFallback } from './figma/ImageWithFallback';
import { Layout } from '../Layout';
import { useParams } from 'react-router-dom';
import { useGetAttractionDetailsByIdQuery } from '../Redux/services/attractions.service';
interface AttractionDetailsPageProps {
onBackClick: () => void;
@@ -13,7 +15,7 @@ interface AttractionDetailsPageProps {
onSignInClick: () => void;
onSignOutClick?: () => void;
user?: { email: string; name: string } | null;
attractionId: string;
// attractionId: string;
}
export function AttractionDetailsPage({
@@ -23,74 +25,33 @@ export function AttractionDetailsPage({
onSignOutClick,
user,
}: AttractionDetailsPageProps) {
const [date, setDate] = useState<Date | undefined>(new Date());
// Featured attraction for the main display
const featuredAttraction = {
id: 'phi-phi',
name: 'Phi Phi Islands Adventure Day Trip with Seaview Lunch by V. Marine Tour',
badges: ['Bestseller', 'Free cancellation', 'Reservation Required'],
images: {
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'
]
};
const { attractionId } = useParams()
const { data: attraction, isLoading } = useGetAttractionDetailsByIdQuery(Number(attractionId));
if (isLoading) {
return <div>loading...</div>
}
return (
<Layout
activeCity=""
onSignInClick={onSignInClick}
onSignOutClick={onSignOutClick}
user={user}
showCitySubmenu={false}
>
activeCity=""
onSignInClick={onSignInClick}
onSignOutClick={onSignOutClick}
user={user}
// showCitySubmenu={false}
>
<div className="container mx-auto px-4 pt-40 pb-16 max-w-6xl">
{/* Back Button */}
<motion.div
<motion.div
className="mb-8"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
>
<Button
variant="ghost"
<Button
variant="ghost"
onClick={onBackClick}
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 */}
<div className="mb-8">
<div className="flex flex-wrap gap-3 mb-6">
{featuredAttraction.badges.map((badge, index) => (
<Badge
key={index}
{attraction.attractionBadges.map((badge: any, index: number) => (
<Badge
key={badge.badgeXid}
variant={index === 0 ? "default" : "secondary"}
className={`px-6 py-2 rounded-full text-sm transition-all duration-200 ${
index === 0
? 'bg-primary text-white shadow-lg'
: 'bg-primary/10 text-primary border border-primary/20'
}`}
className={`px-6 py-2 rounded-full text-sm transition-all duration-200 ${index === 0
? 'bg-primary text-white shadow-lg'
: 'bg-primary/10 text-primary border border-primary/20'
}`}
>
{badge}
{badge.badge.badgeName}
</Badge>
))}
</div>
<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">
Phi Phi Islands Adventure
{attraction.title}
</span>{' '}
<span className="text-[#2d3134]">
Day Trip with Seaview Lunch by V. Marine Tour
Day Trip by {attraction.partner.businessName}
</span>
</h1>
</div>
@@ -132,18 +92,18 @@ export function AttractionDetailsPage({
{/* Main large image */}
<div className="col-span-2 row-span-2">
<ImageWithFallback
src={featuredAttraction.images.main}
src={attraction.attractionGalleries[0].filePathUrl}
alt="Main attraction image"
className="w-full h-full object-cover rounded-lg"
/>
</div>
{/* Gallery images */}
{featuredAttraction.images.gallery.slice(0, 4).map((image, index) => (
<div key={index} className="col-span-1 row-span-1">
{attraction.attractionGalleries.slice().map((image:any) => (
<div key={image.id} className="col-span-1 row-span-1">
<ImageWithFallback
src={image}
alt={`Gallery image ${index + 1}`}
src={image.filePathUrl}
alt={`Gallery image ${image.id}`}
className="w-full h-full object-cover rounded-lg"
/>
</div>
@@ -156,20 +116,43 @@ export function AttractionDetailsPage({
<div className="lg:col-span-2 space-y-12">
{/* Overview Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{Object.entries(featuredAttraction.overview).map(([key, value]) => (
<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">
<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" />}
{key === 'groupSize' && <Users className="w-6 h-6 text-primary" />}
{key === 'ages' && <Users className="w-6 h-6 text-primary" />}
{key === 'languages' && <MapPin className="w-6 h-6 text-primary" />}
</div>
<h3 className="font-normal text-primary capitalize mb-1">
{key === 'groupSize' ? 'Group Size' : key}
</h3>
<p className="text-sm text-[#717171] font-light">{value}</p>
</Card>
))}
{/* Duration */}
<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">
<Clock className="w-6 h-6 text-primary" />
</div>
<h3 className="font-normal text-primary capitalize mb-1">Duration</h3>
<p className="text-sm text-[#717171] font-light">{attraction.durations} mins</p>
</Card>
{/* Group Size */}
<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">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>
{/* Tour Overview */}
@@ -181,7 +164,7 @@ export function AttractionDetailsPage({
</h2>
</div>
<p className="text-[#2d3134] leading-relaxed text-lg font-light">
{featuredAttraction.description}
{attraction.description}
</p>
</div>
@@ -194,12 +177,12 @@ export function AttractionDetailsPage({
</h3>
</div>
<ul className="space-y-4">
{featuredAttraction.highlights.map((highlight, index) => (
<li key={index} className="flex items-start gap-3 group">
{attraction.attractionHighlights.map((highlight: any) => (
<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-2 h-2 bg-primary rounded-full"></div>
</div>
<span className="text-[#2d3134] leading-relaxed font-light">{highlight}</span>
<span className="text-[#2d3134] leading-relaxed font-light">{highlight.title}</span>
</li>
))}
</ul>
@@ -220,30 +203,32 @@ export function AttractionDetailsPage({
<Check className="w-5 h-5" />
Included
</h4>
{featuredAttraction.included.map((item, index) => (
<div key={index} className="flex items-start gap-3 group">
<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" />
{attraction.attractionInclusions.filter((inclusion: any) => inclusion.isInclusion === true)
.map((inclusion: any) => (
<div key={inclusion.id} className="flex items-start gap-3 group">
<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>
<span className="text-[#2d3134] font-light">{item}</span>
</div>
))}
))}
</div>
{/* Not Included */}
<div className="space-y-4">
<h4 className="font-medium text-gray-600 mb-4 flex items-center gap-2">
<X className="w-5 h-5" />
Not Included
</h4>
{featuredAttraction.notIncluded.map((item, index) => (
<div key={index} className="flex items-start gap-3 group">
<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" />
{attraction.attractionInclusions.filter((inclusion: any) => inclusion.isInclusion === false)
.map((inclusion: any) => (
<div key={inclusion.id} className="flex items-start gap-3 group">
<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>
<span className="text-[#2d3134] font-light">{item}</span>
</div>
))}
))}
</div>
</div>
</div>
@@ -262,7 +247,8 @@ export function AttractionDetailsPage({
<MapPin className="w-8 h-8 text-primary" />
</div>
<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>
@@ -276,7 +262,7 @@ export function AttractionDetailsPage({
<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>
</div>
{/* Custom Calendar Design */}
<div className="space-y-4">
{/* Calendar Header */}
@@ -305,7 +291,7 @@ export function AttractionDetailsPage({
<div className="grid grid-cols-7 gap-1">
{/* Previous month */}
<button className="h-10 w-10 text-sm text-gray-300 hover:bg-gray-50 rounded">31</button>
{/* Current month */}
{Array.from({ length: 30 }, (_, i) => {
const day = i + 1;
@@ -314,13 +300,12 @@ export function AttractionDetailsPage({
return (
<button
key={day}
className={`h-10 w-10 text-sm rounded font-medium transition-all duration-200 ${
isSelected
? 'bg-primary text-white shadow-lg scale-105'
: isToday
className={`h-10 w-10 text-sm rounded font-medium transition-all duration-200 ${isSelected
? 'bg-primary text-white shadow-lg scale-105'
: isToday
? 'bg-primary/10 text-primary border border-primary/20'
: 'text-gray-700 hover:bg-primary/5 hover:text-primary'
}`}
}`}
>
{day}
</button>
@@ -356,7 +341,7 @@ export function AttractionDetailsPage({
<div className="space-y-4">
<div className="flex items-center justify-between">
<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 className="flex items-center justify-between">
<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="flex items-center justify-between">
<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>
</Card>
{/* 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"
onClick={() => onCheckoutClick()}
>
@@ -398,7 +383,7 @@ export function AttractionDetailsPage({
</div>
</div>
</Layout>
);
}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { motion } from 'motion/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 { Input } from './ui/input';
import { Card, CardContent } from './ui/card';
@@ -9,12 +9,11 @@ import { Badge } from './ui/badge';
import { Checkbox } from './ui/checkbox';
import { ImageWithFallback } from './figma/ImageWithFallback';
import { Layout } from '../Layout';
import { useGetAttractionFiltersQuery, useGetCustomerAttractionsQuery } from '../Redux/services/attractions.service';
interface User {
email: string;
name: string;
}
interface Attraction {
id: string;
name: string;
@@ -30,191 +29,187 @@ interface Attraction {
passType: string;
}
const attractions: Attraction[] = [
{
id: '1',
name: 'Centipede Tour - Guided Arizona Desert Tour by ATV',
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',
location: 'Paris, France',
duration: '4 days',
rating: 4.8,
price: 189.25,
category: 'adventure',
hasReservation: true,
reviewCount: 243,
passType: 'unlimited'
},
{
id: '2',
name: 'Molokini and Turtle Town Snorkeling Adventure Aboard',
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',
location: 'New York, USA',
duration: '4 days',
rating: 4.8,
price: 225,
category: 'adventure',
hasReservation: false,
reviewCount: 167,
passType: 'selective'
},
{
id: '3',
name: 'Westminster Walking Tour & Westminster Abbey Entry',
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',
location: 'London, UK',
duration: '4 days',
rating: 4.8,
price: 343,
category: 'culture',
hasReservation: true,
reviewCount: 343,
passType: 'unlimited'
},
{
id: '4',
name: 'All Inclusive Ultimate Circle Island Day Tour with Lunch',
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',
location: 'New York, USA',
duration: '4 days',
rating: 4.8,
price: 225,
category: 'adventure',
hasReservation: false,
reviewCount: 243,
passType: 'unlimited'
},
{
id: '5',
name: 'Space Center Houston Admission Ticket',
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',
location: 'Paris, France',
duration: '4 days',
rating: 4.8,
price: 225,
category: 'family',
hasReservation: true,
reviewCount: 243,
passType: 'selective'
},
{
id: '6',
name: 'Melbourne Skydeck Observatory',
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',
location: 'Melbourne CBD',
duration: '2 hours',
rating: 4.5,
price: 25,
category: 'adventure',
hasReservation: true,
reviewCount: 892,
passType: 'selective'
},
{
id: '7',
name: 'Royal Botanic Gardens Melbourne',
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',
location: 'South Yarra',
duration: '3 hours',
rating: 4.7,
price: 0,
category: 'nature',
hasReservation: false,
reviewCount: 1245,
passType: 'selective'
},
{
id: '8',
name: 'Federation Square Cultural Precinct',
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',
location: 'Melbourne CBD',
duration: '3 hours',
rating: 4.3,
price: 0,
category: 'culture',
hasReservation: true,
reviewCount: 672,
passType: 'unlimited'
},
{
id: '9',
name: 'St Kilda Pier & Little Penguins',
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',
location: 'St Kilda',
duration: '2 hours',
rating: 4.4,
price: 0,
category: 'nature',
hasReservation: false,
reviewCount: 543,
passType: 'unlimited'
},
{
id: '10',
name: 'Queen Victoria Market Experience',
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',
location: 'Melbourne CBD',
duration: '2 hours',
rating: 4.6,
price: 0,
category: 'culture',
hasReservation: true,
reviewCount: 987,
passType: 'selective'
},
{
id: '11',
name: 'Melbourne Zoo Adventure',
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',
location: 'Parkville',
duration: '4 hours',
rating: 4.5,
price: 40,
category: 'family',
hasReservation: false,
reviewCount: 1156,
passType: 'selective'
},
{
id: '12',
name: 'Great Ocean Road Day Tour',
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',
location: 'Great Ocean Road',
duration: '12 hours',
rating: 4.9,
price: 85,
category: 'adventure',
hasReservation: true,
reviewCount: 678,
passType: 'unlimited'
}
];
const filterCategories = [
{ value: 'with-reservation', label: 'With Reservation', count: 3 },
{ value: 'without-reservation', label: 'Without Reservation', count: 3 },
{ value: 'beach', label: 'Beach', count: 3 },
{ value: 'adventure', label: 'Adventure', 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 }
];
// {
// id: '1',
// name: 'Centipede Tour - Guided Arizona Desert Tour by ATV',
// 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',
// location: 'Paris, France',
// duration: '4 days',
// rating: 4.8,
// price: 189.25,
// category: 'adventure',
// hasReservation: true,
// reviewCount: 243,
// passType: 'unlimited'
// },
// {
// id: '2',
// name: 'Molokini and Turtle Town Snorkeling Adventure Aboard',
// 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',
// location: 'New York, USA',
// duration: '4 days',
// rating: 4.8,
// price: 225,
// category: 'adventure',
// hasReservation: false,
// reviewCount: 167,
// passType: 'selective'
// },
// {
// id: '3',
// name: 'Westminster Walking Tour & Westminster Abbey Entry',
// 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',
// location: 'London, UK',
// duration: '4 days',
// rating: 4.8,
// price: 343,
// category: 'culture',
// hasReservation: true,
// reviewCount: 343,
// passType: 'unlimited'
// },
// {
// id: '4',
// name: 'All Inclusive Ultimate Circle Island Day Tour with Lunch',
// 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',
// location: 'New York, USA',
// duration: '4 days',
// rating: 4.8,
// price: 225,
// category: 'adventure',
// hasReservation: false,
// reviewCount: 243,
// passType: 'unlimited'
// },
// {
// id: '5',
// name: 'Space Center Houston Admission Ticket',
// 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',
// location: 'Paris, France',
// duration: '4 days',
// rating: 4.8,
// price: 225,
// category: 'family',
// hasReservation: true,
// reviewCount: 243,
// passType: 'selective'
// },
// {
// id: '6',
// name: 'Melbourne Skydeck Observatory',
// 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',
// location: 'Melbourne CBD',
// duration: '2 hours',
// rating: 4.5,
// price: 25,
// category: 'adventure',
// hasReservation: true,
// reviewCount: 892,
// passType: 'selective'
// },
// {
// id: '7',
// name: 'Royal Botanic Gardens Melbourne',
// 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',
// location: 'South Yarra',
// duration: '3 hours',
// rating: 4.7,
// price: 0,
// category: 'nature',
// hasReservation: false,
// reviewCount: 1245,
// passType: 'selective'
// },
// {
// id: '8',
// name: 'Federation Square Cultural Precinct',
// 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',
// location: 'Melbourne CBD',
// duration: '3 hours',
// rating: 4.3,
// price: 0,
// category: 'culture',
// hasReservation: true,
// reviewCount: 672,
// passType: 'unlimited'
// },
// {
// id: '9',
// name: 'St Kilda Pier & Little Penguins',
// 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',
// location: 'St Kilda',
// duration: '2 hours',
// rating: 4.4,
// price: 0,
// category: 'nature',
// hasReservation: false,
// reviewCount: 543,
// passType: 'unlimited'
// },
// {
// id: '10',
// name: 'Queen Victoria Market Experience',
// 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',
// location: 'Melbourne CBD',
// duration: '2 hours',
// rating: 4.6,
// price: 0,
// category: 'culture',
// hasReservation: true,
// reviewCount: 987,
// passType: 'selective'
// },
// {
// id: '11',
// name: 'Melbourne Zoo Adventure',
// 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',
// location: 'Parkville',
// duration: '4 hours',
// rating: 4.5,
// price: 40,
// category: 'family',
// hasReservation: false,
// reviewCount: 1156,
// passType: 'selective'
// },
// {
// id: '12',
// name: 'Great Ocean Road Day Tour',
// 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',
// location: 'Great Ocean Road',
// duration: '12 hours',
// rating: 4.9,
// price: 85,
// category: 'adventure',
// hasReservation: true,
// reviewCount: 678,
// passType: 'unlimited'
// }
// ];
// const filterCategories = [
// { value: 'with-reservation', label: 'With Reservation', count: 3 },
// { value: 'without-reservation', label: 'Without Reservation', count: 3 },
// { value: 'beach', label: 'Beach', count: 3 },
// { value: 'adventure', label: 'Adventure', 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 }
// ];
interface AttractionsPageProps {
onSignInClick: () => void;
onSignOutClick?: () => void;
@@ -226,55 +221,73 @@ export function AttractionsPage({
onSignOutClick,
user
}: AttractionsPageProps) {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedPassTypes, setSelectedPassTypes] = useState<string[]>([]);
const filteredAttractions = attractions.filter(attraction => {
const matchesSearch = attraction.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
attraction.description.toLowerCase().includes(searchQuery.toLowerCase());
const [search, setSearch] = useState("");
const [isBookingRequired, setIsBookingRequired] = useState<boolean | undefined>(undefined)
const [selectedCategory, setSelectedCategory] = useState<number | null>(null);
const [selectedPassType, setSelectedPassType] = useState<string | null>(null);
const matchesCategory = selectedCategories.length === 0 ||
selectedCategories.some(cat => {
if (cat === 'with-reservation') return attraction.hasReservation;
if (cat === 'without-reservation') return !attraction.hasReservation;
return attraction.category === cat;
});
const matchesPassType = selectedPassTypes.length === 0 ||
selectedPassTypes.includes(attraction.passType);
return matchesSearch && matchesCategory && matchesPassType;
const cityId = 1
const { data: filterData, isLoading } = useGetAttractionFiltersQuery(cityId)
const { data: attractions } = useGetCustomerAttractionsQuery({
cityId, // required
categoryId: selectedCategory, // optional
isBookingRequired, // optional
cardType: selectedPassType, // optional
search, // optional
});
const toggleCategory = (category: string) => {
setSelectedCategories(prev =>
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]
);
};
if (isLoading) {
return <div>Loading...</div>
}
const handleAttractionClick = (attractionId: string) => {
navigate(`/attractions/${attractionId}`);
};
const handleCheckoutClick = () => {
navigate('/checkout');
};
const showingFrom = 1;
const showingTo = Math.min(12, filteredAttractions.length);
const totalItems = filteredAttractions.length;
const showingTo = Math.min(12, attractions?.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 (
<Layout
@@ -296,7 +309,6 @@ export function AttractionsPage({
Skip the lines and explore Melbourne's most iconic destinations with your CityCard pass
</p>
</div>
{/* City Card Promotional Banner */}
<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">
@@ -304,20 +316,18 @@ export function AttractionsPage({
<h2 className="font-merchant text-2xl leading-tight font-bold text-white">
Find Your Perfect Adventure
</h2>
{/* Search Bar and Button Container */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
{/* Search Bar */}
<div className="relative flex-1 max-w-lg">
<Input
placeholder="Search An Attraction"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={search}
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"
/>
<Search className="absolute right-4 top-1/2 transform -translate-y-1/2 text-gray-500 w-5 h-5" />
</div>
{/* Call-to-Action 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"
@@ -327,13 +337,11 @@ export function AttractionsPage({
</Button>
</div>
</div>
{/* 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 bottom-0 left-0 w-24 h-24 bg-white/5 rounded-full translate-y-12 -translate-x-12"></div>
</Card>
</div>
<div className="flex gap-8">
{/* Left Sidebar */}
<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>
<h3 className="font-merchant text-[22px] text-[#2d2d2d] font-normal">Search by</h3>
</div>
{/* Filter categories */}
<div className="space-y-4">
{filterCategories.map(category => (
<div key={category.value} className="flex items-center gap-3">
{filterData && filterData.categories.map((category: any) => (
<div key={category.id} className="flex items-center gap-3">
<Checkbox
id={category.value}
checked={selectedCategories.includes(category.value)}
onCheckedChange={() => toggleCategory(category.value)}
id={String(category.id)}
checked={
category.id === 50
? isBookingRequired === true
: category.id === 51
? isBookingRequired === false
: selectedCategory === category.id
}
onCheckedChange={(checked: boolean) =>
handleCategorySelection(category.id, checked)
}
className="border-[#bebebe]"
/>
<label
htmlFor={category.value}
htmlFor={String(category.id)}
className="font-poppins text-sm text-[#414141] cursor-pointer flex-1 font-normal"
>
{category.label} ({category.count})
{category.categoryName} ({category.count})
</label>
</div>
))}
@@ -367,51 +382,49 @@ export function AttractionsPage({
{/* Divider */}
<div className="border-t border-[#e5e5e5]"></div>
{/* Pass Type header */}
<div className="flex items-center gap-4">
<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>
</div>
{/* Pass Type filters */}
<div className="space-y-4">
{passTypeCategories.map(passType => (
<div key={passType.value} className="flex items-center gap-3">
{filterData && Object.entries(filterData.passType).map(([key, count]) => (
<div key={key} className="flex items-center gap-3">
<Checkbox
id={passType.value}
checked={selectedPassTypes.includes(passType.value)}
onCheckedChange={() => togglePassType(passType.value)}
id={key}
checked={selectedPassType === key}
onCheckedChange={(checked: boolean) =>
handlePassTypeSelection(key, checked as boolean)
}
className="border-[#bebebe]"
/>
<label
htmlFor={passType.value}
htmlFor={key}
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>
</div>
))}
</div>
</div>
</Card>
</div>
{/* Main Content */}
<div className="flex-1">
{/* Header */}
<div className="mb-8">
<h1 className="text-[48px] font-medium text-[#2d2d2d] mb-6">Attractions in Melbourne</h1>
{/* Results count */}
<p className="text-[16px] text-[#414141] mb-2">
Showing {showingFrom}-{showingTo} of {totalItems} Item(s)
</p>
</div>
{/* Attractions Grid */}
<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
key={attraction.id}
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">
FREE
</Badge>
) : attraction.passType === 'unlimited' ? (
<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>
) : (
) : attraction.cards[0].cardType.cardTypeDisplayName === "Flexi card" ? (
<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
</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>
<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}
</div>
</div> */}
<h3 className="font-semibold text-foreground mb-3 line-clamp-2 leading-tight min-h-[2.5rem] font-poppins">
{attraction.name}
{attraction.title}
</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 */}
<div className="mt-auto pt-2 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<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 className="text-right">
<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">
${attraction.price}
${attraction.ticketPriceAdult}
</div>
</div>
</div>
{/* 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="flex items-center justify-between gap-2">
<div className="flex-1">
<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 className="text-xs font-poppins font-normal text-gray-600 mt-0.5">
Save ${attraction.price}
Save ${attraction.cards[0].adultPrice}
</p>
</div>
<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"
onClick={(e) => {
onClick={(e:React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleCheckoutClick();
}}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,14 +4,16 @@ import { X } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
interface LoginModalProps {
isOpen: boolean;
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 [email, setEmail] = useState('');
const [otp, setOtp] = useState(['', '', '', '', '', '']);
@@ -19,6 +21,8 @@ export function LoginModal({ isOpen, onClose, onLoginSuccess }: LoginModalProps)
const [isLoading, setIsLoading] = useState(false);
const [helperText, setHelperText] = useState('');
const { login } = useAuth(); // from AuthContext
// Reset modal state when closed
useEffect(() => {
if (!isOpen) {
@@ -46,7 +50,7 @@ export function LoginModal({ isOpen, onClose, onLoginSuccess }: LoginModalProps)
setIsLoading(true);
setHelperText('');
// Simulate API call
setTimeout(() => {
setStep('otp');
@@ -58,7 +62,7 @@ export function LoginModal({ isOpen, onClose, onLoginSuccess }: LoginModalProps)
const handleOTPChange = (index: number, value: string) => {
if (value.length > 1) return; // Only allow single digit
const newOtp = [...otp];
newOtp[index] = value;
setOtp(newOtp);
@@ -92,12 +96,11 @@ export function LoginModal({ isOpen, onClose, onLoginSuccess }: LoginModalProps)
// Generate name from email for demo
const emailParts = email.split('@')[0];
const name = emailParts.charAt(0).toUpperCase() + emailParts.slice(1);
onLoginSuccess({
email,
name: name.length > 8 ? name.substring(0, 8) : name
});
login({ email, name })
setIsLoading(false);
// navigate("/melbourne")
onClose();
}, 1500);
};
@@ -139,7 +142,7 @@ export function LoginModal({ isOpen, onClose, onLoginSuccess }: LoginModalProps)
>
<X className="w-4 h-4 text-gray-600" />
</button>
<h2 className="font-merchant text-2xl font-semibold text-gray-900 mb-2">
Login
</h2>
@@ -231,7 +234,7 @@ export function LoginModal({ isOpen, onClose, onLoginSuccess }: LoginModalProps)
/>
))}
</div>
{/* Countdown */}
{countdown > 0 && (
<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 { MelbourneFAQ } from './MelbourneFAQ';
import { Footer } from './Footer';
import { MinimalHeroBanner } from './MinimalHeroBanner';
// import { MinimalHeroBanner } from './MinimalHeroBanner';
import { Layout } from '../Layout';
import { HeroBannerCarousel } from './HeroBannerCarousel';
import { HotelEsimOffers } from './HotelEsimOffers';
@@ -254,12 +254,12 @@ export function MelbournePage({
{/* Attractions Section */}
<div id="attractions" className="scroll-mt-32">
<MelbourneAttractions onAttractionClick={() => { }} />
<MelbourneAttractions />
</div>
{/* Pass Comparison */}
<div id="passes" className="scroll-mt-32">
<MelbourneCardComparison onSelectPass={() => { }} />
<MelbourneCardComparison />
</div>
{/* Tour Overview */}
@@ -277,7 +277,7 @@ export function MelbournePage({
{/* Blogs */}
<div id="blogs" className="scroll-mt-32">
<MelbourneBlogs onBlogClick={() => { }} />
<MelbourneBlogs />
</div>
{/* Custom Postcards */}

View File

@@ -9,6 +9,8 @@ import { CTAButton } from './CTAButton';
import logoImage from '../assets/cit-logo.png';
import melbourneLogo from '../assets/melbourne-logo.png';
import { CitySelectionDialog } from './CitySelectionDialog';
import { useAuth } from '../context/AuthContext';
import { LoginModal } from './LoginModal';
interface NavbarProps {
activeCity: string;
@@ -63,7 +65,7 @@ export default function Navbar({
onSignInClick,
onSignOutClick,
isUserSignedIn = false,
user
// user
}: NavbarProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
@@ -85,6 +87,22 @@ export default function Navbar({
const navigate = useNavigate();
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
@@ -271,7 +289,7 @@ export default function Navbar({
console.log('City selected from navbar:', cityId);
onCityChange(cityId);
if (cityId.toLowerCase() === 'melbourne') {
if (cityId.toLowerCase() === '1') {
setNavigationSource('melbourne');
navigate('/melbourne');
} else {
@@ -454,7 +472,7 @@ export default function Navbar({
onClick={(e) => e.stopPropagation()}
>
{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>
</div>
)}
@@ -642,7 +660,7 @@ export default function Navbar({
{/* Enhanced City Card Button with Source Tracking */}
<div className="flex items-center gap-3 pl-2">
<div className="relative">
{isUserSignedIn && user ? (
{user ? (
<Dropdown
ref={userRef}
isOpen={activeUserDropdown}
@@ -671,9 +689,7 @@ export default function Navbar({
label: 'Sign Out',
icon: <LogOut className="w-4 h-4" />,
action: () => {
if (onSignOutClick) {
onSignOutClick();
}
logout()
setActiveUserDropdown(false);
}
}
@@ -698,10 +714,10 @@ export default function Navbar({
) : (
<div
className="cursor-pointer"
onClick={handleOpenCityDialogFromCTA}
onClick={handleOpenLoginModal}
>
<CTAButton
user={null}
user={user}
onClick={() => { }}
className="hover:scale-105 transition-transform duration-200"
/>
@@ -887,6 +903,13 @@ export default function Navbar({
onClose={handleCloseCityDialog}
onCitySelect={handleCitySelect}
/>
<LoginModal
isOpen={isLoginOpen}
onClose={() => {
setLoginOpen(false);
}}
/>
</>
);
}

View File

@@ -11,6 +11,7 @@ import { ReviewsSection } from './ReviewsSection';
import { Layout } from '../Layout';
import { LoginModal } from './LoginModal';
import { ImageWithFallback } from './figma/ImageWithFallback';
import { useAuth } from '../context/AuthContext';
interface PassesPageProps {
onCheckoutClick?: () => void;
@@ -149,16 +150,18 @@ export function PassesPage({
onCheckoutClick,
onSignInClick,
onSignOutClick,
user,
// user,
onLoginSuccess
}: PassesPageProps) {
const [selectedPass, setSelectedPass] = useState<string>('unlimited');
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
const handlePurchaseClick = () => {
if (!userData) {
if (!user) {
// User not logged in - show login modal
setIsLoginOpen(true);
} else {
@@ -169,7 +172,7 @@ export function PassesPage({
// ✅ Handle successful login
const handleLoginSuccess = (data: { email: string; name: string }) => {
setUserData(data);
// setUserData(data);
setIsLoginOpen(false);
console.log('Logged in user:', data);
@@ -192,10 +195,10 @@ export function PassesPage({
return (
<Layout
activeCity="shared"
activeCity={sessionStorage.getItem("lastKnownCity")||"shared"}
onSignInClick={onSignInClick}
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">
{/* Page Header */}
@@ -759,12 +762,6 @@ export function PassesPage({
</div>
</div>
<LoginModal
isOpen={isLoginOpen}
onClose={() => setIsLoginOpen(false)}
onLoginSuccess={handleLoginSuccess}
/>
</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 App from "./App";
import "./index.css";
import { Provider } from "react-redux";
import { store } from "./Redux/Store";
createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<App />
</BrowserRouter>
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
);

View File

@@ -19,95 +19,100 @@ import { LandingNewsletterSection } from '../components/LandingNewsletterSection
import { CustomPostcards } from '../components/CustomPostcards';
import { Layout } from '../Layout';
import { getAutoNavigationSource } from '../utils/getAutoNavigationSource';
import { useGetProductsQuery } from '../Redux/services/fakeApi.service';
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 =
"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 =
"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 {
email: string;
name: string;
email: string;
name: string;
}
interface LandingPageProps {
onSignInClick: () => void;
onSignOutClick?: () => void;
user?: User | null;
onSignInClick: () => void;
onSignOutClick?: () => void;
user?: User | null;
}
export function LandingPage({ onSignInClick,
onSignOutClick,
user }: LandingPageProps) {
const [currentCityIndex, setCurrentCityIndex] = useState(0);
const location = useLocation();
const activeCity = getAutoNavigationSource(location);
onSignOutClick,
user }: LandingPageProps) {
const [currentCityIndex, setCurrentCityIndex] = useState(0);
const [isCityDialogOpen, setIsCityDialogOpen] = useState(Boolean)
const location = useLocation();
const activeCity = getAutoNavigationSource(location);
const cities = [
{
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'
}
];
// const { data } = useGetProductsQuery()
// console.log(data)
// Auto-rotate cities
useEffect(() => {
const interval = setInterval(() => {
setCurrentCityIndex((prev) => (prev + 1) % cities.length);
}, 4000);
return () => clearInterval(interval);
}, []);
const cities = [
{
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'
}
];
const scrollToCities = () => {
document.getElementById('cities-section')?.scrollIntoView({
behavior: 'smooth'
});
};
// Auto-rotate cities
useEffect(() => {
const interval = setInterval(() => {
setCurrentCityIndex((prev) => (prev + 1) % cities.length);
}, 4000);
return () => clearInterval(interval);
}, []);
return (
<div className="min-h-screen bg-white">
{/* Navbar */}
<Layout
activeCity={activeCity}
onSignInClick={onSignInClick}
onSignOutClick={onSignOutClick}
user={user} // ✅ Pass the updated user data
>
const scrollToCities = () => {
document.getElementById('cities-section')?.scrollIntoView({
behavior: 'smooth'
});
};
{/* City Submenu */}
{/* <CitySubmenu
return (
<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={() => { }}
/> */}
{/* Hero Section */}
<div
{/* Hero Section */}
<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"
style={{ backgroundImage: `url(${heroBannerImage})` }}
>
@@ -162,34 +167,34 @@ export function LandingPage({ onSignInClick,
</div>
</div>
{/* Features Section */}
<LandingWhyChooseCityCards />
{/* Features Section */}
<LandingWhyChooseCityCards />
{/* LandingVarietyOfAdventures Section */}
<LandingVarietyOfAdventures />
{/* LandingVarietyOfAdventures Section */}
<LandingVarietyOfAdventures />
{/* MagicItinerary Section */}
<LandingMagicItinerary />
{/* MagicItinerary Section */}
<LandingMagicItinerary />
{/* BookAttractionSection Section */}
<LandingBookAttractionSection />
{/* BookAttractionSection Section */}
<LandingBookAttractionSection />
{/* CustomPostcards Section */}
<CustomPostcards/>
{/* CustomPostcards Section */}
<CustomPostcards />
{/* UpcomingCities Section */}
<LandingUpcomingCities />
{/* UpcomingCities Section */}
<LandingUpcomingCities />
{/* TrustSection Section */}
<LandingTrustSection />
{/* TrustSection Section */}
<LandingTrustSection />
{/* MobileAppSection Section */}
<LandingMobileAppSection />
{/* MobileAppSection Section */}
<LandingMobileAppSection />
{/* Newsletter Section */}
<LandingNewsletterSection />
{/* Newsletter Section */}
<LandingNewsletterSection />
</Layout>
</div>
);
</Layout>
</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',
},
server: {
port: 4007,
port: 4008,
open: true,
},
});