Files
CityCards-Website/src/components/CityAttractionsPage.tsx
priyanshuvish 97969c079b new src added
2025-10-09 19:03:24 +05:30

747 lines
30 KiB
TypeScript

import { useState, useEffect } from 'react';
import { motion } from 'motion/react';
import { Search, MapPin, Clock, Star, DollarSign, Filter, X, ArrowLeft } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Card, CardContent } from './ui/card';
import { Badge } from './ui/badge';
import { Checkbox } from './ui/checkbox';
import { Slider } from './ui/slider';
import Navbar from './Navbar';
import { EnhancedTestimonials } from './EnhancedTestimonials';
import { Footer } from './Footer';
import { ImageWithFallback } from './figma/ImageWithFallback';
interface Attraction {
id: string;
name: string;
description: string;
image: string;
location: string;
duration: string;
rating: number;
price: number;
category: string;
passType: 'unlimited' | 'selective' | 'both';
isPopular?: boolean;
}
interface CityData {
id: string;
name: string;
country: string;
hero_image: string;
currency: string;
description: string;
attractions: Attraction[];
}
const cityAttractions: Record<string, CityData> = {
paris: {
id: 'paris',
name: 'Paris',
country: 'France',
hero_image: 'https://images.unsplash.com/photo-1652254693457-5f6d7db674c6?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxwYXJpcyUyMHRvdXJpc20lMjBhdHRyYWN0aW9uc3xlbnwxfHx8fDE3NTc2NjQwMjB8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
currency: 'EUR',
description: 'City of Light with world-famous landmarks, art museums, and romantic atmosphere',
attractions: [
{
id: 'eiffel-tower',
name: 'Eiffel Tower',
description: 'Iconic iron tower offering breathtaking views of Paris from its observation decks',
image: 'https://images.unsplash.com/photo-1431274172761-fca41d930114?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxwYXJpcyUyMGVpZmZlbCUyMHRvd2VyfGVufDF8fHx8MTc1NzYxNjQ2OXww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Champ de Mars',
duration: '2-3 hours',
rating: 4.5,
price: 29,
category: 'culture',
passType: 'unlimited',
isPopular: true
},
{
id: 'louvre-museum',
name: 'Louvre Museum',
description: 'World\'s largest art museum housing the Mona Lisa and countless masterpieces',
image: 'https://images.unsplash.com/photo-1566139536744-6270a2e5a3a3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxsb3V2cmUlMjBtdXNldW18ZW58MXx8fHwxNzU3NjY0MDY3fDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Palais Royal',
duration: '3-4 hours',
rating: 4.7,
price: 17,
category: 'culture',
passType: 'unlimited',
isPopular: true
},
{
id: 'notre-dame',
name: 'Notre-Dame Cathedral',
description: 'Gothic masterpiece and historic cathedral in the heart of Paris',
image: 'https://images.unsplash.com/photo-1539650116574-75c0c6d0d0e3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxub3RyZSUyMGRhbWUlMjBwYXJpc3xlbnwxfHx8fDE3NTc2NjQwNzF8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Île de la Cité',
duration: '1-2 hours',
rating: 4.6,
price: 0,
category: 'culture',
passType: 'unlimited'
},
{
id: 'arc-de-triomphe',
name: 'Arc de Triomphe',
description: 'Monumental arch honoring those who fought for France, with panoramic city views',
image: 'https://images.unsplash.com/photo-1598975106642-dc3a8c79c854?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxhcmMlMjBkZSUyMHRyaW9tcGhlfGVufDF8fHx8MTc1NzY2NDA3NXww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Champs-Élysées',
duration: '1-2 hours',
rating: 4.4,
price: 13,
category: 'culture',
passType: 'both'
},
{
id: 'seine-cruise',
name: 'Seine River Cruise',
description: 'Scenic boat tour along the Seine passing famous Parisian landmarks',
image: 'https://images.unsplash.com/photo-1502602898536-47ad22581b52?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzZWluZSUyMHJpdmVyJTIwY3J1aXNlfGVufDF8fHx8MTc1NzY2NDA3OXww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Various Docks',
duration: '1 hour',
rating: 4.3,
price: 15,
category: 'adventure',
passType: 'both'
},
{
id: 'montmartre',
name: 'Montmartre & Sacré-Cœur',
description: 'Bohemian hilltop district with the stunning Sacré-Cœur Basilica',
image: 'https://images.unsplash.com/photo-1471939743851-c4df8b6ee c95?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtb250bWFydHJlJTIwc2FjcmUlMjBjb2V1cnxlbnwxfHx8fDE3NTc2NjQwODN8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Montmartre',
duration: '2-4 hours',
rating: 4.5,
price: 0,
category: 'culture',
passType: 'unlimited'
}
]
},
tokyo: {
id: 'tokyo',
name: 'Tokyo',
country: 'Japan',
hero_image: 'https://images.unsplash.com/photo-1713263367828-9eafd7fc3797?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHx0b2t5byUyMGF0dHJhY3Rpb25zJTIwY2l0eXNjYXBlfGVufDF8fHx8MTc1NzY2NDAyNHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
currency: 'JPY',
description: 'Vibrant metropolis blending ancient traditions with cutting-edge technology',
attractions: [
{
id: 'tokyo-tower',
name: 'Tokyo Tower',
description: 'Iconic red tower offering spectacular city views and broadcasting facilities',
image: 'https://images.unsplash.com/photo-1598976838083-ad00095bb123?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHx0b2t5byUyMHRvd2VyfGVufDF8fHx8MTc1NzY2NDEwOHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Minato',
duration: '2-3 hours',
rating: 4.4,
price: 25,
category: 'adventure',
passType: 'unlimited',
isPopular: true
},
{
id: 'sensoji-temple',
name: 'Sensoji Temple',
description: 'Ancient Buddhist temple and Tokyo\'s oldest temple with traditional atmosphere',
image: 'https://images.unsplash.com/photo-1503899036084-c55cdd92da26?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzZW5zb2ppJTIwdGVtcGxlfGVufDF8fHx8MTc1NzY2NDExMnww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Asakusa',
duration: '1-2 hours',
rating: 4.6,
price: 0,
category: 'culture',
passType: 'unlimited'
},
{
id: 'shibuya-crossing',
name: 'Shibuya Crossing',
description: 'World\'s busiest pedestrian crossing and symbol of modern Tokyo',
image: 'https://images.unsplash.com/photo-1542051841857-5f90071e7989?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzaGlidXlhJTIwY3Jvc3Npbmd8ZW58MXx8fHwxNzU3NjY0MTE2fDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Shibuya',
duration: '1 hour',
rating: 4.3,
price: 0,
category: 'adventure',
passType: 'unlimited',
isPopular: true
},
{
id: 'tokyo-skytree',
name: 'Tokyo Skytree',
description: 'World\'s second tallest structure with observation decks and stunning views',
image: 'https://images.unsplash.com/photo-1576538509036-c47f3604da84?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHx0b2t5byUyMHNreXRyZWV8ZW58MXx8fHwxNzU3NjY0MTIwfDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Sumida',
duration: '2-3 hours',
rating: 4.5,
price: 32,
category: 'adventure',
passType: 'selective',
isPopular: true
}
]
},
london: {
id: 'london',
name: 'London',
country: 'United Kingdom',
hero_image: 'https://images.unsplash.com/photo-1645544865499-8b768cf70562?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxsb25kb24lMjBsYW5kbWFya3MlMjB0b3VyaXNtfGVufDF8fHx8MTc1NzY2NDAyOHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
currency: 'GBP',
description: 'Historic capital with royal palaces, world-class museums, and modern culture',
attractions: [
{
id: 'big-ben',
name: 'Big Ben & Parliament',
description: 'Iconic clock tower and seat of British government with guided tours',
image: 'https://images.unsplash.com/photo-1486299267070-83823f5448dd?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxsb25kb24lMjBiaWclMjBiZW58ZW58MXx8fHwxNzU3NjQ3OTYxfDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Westminster',
duration: '2 hours',
rating: 4.7,
price: 28,
category: 'culture',
passType: 'unlimited',
isPopular: true
},
{
id: 'british-museum',
name: 'British Museum',
description: 'World-renowned museum with artifacts from across human history',
image: 'https://images.unsplash.com/photo-1603838354146-f5ffc0b45cd9?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxicml0aXNoJTIwbXVzZXVtfGVufDF8fHx8MTc1NzY2NDE0NHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Bloomsbury',
duration: '3-4 hours',
rating: 4.6,
price: 0,
category: 'culture',
passType: 'unlimited'
},
{
id: 'tower-bridge',
name: 'Tower Bridge',
description: 'Victorian bascule bridge with glass floor and panoramic walkways',
image: 'https://images.unsplash.com/photo-1513635269975-59663e0ac1ad?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHx0b3dlciUyMGJyaWRnZSUyMGxvbmRvbnxlbnwxfHx8fDE3NTc2NjQxNDh8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Tower Hamlets',
duration: '1-2 hours',
rating: 4.4,
price: 12,
category: 'culture',
passType: 'both',
isPopular: true
},
{
id: 'buckingham-palace',
name: 'Buckingham Palace',
description: 'Official residence of the British monarch with state rooms tours',
image: 'https://images.unsplash.com/photo-1529655683826-ac6bbde65424?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxidWNraW5naGFtJTIwcGFsYWNlfGVufDF8fHx8MTc1NzY2NDE1Mnww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral',
location: 'Westminster',
duration: '2-3 hours',
rating: 4.3,
price: 30,
category: 'culture',
passType: 'selective'
}
]
}
};
interface CityAttractionsPageProps {
cityId: string;
onBackClick: () => void;
onHomeClick: () => void;
onMelbourneClick: () => void;
onPassesClick: () => void;
onCitiesClick: () => void;
onSignInClick: () => void;
onAttractionsClick: () => void;
onBlogsClick: () => void;
onHowItWorksClick: () => void;
onFAQClick: () => void;
onPrivacyPolicyClick: () => void;
onAboutUsClick: () => void;
onAttractionClick?: (attractionId: string) => void;
currentPage: string;
}
export function CityAttractionsPage({
cityId,
onBackClick,
onHomeClick,
onMelbourneClick,
onPassesClick,
onCitiesClick,
onSignInClick,
onAttractionsClick,
onBlogsClick,
onHowItWorksClick,
onFAQClick,
onPrivacyPolicyClick,
onAboutUsClick,
onAttractionClick,
currentPage
}: CityAttractionsPageProps) {
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedPassTypes, setSelectedPassTypes] = useState<string[]>([]);
const [priceRange, setPriceRange] = useState([0, 50]);
const [showMobileFilters, setShowMobileFilters] = useState(false);
const [pageNumber, setPageNumber] = useState(1);
const [itemsPerPage] = useState(12);
// Get city data
const cityData = cityAttractions[cityId];
if (!cityData) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">City Not Found</h1>
<p className="text-gray-600 mb-4">Sorry, this city is not available yet.</p>
<Button onClick={onBackClick}>Go Back</Button>
</div>
</div>
);
}
const categories = [
{ value: 'adventure', label: 'Adventure', count: cityData.attractions.filter(a => a.category === 'adventure').length },
{ value: 'culture', label: 'Culture', count: cityData.attractions.filter(a => a.category === 'culture').length },
{ value: 'family', label: 'Family Friendly', count: cityData.attractions.filter(a => a.category === 'family').length }
].filter(cat => cat.count > 0);
const passTypes = [
{ value: 'unlimited', label: 'Unlimited', count: cityData.attractions.filter(a => a.passType === 'unlimited').length },
{ value: 'selective', label: 'Selective', count: cityData.attractions.filter(a => a.passType === 'selective').length },
{ value: 'both', label: 'Both', count: cityData.attractions.filter(a => a.passType === 'both').length }
].filter(type => type.count > 0);
const filteredAttractions = cityData.attractions.filter(attraction => {
const matchesSearch = attraction.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
attraction.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(attraction.category);
const matchesPassType = selectedPassTypes.length === 0 || selectedPassTypes.includes(attraction.passType);
const matchesPrice = attraction.price >= priceRange[0] && attraction.price <= priceRange[1];
return matchesSearch && matchesCategory && matchesPassType && matchesPrice;
});
// Pagination logic
const totalPages = Math.ceil(filteredAttractions.length / itemsPerPage);
const startIndex = (pageNumber - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const currentAttractions = filteredAttractions.slice(startIndex, endIndex);
const totalItems = filteredAttractions.length;
const showingFrom = totalItems > 0 ? startIndex + 1 : 0;
const showingTo = Math.min(endIndex, totalItems);
// Reset to first page when filters change
useEffect(() => {
setPageNumber(1);
}, [searchQuery, selectedCategories, selectedPassTypes, priceRange]);
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]
);
};
const clearAllFilters = () => {
setSelectedCategories([]);
setSelectedPassTypes([]);
setPriceRange([0, 50]);
setSearchQuery('');
};
const FilterSidebar = ({ isMobile = false }) => (
<div className={`bg-white ${isMobile ? 'p-6' : 'p-6 sticky top-44'} rounded-lg border border-gray-100 h-fit`}>
<div className="flex items-center justify-between mb-6">
<h3 className="font-semibold text-gray-900">Filters</h3>
{isMobile && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowMobileFilters(false)}
>
<X className="w-4 h-4" />
</Button>
)}
</div>
{/* Categories */}
{categories.length > 0 && (
<div className="mb-6">
<h4 className="font-medium text-gray-900 mb-3">Category</h4>
<div className="space-y-3">
{categories.map(category => (
<div key={category.value} className="flex items-center space-x-3">
<Checkbox
id={category.value}
checked={selectedCategories.includes(category.value)}
onCheckedChange={() => toggleCategory(category.value)}
/>
<label
htmlFor={category.value}
className="text-sm text-gray-700 cursor-pointer flex-1"
>
{category.label} ({category.count})
</label>
</div>
))}
</div>
</div>
)}
{/* Pass Types */}
{passTypes.length > 0 && (
<div className="mb-6">
<h4 className="font-medium text-gray-900 mb-3">Pass Type</h4>
<div className="space-y-3">
{passTypes.map(passType => (
<div key={passType.value} className="flex items-center space-x-3">
<Checkbox
id={passType.value}
checked={selectedPassTypes.includes(passType.value)}
onCheckedChange={() => togglePassType(passType.value)}
/>
<label
htmlFor={passType.value}
className="text-sm text-gray-700 cursor-pointer flex-1"
>
{passType.label} ({passType.count})
</label>
</div>
))}
</div>
</div>
)}
{/* Price Range */}
<div className="mb-6">
<h4 className="font-medium text-gray-900 mb-3">Price Range ({cityData.currency})</h4>
<div className="px-3">
<Slider
value={priceRange}
onValueChange={setPriceRange}
min={0}
max={50}
step={5}
className="mb-3"
/>
<div className="flex justify-between text-sm text-gray-600">
<span>{priceRange[0]} {cityData.currency}</span>
<span>{priceRange[1]} {cityData.currency}</span>
</div>
</div>
</div>
{/* Clear filters */}
<Button
variant="outline"
onClick={clearAllFilters}
className="w-full"
>
Clear All Filters
</Button>
</div>
);
return (
<div className="min-h-screen bg-background">
<Navbar
activeCity=""
onCityChange={() => {}}
onSignInClick={onSignInClick}
onPassesClick={onPassesClick}
onCitiesClick={onCitiesClick}
onHomeClick={onHomeClick}
onAttractionsClick={onAttractionsClick}
onBlogsClick={onBlogsClick}
onHowItWorksClick={onHowItWorksClick}
onFAQClick={onFAQClick}
onPrivacyPolicyClick={onPrivacyPolicyClick}
onAboutUsClick={onAboutUsClick}
currentPage="cities"
isUserSignedIn={!!user}
user={user}
/>
{/* Hero Section */}
<div
className="relative w-full h-[50vh] bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4)), url('${cityData.hero_image}')`
}}
>
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-white">
<motion.button
onClick={onBackClick}
className="absolute top-8 left-8 flex items-center gap-2 text-white hover:text-gray-200 transition-colors duration-200"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
>
<ArrowLeft className="w-5 h-5" />
<span>Back to Cities</span>
</motion.button>
<motion.h1
className="font-merchant text-4xl md:text-5xl lg:text-6xl mb-4"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<span className="font-light">Explore</span>{' '}
<span className="font-bold italic">{cityData.name}</span>
</motion.h1>
<motion.p
className="text-xl md:text-2xl max-w-3xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
{cityData.description}
</motion.p>
</div>
</div>
</div>
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* Desktop Sidebar */}
<div className="hidden lg:block lg:w-1/4">
<FilterSidebar />
</div>
{/* Main Content */}
<div className="flex-1">
{/* Header */}
<div className="mb-8">
<h2 className="mb-2">{cityData.name} Attractions</h2>
<p className="text-gray-600">
Discover the best attractions and experiences in {cityData.name}
</p>
</div>
{/* Search Bar */}
<div className="mb-8">
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<Input
placeholder="Search attractions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-white border-gray-200"
/>
</div>
</div>
{/* Mobile Filter Button */}
<div className="lg:hidden mb-6">
<Button
variant="outline"
onClick={() => setShowMobileFilters(true)}
className="w-full"
>
<Filter className="w-4 h-4 mr-2" />
Filters ({selectedCategories.length + selectedPassTypes.length})
</Button>
</div>
{/* Results Count */}
<div className="mb-6">
<p className="text-gray-600">
Showing {showingFrom}-{showingTo} of {totalItems} attraction(s)
</p>
</div>
{/* Attractions Grid */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
{currentAttractions.map((attraction, index) => (
<motion.div
key={attraction.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Card
className="group cursor-pointer interactive-card h-full"
onClick={() => onAttractionClick?.(attraction.id)}
>
<CardContent className="p-0">
<div className="relative">
<ImageWithFallback
src={attraction.image}
alt={attraction.name}
className="w-full h-48 object-cover rounded-t-lg"
/>
{attraction.isPopular && (
<Badge className="absolute top-3 left-3 bg-primary text-white">
Popular
</Badge>
)}
<div className="absolute top-3 right-3 bg-white rounded-full p-2 shadow-sm">
<Star className="w-4 h-4 fill-current text-yellow-500" />
</div>
</div>
<div className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<p className="text-sm text-gray-500 mb-1">{attraction.location}</p>
<h3 className="font-semibold text-gray-900 group-hover:text-primary transition-colors">
{attraction.name}
</h3>
</div>
</div>
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{attraction.description}
</p>
<div className="flex items-center gap-4 text-sm text-gray-500 mb-3">
<div className="flex items-center gap-1">
<Clock className="w-4 h-4" />
<span>{attraction.duration}</span>
</div>
<div className="flex items-center gap-1">
<Star className="w-4 h-4 fill-current text-yellow-500" />
<span>{attraction.rating}</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
{attraction.price > 0 ? (
<>
<DollarSign className="w-4 h-4 text-gray-500" />
<span className="font-semibold">{attraction.price} {cityData.currency}</span>
</>
) : (
<span className="font-semibold text-green-600">Free</span>
)}
</div>
<Badge
variant={attraction.passType === 'unlimited' ? 'default' : 'secondary'}
className="text-xs"
>
{attraction.passType === 'unlimited' ? 'Unlimited' :
attraction.passType === 'selective' ? 'Selective' : 'Both'}
</Badge>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
{totalItems === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 mb-4">No attractions found matching your criteria</p>
<Button onClick={clearAllFilters} variant="outline">
Clear All Filters
</Button>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="text-sm text-gray-600">
Page {pageNumber} of {totalPages}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setPageNumber(Math.max(1, pageNumber - 1))}
disabled={pageNumber === 1}
className="px-3 py-2"
>
Previous
</Button>
{/* Page numbers */}
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (pageNumber <= 3) {
pageNum = i + 1;
} else if (pageNumber >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = pageNumber - 2 + i;
}
return (
<Button
key={pageNum}
variant={pageNumber === pageNum ? "default" : "outline"}
onClick={() => setPageNumber(pageNum)}
className="px-3 py-2 min-w-[40px]"
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
onClick={() => setPageNumber(Math.min(totalPages, pageNumber + 1))}
disabled={pageNumber === totalPages}
className="px-3 py-2"
>
Next
</Button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Mobile Filter Modal */}
{showMobileFilters && (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowMobileFilters(false)} />
<div className="fixed inset-y-0 left-0 w-80 bg-white overflow-y-auto">
<FilterSidebar isMobile />
</div>
</div>
)}
{/* Enhanced Testimonials Section */}
<EnhancedTestimonials />
<Footer
onHomeClick={onHomeClick}
onMelbourneClick={onMelbourneClick}
onPassesClick={onPassesClick}
onSignInClick={onSignInClick}
onAttractionsClick={onAttractionsClick}
onBlogsClick={onBlogsClick}
onHowItWorksClick={onHowItWorksClick}
onFAQClick={onFAQClick}
onPrivacyPolicyClick={onPrivacyPolicyClick}
currentPage={currentPage}
/>
</div>
);
}