747 lines
30 KiB
TypeScript
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>
|
|
);
|
|
} |