Compare commits

..

No commits in common. "feature/apis" and "main" have entirely different histories.

16 changed files with 143 additions and 1006 deletions

5
.env
View File

@ -1,5 +0,0 @@
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=

View File

@ -1,31 +0,0 @@
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<ApiResponse<any>>('/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<ApiResponse<any>>('/auth/register', payload);
return response.data;
},
logout: async () => {
const response = await apiClient.post<ApiResponse<any>>('/auth/logout');
return response.data;
},
getMe: async () => {
const response = await apiClient.get<ApiResponse<any>>('/auth/me');
return response.data;
}
};

View File

@ -1,17 +0,0 @@
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<ApiResponse<{ url: string }>>('/media/upload', formData);
return response.data;
},
};
export default mediaApi;

View File

@ -1,59 +0,0 @@
import apiClient from './client';
import type { ApiResponse, Profile } from '@/types';
export const profileApi = {
getMyProfile: async () => {
const response = await apiClient.get<ApiResponse<Profile>>('/clients/my-profile');
return response.data;
},
createListing: async (data: Partial<Profile>) => {
const response = await apiClient.post<ApiResponse<Profile>>('/clients/listings', data);
return response.data;
},
updateProfile: async (listingId: string, data: any) => {
const response = await apiClient.put<ApiResponse<any>>(`/clients/listings/${listingId}`, data);
return response.data;
},
uploadGallery: async (listingId: string, url: string, isPrimary: boolean = false) => {
const response = await apiClient.post<ApiResponse<any>>('/clients/my-profile/gallery', {
listingId,
url,
mediaType: 'IMAGE',
isPrimary
});
return response.data;
},
setPrimaryGalleryImage: async (listingId: string, galleryItemId: string) => {
const response = await apiClient.patch<ApiResponse<any>>(`/clients/listings/${listingId}/gallery/${galleryItemId}/primary`);
return response.data;
},
deleteGalleryImage: async (galleryItemId: string) => {
const response = await apiClient.delete<ApiResponse<any>>(`/clients/gallery/${galleryItemId}`);
return response.data;
},
searchProfiles: async (params?: any) => {
const response = await apiClient.get<ApiResponse<any>>('/search/profiles', { params });
return response.data;
},
getProfileBySlug: async (slug: string) => {
const response = await apiClient.get<ApiResponse<any>>(`/profiles/${slug}`);
return response.data;
},
getCities: async () => {
const response = await apiClient.get<ApiResponse<any>>('/cities');
return response.data;
},
getCategories: async () => {
const response = await apiClient.get<ApiResponse<any>>('/categories');
return response.data;
}
};

View File

@ -1,17 +0,0 @@
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<ApiResponse<any>>(`/profiles/${slug}/reviews`, {
rating,
comment
});
return response.data;
},
getReviews: async (slug: string) => {
const response = await apiClient.get<ApiResponse<any[]>>(`/profiles/${slug}/reviews`);
return response.data;
}
};

View File

@ -19,7 +19,6 @@ 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: {
@ -56,7 +55,6 @@ export function AppRouter() {
<Route path={ROUTES.PROFILE} element={<ProfilePage />} />
<Route path={ROUTES.BLOG} element={<BlogListPage />} />
<Route path={ROUTES.BLOG_POST} element={<BlogPostPage />} />
<Route path={ROUTES.DASHBOARD.PROFILE} element={<DashboardProfilePage />} />
</Route>
<Route element={<AuthLayout />}>
<Route path={ROUTES.AUTH.LOGIN} element={<LoginPage />} />

View File

@ -85,7 +85,7 @@ export function Header() {
{!isMobile && (
<>
{isAuthenticated ? (
<LuxeButton component={RouterLink} to={ROUTES.DASHBOARD.PROFILE} variant="outlined" size="small">
<LuxeButton component={RouterLink} to={ROUTES.DASHBOARD.ROOT} variant="outlined" size="small">
{t('nav.dashboard')}
</LuxeButton>
) : (

View File

@ -17,12 +17,10 @@ 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<SearchFormData>({
resolver: zodResolver(searchSchema),
defaultValues: { query: '', city: '', category: '' },
@ -203,23 +201,7 @@ export function HeroSection() {
<LuxeButton variant="contained" size="large" onClick={() => navigate(ROUTES.SEARCH)}>
{t('hero.cta')}
</LuxeButton>
<LuxeButton
variant="outlined"
size="large"
onClick={() => {
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);
}
}}
>
<LuxeButton variant="outlined" size="large" onClick={() => navigate(ROUTES.AUTH.REGISTER)}>
{t('hero.secondaryCta')}
</LuxeButton>
</Box>

View File

@ -1,69 +1,29 @@
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 { profileApi } from '@/api/profile';
import { mockProfiles } from '@/constants/mockData';
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,
params = {},
profiles,
filter,
viewAllPath = '/search',
gradient = false,
}: {
titleKey: string;
subtitle?: string;
params?: any;
viewAllPath?: string;
gradient?: boolean;
}) {
}: ProfileGridSectionProps) {
const { t } = useTranslation();
const [profiles, setProfiles] = useState<any[]>([]);
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)]);
const data = filter ? profiles.filter(filter) : profiles;
return (
<SectionWrapper>
@ -78,35 +38,36 @@ export function ProfileGridSection({
</LuxeButton>
}
/>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress color="secondary" size={36} />
</Box>
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
lg: 'repeat(4, 1fr)',
},
gap: 3,
}}
>
{profiles.slice(0, 8).map((profile, i) => (
<ProfileCard key={profile.id} profile={profile} index={i} />
))}
</Box>
)}
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
lg: 'repeat(4, 1fr)',
},
gap: 3,
}}
>
{data.slice(0, 8).map((profile, i) => (
<ProfileCard key={profile.id} profile={profile} index={i} />
))}
</Box>
</PageContainer>
</SectionWrapper>
);
}
export function TrendingProfilesSection() {
return <ProfileGridSection titleKey="sections.trending" params={{ sortBy: 'rating' }} gradient />;
return (
<ProfileGridSection
titleKey="sections.trending"
profiles={mockProfiles}
filter={(p) => p.isOnline || p.rating >= 4.7}
gradient
/>
);
}
export function FeaturedProfilesSection() {
@ -114,13 +75,14 @@ export function FeaturedProfilesSection() {
<ProfileGridSection
titleKey="sections.featured"
subtitle="Hand-picked profiles showcasing exceptional quality and service."
params={{ featured: 'true' }}
profiles={mockProfiles}
filter={(p) => p.isPremium}
/>
);
}
export function LatestProfilesSection() {
return <ProfileGridSection titleKey="sections.latest" params={{ sortBy: 'createdAt' }} />;
return <ProfileGridSection titleKey="sections.latest" profiles={[...mockProfiles].reverse()} />;
}
export function VerifiedProfilesSection() {
@ -128,13 +90,21 @@ export function VerifiedProfilesSection() {
<ProfileGridSection
titleKey="sections.verified"
subtitle="Identity-verified companions you can trust."
params={{ verified: 'true' }}
profiles={mockProfiles}
filter={(p) => p.isVerified}
/>
);
}
export function PremiumAdvertisersSection() {
return <ProfileGridSection titleKey="sections.premium" params={{ featured: 'true' }} gradient />;
return (
<ProfileGridSection
titleKey="sections.premium"
profiles={mockProfiles}
filter={(p) => p.isPremium}
gradient
/>
);
}
export function NearbyListingsSection() {
@ -142,7 +112,7 @@ export function NearbyListingsSection() {
<ProfileGridSection
titleKey="sections.nearby"
subtitle="Discover companions in your area."
params={{ pageSize: 4 }}
profiles={mockProfiles.slice(0, 4)}
/>
);
}

View File

@ -2,15 +2,6 @@ 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(
<StrictMode>
<App />

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState } 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,7 +18,6 @@ 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';
@ -44,12 +43,10 @@ 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({
@ -60,23 +57,14 @@ const reviewSchema = z.object({
type ReviewFormData = z.infer<typeof reviewSchema>;
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<Profile | null>(null);
const [loading, setLoading] = useState(true);
const [galleryImages, setGalleryImages] = useState<string[]>([]);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [activePhotoIdx, setActivePhotoIdx] = useState(0);
const [copied, setCopied] = useState(false);
@ -85,67 +73,10 @@ export function ProfilePage() {
const [verificationStep, setVerificationStep] = useState(1); // 1: Info, 2: Document, 3: Selfie, 4: Submitted
const [uploadedDoc, setUploadedDoc] = useState<File | null>(null);
const [uploadedSelfie, setUploadedSelfie] = useState<File | null>(null);
const [reviewsList, setReviewsList] = useState<Review[]>([]);
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);
}
};
const [reviewsList, setReviewsList] = useState<any[]>([
{ 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.' },
]);
// React Hook Form for review
const { register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<ReviewFormData>({
@ -155,14 +86,6 @@ export function ProfilePage() {
const ratingVal = watch('rating');
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '80vh' }}>
<CircularProgress color="secondary" />
</Box>
);
}
if (!profile) {
return (
<SectionWrapper>
@ -177,15 +100,13 @@ export function ProfilePage() {
);
}
// 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',
];
// 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',
];
// Rates calculation
const incallRates = [
@ -215,24 +136,16 @@ export function ProfilePage() {
setTimeout(() => setCopied(false), 2000);
};
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);
}
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();
};
// Mock schedule grid

View File

@ -13,13 +13,11 @@ 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();
@ -42,66 +40,54 @@ export function SearchPage() {
}
}, [queryParam, cityParam, categoryParam, setFilters]);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [loading, setLoading] = useState(true);
// 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;
});
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;
// 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);
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;
// 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;
});
const handleSearchInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
@ -256,11 +242,7 @@ export function SearchPage() {
Showing {sortedResults.length} premium profile{sortedResults.length !== 1 && 's'}
</Typography>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
<CircularProgress color="secondary" />
</Box>
) : sortedResults.length > 0 ? (
{sortedResults.length > 0 ? (
<Grid container spacing={3}>
{sortedResults.map((profile, i) => (
<Grid key={profile.id} size={{ xs: 12, sm: 6, lg: 4 }}>

View File

@ -12,48 +12,21 @@ 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, STORAGE_KEYS } from '@/constants';
import { authApi } from '@/api/auth';
import { useAuthStore } from '@/store';
import { setStorageItem } from '@/utils';
import { useNavigate } from 'react-router-dom';
import { ROUTES } from '@/constants';
export function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { setUser } = useAuthStore();
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: { rememberMe: false },
});
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' });
}
const onSubmit = async (_data: LoginFormData) => {
await new Promise((r) => setTimeout(r, 800));
};
return (
@ -66,11 +39,6 @@ export function LoginPage() {
</Typography>
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
{errors.root && (
<Typography color="error" variant="body2" sx={{ mb: 2 }}>
{errors.root.message}
</Typography>
)}
<TextField
{...register('email')}
label={t('auth.email')}

View File

@ -12,51 +12,21 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslation } from 'react-i18next';
import { LuxeButton } from '@/components/ui';
import { registerSchema, type RegisterFormData } from '@/validators/auth';
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';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import { ROUTES } from '@/constants';
export function RegisterPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { setUser } = useAuthStore();
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
defaultValues: { acceptTerms: false, role: 'USER' } as any,
defaultValues: { acceptTerms: false },
});
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' });
}
const onSubmit = async (_data: RegisterFormData) => {
await new Promise((r) => setTimeout(r, 800));
};
return (
@ -69,24 +39,6 @@ export function RegisterPage() {
</Typography>
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
{errors.root && (
<Typography color="error" variant="body2" sx={{ mb: 2 }}>
{errors.root.message}
</Typography>
)}
<FormControl fullWidth margin="normal">
<InputLabel id="role-label">Account Type</InputLabel>
<Select
labelId="role-label"
label="Account Type"
defaultValue="USER"
{...register('role' as any)}
>
<MenuItem value="USER">User (Browse & Review)</MenuItem>
<MenuItem value="ADVERTISER">Client (Create Profile)</MenuItem>
<MenuItem value="ADMIN">Admin (Manage Platform)</MenuItem>
</Select>
</FormControl>
<TextField
{...register('displayName')}
label="Display Name"

View File

@ -1,485 +0,0 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import Alert from '@mui/material/Alert';
import CircularProgress from '@mui/material/CircularProgress';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import { useDropzone } from 'react-dropzone';
import { PageContainer, SectionWrapper, GlassCard, LuxeButton } from '@/components/ui';
import { profileApi } from '@/api/profile';
import { mediaApi } from '@/api/media';
const profileFormSchema = z.object({
title: z.string().min(5, 'Title must be at least 5 characters'),
description: z.string().min(20, 'Description must be at least 20 characters'),
price: z.number().min(1, 'Price must be greater than 0'),
currency: z.string().default('USD'),
age: z.number().min(18, 'Must be at least 18 years old').max(99).optional(),
height: z.string().optional(),
tagline: z.string().optional(),
languages: z.string().default('English'),
categoryId: z.string().min(1, 'Please select a category'),
cityId: z.string().min(1, 'Please select a city'),
areaId: z.string().optional(),
});
type ProfileFormData = z.infer<typeof profileFormSchema>;
export function DashboardProfilePage() {
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const [uploadPreviews, setUploadPreviews] = useState<string[]>([]);
const [cities, setCities] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<any>({
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 (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}>
<CircularProgress color="secondary" />
</Box>
);
}
return (
<SectionWrapper>
<PageContainer maxWidth="md">
<Typography variant="h4" sx={{ mb: 1, fontFamily: '"Playfair Display", serif' }}>
{profile ? 'Edit Your Companion Profile' : 'Create Your Companion Profile'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
This information will be displayed publicly to members. Make sure to use high quality details.
</Typography>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
{success && <Alert severity="success" sx={{ mb: 3 }}>{success}</Alert>}
<Grid container spacing={4}>
<Grid size={{ xs: 12, md: 8 }}>
<GlassCard sx={{ p: 4 }}>
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<TextField
{...register('title')}
label="Profile / Listing Title"
fullWidth
error={!!errors.title}
helperText={errors.title?.message?.toString()}
/>
<TextField
{...register('tagline')}
label="Short Tagline / Catchphrase"
fullWidth
error={!!errors.tagline}
helperText={errors.tagline?.message?.toString()}
/>
<TextField
{...register('description')}
label="About Me (Description)"
multiline
rows={4}
fullWidth
error={!!errors.description}
helperText={errors.description?.message?.toString()}
/>
<Grid container spacing={2}>
<Grid size={{ xs: 6 }}>
<TextField
{...register('price', { valueAsNumber: true })}
label="Hourly Rate"
type="number"
fullWidth
error={!!errors.price}
helperText={errors.price?.message?.toString()}
/>
</Grid>
<Grid size={{ xs: 6 }}>
<TextField
{...register('age', { valueAsNumber: true })}
label="Age"
type="number"
fullWidth
error={!!errors.age}
helperText={errors.age?.message?.toString()}
/>
</Grid>
</Grid>
<Grid container spacing={2}>
<Grid size={{ xs: 6 }}>
<TextField
{...register('height')}
label="Height (e.g. 170 cm)"
fullWidth
error={!!errors.height}
helperText={errors.height?.message?.toString()}
/>
</Grid>
<Grid size={{ xs: 6 }}>
<TextField
{...register('languages')}
label="Languages (comma separated)"
fullWidth
error={!!errors.languages}
helperText={errors.languages?.message?.toString()}
/>
</Grid>
</Grid>
<Grid container spacing={2}>
<Grid size={{ xs: 6 }}>
<FormControl fullWidth>
<InputLabel id="category-label">Category</InputLabel>
<Select
labelId="category-label"
label="Category"
{...register('categoryId')}
>
{categories.map((cat) => (
<MenuItem key={cat.id} value={cat.id}>{cat.name}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6 }}>
<FormControl fullWidth>
<InputLabel id="city-label">City</InputLabel>
<Select
labelId="city-label"
label="City"
{...register('cityId')}
>
{cities.map((city) => (
<MenuItem key={city.id} value={city.id}>{city.name}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
<LuxeButton type="submit" variant="contained" disabled={isSubmitting} sx={{ py: 1.5 }}>
{isSubmitting ? 'Saving...' : 'Save Profile Details'}
</LuxeButton>
</Box>
</GlassCard>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<GlassCard sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" sx={{ mb: 2, fontFamily: '"Playfair Display", serif' }}>
Gallery & Photos
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Drag and drop your photos here, or click to browse files.
</Typography>
<Box
{...getRootProps()}
sx={{
border: '2px dashed',
borderColor: isDragActive ? 'gold.main' : 'rgba(255, 255, 255, 0.15)',
borderRadius: 2,
p: 3,
textAlign: 'center',
cursor: 'pointer',
bgcolor: isDragActive ? 'rgba(201, 169, 98, 0.05)' : 'transparent',
transition: 'all 0.25s ease-in-out',
'&:hover': {
borderColor: 'gold.main',
bgcolor: 'rgba(201, 169, 98, 0.02)',
},
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
minHeight: 160,
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
}}
>
<input {...getInputProps()} />
{uploadingImage ? (
<Box sx={{ textAlign: 'center' }}>
{uploadPreviews.length > 0 && (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'center', mb: 1 }}>
{uploadPreviews.map((src, i) => (
<Box
key={i}
component="img"
src={src}
alt="preview"
sx={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 1, opacity: 0.5 }}
/>
))}
</Box>
)}
<CircularProgress size={28} color="secondary" />
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
Uploading {uploadPreviews.length} image{uploadPreviews.length > 1 ? 's' : ''}...
</Typography>
</Box>
) : (
<>
<CloudUploadIcon sx={{ fontSize: 44, color: 'text.secondary' }} />
<Typography variant="body2" fontWeight={500}>
{isDragActive ? 'Drop your photos here' : 'Drag & drop images'}
</Typography>
<Typography variant="caption" color="text.secondary">
Multiple files supported · JPG, PNG, WEBP, GIF
</Typography>
</>
)}
</Box>
</GlassCard>
{(profile as any)?.listings?.[0]?.gallery?.length > 0 && (
<GlassCard sx={{ p: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2 }}>Gallery Images ({(profile as any).listings[0].gallery.length})</Typography>
<Grid container spacing={1}>
{(profile as any).listings[0].gallery.map((img: any) => (
<Grid size={{ xs: 6 }} key={img.id}>
<Box sx={{ position: 'relative', borderRadius: 1, overflow: 'hidden' }}>
<Box
component="img"
src={img.url}
alt="Gallery"
sx={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }}
/>
{img.isPrimary && (
<Box sx={{
position: 'absolute', top: 4, left: 4,
bgcolor: 'rgba(201,169,98,0.9)', borderRadius: 1,
px: 0.75, py: 0.25
}}>
<Typography variant="caption" sx={{ color: '#000', fontWeight: 700, fontSize: 9 }}>PRIMARY</Typography>
</Box>
)}
<Box sx={{
position: 'absolute', bottom: 0, insetInline: 0,
display: 'flex', gap: 0.5, p: 0.5,
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))'
}}>
{!img.isPrimary && (
<Box
component="button"
onClick={() => 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</Box>
)}
<Box
component="button"
onClick={() => 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</Box>
</Box>
</Box>
</Grid>
))}
</Grid>
</GlassCard>
)}
</Grid>
</Grid>
</PageContainer>
</SectionWrapper>
);
}
export default DashboardProfilePage;

View File

@ -28,19 +28,14 @@ interface AuthState {
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
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' }
)
);
export const useAuthStore = create<AuthState>()((set) => ({
user: null,
isAuthenticated: false,
isLoading: false,
setUser: (user) => set({ user, isAuthenticated: !!user }),
setLoading: (isLoading) => set({ isLoading }),
logout: () => set({ user: null, isAuthenticated: false }),
}));
interface SearchState {
filters: SearchFilters;