From 2c622b303d7b0e96f29823d8861778ac14414d03 Mon Sep 17 00:00:00 2001 From: hardik Date: Sat, 8 Aug 2026 01:10:49 +0530 Subject: [PATCH] integrated apis --- .env | 5 + src/api/auth.ts | 31 ++ src/api/media.ts | 17 + src/api/profile.ts | 59 +++ src/api/reviews.ts | 17 + src/app/App.tsx | 2 + src/components/layouts/Header.tsx | 2 +- src/features/home/sections/HeroSection.tsx | 20 +- .../home/sections/ProfileSections.tsx | 134 +++-- src/main.tsx | 9 + src/pages/ProfilePage.tsx | 137 ++++- src/pages/SearchPage.tsx | 116 +++-- src/pages/auth/LoginPage.tsx | 38 +- src/pages/auth/RegisterPage.tsx | 56 +- src/pages/dashboard/DashboardProfilePage.tsx | 485 ++++++++++++++++++ src/store/index.ts | 21 +- 16 files changed, 1006 insertions(+), 143 deletions(-) create mode 100644 .env create mode 100644 src/api/auth.ts create mode 100644 src/api/media.ts create mode 100644 src/api/profile.ts create mode 100644 src/api/reviews.ts create mode 100644 src/pages/dashboard/DashboardProfilePage.tsx diff --git a/.env b/.env new file mode 100644 index 0000000..8fd6af2 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +VITE_API_BASE_URL=http://localhost:3000/api/v1 +VITE_APP_NAME=Luxe +VITE_APP_URL=http://localhost:5173 +VITE_SOCKET_URL=http://localhost:3000 +VITE_CDN_URL= diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..1340bf2 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,31 @@ +import apiClient from './client'; +import type { ApiResponse } from '@/types'; +import type { LoginFormData, RegisterFormData } from '@/validators/auth'; + +export const authApi = { + login: async (data: LoginFormData) => { + const response = await apiClient.post>('/auth/login', data); + return response.data; + }, + + register: async (data: RegisterFormData & { role?: string, username?: string }) => { + const payload = { + email: data.email, + password: data.password, + username: data.username || data.displayName.replace(/\s+/g, '').toLowerCase() + Math.floor(Math.random() * 1000), + role: data.role || 'USER', // Default to USER + }; + const response = await apiClient.post>('/auth/register', payload); + return response.data; + }, + + logout: async () => { + const response = await apiClient.post>('/auth/logout'); + return response.data; + }, + + getMe: async () => { + const response = await apiClient.get>('/auth/me'); + return response.data; + } +}; diff --git a/src/api/media.ts b/src/api/media.ts new file mode 100644 index 0000000..ebed535 --- /dev/null +++ b/src/api/media.ts @@ -0,0 +1,17 @@ +import apiClient from './client'; +import type { ApiResponse } from '@/types'; + +export const mediaApi = { + uploadFile: async (file: File) => { + const formData = new FormData(); + formData.append('file', file); + + // DO NOT set Content-Type manually — Axios must auto-set it with the multipart boundary + // This overrides the API client's JSON default and lets Axios/browser add + // the multipart boundary required by the upload endpoint. + const response = await apiClient.postForm>('/media/upload', formData); + return response.data; + }, +}; + +export default mediaApi; diff --git a/src/api/profile.ts b/src/api/profile.ts new file mode 100644 index 0000000..41d4fb3 --- /dev/null +++ b/src/api/profile.ts @@ -0,0 +1,59 @@ +import apiClient from './client'; +import type { ApiResponse, Profile } from '@/types'; + +export const profileApi = { + getMyProfile: async () => { + const response = await apiClient.get>('/clients/my-profile'); + return response.data; + }, + + createListing: async (data: Partial) => { + const response = await apiClient.post>('/clients/listings', data); + return response.data; + }, + + updateProfile: async (listingId: string, data: any) => { + const response = await apiClient.put>(`/clients/listings/${listingId}`, data); + return response.data; + }, + + uploadGallery: async (listingId: string, url: string, isPrimary: boolean = false) => { + const response = await apiClient.post>('/clients/my-profile/gallery', { + listingId, + url, + mediaType: 'IMAGE', + isPrimary + }); + return response.data; + }, + + setPrimaryGalleryImage: async (listingId: string, galleryItemId: string) => { + const response = await apiClient.patch>(`/clients/listings/${listingId}/gallery/${galleryItemId}/primary`); + return response.data; + }, + + deleteGalleryImage: async (galleryItemId: string) => { + const response = await apiClient.delete>(`/clients/gallery/${galleryItemId}`); + return response.data; + }, + + searchProfiles: async (params?: any) => { + const response = await apiClient.get>('/search/profiles', { params }); + return response.data; + }, + + getProfileBySlug: async (slug: string) => { + const response = await apiClient.get>(`/profiles/${slug}`); + return response.data; + }, + + getCities: async () => { + const response = await apiClient.get>('/cities'); + return response.data; + }, + + getCategories: async () => { + const response = await apiClient.get>('/categories'); + return response.data; + } +}; diff --git a/src/api/reviews.ts b/src/api/reviews.ts new file mode 100644 index 0000000..bcf570c --- /dev/null +++ b/src/api/reviews.ts @@ -0,0 +1,17 @@ +import apiClient from './client'; +import type { ApiResponse } from '@/types'; + +export const reviewsApi = { + submitReview: async (slug: string, rating: number, comment: string) => { + const response = await apiClient.post>(`/profiles/${slug}/reviews`, { + rating, + comment + }); + return response.data; + }, + + getReviews: async (slug: string) => { + const response = await apiClient.get>(`/profiles/${slug}/reviews`); + return response.data; + } +}; diff --git a/src/app/App.tsx b/src/app/App.tsx index 26c5036..f5d043d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -19,6 +19,7 @@ const ProfilePage = lazy(() => import('@/pages/ProfilePage')); const BlogListPage = lazy(() => import('@/pages/blog/BlogListPage')); const BlogPostPage = lazy(() => import('@/pages/blog/BlogPostPage')); const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')); +const DashboardProfilePage = lazy(() => import('@/pages/dashboard/DashboardProfilePage')); const queryClient = new QueryClient({ defaultOptions: { @@ -55,6 +56,7 @@ export function AppRouter() { } /> } /> } /> + } /> }> } /> diff --git a/src/components/layouts/Header.tsx b/src/components/layouts/Header.tsx index d7ec8ca..1c5c523 100644 --- a/src/components/layouts/Header.tsx +++ b/src/components/layouts/Header.tsx @@ -85,7 +85,7 @@ export function Header() { {!isMobile && ( <> {isAuthenticated ? ( - + {t('nav.dashboard')} ) : ( diff --git a/src/features/home/sections/HeroSection.tsx b/src/features/home/sections/HeroSection.tsx index 9348497..860540f 100644 --- a/src/features/home/sections/HeroSection.tsx +++ b/src/features/home/sections/HeroSection.tsx @@ -17,10 +17,12 @@ import { searchSchema, type SearchFormData } from '@/validators/auth'; import { mockCategories, mockCities } from '@/constants/mockData'; import { ROUTES } from '@/constants'; import { gradients } from '@/theme/tokens'; +import { useAuthStore } from '@/store'; export function HeroSection() { const { t } = useTranslation(); const navigate = useNavigate(); + const { isAuthenticated, user } = useAuthStore(); const { control, handleSubmit } = useForm({ resolver: zodResolver(searchSchema), defaultValues: { query: '', city: '', category: '' }, @@ -201,7 +203,23 @@ export function HeroSection() { navigate(ROUTES.SEARCH)}> {t('hero.cta')} - navigate(ROUTES.AUTH.REGISTER)}> + { + const isClientOrAdmin = + isAuthenticated && + user && + ['admin', 'super_admin', 'advertiser', 'agency', 'ADMIN', 'SUPER_ADMIN', 'ADVERTISER', 'AGENCY'].includes( + user.role + ); + if (isClientOrAdmin) { + navigate(ROUTES.DASHBOARD.PROFILE); + } else { + navigate(ROUTES.AUTH.REGISTER); + } + }} + > {t('hero.secondaryCta')} diff --git a/src/features/home/sections/ProfileSections.tsx b/src/features/home/sections/ProfileSections.tsx index dd1c583..aff1601 100644 --- a/src/features/home/sections/ProfileSections.tsx +++ b/src/features/home/sections/ProfileSections.tsx @@ -1,29 +1,69 @@ +import { useState, useEffect } from 'react'; import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; import { Link as RouterLink } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { PageContainer, SectionWrapper, SectionHeader, ProfileCard } from '@/components/ui'; -import { mockProfiles } from '@/constants/mockData'; +import { profileApi } from '@/api/profile'; import LuxeButton from '@/components/ui/LuxeButton'; -interface ProfileGridSectionProps { - titleKey: string; - subtitle?: string; - profiles: typeof mockProfiles; - filter?: (p: (typeof mockProfiles)[0]) => boolean; - viewAllPath?: string; - gradient?: boolean; -} - export function ProfileGridSection({ titleKey, subtitle, - profiles, - filter, + params = {}, viewAllPath = '/search', gradient = false, -}: ProfileGridSectionProps) { +}: { + titleKey: string; + subtitle?: string; + params?: any; + viewAllPath?: string; + gradient?: boolean; +}) { const { t } = useTranslation(); - const data = filter ? profiles.filter(filter) : profiles; + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchProfiles = async () => { + try { + setLoading(true); + const res = await profileApi.searchProfiles(params); + if (res.success && res.data) { + const mapped = res.data.map((listing: any) => { + const primaryImg = listing.gallery?.find((g: any) => g.isPrimary)?.url || listing.gallery?.[0]?.url || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=600&h=800&fit=crop'; + return { + id: listing.id, + slug: listing.slug, + name: listing.title, + age: listing.age || 21, + city: listing.city?.name || 'New York', + area: listing.area?.name || '', + avatar: primaryImg, + coverImage: primaryImg, + rating: listing.advertiser?.rating || 5.0, + reviewCount: listing.advertiser?.reviewCount || 0, + isVerified: listing.isVerified || false, + isPremium: listing.isPremium || false, + isOnline: listing.isOnline || true, + priceFrom: listing.price || 200, + currency: listing.currency || 'USD', + categories: listing.category ? [listing.category.name] : ['VIP Escorts'], + languages: listing.languages || ['English'], + height: listing.height || '170 cm', + tagline: listing.tagline || '', + }; + }); + setProfiles(mapped); + } + } catch (err) { + console.error('Failed to load profiles:', err); + } finally { + setLoading(false); + } + }; + fetchProfiles(); + }, [JSON.stringify(params)]); return ( @@ -38,36 +78,35 @@ export function ProfileGridSection({ } /> - - {data.slice(0, 8).map((profile, i) => ( - - ))} - + {loading ? ( + + + + ) : ( + + {profiles.slice(0, 8).map((profile, i) => ( + + ))} + + )} ); } export function TrendingProfilesSection() { - return ( - p.isOnline || p.rating >= 4.7} - gradient - /> - ); + return ; } export function FeaturedProfilesSection() { @@ -75,14 +114,13 @@ export function FeaturedProfilesSection() { p.isPremium} + params={{ featured: 'true' }} /> ); } export function LatestProfilesSection() { - return ; + return ; } export function VerifiedProfilesSection() { @@ -90,21 +128,13 @@ export function VerifiedProfilesSection() { p.isVerified} + params={{ verified: 'true' }} /> ); } export function PremiumAdvertisersSection() { - return ( - p.isPremium} - gradient - /> - ); + return ; } export function NearbyListingsSection() { @@ -112,7 +142,7 @@ export function NearbyListingsSection() { ); } diff --git a/src/main.tsx b/src/main.tsx index 2f1e3c6..d8e3e6b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,15 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from '@/app/App'; +// A previously installed production PWA service worker can keep serving an old +// bundle while developing on localhost. Remove it so current API-backed pages +// (including listing galleries) are always loaded. +if (import.meta.env.DEV && 'serviceWorker' in navigator) { + navigator.serviceWorker.getRegistrations().then((registrations) => { + registrations.forEach((registration) => registration.unregister()); + }); +} + createRoot(document.getElementById('root')!).render( diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 1362869..2996786 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useParams, Link as RouterLink, useNavigate } from 'react-router-dom'; import { Helmet } from 'react-helmet-async'; import Box from '@mui/material/Box'; @@ -18,6 +18,7 @@ import TableRow from '@mui/material/TableRow'; import Paper from '@mui/material/Paper'; import Dialog from '@mui/material/Dialog'; import Alert from '@mui/material/Alert'; +import CircularProgress from '@mui/material/CircularProgress'; import IconButton from '@mui/material/IconButton'; import PhoneIcon from '@mui/icons-material/Phone'; import WhatsAppIcon from '@mui/icons-material/WhatsApp'; @@ -43,10 +44,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { PageContainer, SectionWrapper, GlassCard, LuxeButton, StatusBadge } from '@/components/ui'; -import { mockProfiles } from '@/constants/mockData'; import { formatCurrency } from '@/utils'; +import { profileApi } from '@/api/profile'; +import type { Profile } from '@/types'; import { ROUTES } from '@/constants'; import { useMediaQuery } from '@/hooks'; +import { reviewsApi } from '@/api/reviews'; // Form validation schema for Reviews const reviewSchema = z.object({ @@ -57,14 +60,23 @@ const reviewSchema = z.object({ type ReviewFormData = z.infer; +interface Review { + id: number; + name: string; + rating: number; + date: string; + comment: string; +} + export function ProfilePage() { const { slug } = useParams(); const navigate = useNavigate(); const isSmUp = useMediaQuery('(min-width:600px)'); - const profile = mockProfiles.find((p) => p.slug === slug); - // States + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [galleryImages, setGalleryImages] = useState([]); const [lightboxOpen, setLightboxOpen] = useState(false); const [activePhotoIdx, setActivePhotoIdx] = useState(0); const [copied, setCopied] = useState(false); @@ -73,10 +85,67 @@ export function ProfilePage() { const [verificationStep, setVerificationStep] = useState(1); // 1: Info, 2: Document, 3: Selfie, 4: Submitted const [uploadedDoc, setUploadedDoc] = useState(null); const [uploadedSelfie, setUploadedSelfie] = useState(null); - const [reviewsList, setReviewsList] = useState([ - { id: 1, name: 'Alex M.', rating: 5, date: '2026-07-10', comment: 'Absolutely stunning and highly professional companion. Discretion was top tier, and we had an incredible dinner date. Highly recommend!' }, - { id: 2, name: 'Julian F.', rating: 4, date: '2026-07-02', comment: 'Very pleasant wellness companion. Speaks perfect French and English. Will definitely book again.' }, - ]); + const [reviewsList, setReviewsList] = useState([]); + + useEffect(() => { + if (slug) { + loadProfile(slug); + } + }, [slug]); + + const loadProfile = async (profileSlug: string) => { + try { + setLoading(true); + const res = await profileApi.getProfileBySlug(profileSlug); + if (res.success && res.data) { + const listing = res.data; + const primaryImg = listing.gallery?.find((g: any) => g.isPrimary)?.url || listing.gallery?.[0]?.url || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=600&h=800&fit=crop'; + + const mappedProfile: Profile = { + id: listing.id, + slug: listing.slug, + name: listing.title, + age: listing.age || 21, + city: listing.city?.name || 'New York', + area: listing.area?.name || '', + avatar: primaryImg, + coverImage: primaryImg, + rating: listing.advertiser?.rating || 5.0, + reviewCount: listing.advertiser?.reviewCount || 0, + isVerified: listing.isVerified || false, + isPremium: listing.isPremium || false, + isOnline: listing.isOnline || true, + priceFrom: listing.price || 200, + currency: listing.currency || 'USD', + categories: listing.category ? [listing.category.name] : ['VIP Escorts'], + languages: listing.languages || ['English'], + height: listing.height || '170 cm', + tagline: listing.tagline || '', + }; + + setProfile(mappedProfile); + + // Store full gallery sorted primary-first + const sortedGallery = [...(listing.gallery || [])].sort((a: any, b: any) => (b.isPrimary ? 1 : 0) - (a.isPrimary ? 1 : 0)); + setGalleryImages(sortedGallery.map((g: any) => g.url)); + + // Map reviews + const backendReviews = listing.reviews || []; + const mappedReviews = backendReviews.map((r: any, idx: number) => ({ + id: r.id || idx, + name: r.user?.username || 'Anonymous', + rating: r.rating, + date: r.createdAt ? new Date(r.createdAt).toISOString().split('T')[0] : 'Recent', + comment: r.comment, + })); + setReviewsList(mappedReviews); + } + } catch (err) { + console.error('Failed to load profile details:', err); + } finally { + setLoading(false); + } + }; // React Hook Form for review const { register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm({ @@ -86,6 +155,14 @@ export function ProfilePage() { const ratingVal = watch('rating'); + if (loading) { + return ( + + + + ); + } + if (!profile) { return ( @@ -100,13 +177,15 @@ export function ProfilePage() { ); } - // Sample photos for slider - const photos = [ - profile.avatar, - 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=600&h=800&fit=crop', - 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=600&h=800&fit=crop', - 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=600&h=800&fit=crop', - ]; + // Build photo array from real gallery, fallback to stock images if none uploaded yet + const photos = galleryImages.length > 0 + ? galleryImages + : [ + profile.avatar, + 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=600&h=800&fit=crop', + 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=600&h=800&fit=crop', + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=600&h=800&fit=crop', + ]; // Rates calculation const incallRates = [ @@ -136,16 +215,24 @@ export function ProfilePage() { setTimeout(() => setCopied(false), 2000); }; - const handleReviewSubmit = (data: ReviewFormData) => { - const newRev = { - id: reviewsList.length + 1, - name: data.reviewerName, - rating: data.rating, - date: new Date().toISOString().split('T')[0], - comment: data.comment, - }; - setReviewsList([newRev, ...reviewsList]); - reset(); + const handleReviewSubmit = async (data: ReviewFormData) => { + if (!profile) return; + try { + const res = await reviewsApi.submitReview(profile.slug, data.rating, data.comment); + if (res.success) { + const newRev = { + id: reviewsList.length + 1, + name: data.reviewerName, + rating: data.rating, + date: new Date().toISOString().substring(0, 10), + comment: data.comment, + }; + setReviewsList([newRev, ...reviewsList]); + reset(); + } + } catch (e: any) { + console.error('Failed to submit review', e); + } }; // Mock schedule grid diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index f3df287..cb8236c 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -13,11 +13,13 @@ import FilterListIcon from '@mui/icons-material/FilterList'; import CloseIcon from '@mui/icons-material/Close'; import SearchIcon from '@mui/icons-material/Search'; import InputAdornment from '@mui/material/InputAdornment'; +import CircularProgress from '@mui/material/CircularProgress'; import { PageContainer, SectionWrapper, ProfileCard, SectionHeader, LuxeButton } from '@/components/ui'; import { SearchFilterPanel } from '@/components/search/SearchFilterPanel'; -import { mockProfiles } from '@/constants/mockData'; import { useSearchStore } from '@/store'; import { useMediaQuery } from '@/hooks'; +import { profileApi } from '@/api/profile'; +import type { Profile } from '@/types'; export function SearchPage() { const [searchParams, setSearchParams] = useSearchParams(); @@ -40,54 +42,66 @@ export function SearchPage() { } }, [queryParam, cityParam, categoryParam, setFilters]); - // Apply filters - const results = mockProfiles.filter((p) => { - if (filters.query && - !p.name.toLowerCase().includes(filters.query.toLowerCase()) && - !p.city.toLowerCase().includes(filters.query.toLowerCase()) && - !(p.tagline ?? '').toLowerCase().includes(filters.query.toLowerCase())) { - return false; - } - if (filters.city && p.city.toLowerCase() !== filters.city.toLowerCase()) { - return false; - } - if (filters.categories && filters.categories.length > 0) { - const matchesCategory = p.categories.some((cat) => - filters.categories!.some((fc) => fc.toLowerCase() === cat.toLowerCase()) - ); - if (!matchesCategory) return false; - } - if (filters.ageMin !== undefined && p.age < filters.ageMin) return false; - if (filters.ageMax !== undefined && p.age > filters.ageMax) return false; - if (filters.priceMin !== undefined && p.priceFrom < filters.priceMin) return false; - if (filters.priceMax !== undefined && p.priceFrom > filters.priceMax) return false; - if (filters.verified && !p.isVerified) return false; - if (filters.online && !p.isOnline) return false; - if (filters.premium && !p.isPremium) return false; - if (filters.languages && filters.languages.length > 0) { - const matchesLanguage = p.languages.some((lang) => - filters.languages!.some((fl) => fl.toLowerCase() === lang.toLowerCase()) - ); - if (!matchesLanguage) return false; - } - return true; - }); + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); - // Apply sorting - const sortedResults = [...results].sort((a, b) => { - const sortBy = filters.sortBy ?? 'relevance'; - if (sortBy === 'price_asc') return a.priceFrom - b.priceFrom; - if (sortBy === 'price_desc') return b.priceFrom - a.priceFrom; - if (sortBy === 'rating') return b.rating - a.rating; - if (sortBy === 'newest') return b.id.localeCompare(a.id); - - // Relevance: Premium first, then verified first - if (a.isPremium && !b.isPremium) return -1; - if (!a.isPremium && b.isPremium) return 1; - if (a.isVerified && !b.isVerified) return -1; - if (!a.isVerified && b.isVerified) return 1; - return 0; - }); + useEffect(() => { + const fetchProfiles = async () => { + try { + setLoading(true); + const params: any = {}; + if (filters.query) params.query = filters.query; + if (filters.city) params.city = filters.city; + if (filters.categories && filters.categories.length > 0) { + params.category = filters.categories[0]; + } + if (filters.ageMin !== undefined) params.ageMin = filters.ageMin; + if (filters.ageMax !== undefined) params.ageMax = filters.ageMax; + if (filters.priceMin !== undefined) params.priceMin = filters.priceMin; + if (filters.priceMax !== undefined) params.priceMax = filters.priceMax; + if (filters.verified) params.verified = 'true'; + if (filters.premium) params.featured = 'true'; + if (filters.sortBy) params.sortBy = filters.sortBy; + + const res = await profileApi.searchProfiles(params); + if (res.success && res.data) { + const mapped = res.data.map((listing: any) => { + const primaryImg = listing.gallery?.find((g: any) => g.isPrimary)?.url || listing.gallery?.[0]?.url || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=600&h=800&fit=crop'; + return { + id: listing.id, + slug: listing.slug, + name: listing.title, + age: listing.age || 21, + city: listing.city?.name || 'New York', + area: listing.area?.name || '', + avatar: primaryImg, + coverImage: primaryImg, + rating: listing.advertiser?.rating || 5.0, + reviewCount: listing.advertiser?.reviewCount || 0, + isVerified: listing.isVerified || false, + isPremium: listing.isPremium || false, + isOnline: listing.isOnline || true, + priceFrom: listing.price || 200, + currency: listing.currency || 'USD', + categories: listing.category ? [listing.category.name] : ['VIP Escorts'], + languages: listing.languages || ['English'], + height: listing.height || '170 cm', + tagline: listing.tagline || '', + }; + }); + setProfiles(mapped); + } + } catch (err) { + console.error('Failed to load profiles:', err); + } finally { + setLoading(false); + } + }; + + fetchProfiles(); + }, [filters]); + + const sortedResults = profiles; const handleSearchInputChange = (e: React.ChangeEvent) => { const val = e.target.value; @@ -242,7 +256,11 @@ export function SearchPage() { Showing {sortedResults.length} premium profile{sortedResults.length !== 1 && 's'} - {sortedResults.length > 0 ? ( + {loading ? ( + + + + ) : sortedResults.length > 0 ? ( {sortedResults.map((profile, i) => ( diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index c2f054a..d6bc962 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -12,21 +12,48 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useTranslation } from 'react-i18next'; import { LuxeButton } from '@/components/ui'; import { loginSchema, type LoginFormData } from '@/validators/auth'; -import { ROUTES } from '@/constants'; +import { ROUTES, STORAGE_KEYS } from '@/constants'; +import { authApi } from '@/api/auth'; +import { useAuthStore } from '@/store'; +import { setStorageItem } from '@/utils'; +import { useNavigate } from 'react-router-dom'; export function LoginPage() { const { t } = useTranslation(); + const navigate = useNavigate(); + const { setUser } = useAuthStore(); const { register, handleSubmit, + setError, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(loginSchema), defaultValues: { rememberMe: false }, }); - const onSubmit = async (_data: LoginFormData) => { - await new Promise((r) => setTimeout(r, 800)); + const onSubmit = async (data: LoginFormData) => { + try { + const response = await authApi.login(data); + if (response.success && response.data) { + setStorageItem(STORAGE_KEYS.AUTH_TOKEN, response.data.accessToken); + setUser(response.data.user); + + // Route based on role + if ( + response.data.user.role === 'ADMIN' || + response.data.user.role === 'SUPER_ADMIN' || + response.data.user.role === 'ADVERTISER' || + response.data.user.role === 'AGENCY' + ) { + navigate(ROUTES.DASHBOARD.PROFILE); + } else { + navigate(ROUTES.HOME); + } + } + } catch (err: any) { + setError('root', { message: err.response?.data?.message || 'Login failed' }); + } }; return ( @@ -39,6 +66,11 @@ export function LoginPage() { + {errors.root && ( + + {errors.root.message} + + )} ({ resolver: zodResolver(registerSchema), - defaultValues: { acceptTerms: false }, + defaultValues: { acceptTerms: false, role: 'USER' } as any, }); - const onSubmit = async (_data: RegisterFormData) => { - await new Promise((r) => setTimeout(r, 800)); + const onSubmit = async (data: RegisterFormData & { role?: string }) => { + try { + const response = await authApi.register(data); + if (response.success && response.data) { + setStorageItem(STORAGE_KEYS.AUTH_TOKEN, response.data.accessToken); + setUser(response.data.user); + + if ( + response.data.user.role === 'ADMIN' || + response.data.user.role === 'SUPER_ADMIN' || + response.data.user.role === 'ADVERTISER' || + response.data.user.role === 'AGENCY' + ) { + navigate(ROUTES.DASHBOARD.PROFILE); + } else { + navigate(ROUTES.HOME); + } + } + } catch (err: any) { + setError('root', { message: err.response?.data?.message || 'Registration failed' }); + } }; return ( @@ -39,6 +69,24 @@ export function RegisterPage() { + {errors.root && ( + + {errors.root.message} + + )} + + Account Type + + ; + +export function DashboardProfilePage() { + const [loading, setLoading] = useState(true); + const [profile, setProfile] = useState(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [uploadingImage, setUploadingImage] = useState(false); + const [uploadPreviews, setUploadPreviews] = useState([]); + const [cities, setCities] = useState([]); + const [categories, setCategories] = useState([]); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(profileFormSchema), + }); + + useEffect(() => { + loadProfile(); + }, []); + + const loadProfile = async () => { + try { + setLoading(true); + const [citiesRes, categoriesRes] = await Promise.all([ + profileApi.getCities(), + profileApi.getCategories() + ]); + + if (citiesRes.success && citiesRes.data) { + setCities(citiesRes.data); + } + if (categoriesRes.success && categoriesRes.data) { + setCategories(categoriesRes.data); + } + + const res = await profileApi.getMyProfile(); + if (res.success && res.data) { + setProfile(res.data); + const currentListing = (res.data as any).listings?.[0] || {}; + reset({ + title: currentListing.title || '', + description: currentListing.description || '', + price: currentListing.price || 200, + currency: currentListing.currency || 'USD', + age: currentListing.age || 21, + height: currentListing.height || '170 cm', + tagline: currentListing.tagline || '', + languages: currentListing.languages?.join(', ') || 'English', + categoryId: currentListing.categoryId || (categoriesRes.data?.[0]?.id || ''), + cityId: currentListing.cityId || (citiesRes.data?.[0]?.id || ''), + areaId: currentListing.areaId || '', + }); + } + } catch (err: any) { + if (err.response?.status === 404) { + // No profile exists yet, user can create one + setProfile(null); + } else { + setError('Failed to load profile details.'); + } + } finally { + setLoading(false); + } + }; + + const onSubmit = async (data: ProfileFormData) => { + setError(null); + setSuccess(null); + try { + const payload = { + ...data, + // Convert comma-separated string to array for backend + languages: data.languages + ? data.languages.split(',').map((l: string) => l.trim()).filter(Boolean) + : [], + // Default empty tags array + tags: [], + // Ensure price is a number + price: Number(data.price), + // Ensure age is a number or undefined + age: data.age ? Number(data.age) : undefined, + // Remove empty optional fields + areaId: data.areaId || undefined, + }; + + let res; + const existingListingId = (profile as any)?.listings?.[0]?.id; + if (existingListingId) { + // Update existing listing + res = await profileApi.updateProfile(existingListingId, payload); + } else { + // Create new listing + res = await profileApi.createListing(payload); + } + + if (res.success) { + setSuccess('Profile saved successfully!'); + loadProfile(); + } else { + setError('Failed to save profile. Please check all fields and try again.'); + } + } catch (err: any) { + const apiError = err.response?.data; + if (apiError?.message && Array.isArray(apiError.errors)) { + // Show first validation error from backend + const firstError = apiError.errors[0]; + setError(firstError ? `${firstError.field}: ${firstError.message}` : apiError.message); + } else { + setError(err.response?.data?.message || 'Failed to save profile.'); + } + } + }; + + const onDrop = async (acceptedFiles: File[]) => { + if (acceptedFiles.length === 0) return; + setError(null); + setSuccess(null); + + const listingId = (profile as any)?.listings?.[0]?.id; + if (!listingId) { + setError('Please save your profile details first before uploading images.'); + return; + } + + // Show local previews immediately + const localUrls = acceptedFiles.map((f) => URL.createObjectURL(f)); + setUploadPreviews(localUrls); + setUploadingImage(true); + + try { + const existingGallery = (profile as any)?.listings?.[0]?.gallery || []; + const isFirstImage = existingGallery.length === 0; + + for (const [i, file] of acceptedFiles.entries()) { + const uploadRes = await mediaApi.uploadFile(file); + if (uploadRes.success && uploadRes.data?.url) { + // First uploaded image becomes primary if gallery is empty + const makePrimary = isFirstImage && i === 0; + await profileApi.uploadGallery(listingId, uploadRes.data.url, makePrimary); + } else { + throw new Error('The server did not return an image URL.'); + } + } + + setSuccess(`${acceptedFiles.length} image${acceptedFiles.length > 1 ? 's' : ''} uploaded successfully!`); + loadProfile(); + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to upload image.'); + } finally { + setUploadingImage(false); + setUploadPreviews([]); + } + }; + + const handleSetPrimary = async (galleryItemId: string) => { + const listingId = (profile as any)?.listings?.[0]?.id; + if (!listingId) return; + try { + await profileApi.setPrimaryGalleryImage(listingId, galleryItemId); + loadProfile(); + } catch (err) { + console.error('Failed to set primary image:', err); + } + }; + + const handleDeleteGalleryImage = async (galleryItemId: string) => { + try { + await profileApi.deleteGalleryImage(galleryItemId); + loadProfile(); + } catch (err) { + console.error('Failed to delete image:', err); + } + }; + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + 'image/*': ['.jpeg', '.jpg', '.png', '.webp', '.gif'], + }, + multiple: true, + }); + + if (loading) { + return ( + + + + ); + } + + return ( + + + + {profile ? 'Edit Your Companion Profile' : 'Create Your Companion Profile'} + + + This information will be displayed publicly to members. Make sure to use high quality details. + + + {error && {error}} + {success && {success}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Category + + + + + + City + + + + + + + {isSubmitting ? 'Saving...' : 'Save Profile Details'} + + + + + + + + + Gallery & Photos + + + Drag and drop your photos here, or click to browse files. + + + + + {uploadingImage ? ( + + {uploadPreviews.length > 0 && ( + + {uploadPreviews.map((src, i) => ( + + ))} + + )} + + + Uploading {uploadPreviews.length} image{uploadPreviews.length > 1 ? 's' : ''}... + + + ) : ( + <> + + + {isDragActive ? 'Drop your photos here' : 'Drag & drop images'} + + + Multiple files supported · JPG, PNG, WEBP, GIF + + + )} + + + + {(profile as any)?.listings?.[0]?.gallery?.length > 0 && ( + + Gallery Images ({(profile as any).listings[0].gallery.length}) + + {(profile as any).listings[0].gallery.map((img: any) => ( + + + + {img.isPrimary && ( + + PRIMARY + + )} + + {!img.isPrimary && ( + handleSetPrimary(img.id)} + sx={{ + flex: 1, fontSize: 9, fontWeight: 600, cursor: 'pointer', + bgcolor: 'rgba(201,169,98,0.8)', border: 'none', borderRadius: 0.5, + color: '#000', py: 0.5, '&:hover': { bgcolor: 'gold.main' } + }} + >Set Primary + )} + handleDeleteGalleryImage(img.id)} + sx={{ + flex: 1, fontSize: 9, fontWeight: 600, cursor: 'pointer', + bgcolor: 'rgba(255,50,50,0.7)', border: 'none', borderRadius: 0.5, + color: '#fff', py: 0.5, '&:hover': { bgcolor: 'error.main' } + }} + >Delete + + + + ))} + + + )} + + + + + ); +} + +export default DashboardProfilePage; diff --git a/src/store/index.ts b/src/store/index.ts index fd704f9..c711978 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -28,14 +28,19 @@ interface AuthState { logout: () => void; } -export const useAuthStore = create()((set) => ({ - user: null, - isAuthenticated: false, - isLoading: false, - setUser: (user) => set({ user, isAuthenticated: !!user }), - setLoading: (isLoading) => set({ isLoading }), - logout: () => set({ user: null, isAuthenticated: false }), -})); +export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + isAuthenticated: false, + isLoading: false, + setUser: (user) => set({ user, isAuthenticated: !!user }), + setLoading: (isLoading) => set({ isLoading }), + logout: () => set({ user: null, isAuthenticated: false }), + }), + { name: 'luxe_auth_state' } + ) +); interface SearchState { filters: SearchFilters;