324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import { Helmet } from 'react-helmet-async';
|
|
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 Chip from '@mui/material/Chip';
|
|
import Drawer from '@mui/material/Drawer';
|
|
import IconButton from '@mui/material/IconButton';
|
|
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 { useSearchStore } from '@/store';
|
|
import { useMediaQuery } from '@/hooks';
|
|
import { profileApi } from '@/api/profile';
|
|
import type { Profile } from '@/types';
|
|
|
|
export function SearchPage() {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const { filters, setFilters, resetFilters } = useSearchStore();
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const isMdUp = useMediaQuery('(min-width:900px)');
|
|
|
|
// Sync URL query param to search store on load
|
|
const queryParam = searchParams.get('q') ?? '';
|
|
const cityParam = searchParams.get('city') ?? '';
|
|
const categoryParam = searchParams.get('category') ?? '';
|
|
|
|
useEffect(() => {
|
|
const initialFilters: any = {};
|
|
if (queryParam) initialFilters.query = queryParam;
|
|
if (cityParam) initialFilters.city = cityParam;
|
|
if (categoryParam) initialFilters.categories = [categoryParam];
|
|
if (Object.keys(initialFilters).length > 0) {
|
|
setFilters(initialFilters);
|
|
}
|
|
}, [queryParam, cityParam, categoryParam, setFilters]);
|
|
|
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
|
const [loading, setLoading] = useState(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;
|
|
|
|
const res = await profileApi.searchProfiles(params);
|
|
if (res.success && res.data) {
|
|
const mapped = res.data.map((listing: any) => {
|
|
const primaryImg = listing.gallery?.find((g: any) => g.isPrimary)?.url || listing.gallery?.[0]?.url || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=600&h=800&fit=crop';
|
|
return {
|
|
id: listing.id,
|
|
slug: listing.slug,
|
|
name: listing.title,
|
|
age: listing.age || 21,
|
|
city: listing.city?.name || 'New York',
|
|
area: listing.area?.name || '',
|
|
avatar: primaryImg,
|
|
coverImage: primaryImg,
|
|
rating: listing.advertiser?.rating || 5.0,
|
|
reviewCount: listing.advertiser?.reviewCount || 0,
|
|
isVerified: listing.isVerified || false,
|
|
isPremium: listing.isPremium || false,
|
|
isOnline: listing.isOnline || true,
|
|
priceFrom: listing.price || 200,
|
|
currency: listing.currency || 'USD',
|
|
categories: listing.category ? [listing.category.name] : ['VIP Escorts'],
|
|
languages: listing.languages || ['English'],
|
|
height: listing.height || '170 cm',
|
|
tagline: listing.tagline || '',
|
|
};
|
|
});
|
|
setProfiles(mapped);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load profiles:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchProfiles();
|
|
}, [filters]);
|
|
|
|
const sortedResults = profiles;
|
|
|
|
const handleSearchInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const val = e.target.value;
|
|
setFilters({ query: val || undefined });
|
|
|
|
// Update URL params
|
|
const nextParams = new URLSearchParams(searchParams);
|
|
if (val) {
|
|
nextParams.set('q', val);
|
|
} else {
|
|
nextParams.delete('q');
|
|
}
|
|
setSearchParams(nextParams);
|
|
};
|
|
|
|
const handleSortChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setFilters({ sortBy: e.target.value as any });
|
|
};
|
|
|
|
const handleRemoveFilter = (key: keyof typeof filters, value?: any) => {
|
|
if (key === 'categories' && value) {
|
|
setFilters({ categories: filters.categories?.filter((c) => c !== value) });
|
|
} else if (key === 'languages' && value) {
|
|
setFilters({ languages: filters.languages?.filter((l) => l !== value) });
|
|
} else {
|
|
setFilters({ [key]: undefined });
|
|
}
|
|
};
|
|
|
|
const activeFilterChips = [];
|
|
if (filters.city) activeFilterChips.push({ label: `City: ${filters.city}`, onClick: () => handleRemoveFilter('city') });
|
|
if (filters.verified) activeFilterChips.push({ label: 'Verified', onClick: () => handleRemoveFilter('verified') });
|
|
if (filters.online) activeFilterChips.push({ label: 'Online Now', onClick: () => handleRemoveFilter('online') });
|
|
if (filters.premium) activeFilterChips.push({ label: 'Premium Showcase', onClick: () => handleRemoveFilter('premium') });
|
|
if (filters.ageMin || filters.ageMax) {
|
|
activeFilterChips.push({
|
|
label: `Age: ${filters.ageMin ?? 18}-${filters.ageMax ?? 50}`,
|
|
onClick: () => { setFilters({ ageMin: undefined, ageMax: undefined }); }
|
|
});
|
|
}
|
|
if (filters.priceMin || filters.priceMax) {
|
|
activeFilterChips.push({
|
|
label: `Price: $${filters.priceMin ?? 100}-$${filters.priceMax ?? 2000}`,
|
|
onClick: () => { setFilters({ priceMin: undefined, priceMax: undefined }); }
|
|
});
|
|
}
|
|
filters.categories?.forEach((cat) => {
|
|
activeFilterChips.push({ label: `Category: ${cat}`, onClick: () => handleRemoveFilter('categories', cat) });
|
|
});
|
|
filters.languages?.forEach((lang) => {
|
|
activeFilterChips.push({ label: `Lang: ${lang}`, onClick: () => handleRemoveFilter('languages', lang) });
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<Helmet>
|
|
<title>{filters.query ? `Search: ${filters.query}` : 'Premium Companions Directory'} — Luxe</title>
|
|
<meta name="description" content="Browse our luxury companion directory with advanced filters to find the perfect wellness or dining partner." />
|
|
</Helmet>
|
|
|
|
<SectionWrapper sx={{ pt: 5, pb: 10 }}>
|
|
<PageContainer>
|
|
{/* Header */}
|
|
<SectionHeader
|
|
title="Premium Companion Directory"
|
|
subtitle="Explore high-end companions, verified hosts, and premium wellness providers."
|
|
/>
|
|
|
|
{/* Controls Bar */}
|
|
<Box sx={{ display: 'flex', gap: 2, mb: 4, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<TextField
|
|
placeholder="Search by name, city, keyword..."
|
|
value={filters.query ?? ''}
|
|
onChange={handleSearchInputChange}
|
|
size="small"
|
|
sx={{ flex: 1, minWidth: 260, '& .MuiOutlinedInput-root': { borderRadius: 3 } }}
|
|
slotProps={{
|
|
input: {
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<SearchIcon color="action" />
|
|
</InputAdornment>
|
|
),
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<Box sx={{ display: 'flex', gap: 2, width: { xs: '100%', sm: 'auto' }, justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<TextField
|
|
select
|
|
size="small"
|
|
label="Sort By"
|
|
value={filters.sortBy ?? 'relevance'}
|
|
onChange={handleSortChange}
|
|
sx={{ minWidth: 160, '& .MuiOutlinedInput-root': { borderRadius: 3 } }}
|
|
>
|
|
<MenuItem value="relevance">Premium First</MenuItem>
|
|
<MenuItem value="rating">Highest Rated</MenuItem>
|
|
<MenuItem value="price_asc">Price: Low to High</MenuItem>
|
|
<MenuItem value="price_desc">Price: High to Low</MenuItem>
|
|
<MenuItem value="newest">New Listings</MenuItem>
|
|
</TextField>
|
|
|
|
{!isMdUp && (
|
|
<LuxeButton
|
|
variant="outlined"
|
|
startIcon={<FilterListIcon />}
|
|
onClick={() => setMobileOpen(true)}
|
|
sx={{ py: 1 }}
|
|
>
|
|
Filters
|
|
</LuxeButton>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Active Filters Display */}
|
|
{activeFilterChips.length > 0 && (
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3, alignItems: 'center' }}>
|
|
<Typography variant="body2" color="text.secondary" fontWeight={600} sx={{ mr: 1 }}>
|
|
Active Filters:
|
|
</Typography>
|
|
{activeFilterChips.map((chip, idx) => (
|
|
<Chip
|
|
key={idx}
|
|
label={chip.label}
|
|
onDelete={chip.onClick}
|
|
size="small"
|
|
color="secondary"
|
|
variant="outlined"
|
|
sx={{ borderRadius: 2 }}
|
|
/>
|
|
))}
|
|
<LuxeButton variant="text" size="small" onClick={resetFilters} sx={{ ml: 'auto', p: 0, minWidth: 'auto' }}>
|
|
Reset Filters
|
|
</LuxeButton>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Directory Listings and Filter Sidebar Grid */}
|
|
<Grid container spacing={4}>
|
|
{/* Desktop Filters Panel */}
|
|
{isMdUp && (
|
|
<Grid size={{ md: 3.5, lg: 3 }}>
|
|
<SearchFilterPanel />
|
|
</Grid>
|
|
)}
|
|
|
|
{/* Profiles Directory */}
|
|
<Grid size={{ xs: 12, md: 8.5, lg: 9 }}>
|
|
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 2, fontWeight: 600 }}>
|
|
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 ? (
|
|
<Grid container spacing={3}>
|
|
{sortedResults.map((profile, i) => (
|
|
<Grid key={profile.id} size={{ xs: 12, sm: 6, lg: 4 }}>
|
|
<ProfileCard profile={profile} index={i} />
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
) : (
|
|
<Box
|
|
sx={{
|
|
py: 12,
|
|
textAlign: 'center',
|
|
border: '1px dashed rgba(255, 255, 255, 0.1)',
|
|
borderRadius: 4,
|
|
bgcolor: 'background.paper',
|
|
}}
|
|
>
|
|
<Typography variant="h6" fontWeight={600} gutterBottom>
|
|
No Companions Found
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
|
We couldn't find any profiles matching your filters. Try clearing some options or changing your search terms.
|
|
</Typography>
|
|
<LuxeButton variant="contained" onClick={resetFilters}>
|
|
Clear All Filters
|
|
</LuxeButton>
|
|
</Box>
|
|
)}
|
|
</Grid>
|
|
</Grid>
|
|
</PageContainer>
|
|
</SectionWrapper>
|
|
|
|
{/* Mobile Drawer Filter Panel */}
|
|
<Drawer
|
|
anchor="right"
|
|
open={mobileOpen}
|
|
onClose={() => setMobileOpen(false)}
|
|
slotProps={{
|
|
backdrop: { sx: { backdropFilter: 'blur(4px)' } }
|
|
}}
|
|
PaperProps={{
|
|
sx: { width: '100%', maxWidth: 360, bgcolor: 'background.default', p: 0 }
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
|
<Typography variant="h6" fontWeight={700}>Filter Options</Typography>
|
|
<IconButton onClick={() => setMobileOpen(false)} size="small" aria-label="Close filters">
|
|
<CloseIcon />
|
|
</IconButton>
|
|
</Box>
|
|
<Box sx={{ p: 2, overflowY: 'auto', height: 'calc(100% - 64px)' }}>
|
|
<SearchFilterPanel />
|
|
</Box>
|
|
</Drawer>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default SearchPage;
|