Files
CityCards-Website/src/pages/ProfilePage.tsx

684 lines
31 KiB
TypeScript

import { useEffect, useState } from 'react';
import { motion } from 'motion/react';
import {
ArrowLeft,
User,
CreditCard,
Calendar,
MapPin,
Settings,
Download,
QrCode,
Plus,
Clock,
Star,
Badge as BadgeIcon,
Camera,
AlertCircle
} from 'lucide-react';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
import { Separator } from '../components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs';
import { Badge } from '../components/ui/badge';
import Navbar from '../components/Navbar';
import { Footer } from '../components/Footer';
import { ImageWithFallback } from '../components/figma/ImageWithFallback';
import { useGetUserCardsQuery, useGetUserProfileDetailsQuery, useUpdateUserProfileDetailsMutation } from '../Redux/services/profile.service';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
import LoadingSpinner from '../components/LoadingSpinner';
import { useGetUserItinerariesQuery } from '../Redux/services/itinerary.service';
interface ProfilePageProps {
onBackClick: () => void;
onHomeClick: () => void;
onMelbourneClick: () => void;
onPassesClick: () => void;
onCheckoutClick: () => void;
onSignInClick: () => void;
onSignOutClick?: () => void;
onAttractionsClick: () => void;
onBlogsClick: () => void;
onHowItWorksClick: () => void;
onFAQClick: () => void;
onPrivacyPolicyClick: () => void;
onAboutUsClick: () => void;
onProfileClick: () => void;
onCityCardsClick: () => void;
onMagicItineraryClick: () => void;
onPostCardsClick: () => void;
onCreateItineraryClick: () => void;
onViewItineraryClick?: () => void;
onOffersClick: () => void;
onDownloadAppClick?: () => void;
onContactUsClick?: () => void;
onEsimsClick?: () => void;
onHotelDiscountsClick?: () => void;
currentPage: string;
}
export function ProfilePage({
onBackClick,
onHomeClick,
onMelbourneClick,
onPassesClick,
onCheckoutClick,
onSignInClick,
onSignOutClick,
onAttractionsClick,
onBlogsClick,
onHowItWorksClick,
onFAQClick,
onPrivacyPolicyClick,
onAboutUsClick,
onProfileClick,
onCityCardsClick,
onMagicItineraryClick,
onPostCardsClick,
onCreateItineraryClick,
onViewItineraryClick,
onOffersClick,
onDownloadAppClick,
onContactUsClick,
onEsimsClick,
onHotelDiscountsClick,
currentPage
}: ProfilePageProps) {
const [activeTab, setActiveTab] = useState('profile');
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
country: '',
address1: '',
address2: '',
city: '',
postalCode: ''
});
const [sort, setSort] = useState("latest")
const navigate = useNavigate()
const userId = localStorage.getItem("userId")
const cityId = localStorage.getItem("cityId")
const { data: userDetails, isLoading } = useGetUserProfileDetailsQuery(userId)
const [updateUserProfileDetails, { isLoading: savingChanges }] = useUpdateUserProfileDetailsMutation();
const { data, isLoading: loadingCards } = useGetUserCardsQuery({ sort, cityId })
const { data: userItineraries, isLoading: loadingItineraries } = useGetUserItinerariesQuery(cityId)
const cards = data ?? []
const itineraries = userItineraries?.itineraries ?? []
useEffect(() => {
if (userDetails) {
setFormData({
firstName: userDetails?.firstName,
lastName: userDetails?.lastName,
email: userDetails?.emailAddress,
phone: userDetails?.mobileNumber,
country: userDetails?.country,
address1: userDetails?.address1,
address2: userDetails?.address2,
city: userDetails?.cityName,
postalCode: userDetails?.zipCode
})
}
}, [userDetails])
// const validateForm = () => {
// if (!formData.firstName.trim()) return toast.error('First name is required'), false;
// if (/\s/.test(formData.firstName)) return toast.error('First name must not contain spaces'), false;
// if (!/^[A-Za-z]+$/.test(formData.firstName)) return toast.error('First name must contain only letters'), false;
// if (!formData.lastName.trim()) return toast.error('Last name is required'), false;
// if (/\s/.test(formData.lastName)) return toast.error('Last name must not contain spaces'), false;
// if (!/^[A-Za-z]+$/.test(formData.lastName)) return toast.error('Last name must contain only letters'), false;
// if (!formData.phone.trim()) return toast.error('Mobile number is required'), false;
// if (/\s/.test(formData.phone)) return toast.error('Mobile number must not contain spaces'), false;
// if (!/^\d+$/.test(formData.phone)) return toast.error('Mobile number must contain only digits'), false;
// if (!formData.address1.trim()) return toast.error('Address is required'), false;
// if (!/^[A-Za-z0-9\s,\-.]+$/.test(formData.address1)) return toast.error('Address contains invalid characters'), false;
// if (!formData.city.trim()) return toast.error('City is required'), false;
// if (!/^[A-Za-z\s\-'À-ÿ]+$/.test(formData.city)) return toast.error('City can only contain letters and spaces'), false;
// if (/\s{2,}/.test(formData.city)) return toast.error('City must not contain multiple consecutive spaces'), false;
// if (!formData.country.trim()) return toast.error('Country is required'), false;
// if (!/^[A-Za-z\s\-'À-ÿ]+$/.test(formData.country)) return toast.error('Country can only contain letters and spaces'), false;
// if (!formData.postalCode.trim()) return toast.error('Postal code is required'), false;
// if (/\s/.test(formData.postalCode)) return toast.error('Postal code must not contain spaces'), false;
// if (!/^[A-Za-z0-9]+$/.test(formData.postalCode)) return toast.error('Postal code must contain only letters and numbers'), false;
// return true;
// };
const validateForm = () => {
const e: Record<string, string> = {};
if (!formData.firstName.trim()) e.firstName = 'First name is required';
else if (/\s/.test(formData.firstName)) e.firstName = 'First name must not contain spaces';
else if (!/^[A-Za-z]+$/.test(formData.firstName)) e.firstName = 'First name must contain only letters';
if (!formData.lastName.trim()) e.lastName = 'Last name is required';
else if (/\s/.test(formData.lastName)) e.lastName = 'Last name must not contain spaces';
else if (!/^[A-Za-z]+$/.test(formData.lastName)) e.lastName = 'Last name must contain only letters';
if (!formData.phone.trim()) e.phone = 'Mobile number is required';
else if (/\s/.test(formData.phone)) e.phone = 'Mobile number must not contain spaces';
else if (!/^\d+$/.test(formData.phone)) e.phone = 'Mobile number must contain only digits';
if (!formData.address1.trim()) e.address1 = 'Address is required';
else if (!/^[A-Za-z0-9\s,\-.]+$/.test(formData.address1)) e.address1 = 'Address contains invalid characters';
if (!formData.city.trim()) e.city = 'City is required';
else if (!/^[A-Za-z\s\-'À-ÿ]+$/.test(formData.city)) e.city = 'City can only contain letters and spaces';
else if (/\s{2,}/.test(formData.city)) e.city = 'City must not contain multiple consecutive spaces';
if (!formData.country.trim()) e.country = 'Country is required';
else if (!/^[A-Za-z\s\-'À-ÿ]+$/.test(formData.country)) e.country = 'Country can only contain letters and spaces';
if (!formData.postalCode.trim()) e.postalCode = 'Postal code is required';
else if (/\s/.test(formData.postalCode)) e.postalCode = 'Postal code must not contain spaces';
else if (!/^[A-Za-z0-9]+$/.test(formData.postalCode)) e.postalCode = 'Postal code must contain only letters and numbers';
setFieldErrors(e);
return Object.keys(e).length === 0;
};
// inside ProfilePage function body:
const FieldError = ({ name }: { name: string }) =>
fieldErrors[name] ? (
<p className="text-xs text-red-500 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />{fieldErrors[name]}
</p>
) : null;
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSaveProfile = async () => {
if (!validateForm()) return;
try {
const response = await updateUserProfileDetails({ userDetails: formData, userId });
toast.success("Profile updated successfully!");
} catch (error) {
toast.error("Failed to update profile. Please try again.");
}
};
const activeCards = cards.filter((card: any) => card.isActive === true);
const expiredCards = cards.filter((card: any) => card.isActive === false);
if (isLoading && loadingCards) {
return (
<LoadingSpinner />
);
}
return (
<div className="min-h-screen bg-background">
{/* Navbar */}
<Navbar
activeCity=""
onSignInClick={onSignInClick}
onSignOutClick={onSignOutClick}
isUserSignedIn={true}
user={{ email: "user@example.com", name: "John Doe" }}
onCityChange={function (city: string): void {
throw new Error('Function not implemented.');
}} />
{/* Header Section */}
<section className="pt-40 pb-8 bg-gradient-to-br from-muted/30 to-background">
<div className="container mx-auto px-4">
{/* Back Button */}
<motion.button
onClick={() => navigate(-1)}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6 transition-colors duration-200 cursor-pointer"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
>
<ArrowLeft className="w-5 h-5" />
<span className="font-normal">Back</span>
</motion.button>
{/* Page Title */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
>
<h1 className="font-poppins text-3xl md:text-4xl lg:text-5xl mb-4">
<span className="font-light">My</span>{' '}
<span className="font-bold italic bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent pr-2">Profile</span>
</h1>
<p className="font-poppins text-xl leading-relaxed font-normal text-gray-600">
Manage your account, cards, and travel itineraries
</p>
</motion.div>
</div>
</section>
{/* Main Content */}
<section className="py-12">
<div className="container mx-auto px-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-8">
{/* Tab Navigation */}
<TabsList className="grid w-full grid-cols-3 lg:w-[400px]">
<TabsTrigger value="profile" className="font-poppins font-light">My Profile</TabsTrigger>
<TabsTrigger value="passes" className="font-poppins font-light">My Cards</TabsTrigger>
<TabsTrigger value="itineraries" className="font-poppins font-light">My Itineraries</TabsTrigger>
</TabsList>
{/* My Profile Tab */}
<TabsContent value="profile" className="space-y-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Profile Form */}
<div className="lg:col-span-2">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 font-poppins font-normal">
<User className="w-5 h-5" />
Personal Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="firstName" className="font-poppins font-light">
First Name <span className="text-red-500">*</span>
</Label>
<Input
id="firstName"
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
className={`mt-1 font-poppins font-light ${fieldErrors.firstName ? 'border-red-400' : ''}`}
/>
<FieldError name="firstName" />
</div>
<div>
<Label htmlFor="lastName" className="font-poppins font-light">
Last Name <span className="text-red-500">*</span>
</Label>
<Input
id="lastName"
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
className={`mt-1 font-poppins font-light ${fieldErrors.lastName ? 'border-red-400' : ''}`}
/>
<FieldError name="lastName" />
</div>
</div>
<div>
<Label htmlFor="email" className="font-poppins font-light">
Email Address
</Label>
<Input
id="email"
type="email"
value={formData.email}
disabled
onChange={(e) => handleInputChange('email', e.target.value)}
className="mt-1 font-poppins font-light"
/>
</div>
<div>
<Label htmlFor="phone" className="font-poppins font-light">
Phone Number <span className="text-red-500">*</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
className={`mt-1 font-poppins font-light ${fieldErrors.phone ? 'border-red-400' : ''}`}
/>
<FieldError name="phone" />
</div>
<Separator />
<h3 className="font-poppins font-normal">Billing Address</h3>
<div>
<Label htmlFor="country" className="font-poppins font-light">
Country <span className="text-red-500">*</span>
</Label>
<Input
id="country"
value={formData.country}
onChange={(e) => handleInputChange('country', e.target.value)}
className={`mt-1 font-poppins font-light ${fieldErrors.country ? 'border-red-400' : ''}`}
/>
<FieldError name="country" />
</div>
<div>
<Label htmlFor="address1" className="font-poppins font-light">
Address Line 1 <span className="text-red-500">*</span>
</Label>
<Input
id="address1"
value={formData.address1}
onChange={(e) => handleInputChange('address1', e.target.value)}
className={`mt-1 font-poppins font-light mb-4 ${fieldErrors.address1 ? 'border-red-400' : ''}`}
/>
<FieldError name="address1" />
<Label htmlFor="address2" className="font-poppins font-light">Address Line 2</Label>
<Input
id="address2"
value={formData.address2}
onChange={(e) => handleInputChange('address2', e.target.value)}
className="mt-1 font-poppins font-light"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="city" className="font-poppins font-light">
City <span className="text-red-500">*</span>
</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleInputChange('city', e.target.value)}
className={`mt-1 font-poppins font-light ${fieldErrors.city ? 'border-red-400' : ''}`}
/>
<FieldError name="city" />
</div>
<div>
<Label htmlFor="postalCode" className="font-poppins font-light">
Postal Code <span className="text-red-500">*</span>
</Label>
<Input
id="postalCode"
value={formData.postalCode}
onChange={(e) => handleInputChange('postalCode', e.target.value)}
className={`mt-1 font-poppins font-light ${fieldErrors.postalCode ? 'border-red-400' : ''}`}
/>
<FieldError name="postalCode" />
</div>
</div>
<Button
onClick={handleSaveProfile}
className="w-full bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90 text-white font-normal py-3 font-poppins cursor-pointer"
>
{savingChanges ? "Saving Changes..." : "Save Changes"}
</Button>
</CardContent>
</Card>
</motion.div>
</div>
</div>
</TabsContent>
{/* My Cards Tab */}
<TabsContent value="passes" className="space-y-8">
{/* Active Cards */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<h2 className="font-poppins text-2xl mb-6 font-normal">Active Cards</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{activeCards.map((card: any) => (
<Card key={card.id} className="overflow-hidden">
<div
className="flex cursor-pointer hover:bg-gray-50 transition-colors duration-200 rounded-lg p-2 -m-2"
onClick={() => navigate(`/view-card-details/${card.id}`)}
>
<div className="w-32 h-32 flex-shrink-0">
<ImageWithFallback
src={card.city.bannerImage}
alt={card.city.name}
className="w-full object-cover"
/>
</div>
<div className="flex-1 p-6">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="font-normal font-poppins">{card.card.title}</h3>
<Badge variant={card.isActive === true ? 'default' : 'secondary'} className="mt-1">
{card.isActive && "Active"}
</Badge>
</div>
<div className="text-right">
<div className="font-semibold text-lg font-poppins">${card.totalAmount}</div>
<div className="text-sm text-gray-500 line-through font-poppins font-light">${card.totalAmount + 50}</div>
</div>
</div>
<div className="space-y-2 text-sm font-poppins font-light">
<div className="flex justify-between">
{card.cardMode === "flexi" ? (
<>
<span>Attractions:</span>
<span>{card.noOfAttractions}</span>
</>
) : (
<>
<span>Days:</span>
<span>{card.noOfDays}</span>
</>
)
}
</div>
<div className="flex justify-between">
<span>Valid until:</span>
<span>{new Date(card.validUpto).toLocaleDateString()}</span>
</div>
<div className="flex justify-between">
<span>Days remaining:</span>
<span className="text-primary font-normal">{Math.max(
0,
Math.ceil(
(new Date(card.validUpto).getTime() - new Date().getTime()) /
(1000 * 60 * 60 * 24)
)
)} days</span>
</div>
</div>
</div>
</div>
</Card>
))}
</div>
{/* Offers Button */}
<div className="mt-8 text-center">
<Button
onClick={() => navigate("/super-savings")}
className="bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90 text-white font-poppins px-8 py-3 font-normal"
>
<Star className="w-4 h-4 mr-2" />
View Special Offers
</Button>
</div>
</motion.div>
{/* Expired Cards */}
{expiredCards.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<h2 className="font-poppins text-2xl mb-6 font-normal">Expired Cards</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{expiredCards.map((card: any) => (
<Card key={card.id} className="overflow-hidden opacity-60">
<div className="flex">
<div className="w-32 h-32 flex-shrink-0">
<ImageWithFallback
src={card.city.bannerImage}
alt={card.city.name}
className="w-full h-full object-cover grayscale"
/>
</div>
<div className="flex-1 p-6">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="font-normal font-poppins">{card.card.title}</h3>
<Badge variant="secondary" className="mt-1">
{!card.isActive && "Expired"}
</Badge>
</div>
<div className="text-right">
<div className="font-semibold text-lg font-poppins">${card.price}</div>
</div>
</div>
<div className="space-y-2 text-sm font-poppins font-light">
<div className="flex justify-between">
{card.cardMode === "flexi" ? (
<>
<span>Attractions:</span>
<span>{card.noOfAttractions}</span>
</>
) : (
<>
<span>Days:</span>
<span>{card.noOfDays}</span>
</>
)
}
</div>
<div className="flex justify-between">
<span>Expired on:</span>
<span>{new Date(card.validUpto).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
</Card>
))}
</div>
</motion.div>
)}
</TabsContent>
{/* My Itineraries Tab */}
<TabsContent value="itineraries" className="space-y-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<div className="flex items-center justify-between mb-6">
<h2 className="font-poppins text-2xl font-normal">My Itineraries</h2>
<Button
className="bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90 text-white font-poppins font-normal"
onClick={() => navigate("/create-itinerary")}
>
<Plus className="w-4 h-4 mr-2" />
Create Itinerary
</Button>
</div>
{itineraries?.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{itineraries.map((itinerary: any) => (
<Card key={itinerary.id}>
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-normal font-poppins">{ }</h3>
<p className="text-sm text-gray-600 font-poppins font-light">{itinerary.city.cityName} Travel Plan</p>
</div>
<Badge variant={itinerary.isActive === true ? 'default' : 'secondary'}>
{itinerary.isActive ? "Active" : "Inactive"}
</Badge>
</div>
<div className="space-y-2 text-sm font-poppins font-light">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-500" />
<span>{itinerary.totalDays}</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-500" />
<span>{itinerary?.attractions} attractions</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-gray-500" />
<span>Created {new Date(itinerary.createdAt).toLocaleDateString()}</span>
</div>
</div>
<Button
variant="outline"
className="w-full mt-4 font-poppins font-normal"
onClick={() => navigate(`/view-itinerary/${itinerary.id}`)}
>
View Itinerary
</Button>
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center py-16">
<div className="max-w-md mx-auto">
<div className="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
<Calendar className="w-12 h-12 text-gray-400" />
</div>
<h3 className="font-poppins text-xl mb-4 font-normal">You don't have an itinerary yet</h3>
<p className="text-gray-600 mb-6 font-poppins font-light">
Create your first itinerary to plan your perfect trip
</p>
<Button
className="bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90 text-white font-poppins font-normal"
onClick={() => navigate("/create-itinerary")}
>
<Plus className="w-4 h-4 mr-2" />
Create Itinerary
</Button>
</div>
</div>
)}
</motion.div>
</TabsContent>
</Tabs>
</div>
</section>
{/* Footer */}
<Footer
onHomeClick={onHomeClick}
onMelbourneClick={onMelbourneClick}
onPassesClick={onPassesClick}
onSignInClick={onSignInClick}
onAttractionsClick={onAttractionsClick}
onBlogsClick={onBlogsClick}
onHowItWorksClick={onHowItWorksClick}
onFAQClick={onFAQClick}
onPrivacyPolicyClick={onPrivacyPolicyClick}
onContactUsClick={onContactUsClick}
currentPage={currentPage}
/>
</div>
);
}
export default ProfilePage;