Compare commits
No commits in common. "feature/apis" and "main" have entirely different histories.
feature/ap
...
main
5
.env
5
.env
|
|
@ -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=
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -19,7 +19,6 @@ const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
|
||||||
const BlogListPage = lazy(() => import('@/pages/blog/BlogListPage'));
|
const BlogListPage = lazy(() => import('@/pages/blog/BlogListPage'));
|
||||||
const BlogPostPage = lazy(() => import('@/pages/blog/BlogPostPage'));
|
const BlogPostPage = lazy(() => import('@/pages/blog/BlogPostPage'));
|
||||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'));
|
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'));
|
||||||
const DashboardProfilePage = lazy(() => import('@/pages/dashboard/DashboardProfilePage'));
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|
@ -56,7 +55,6 @@ export function AppRouter() {
|
||||||
<Route path={ROUTES.PROFILE} element={<ProfilePage />} />
|
<Route path={ROUTES.PROFILE} element={<ProfilePage />} />
|
||||||
<Route path={ROUTES.BLOG} element={<BlogListPage />} />
|
<Route path={ROUTES.BLOG} element={<BlogListPage />} />
|
||||||
<Route path={ROUTES.BLOG_POST} element={<BlogPostPage />} />
|
<Route path={ROUTES.BLOG_POST} element={<BlogPostPage />} />
|
||||||
<Route path={ROUTES.DASHBOARD.PROFILE} element={<DashboardProfilePage />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<AuthLayout />}>
|
<Route element={<AuthLayout />}>
|
||||||
<Route path={ROUTES.AUTH.LOGIN} element={<LoginPage />} />
|
<Route path={ROUTES.AUTH.LOGIN} element={<LoginPage />} />
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ export function Header() {
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<>
|
<>
|
||||||
{isAuthenticated ? (
|
{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')}
|
{t('nav.dashboard')}
|
||||||
</LuxeButton>
|
</LuxeButton>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,10 @@ import { searchSchema, type SearchFormData } from '@/validators/auth';
|
||||||
import { mockCategories, mockCities } from '@/constants/mockData';
|
import { mockCategories, mockCities } from '@/constants/mockData';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { gradients } from '@/theme/tokens';
|
import { gradients } from '@/theme/tokens';
|
||||||
import { useAuthStore } from '@/store';
|
|
||||||
|
|
||||||
export function HeroSection() {
|
export function HeroSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAuthenticated, user } = useAuthStore();
|
|
||||||
const { control, handleSubmit } = useForm<SearchFormData>({
|
const { control, handleSubmit } = useForm<SearchFormData>({
|
||||||
resolver: zodResolver(searchSchema),
|
resolver: zodResolver(searchSchema),
|
||||||
defaultValues: { query: '', city: '', category: '' },
|
defaultValues: { query: '', city: '', category: '' },
|
||||||
|
|
@ -203,23 +201,7 @@ export function HeroSection() {
|
||||||
<LuxeButton variant="contained" size="large" onClick={() => navigate(ROUTES.SEARCH)}>
|
<LuxeButton variant="contained" size="large" onClick={() => navigate(ROUTES.SEARCH)}>
|
||||||
{t('hero.cta')}
|
{t('hero.cta')}
|
||||||
</LuxeButton>
|
</LuxeButton>
|
||||||
<LuxeButton
|
<LuxeButton variant="outlined" size="large" onClick={() => navigate(ROUTES.AUTH.REGISTER)}>
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('hero.secondaryCta')}
|
{t('hero.secondaryCta')}
|
||||||
</LuxeButton>
|
</LuxeButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,29 @@
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PageContainer, SectionWrapper, SectionHeader, ProfileCard } from '@/components/ui';
|
import { PageContainer, SectionWrapper, SectionHeader, ProfileCard } from '@/components/ui';
|
||||||
import { profileApi } from '@/api/profile';
|
import { mockProfiles } from '@/constants/mockData';
|
||||||
import LuxeButton from '@/components/ui/LuxeButton';
|
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({
|
export function ProfileGridSection({
|
||||||
titleKey,
|
titleKey,
|
||||||
subtitle,
|
subtitle,
|
||||||
params = {},
|
profiles,
|
||||||
|
filter,
|
||||||
viewAllPath = '/search',
|
viewAllPath = '/search',
|
||||||
gradient = false,
|
gradient = false,
|
||||||
}: {
|
}: ProfileGridSectionProps) {
|
||||||
titleKey: string;
|
|
||||||
subtitle?: string;
|
|
||||||
params?: any;
|
|
||||||
viewAllPath?: string;
|
|
||||||
gradient?: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [profiles, setProfiles] = useState<any[]>([]);
|
const data = filter ? profiles.filter(filter) : profiles;
|
||||||
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 (
|
return (
|
||||||
<SectionWrapper>
|
<SectionWrapper>
|
||||||
|
|
@ -78,35 +38,36 @@ export function ProfileGridSection({
|
||||||
</LuxeButton>
|
</LuxeButton>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{loading ? (
|
<Box
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
sx={{
|
||||||
<CircularProgress color="secondary" size={36} />
|
display: 'grid',
|
||||||
</Box>
|
gridTemplateColumns: {
|
||||||
) : (
|
xs: '1fr',
|
||||||
<Box
|
sm: 'repeat(2, 1fr)',
|
||||||
sx={{
|
md: 'repeat(3, 1fr)',
|
||||||
display: 'grid',
|
lg: 'repeat(4, 1fr)',
|
||||||
gridTemplateColumns: {
|
},
|
||||||
xs: '1fr',
|
gap: 3,
|
||||||
sm: 'repeat(2, 1fr)',
|
}}
|
||||||
md: 'repeat(3, 1fr)',
|
>
|
||||||
lg: 'repeat(4, 1fr)',
|
{data.slice(0, 8).map((profile, i) => (
|
||||||
},
|
<ProfileCard key={profile.id} profile={profile} index={i} />
|
||||||
gap: 3,
|
))}
|
||||||
}}
|
</Box>
|
||||||
>
|
|
||||||
{profiles.slice(0, 8).map((profile, i) => (
|
|
||||||
<ProfileCard key={profile.id} profile={profile} index={i} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</SectionWrapper>
|
</SectionWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrendingProfilesSection() {
|
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() {
|
export function FeaturedProfilesSection() {
|
||||||
|
|
@ -114,13 +75,14 @@ export function FeaturedProfilesSection() {
|
||||||
<ProfileGridSection
|
<ProfileGridSection
|
||||||
titleKey="sections.featured"
|
titleKey="sections.featured"
|
||||||
subtitle="Hand-picked profiles showcasing exceptional quality and service."
|
subtitle="Hand-picked profiles showcasing exceptional quality and service."
|
||||||
params={{ featured: 'true' }}
|
profiles={mockProfiles}
|
||||||
|
filter={(p) => p.isPremium}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LatestProfilesSection() {
|
export function LatestProfilesSection() {
|
||||||
return <ProfileGridSection titleKey="sections.latest" params={{ sortBy: 'createdAt' }} />;
|
return <ProfileGridSection titleKey="sections.latest" profiles={[...mockProfiles].reverse()} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VerifiedProfilesSection() {
|
export function VerifiedProfilesSection() {
|
||||||
|
|
@ -128,13 +90,21 @@ export function VerifiedProfilesSection() {
|
||||||
<ProfileGridSection
|
<ProfileGridSection
|
||||||
titleKey="sections.verified"
|
titleKey="sections.verified"
|
||||||
subtitle="Identity-verified companions you can trust."
|
subtitle="Identity-verified companions you can trust."
|
||||||
params={{ verified: 'true' }}
|
profiles={mockProfiles}
|
||||||
|
filter={(p) => p.isVerified}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PremiumAdvertisersSection() {
|
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() {
|
export function NearbyListingsSection() {
|
||||||
|
|
@ -142,7 +112,7 @@ export function NearbyListingsSection() {
|
||||||
<ProfileGridSection
|
<ProfileGridSection
|
||||||
titleKey="sections.nearby"
|
titleKey="sections.nearby"
|
||||||
subtitle="Discover companions in your area."
|
subtitle="Discover companions in your area."
|
||||||
params={{ pageSize: 4 }}
|
profiles={mockProfiles.slice(0, 4)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,6 @@ import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from '@/app/App';
|
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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams, Link as RouterLink, useNavigate } from 'react-router-dom';
|
import { useParams, Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
|
@ -18,7 +18,6 @@ import TableRow from '@mui/material/TableRow';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import Alert from '@mui/material/Alert';
|
import Alert from '@mui/material/Alert';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import PhoneIcon from '@mui/icons-material/Phone';
|
import PhoneIcon from '@mui/icons-material/Phone';
|
||||||
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
|
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
|
||||||
|
|
@ -44,12 +43,10 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { PageContainer, SectionWrapper, GlassCard, LuxeButton, StatusBadge } from '@/components/ui';
|
import { PageContainer, SectionWrapper, GlassCard, LuxeButton, StatusBadge } from '@/components/ui';
|
||||||
|
import { mockProfiles } from '@/constants/mockData';
|
||||||
import { formatCurrency } from '@/utils';
|
import { formatCurrency } from '@/utils';
|
||||||
import { profileApi } from '@/api/profile';
|
|
||||||
import type { Profile } from '@/types';
|
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { useMediaQuery } from '@/hooks';
|
import { useMediaQuery } from '@/hooks';
|
||||||
import { reviewsApi } from '@/api/reviews';
|
|
||||||
|
|
||||||
// Form validation schema for Reviews
|
// Form validation schema for Reviews
|
||||||
const reviewSchema = z.object({
|
const reviewSchema = z.object({
|
||||||
|
|
@ -60,23 +57,14 @@ const reviewSchema = z.object({
|
||||||
|
|
||||||
type ReviewFormData = z.infer<typeof reviewSchema>;
|
type ReviewFormData = z.infer<typeof reviewSchema>;
|
||||||
|
|
||||||
interface Review {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
rating: number;
|
|
||||||
date: string;
|
|
||||||
comment: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isSmUp = useMediaQuery('(min-width:600px)');
|
const isSmUp = useMediaQuery('(min-width:600px)');
|
||||||
|
|
||||||
|
const profile = mockProfiles.find((p) => p.slug === slug);
|
||||||
|
|
||||||
// States
|
// States
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [galleryImages, setGalleryImages] = useState<string[]>([]);
|
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
const [activePhotoIdx, setActivePhotoIdx] = useState(0);
|
const [activePhotoIdx, setActivePhotoIdx] = useState(0);
|
||||||
const [copied, setCopied] = useState(false);
|
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 [verificationStep, setVerificationStep] = useState(1); // 1: Info, 2: Document, 3: Selfie, 4: Submitted
|
||||||
const [uploadedDoc, setUploadedDoc] = useState<File | null>(null);
|
const [uploadedDoc, setUploadedDoc] = useState<File | null>(null);
|
||||||
const [uploadedSelfie, setUploadedSelfie] = useState<File | null>(null);
|
const [uploadedSelfie, setUploadedSelfie] = useState<File | null>(null);
|
||||||
const [reviewsList, setReviewsList] = useState<Review[]>([]);
|
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!' },
|
||||||
useEffect(() => {
|
{ 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.' },
|
||||||
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
|
// React Hook Form for review
|
||||||
const { register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<ReviewFormData>({
|
const { register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<ReviewFormData>({
|
||||||
|
|
@ -155,14 +86,6 @@ export function ProfilePage() {
|
||||||
|
|
||||||
const ratingVal = watch('rating');
|
const ratingVal = watch('rating');
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '80vh' }}>
|
|
||||||
<CircularProgress color="secondary" />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return (
|
return (
|
||||||
<SectionWrapper>
|
<SectionWrapper>
|
||||||
|
|
@ -177,15 +100,13 @@ export function ProfilePage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build photo array from real gallery, fallback to stock images if none uploaded yet
|
// Sample photos for slider
|
||||||
const photos = galleryImages.length > 0
|
const photos = [
|
||||||
? galleryImages
|
profile.avatar,
|
||||||
: [
|
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=600&h=800&fit=crop',
|
||||||
profile.avatar,
|
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=600&h=800&fit=crop',
|
||||||
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=600&h=800&fit=crop',
|
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?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
|
// Rates calculation
|
||||||
const incallRates = [
|
const incallRates = [
|
||||||
|
|
@ -215,24 +136,16 @@ export function ProfilePage() {
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReviewSubmit = async (data: ReviewFormData) => {
|
const handleReviewSubmit = (data: ReviewFormData) => {
|
||||||
if (!profile) return;
|
const newRev = {
|
||||||
try {
|
id: reviewsList.length + 1,
|
||||||
const res = await reviewsApi.submitReview(profile.slug, data.rating, data.comment);
|
name: data.reviewerName,
|
||||||
if (res.success) {
|
rating: data.rating,
|
||||||
const newRev = {
|
date: new Date().toISOString().split('T')[0],
|
||||||
id: reviewsList.length + 1,
|
comment: data.comment,
|
||||||
name: data.reviewerName,
|
};
|
||||||
rating: data.rating,
|
setReviewsList([newRev, ...reviewsList]);
|
||||||
date: new Date().toISOString().substring(0, 10),
|
reset();
|
||||||
comment: data.comment,
|
|
||||||
};
|
|
||||||
setReviewsList([newRev, ...reviewsList]);
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('Failed to submit review', e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock schedule grid
|
// Mock schedule grid
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,11 @@ import FilterListIcon from '@mui/icons-material/FilterList';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
|
||||||
import { PageContainer, SectionWrapper, ProfileCard, SectionHeader, LuxeButton } from '@/components/ui';
|
import { PageContainer, SectionWrapper, ProfileCard, SectionHeader, LuxeButton } from '@/components/ui';
|
||||||
import { SearchFilterPanel } from '@/components/search/SearchFilterPanel';
|
import { SearchFilterPanel } from '@/components/search/SearchFilterPanel';
|
||||||
|
import { mockProfiles } from '@/constants/mockData';
|
||||||
import { useSearchStore } from '@/store';
|
import { useSearchStore } from '@/store';
|
||||||
import { useMediaQuery } from '@/hooks';
|
import { useMediaQuery } from '@/hooks';
|
||||||
import { profileApi } from '@/api/profile';
|
|
||||||
import type { Profile } from '@/types';
|
|
||||||
|
|
||||||
export function SearchPage() {
|
export function SearchPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -42,66 +40,54 @@ export function SearchPage() {
|
||||||
}
|
}
|
||||||
}, [queryParam, cityParam, categoryParam, setFilters]);
|
}, [queryParam, cityParam, categoryParam, setFilters]);
|
||||||
|
|
||||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
// Apply filters
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
// Apply sorting
|
||||||
const fetchProfiles = async () => {
|
const sortedResults = [...results].sort((a, b) => {
|
||||||
try {
|
const sortBy = filters.sortBy ?? 'relevance';
|
||||||
setLoading(true);
|
if (sortBy === 'price_asc') return a.priceFrom - b.priceFrom;
|
||||||
const params: any = {};
|
if (sortBy === 'price_desc') return b.priceFrom - a.priceFrom;
|
||||||
if (filters.query) params.query = filters.query;
|
if (sortBy === 'rating') return b.rating - a.rating;
|
||||||
if (filters.city) params.city = filters.city;
|
if (sortBy === 'newest') return b.id.localeCompare(a.id);
|
||||||
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);
|
// Relevance: Premium first, then verified first
|
||||||
if (res.success && res.data) {
|
if (a.isPremium && !b.isPremium) return -1;
|
||||||
const mapped = res.data.map((listing: any) => {
|
if (!a.isPremium && b.isPremium) return 1;
|
||||||
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';
|
if (a.isVerified && !b.isVerified) return -1;
|
||||||
return {
|
if (!a.isVerified && b.isVerified) return 1;
|
||||||
id: listing.id,
|
return 0;
|
||||||
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<HTMLInputElement>) => {
|
const handleSearchInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
|
|
@ -256,11 +242,7 @@ export function SearchPage() {
|
||||||
Showing {sortedResults.length} premium profile{sortedResults.length !== 1 && 's'}
|
Showing {sortedResults.length} premium profile{sortedResults.length !== 1 && 's'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{loading ? (
|
{sortedResults.length > 0 ? (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
|
|
||||||
<CircularProgress color="secondary" />
|
|
||||||
</Box>
|
|
||||||
) : sortedResults.length > 0 ? (
|
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{sortedResults.map((profile, i) => (
|
{sortedResults.map((profile, i) => (
|
||||||
<Grid key={profile.id} size={{ xs: 12, sm: 6, lg: 4 }}>
|
<Grid key={profile.id} size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||||
|
|
|
||||||
|
|
@ -12,48 +12,21 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LuxeButton } from '@/components/ui';
|
import { LuxeButton } from '@/components/ui';
|
||||||
import { loginSchema, type LoginFormData } from '@/validators/auth';
|
import { loginSchema, type LoginFormData } from '@/validators/auth';
|
||||||
import { ROUTES, STORAGE_KEYS } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { authApi } from '@/api/auth';
|
|
||||||
import { useAuthStore } from '@/store';
|
|
||||||
import { setStorageItem } from '@/utils';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { setUser } = useAuthStore();
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setError,
|
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<LoginFormData>({
|
} = useForm<LoginFormData>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
defaultValues: { rememberMe: false },
|
defaultValues: { rememberMe: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: LoginFormData) => {
|
const onSubmit = async (_data: LoginFormData) => {
|
||||||
try {
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
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 (
|
return (
|
||||||
|
|
@ -66,11 +39,6 @@ export function LoginPage() {
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||||
{errors.root && (
|
|
||||||
<Typography color="error" variant="body2" sx={{ mb: 2 }}>
|
|
||||||
{errors.root.message}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('email')}
|
{...register('email')}
|
||||||
label={t('auth.email')}
|
label={t('auth.email')}
|
||||||
|
|
|
||||||
|
|
@ -12,51 +12,21 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LuxeButton } from '@/components/ui';
|
import { LuxeButton } from '@/components/ui';
|
||||||
import { registerSchema, type RegisterFormData } from '@/validators/auth';
|
import { registerSchema, type RegisterFormData } from '@/validators/auth';
|
||||||
import { ROUTES, STORAGE_KEYS } from '@/constants';
|
import { ROUTES } 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';
|
|
||||||
|
|
||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { setUser } = useAuthStore();
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setError,
|
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<RegisterFormData>({
|
} = useForm<RegisterFormData>({
|
||||||
resolver: zodResolver(registerSchema),
|
resolver: zodResolver(registerSchema),
|
||||||
defaultValues: { acceptTerms: false, role: 'USER' } as any,
|
defaultValues: { acceptTerms: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: RegisterFormData & { role?: string }) => {
|
const onSubmit = async (_data: RegisterFormData) => {
|
||||||
try {
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
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 (
|
return (
|
||||||
|
|
@ -69,24 +39,6 @@ export function RegisterPage() {
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
|
<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
|
<TextField
|
||||||
{...register('displayName')}
|
{...register('displayName')}
|
||||||
label="Display Name"
|
label="Display Name"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -28,19 +28,14 @@ interface AuthState {
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()((set) => ({
|
||||||
persist(
|
user: null,
|
||||||
(set) => ({
|
isAuthenticated: false,
|
||||||
user: null,
|
isLoading: false,
|
||||||
isAuthenticated: false,
|
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||||
isLoading: false,
|
setLoading: (isLoading) => set({ isLoading }),
|
||||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
logout: () => set({ user: null, isAuthenticated: false }),
|
||||||
setLoading: (isLoading) => set({ isLoading }),
|
}));
|
||||||
logout: () => set({ user: null, isAuthenticated: false }),
|
|
||||||
}),
|
|
||||||
{ name: 'luxe_auth_state' }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
interface SearchState {
|
interface SearchState {
|
||||||
filters: SearchFilters;
|
filters: SearchFilters;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue