arya-branch #29
@@ -2,6 +2,7 @@ import { Routes, Route, useParams, useLocation, useNavigate } from 'react-router
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
// Import all your pages
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { MelbournePage } from './pages/MelbournePage';
|
||||
import { PassesPage } from './pages/PassesPage';
|
||||
import { AttractionsPage } from './pages/AttractionsPage';
|
||||
@@ -178,31 +179,43 @@ export function AppRouter({
|
||||
{/* User Routes */}
|
||||
<Route path="/profile" element={
|
||||
<motion.div key="profile" {...pageTransition}>
|
||||
<ProfilePage {...commonNavHandlers} />
|
||||
<ProtectedRoute>
|
||||
<ProfilePage {...commonNavHandlers} />
|
||||
</ProtectedRoute>
|
||||
</motion.div>
|
||||
} />
|
||||
<Route path="/view-card-details/:cardId" element={
|
||||
<motion.div key="profile" {...pageTransition}>
|
||||
<ViewCardDetailsPage {...commonNavHandlers} />
|
||||
<ProtectedRoute>
|
||||
<ViewCardDetailsPage {...commonNavHandlers} />
|
||||
</ProtectedRoute>
|
||||
</motion.div>
|
||||
} />
|
||||
|
||||
|
||||
|
||||
{/* Itinerary Routes */}
|
||||
<Route path="/create-itinerary" element={
|
||||
<motion.div key="create-itinerary" {...pageTransition}>
|
||||
<CreateMagicItineraryPage {...commonNavHandlers} />
|
||||
<ProtectedRoute>
|
||||
|
||||
<CreateMagicItineraryPage {...commonNavHandlers} />
|
||||
</ProtectedRoute>
|
||||
</motion.div>
|
||||
} />
|
||||
|
||||
<Route path="/view-itinerary/:itineraryId" element={
|
||||
<motion.div key="itinerary-view" {...pageTransition}>
|
||||
<ItineraryViewPage {...commonNavHandlers} />
|
||||
<ProtectedRoute>
|
||||
<ItineraryViewPage {...commonNavHandlers} />
|
||||
</ProtectedRoute>
|
||||
</motion.div>
|
||||
} />
|
||||
<Route path="/itinerary-summary/:itineraryId" element={
|
||||
<motion.div key="itinerary-summary" {...pageTransition}>
|
||||
<ItinerarySummaryPage {...commonNavHandlers} />
|
||||
<ProtectedRoute>
|
||||
|
||||
<ItinerarySummaryPage {...commonNavHandlers} />
|
||||
</ProtectedRoute>
|
||||
</motion.div>
|
||||
} />
|
||||
|
||||
@@ -276,12 +289,17 @@ export function AppRouter({
|
||||
|
||||
<Route path="/cart" element={
|
||||
<motion.div key="super-savings" {...pageTransition}>
|
||||
<CartPage {...commonNavHandlers} />
|
||||
<ProtectedRoute>
|
||||
|
||||
<CartPage {...commonNavHandlers} />
|
||||
</ProtectedRoute>
|
||||
</motion.div>
|
||||
} />
|
||||
<Route path="/checkout" element={
|
||||
<motion.div key="super-savings" {...pageTransition}>
|
||||
<CheckoutPage {...commonNavHandlers} />
|
||||
<ProtectedRoute>
|
||||
<CheckoutPage {...commonNavHandlers} />
|
||||
</ProtectedRoute>
|
||||
</motion.div>
|
||||
} />
|
||||
<Route path="/register" element={
|
||||
@@ -289,11 +307,13 @@ export function AppRouter({
|
||||
<RegisterPage {...commonNavHandlers} />
|
||||
</motion.div>
|
||||
} />
|
||||
|
||||
|
||||
<Route path="/payment/:bookingId" element={
|
||||
<motion.div key="super-savings" {...pageTransition}>
|
||||
<PaymentDetailsPage {...commonNavHandlers} />
|
||||
</motion.div>
|
||||
<ProtectedRoute>
|
||||
<PaymentDetailsPage {...commonNavHandlers} />
|
||||
</ProtectedRoute>
|
||||
</motion.div>
|
||||
} />
|
||||
<Route path="/super-savings/:id" element={
|
||||
<motion.div key="super-savings" {...pageTransition}>
|
||||
@@ -305,26 +325,32 @@ export function AppRouter({
|
||||
|
||||
<Route path="/success" element={
|
||||
<motion.div key="super-savings" {...pageTransition}>
|
||||
<PaymentSuccessPage
|
||||
// onHomeClick={onHomeClick}
|
||||
// onPassesClick={onPassesClick}
|
||||
onSignInClick={onSignInClick}
|
||||
onSignOutClick={onSignOutClick}
|
||||
currentPage="success"
|
||||
user={user}
|
||||
/>
|
||||
<ProtectedRoute>
|
||||
|
||||
<PaymentSuccessPage
|
||||
// onHomeClick={onHomeClick}
|
||||
// onPassesClick={onPassesClick}
|
||||
onSignInClick={onSignInClick}
|
||||
onSignOutClick={onSignOutClick}
|
||||
currentPage="success"
|
||||
user={user}
|
||||
/>
|
||||
</ProtectedRoute>
|
||||
</motion.div>
|
||||
} />
|
||||
<Route path="/cancel" element={
|
||||
<motion.div key="super-savings" {...pageTransition}>
|
||||
<PaymentCancelPage
|
||||
// onHomeClick={onHomeClick}
|
||||
// onPassesClick={onPassesClick}
|
||||
onSignInClick={onSignInClick}
|
||||
onSignOutClick={onSignOutClick}
|
||||
currentPage="cancel"
|
||||
user={user}
|
||||
/>
|
||||
<ProtectedRoute>
|
||||
|
||||
<PaymentCancelPage
|
||||
// onHomeClick={onHomeClick}
|
||||
// onPassesClick={onPassesClick}
|
||||
onSignInClick={onSignInClick}
|
||||
onSignOutClick={onSignOutClick}
|
||||
currentPage="cancel"
|
||||
user={user}
|
||||
/>
|
||||
</ProtectedRoute>
|
||||
</motion.div>
|
||||
} />
|
||||
</Routes>
|
||||
|
||||
@@ -30,6 +30,20 @@ export const attractionsApi = createApi({
|
||||
return `/attractions/customer/customer-attractions?${params.toString()}`;
|
||||
},
|
||||
}),
|
||||
getAttractionsForHomePage: builder.query({
|
||||
// cityId is required, others optional
|
||||
query: ({ cityId, categoryId}) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// required
|
||||
params.append('cityXid', cityId);
|
||||
|
||||
// optional
|
||||
if (categoryId) params.append('categoryXid', categoryId);
|
||||
|
||||
return `/attractions/list/city-attractions?${params.toString()}`;
|
||||
},
|
||||
}),
|
||||
|
||||
getAttractionDetailsById: builder.query({
|
||||
query: (id: number) => `/attractions/customer/${id}`,
|
||||
@@ -38,4 +52,4 @@ export const attractionsApi = createApi({
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetAttractionFiltersQuery,useGetCustomerAttractionsQuery,useGetAttractionDetailsByIdQuery } = attractionsApi;
|
||||
export const { useGetAttractionFiltersQuery,useGetCustomerAttractionsQuery,useGetAttractionDetailsByIdQuery,useGetAttractionsForHomePageQuery } = attractionsApi;
|
||||
@@ -1,11 +1,8 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||
import { createApi } 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) => ({
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// LoginModal.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
@@ -3,271 +3,167 @@ import { ChevronLeft, ChevronRight, Clock, Users, Star, Zap, CheckCircle, MapPin
|
||||
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||
import { motion } from 'motion/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const melbourneAttractions = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Royal Botanic Gardens",
|
||||
city: "Melbourne",
|
||||
country: "Australia",
|
||||
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",
|
||||
rating: 4.8,
|
||||
reviews: "15,600+",
|
||||
category: "Gardens",
|
||||
originalPrice: "Free",
|
||||
includedValue: "$25",
|
||||
perks: [
|
||||
{ icon: Volume2, label: "Audio garden tour", color: "text-green-600" },
|
||||
{ icon: MapPin, label: "Garden maps", color: "text-blue-600" },
|
||||
{ icon: Camera, label: "Photo spots guide", color: "text-purple-600" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Federation Square",
|
||||
city: "Melbourne",
|
||||
country: "Australia",
|
||||
image: "https://images.unsplash.com/photo-1639655001512-e4b58d4874b8?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBmZWRlcmF0aW9uJTIwc3F1YXJlfGVufDF8fHx8MTc1NzMzNzc5Mnww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral",
|
||||
rating: 4.6,
|
||||
reviews: "22,400+",
|
||||
category: "Landmarks",
|
||||
originalPrice: "Free",
|
||||
includedValue: "$35",
|
||||
perks: [
|
||||
{ icon: Volume2, label: "Cultural tours", color: "text-orange-600" },
|
||||
{ icon: Eye, label: "Gallery access", color: "text-blue-600" },
|
||||
{ icon: Users, label: "Event access", color: "text-purple-600" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Queen Victoria Market",
|
||||
city: "Melbourne",
|
||||
country: "Australia",
|
||||
image: "https://images.unsplash.com/photo-1676454953709-e0be46f62490?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBxdWVlbiUyMHZpY3RvcmlhJTIwbWFya2V0fGVufDF8fHx8MTc1NzMzNzc5Nnww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral",
|
||||
rating: 4.7,
|
||||
reviews: "18,200+",
|
||||
category: "Markets",
|
||||
originalPrice: "$45",
|
||||
includedValue: "$45",
|
||||
perks: [
|
||||
{ icon: Users, label: "Food tours", color: "text-orange-600" },
|
||||
{ icon: Coffee, label: "Tastings", color: "text-brown-600" },
|
||||
{ icon: Volume2, label: "History guide", color: "text-blue-600" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Eureka Skydeck",
|
||||
city: "Melbourne",
|
||||
country: "Australia",
|
||||
image: "https://images.unsplash.com/photo-1629677713183-29248e1268d7?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBldXJla2ElMjB0b3dlcnxlbnwxfHx8fDE3NTczMzc4MDB8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral",
|
||||
rating: 4.9,
|
||||
reviews: "11,800+",
|
||||
category: "Views",
|
||||
originalPrice: "$32",
|
||||
includedValue: "$32",
|
||||
perks: [
|
||||
{ icon: Zap, label: "Skip-the-line", color: "text-green-600" },
|
||||
{ icon: Eye, label: "360° views", color: "text-purple-600" },
|
||||
{ icon: Camera, label: "Photo experiences", color: "text-blue-600" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "St Kilda Beach & Pier",
|
||||
city: "Melbourne",
|
||||
country: "Australia",
|
||||
image: "https://images.unsplash.com/photo-1674732954456-159835c0a46b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBzdCUyMGtpbGRhJTIwYmVhY2h8ZW58MXx8fHwxNzU3MzM3ODAzfDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral",
|
||||
rating: 4.5,
|
||||
reviews: "14,300+",
|
||||
category: "Beach",
|
||||
originalPrice: "Free",
|
||||
includedValue: "$20",
|
||||
perks: [
|
||||
{ icon: Users, label: "Penguin tours", color: "text-blue-600" },
|
||||
{ icon: MapPin, label: "Beach activities", color: "text-green-600" },
|
||||
{ icon: Camera, label: "Sunset spots", color: "text-purple-600" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Melbourne Laneways",
|
||||
city: "Melbourne",
|
||||
country: "Australia",
|
||||
image: "https://images.unsplash.com/photo-1705120624704-0970afc29fea?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjBsYW5ld2F5cyUyMHN0cmVldCUyMGFydHxlbnwxfHx8fDE3NTczMzc4MDd8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral",
|
||||
rating: 4.8,
|
||||
reviews: "19,500+",
|
||||
category: "Street Art",
|
||||
originalPrice: "$55",
|
||||
includedValue: "$55",
|
||||
perks: [
|
||||
{ icon: Palette, label: "Art tours", color: "text-pink-600" },
|
||||
{ icon: Coffee, label: "Café stops", color: "text-brown-600" },
|
||||
{ icon: Camera, label: "Photo walks", color: "text-purple-600" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "Melbourne Zoo",
|
||||
city: "Melbourne",
|
||||
country: "Australia",
|
||||
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",
|
||||
rating: 4.7,
|
||||
reviews: "13,900+",
|
||||
category: "Wildlife",
|
||||
originalPrice: "$42",
|
||||
includedValue: "$42",
|
||||
perks: [
|
||||
{ icon: Zap, label: "Skip-the-line", color: "text-green-600" },
|
||||
{ icon: Users, label: "Animal encounters", color: "text-orange-600" },
|
||||
{ icon: Volume2, label: "Keeper talks", color: "text-blue-600" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "Royal Exhibition Building",
|
||||
city: "Melbourne",
|
||||
country: "Australia",
|
||||
image: "https://images.unsplash.com/photo-1720523794299-c3b445d71a51?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtZWxib3VybmUlMjByb3lhbCUyMGV4aGliaXRpb24lMjBidWlsZGluZ3xlbnwxfHx8fDE3NTczMzc4MTR8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral",
|
||||
rating: 4.6,
|
||||
reviews: "8,700+",
|
||||
category: "Heritage",
|
||||
originalPrice: "$25",
|
||||
includedValue: "$25",
|
||||
perks: [
|
||||
{ icon: Volume2, label: "Audio tours", color: "text-blue-600" },
|
||||
{ icon: Eye, label: "Exhibitions", color: "text-purple-600" },
|
||||
{ icon: MapPin, label: "Heritage walks", color: "text-green-600" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const categories = ["All", "Landmarks", "Gardens", "Markets", "Views", "Beach", "Street Art", "Wildlife", "Heritage"];
|
||||
import { useGetAttractionsForHomePageQuery } from '../Redux/services/attractions.service';
|
||||
|
||||
export function MelbourneAttractions() {
|
||||
const [activeCategory, setActiveCategory] = useState("All");
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const cityName = localStorage.getItem("cityName")
|
||||
const cityName = localStorage.getItem("cityName");
|
||||
const cityId = localStorage.getItem("cityId");
|
||||
|
||||
const filteredAttractions = activeCategory === "All"
|
||||
? melbourneAttractions
|
||||
: melbourneAttractions.filter(attraction => attraction.category === activeCategory);
|
||||
const { data: homePageAttractionsData } = useGetAttractionsForHomePageQuery({ cityId });
|
||||
|
||||
const AttractionCard = ({ attraction, index }: { attraction: typeof melbourneAttractions[0], index: number }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
className="group cursor-pointer flex-shrink-0 w-[280px] md:w-auto md:flex-shrink h-96 flip-card-container"
|
||||
>
|
||||
{/* 3D Flip Container */}
|
||||
<div className="flip-card-inner group-hover:[transform:rotateY(180deg)] relative w-full h-full">
|
||||
|
||||
{/* FRONT FACE */}
|
||||
<div className="flip-card-face absolute inset-0 w-full h-full rounded-2xl overflow-hidden shadow-lg">
|
||||
{/* Background Image */}
|
||||
<ImageWithFallback
|
||||
src={attraction.image}
|
||||
alt={attraction.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
const apiAttractions = homePageAttractionsData?.attractions || [];
|
||||
const apiCategories = homePageAttractionsData?.categories || [];
|
||||
|
||||
{/* Rating Badge */}
|
||||
{/* <div className="absolute top-4 right-4 bg-white/95 backdrop-blur-sm rounded-full px-3 py-1.5 flex items-center gap-1 shadow-lg z-10">
|
||||
<div className="w-4 h-4 bg-gradient-to-r from-yellow-400 to-yellow-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-xs">★</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900">{attraction.rating}</span>
|
||||
</div> */}
|
||||
// Filter attractions by selected category
|
||||
const filteredAttractions = selectedCategoryId === null
|
||||
? apiAttractions
|
||||
: apiAttractions.filter((attraction: any) =>
|
||||
attraction.categories?.some((cat: any) => cat.id === selectedCategoryId)
|
||||
);
|
||||
|
||||
{/* Front Content - Clean Title & Location */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<div className="bg-gradient-to-t from-black/80 via-black/50 to-transparent p-6">
|
||||
<h3 className="font-bold text-xl text-white mb-1">{attraction.name}</h3>
|
||||
<p className="text-white/90 text-sm">
|
||||
{attraction.city}, {attraction.country}
|
||||
</p>
|
||||
const AttractionCard = ({ attraction, index }: { attraction: any; index: number }) => {
|
||||
// Get cover image or first image from galleries
|
||||
const coverImage = attraction.galleries?.find((g: any) => g.isCoverImage)?.filePathUrl
|
||||
|| attraction.galleries?.[0]?.filePathUrl
|
||||
|| '';
|
||||
|
||||
// Filter only inclusions (isInclusion: true)
|
||||
const inclusions = attraction.inclusions?.filter((inc: any) => inc.isInclusion) || [];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
className="group cursor-pointer flex-shrink-0 w-[280px] md:w-auto md:flex-shrink h-96 flip-card-container"
|
||||
>
|
||||
<div className="flip-card-inner group-hover:[transform:rotateY(180deg)] relative w-full h-full">
|
||||
|
||||
{/* FRONT FACE */}
|
||||
<div className="flip-card-face absolute inset-0 w-full h-full rounded-2xl overflow-hidden shadow-lg">
|
||||
<ImageWithFallback
|
||||
src={coverImage}
|
||||
alt={attraction.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<div className="bg-gradient-to-t from-black/80 via-black/50 to-transparent p-6">
|
||||
<h3 className="font-bold text-xl text-white mb-1">{attraction.title}</h3>
|
||||
<p className="text-white/90 text-sm">{attraction.city?.cityName}, Australia</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BACK FACE */}
|
||||
<div className="flip-card-face flip-card-back absolute inset-0 w-full h-full rounded-2xl overflow-hidden shadow-lg bg-gradient-to-br from-gray-900 to-black">
|
||||
{/* Back Content Container */}
|
||||
<div className="relative w-full h-full p-6 flex flex-col justify-center text-white">
|
||||
|
||||
{/* Included Value Section */}
|
||||
<div className="mb-4">
|
||||
<div className="inline-flex items-center gap-2 bg-gradient-to-r from-green-500 to-emerald-600 text-white px-3 py-1.5 rounded-full text-sm font-medium mb-3">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>Included Value</span>
|
||||
{/* BACK FACE */}
|
||||
<div className="flip-card-face flip-card-back absolute inset-0 w-full h-full rounded-2xl overflow-hidden shadow-lg bg-gradient-to-br from-gray-900 to-black">
|
||||
<div className="relative w-full h-full p-6 flex flex-col justify-center text-white">
|
||||
|
||||
{/* Pricing Section */}
|
||||
<div className="mb-4">
|
||||
<div className="inline-flex items-center gap-2 bg-gradient-to-r from-green-500 to-emerald-600 text-white px-3 py-1.5 rounded-full text-sm font-medium mb-3">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>Included Value</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold mb-1">
|
||||
${attraction.ticketPriceAdult}
|
||||
{attraction.ticketPriceChild && (
|
||||
<span className="text-sm font-normal text-white/70 ml-2">
|
||||
/ Child ${attraction.ticketPriceChild}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-white/80 text-sm">
|
||||
{attraction.isBookingRequired ? 'Booking required' : 'No booking required'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-2xl font-bold mb-1">{attraction.includedValue}</div>
|
||||
<p className="text-white/80 text-sm">
|
||||
{attraction.originalPrice === "Free"
|
||||
? "Premium access included"
|
||||
: "Save money with CityCard"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* What's Included List */}
|
||||
<div className="mb-4">
|
||||
<h4 className="font-semibold text-sm mb-3">What's Included:</h4>
|
||||
<div className="space-y-2">
|
||||
{attraction.perks.slice(0, 3).map((perk, perkIndex) => (
|
||||
<div key={perkIndex} className="flex items-center gap-3 text-white/90">
|
||||
<div className="w-6 h-6 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center">
|
||||
<perk.icon className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span className="text-sm">{perk.label}</span>
|
||||
{/* Inclusions List */}
|
||||
{inclusions.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="font-semibold text-sm mb-3">What's Included:</h4>
|
||||
<div className="space-y-2">
|
||||
{inclusions.slice(0, 3).map((inc: any) => (
|
||||
<div key={inc.id} className="flex items-center gap-3 text-white/90">
|
||||
<div className="w-6 h-6 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center flex-shrink-0">
|
||||
<CheckCircle className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span className="text-sm">{inc.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration & Meta Info */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-4 text-white/80 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>2-3 hours</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>All ages</span>
|
||||
)}
|
||||
|
||||
{/* Duration & Group Info */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-4 text-white/80 text-sm">
|
||||
{attraction.durations && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{attraction.durations} mins</span>
|
||||
</div>
|
||||
)}
|
||||
{attraction.groupSize && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>Max {attraction.groupSize}</span>
|
||||
</div>
|
||||
)}
|
||||
{attraction.ageRange && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-4 h-4" />
|
||||
<span>{attraction.ageRange}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Features */}
|
||||
<div className="border-t border-white/20 pt-4">
|
||||
<div className="flex items-center justify-between text-white/80 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span>Mobile ticket</span>
|
||||
{/* Categories */}
|
||||
{attraction.categories?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-4">
|
||||
{attraction.categories.slice(0, 2).map((cat: any) => (
|
||||
<span
|
||||
key={cat.id}
|
||||
className="text-xs bg-white/20 text-white/90 px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{cat.categoryName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
<span>Instant confirmation</span>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-white/20 pt-4">
|
||||
<div className="flex items-center justify-between text-white/80 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span>Mobile ticket</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
<span>Instant confirmation</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Elements */}
|
||||
<div className="absolute top-4 right-4 w-16 h-16 bg-gradient-to-br from-primary/20 to-secondary/20 rounded-full blur-xl"></div>
|
||||
<div className="absolute bottom-4 left-4 w-12 h-12 bg-gradient-to-tr from-secondary/15 to-primary/15 rounded-full blur-lg"></div>
|
||||
{/* Decorative Elements */}
|
||||
<div className="absolute top-4 right-4 w-16 h-16 bg-gradient-to-br from-primary/20 to-secondary/20 rounded-full blur-xl"></div>
|
||||
<div className="absolute bottom-4 left-4 w-12 h-12 bg-gradient-to-tr from-secondary/15 to-primary/15 rounded-full blur-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-20 bg-gradient-to-br from-gray-50 to-white relative overflow-hidden">
|
||||
<div className="container mx-auto px-4">
|
||||
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
@@ -291,7 +187,7 @@ export function MelbourneAttractions() {
|
||||
<span className="font-semibold text-emphasis">Experiences</span>
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Discover {cityName}'s iconic landmarks, vibrant culture, world-class dining, and hidden gems - all included with your {cityName} CityCard
|
||||
Discover {cityName}'s iconic landmarks, vibrant culture, world-class dining, and hidden gems — all included with your {cityName} CityCard
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -303,23 +199,41 @@ export function MelbourneAttractions() {
|
||||
viewport={{ once: true }}
|
||||
className="flex flex-wrap justify-center gap-3 mb-12"
|
||||
>
|
||||
{categories.map((category, index) => (
|
||||
{/* "All" button */}
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
onClick={() => setSelectedCategoryId(null)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={`px-6 py-4 h-14 rounded-2xl font-medium transition-all duration-300 ${
|
||||
selectedCategoryId === null
|
||||
? 'bg-gradient-to-r from-primary to-secondary text-white shadow-xl shadow-primary/25 ring-2 ring-primary/20'
|
||||
: 'bg-white/80 backdrop-blur-sm text-gray-700 hover:text-gray-900 hover:shadow-lg border border-gray-200/50 hover:border-primary/20 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</motion.button>
|
||||
|
||||
{/* Dynamic category buttons from API */}
|
||||
{apiCategories.map((category: any, index: number) => (
|
||||
<motion.button
|
||||
key={category}
|
||||
key={category.id}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
viewport={{ once: true }}
|
||||
onClick={() => setActiveCategory(category)}
|
||||
onClick={() => setSelectedCategoryId(category.id)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={`px-6 py-4 h-14 rounded-2xl font-medium transition-all duration-300 ${
|
||||
activeCategory === category
|
||||
selectedCategoryId === category.id
|
||||
? 'bg-gradient-to-r from-primary to-secondary text-white shadow-xl shadow-primary/25 ring-2 ring-primary/20'
|
||||
: 'bg-white/80 backdrop-blur-sm text-gray-700 hover:text-gray-900 hover:shadow-lg border border-gray-200/50 hover:border-primary/20 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
{category.categoryName}
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
@@ -327,52 +241,44 @@ export function MelbourneAttractions() {
|
||||
{/* Mobile Horizontal Carousel */}
|
||||
<div className="block md:hidden mb-8">
|
||||
<div className="relative">
|
||||
{/* Scroll Container */}
|
||||
<div className="flex gap-6 overflow-x-auto scrollbar-hide pb-4 px-4 -mx-4">
|
||||
{filteredAttractions.map((attraction, index) => (
|
||||
{filteredAttractions.map((attraction: any, index: number) => (
|
||||
<AttractionCard key={attraction.id} attraction={attraction} index={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Scroll Indicators */}
|
||||
<div className="flex justify-center mt-6 gap-2">
|
||||
{Array.from({ length: Math.ceil(filteredAttractions.length / 2) }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-2 h-2 rounded-full bg-gray-300"
|
||||
/>
|
||||
{Array.from({ length: Math.ceil(filteredAttractions.length / 2) }).map((_: any, index: number) => (
|
||||
<div key={index} className="w-2 h-2 rounded-full bg-gray-300" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile Hint Text */}
|
||||
<div className="text-center mt-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Swipe to explore more Melbourne attractions
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Swipe to explore more {cityName} attractions</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Bento Grid */}
|
||||
<div className="hidden md:block w-full">
|
||||
{/* Top Row - 3 equal cards */}
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{filteredAttractions.slice(0, 3).map((attraction, index) => (
|
||||
{filteredAttractions.slice(0, 3).map((attraction: any, index: number) => (
|
||||
<AttractionCard key={attraction.id} attraction={attraction} index={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Consistent Vertical Spacing */}
|
||||
<div className="h-6"></div>
|
||||
|
||||
{/* Bottom Row - 2 larger cards */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{filteredAttractions.slice(3, 5).map((attraction, index) => (
|
||||
{filteredAttractions.slice(3, 5).map((attraction: any, index: number) => (
|
||||
<AttractionCard key={attraction.id} attraction={attraction} index={index + 3} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredAttractions.length === 0 && (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
<p className="text-lg">No attractions found for this category.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Call to Action */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
@@ -385,16 +291,15 @@ export function MelbourneAttractions() {
|
||||
whileHover={{ scale: 1.05, boxShadow: "0 20px 40px rgba(99,102,241,0.3)" }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="relative bg-gradient-to-r from-primary to-secondary text-white py-4 px-12 rounded-lg text-lg shadow-xl transition-all duration-300 overflow-hidden group"
|
||||
onClick={()=>navigate('/passes')}
|
||||
onClick={() => navigate('/passes')}
|
||||
>
|
||||
<span className="relative z-10">Get Your {cityName} Card</span>
|
||||
|
||||
{/* Shine animation */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="h-full bg-gradient-to-r from-transparent via-white to-transparent animate-shine"></div>
|
||||
</div>
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Button } from './ui/button';
|
||||
import { motion } from 'motion/react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
// const cardOptions = [
|
||||
// {
|
||||
// id: 'selective',
|
||||
// name: 'Flexi Card',
|
||||
|
||||
29
src/components/ProtectedRoute.tsx
Normal file
29
src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
// ProtectedRoute.tsx
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { LoginModal } from './LoginModal';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [isLoginOpen, setIsLoginOpen] = useState(!user);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<LoginModal
|
||||
isOpen={isLoginOpen}
|
||||
onClose={() => {
|
||||
setIsLoginOpen(false);
|
||||
navigate(-1);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
//RegisterPage.tsx
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
@@ -8,9 +9,10 @@ import Navbar from './Navbar';
|
||||
import { Footer } from './Footer';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Label } from './ui/label';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { login } = useAuth();
|
||||
const { login, user } = useAuth();
|
||||
const email = localStorage.getItem("userEmail")
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
@@ -31,6 +33,13 @@ export default function RegisterPage() {
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const pendingEmail = localStorage.getItem("userEmail");
|
||||
if (user || !pendingEmail) {
|
||||
navigate("/");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const [register, { isLoading: isRegistering }] = useRegisterMutation();
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// AuthContext.tsx
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user