first commit
This commit is contained in:
commit
498bd34178
|
|
@ -0,0 +1,5 @@
|
|||
VITE_API_BASE_URL=http://localhost:3000/api
|
||||
VITE_APP_NAME=Luxe
|
||||
VITE_APP_URL=http://localhost:5173
|
||||
VITE_SOCKET_URL=http://localhost:3000
|
||||
VITE_CDN_URL=
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# Luxe — Premium Companion Directory Platform
|
||||
|
||||
Enterprise-grade React frontend built for scale, performance, and premium UX.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 19 + TypeScript + Vite
|
||||
- Material UI v7 (custom luxury theme)
|
||||
- TanStack Query · Zustand · React Hook Form · Zod
|
||||
- Framer Motion · i18next · React Helmet Async
|
||||
- PWA-ready with service worker
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:5173](http://localhost:5173).
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run dev` | Start development server |
|
||||
| `npm run build` | Production build |
|
||||
| `npm run preview` | Preview production build |
|
||||
| `npm run lint` | Run ESLint |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # App shell, providers, router
|
||||
├── components/ # Shared UI (design system, layouts, search)
|
||||
├── features/ # Feature modules (home sections, etc.)
|
||||
├── pages/ # Route-level page components
|
||||
├── layouts/ # Main, Auth, Dashboard layouts
|
||||
├── theme/ # MUI theme, design tokens
|
||||
├── store/ # Zustand stores
|
||||
├── hooks/ # Custom React hooks
|
||||
├── api/ # Axios client & interceptors
|
||||
├── services/ # API service layer
|
||||
├── validators/ # Zod schemas
|
||||
├── types/ # TypeScript definitions
|
||||
├── constants/ # App constants & mock data
|
||||
├── config/ # i18n, env config
|
||||
├── utils/ # Utility functions
|
||||
└── styles/ # Global CSS
|
||||
```
|
||||
|
||||
## Phase 1 (Complete)
|
||||
|
||||
- Enterprise folder structure
|
||||
- Luxury design system (dark/light/auto themes)
|
||||
- Responsive header with mobile drawer
|
||||
- Global search with recent queries
|
||||
- Premium landing page (all sections)
|
||||
- Auth layout (login, register, forgot password)
|
||||
- SEO metadata & structured data
|
||||
- PWA manifest
|
||||
|
||||
## Phases Roadmap
|
||||
|
||||
- **Phase 2** — User-facing pages (profiles, cities, categories, blog)
|
||||
- **Phase 3** — Advertiser dashboard
|
||||
- **Phase 4** — Admin dashboard
|
||||
- **Phase 5** — Blog & forum
|
||||
- **Phase 6** — Wallet, ads, notifications, verification, analytics
|
||||
|
||||
## License
|
||||
|
||||
Private — All rights reserved.
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0A0A0B" />
|
||||
<meta name="description" content="Luxe — Premium companion directory platform. Verified profiles, elegant experience." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<title>Luxe — Premium Companion Directory</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"name": "luxe",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint src",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^7.3.11",
|
||||
"@mui/material": "^7.3.11",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-virtual": "^3.14.8",
|
||||
"axios": "^1.18.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"framer-motion": "^12.42.2",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-dropzone": "^19.1.1",
|
||||
"react-helmet-async": "^3.0.0",
|
||||
"react-hook-form": "^7.82.0",
|
||||
"react-i18next": "^17.0.10",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"recharts": "^3.10.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"swiper": "^14.0.6",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"oxlint": "^1.71.0",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect width="64" height="64" rx="16" fill="#0A0A0B"/>
|
||||
<path d="M32 12L38 26H52L41 35L45 50L32 41L19 50L23 35L12 26H26L32 12Z" fill="#C9A962"/>
|
||||
<circle cx="32" cy="32" r="28" stroke="#C9A962" stroke-width="1.5" stroke-opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 319 B |
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -0,0 +1,7 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /dashboard/
|
||||
Disallow: /admin/
|
||||
Disallow: /auth/
|
||||
|
||||
Sitemap: /sitemap.xml
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import axios from 'axios';
|
||||
import { API_BASE_URL, STORAGE_KEYS } from '@/constants';
|
||||
import { getStorageItem, removeStorageItem } from '@/utils';
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getStorageItem<string | null>(STORAGE_KEYS.AUTH_TOKEN, null);
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
removeStorageItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
removeStorageItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { Suspense, lazy } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from '@/contexts/ThemeProvider';
|
||||
import { MainLayout } from '@/layouts/MainLayout';
|
||||
import { AuthLayout } from '@/layouts/AuthLayout';
|
||||
import { LoadingSpinner } from '@/components/ui';
|
||||
import { ROUTES } from '@/constants';
|
||||
import '@/config/i18n';
|
||||
import '@/styles/global.css';
|
||||
|
||||
const HomePage = lazy(() => import('@/pages/HomePage'));
|
||||
const LoginPage = lazy(() => import('@/pages/auth/LoginPage'));
|
||||
const RegisterPage = lazy(() => import('@/pages/auth/RegisterPage'));
|
||||
const ForgotPasswordPage = lazy(() => import('@/pages/auth/ForgotPasswordPage'));
|
||||
const SearchPage = lazy(() => import('@/pages/SearchPage'));
|
||||
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 queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function PageLoader() {
|
||||
return <LoadingSpinner fullScreen message="Loading…" />;
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<HelmetProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</HelmetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path={ROUTES.SEARCH} element={<SearchPage />} />
|
||||
<Route path={ROUTES.PROFILE} element={<ProfilePage />} />
|
||||
<Route path={ROUTES.BLOG} element={<BlogListPage />} />
|
||||
<Route path={ROUTES.BLOG_POST} element={<BlogPostPage />} />
|
||||
</Route>
|
||||
<Route element={<AuthLayout />}>
|
||||
<Route path={ROUTES.AUTH.LOGIN} element={<LoginPage />} />
|
||||
<Route path={ROUTES.AUTH.REGISTER} element={<RegisterPage />} />
|
||||
<Route path={ROUTES.AUTH.FORGOT_PASSWORD} element={<ForgotPasswordPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AppProviders>
|
||||
<AppRouter />
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -0,0 +1,102 @@
|
|||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Link from '@mui/material/Link';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import TwitterIcon from '@mui/icons-material/Twitter';
|
||||
import InstagramIcon from '@mui/icons-material/Instagram';
|
||||
import LinkedInIcon from '@mui/icons-material/LinkedIn';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Logo, PageContainer } from '@/components/ui';
|
||||
import { footerLinks } from '@/constants/mockData';
|
||||
import { APP_NAME } from '@/constants';
|
||||
|
||||
export function Footer() {
|
||||
const { t } = useTranslation();
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
pt: { xs: 6, md: 10 },
|
||||
pb: 4,
|
||||
mt: 'auto',
|
||||
}}
|
||||
>
|
||||
<PageContainer>
|
||||
<Grid container spacing={4}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Logo size="md" />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2, maxWidth: 320 }}>
|
||||
{t('footer.tagline')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 3 }}>
|
||||
{[TwitterIcon, InstagramIcon, LinkedInIcon].map((Icon, i) => (
|
||||
<IconButton key={i} size="small" aria-label="Social link" sx={{ color: 'text.secondary' }}>
|
||||
<Icon fontSize="small" />
|
||||
</IconButton>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{Object.entries(footerLinks).map(([section, links]) => (
|
||||
<Grid key={section} size={{ xs: 6, sm: 4, md: 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, textTransform: 'capitalize' }}>
|
||||
{t(`footer.${section}`)}
|
||||
</Typography>
|
||||
<Box component="nav" sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.path}
|
||||
component={RouterLink}
|
||||
to={link.path}
|
||||
underline="hover"
|
||||
color="text.secondary"
|
||||
variant="body2"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('footer.copyright', { year, app: APP_NAME })}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<Link component={RouterLink} to="/privacy" variant="caption" color="text.secondary">
|
||||
Privacy
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/terms" variant="caption" color="text.secondary">
|
||||
Terms
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/cookies" variant="caption" color="text.secondary">
|
||||
Cookies
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import { useState } from 'react';
|
||||
import { Link as RouterLink, useLocation } from 'react-router-dom';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Button from '@mui/material/Button';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Logo, LuxeButton, ThemeToggle } from '@/components/ui';
|
||||
import { GlobalSearch } from '@/components/search/GlobalSearch';
|
||||
import { useAuthStore, useSearchStore } from '@/store';
|
||||
import { navItems } from '@/constants/mockData';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useScrollPosition } from '@/hooks';
|
||||
|
||||
export function Header() {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const location = useLocation();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const scrolled = useScrollPosition(20);
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const { isSearchOpen, setSearchOpen } = useSearchStore();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const isAuthPage = location.pathname.startsWith('/auth');
|
||||
|
||||
if (isAuthPage) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar
|
||||
position="sticky"
|
||||
elevation={scrolled ? 2 : 0}
|
||||
component={motion.header}
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
sx={{
|
||||
bgcolor: scrolled ? undefined : 'transparent',
|
||||
backgroundImage: 'none',
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ minHeight: { xs: 64, md: 72 }, gap: 2 }}>
|
||||
<Logo size="sm" />
|
||||
|
||||
{!isMobile && (
|
||||
<Box component="nav" sx={{ display: 'flex', gap: 0.5, ml: 4, flex: 1 }} aria-label="Main navigation">
|
||||
{navItems.map((item) => (
|
||||
<Button
|
||||
key={item.path}
|
||||
component={RouterLink}
|
||||
to={item.path}
|
||||
sx={{
|
||||
color: location.pathname === item.path ? 'gold.main' : 'text.primary',
|
||||
fontWeight: location.pathname === item.path ? 600 : 500,
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
{t(item.label)}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: isMobile ? 1 : 0 }} />
|
||||
|
||||
<IconButton onClick={() => setSearchOpen(true)} aria-label="Open search" sx={{ color: 'text.primary' }}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
{!isMobile && (
|
||||
<>
|
||||
{isAuthenticated ? (
|
||||
<LuxeButton component={RouterLink} to={ROUTES.DASHBOARD.ROOT} variant="outlined" size="small">
|
||||
{t('nav.dashboard')}
|
||||
</LuxeButton>
|
||||
) : (
|
||||
<>
|
||||
<Button component={RouterLink} to={ROUTES.AUTH.LOGIN} sx={{ fontWeight: 600 }}>
|
||||
{t('common.login')}
|
||||
</Button>
|
||||
<LuxeButton component={RouterLink} to={ROUTES.AUTH.REGISTER} variant="contained" size="small">
|
||||
{t('common.register')}
|
||||
</LuxeButton>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<IconButton onClick={() => setMobileOpen(true)} aria-label="Open menu" edge="end">
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
PaperProps={{ sx: { width: 300, pt: 2 } }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', px: 2, mb: 1 }}>
|
||||
<IconButton onClick={() => setMobileOpen(false)} aria-label="Close menu">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<List>
|
||||
{navItems.map((item) => (
|
||||
<ListItemButton
|
||||
key={item.path}
|
||||
component={RouterLink}
|
||||
to={item.path}
|
||||
selected={location.pathname === item.path}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<ListItemText primary={t(item.label)} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Box sx={{ px: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<LuxeButton
|
||||
fullWidth
|
||||
component={RouterLink}
|
||||
to={ROUTES.AUTH.LOGIN}
|
||||
variant="outlined"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{t('common.login')}
|
||||
</LuxeButton>
|
||||
<LuxeButton
|
||||
fullWidth
|
||||
component={RouterLink}
|
||||
to={ROUTES.AUTH.REGISTER}
|
||||
variant="contained"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{t('common.register')}
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
<GlobalSearch open={isSearchOpen} onClose={() => setSearchOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import Box from '@mui/material/Box';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import LocationOnIcon from '@mui/icons-material/LocationOn';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchStore } from '@/store';
|
||||
import { useDebounce } from '@/hooks';
|
||||
import { mockProfiles, mockCities } from '@/constants/mockData';
|
||||
import { ProfileCardCompact } from '@/components/ui';
|
||||
import { ROUTES } from '@/constants';
|
||||
|
||||
interface GlobalSearchProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GlobalSearch({ open, onClose }: GlobalSearchProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { recentSearches, addRecentSearch } = useSearchStore();
|
||||
const [query, setQuery] = useState('');
|
||||
const debouncedQuery = useDebounce(query, 200);
|
||||
|
||||
const results = debouncedQuery.trim()
|
||||
? mockProfiles.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(debouncedQuery.toLowerCase()) ||
|
||||
p.city.toLowerCase().includes(debouncedQuery.toLowerCase()),
|
||||
).slice(0, 5)
|
||||
: [];
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(q: string) => {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) return;
|
||||
addRecentSearch(trimmed);
|
||||
onClose();
|
||||
navigate(`${ROUTES.SEARCH}?q=${encodeURIComponent(trimmed)}`);
|
||||
},
|
||||
[addRecentSearch, navigate, onClose],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSearch(query);
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
fullWidth
|
||||
maxWidth="md"
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
mt: { xs: 2, md: 8 },
|
||||
mx: 2,
|
||||
maxHeight: '80vh',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
slotProps={{ backdrop: { sx: { backdropFilter: 'blur(8px)' } } }}
|
||||
>
|
||||
<DialogContent sx={{ p: 0 }}>
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
placeholder={t('search.placeholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: query && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={() => setQuery('')} aria-label="Clear search">
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2, maxHeight: 400, overflow: 'auto' }}>
|
||||
{!debouncedQuery && recentSearches.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
|
||||
<HistoryIcon fontSize="inherit" /> Recent
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{recentSearches.map((s) => (
|
||||
<Chip key={s} label={s} size="small" onClick={() => handleSearch(s)} clickable />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!debouncedQuery && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
|
||||
<LocationOnIcon fontSize="inherit" /> Popular Cities
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{mockCities.slice(0, 6).map((city) => (
|
||||
<Chip
|
||||
key={city.id}
|
||||
label={city.name}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => handleSearch(city.name)}
|
||||
clickable
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{results.length > 0 && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
{t('search.results', { count: results.length })}
|
||||
</Typography>
|
||||
<List disablePadding>
|
||||
{results.map((profile) => (
|
||||
<ListItemButton
|
||||
key={profile.id}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(`/profile/${profile.slug}`);
|
||||
}}
|
||||
sx={{ borderRadius: 2, mb: 0.5 }}
|
||||
>
|
||||
<ProfileCardCompact profile={profile} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{debouncedQuery && results.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||
No profiles found for "{debouncedQuery}"
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default GlobalSearch;
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Slider from '@mui/material/Slider';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import { useSearchStore } from '@/store';
|
||||
import { LuxeButton, GlassCard } from '@/components/ui';
|
||||
import { mockCities, mockCategories } from '@/constants/mockData';
|
||||
|
||||
const AVAILABLE_LANGUAGES = ['English', 'French', 'Spanish', 'Italian', 'German', 'Russian', 'Japanese'];
|
||||
|
||||
export function SearchFilterPanel() {
|
||||
const { filters, setFilters, resetFilters } = useSearchStore();
|
||||
|
||||
const handleCityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFilters({ city: e.target.value || undefined });
|
||||
};
|
||||
|
||||
const handleAgeChange = (_event: Event, value: number | number[]) => {
|
||||
const [min, max] = value as number[];
|
||||
setFilters({ ageMin: min, ageMax: max });
|
||||
};
|
||||
|
||||
const handlePriceChange = (_event: Event, value: number | number[]) => {
|
||||
const [min, max] = value as number[];
|
||||
setFilters({ priceMin: min, priceMax: max });
|
||||
};
|
||||
|
||||
const handleToggle = (key: 'verified' | 'online' | 'premium') => {
|
||||
setFilters({ [key]: !filters[key] || undefined });
|
||||
};
|
||||
|
||||
const handleCategoryToggle = (slug: string) => {
|
||||
const current = filters.categories ?? [];
|
||||
const next = current.includes(slug)
|
||||
? current.filter((c) => c !== slug)
|
||||
: [...current, slug];
|
||||
setFilters({ categories: next.length > 0 ? next : undefined });
|
||||
};
|
||||
|
||||
const handleLanguageToggle = (lang: string) => {
|
||||
const current = filters.languages ?? [];
|
||||
const next = current.includes(lang)
|
||||
? current.filter((l) => l !== lang)
|
||||
: [...current, lang];
|
||||
setFilters({ languages: next.length > 0 ? next : undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard sx={{ p: 3, height: 'fit-content' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h6" fontWeight={700} sx={{ fontFamily: '"Playfair Display", serif' }}>
|
||||
Filters
|
||||
</Typography>
|
||||
<LuxeButton variant="text" size="small" onClick={resetFilters} sx={{ color: 'text.secondary', p: 0, minWidth: 'auto' }}>
|
||||
Clear All
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Toggle Switches */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={!!filters.verified} onChange={() => handleToggle('verified')} color="secondary" />}
|
||||
label={<Typography variant="body2">Verified Only</Typography>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={!!filters.online} onChange={() => handleToggle('online')} color="secondary" />}
|
||||
label={<Typography variant="body2">Online Now</Typography>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={!!filters.premium} onChange={() => handleToggle('premium')} color="secondary" />}
|
||||
label={<Typography variant="body2">Premium Showcase</Typography>}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Location Dropdown */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5, fontWeight: 600 }}>
|
||||
Location
|
||||
</Typography>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
size="small"
|
||||
value={filters.city ?? ''}
|
||||
onChange={handleCityChange}
|
||||
slotProps={{
|
||||
select: {
|
||||
displayEmpty: true,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">All Cities</MenuItem>
|
||||
{mockCities.map((city) => (
|
||||
<MenuItem key={city.id} value={city.name}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Categories Section */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5, fontWeight: 600 }}>
|
||||
Categories
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{mockCategories.map((cat) => {
|
||||
const selected = filters.categories?.includes(cat.slug);
|
||||
return (
|
||||
<Chip
|
||||
key={cat.id}
|
||||
label={cat.name}
|
||||
size="small"
|
||||
clickable
|
||||
variant={selected ? 'filled' : 'outlined'}
|
||||
color={selected ? 'primary' : 'default'}
|
||||
onClick={() => handleCategoryToggle(cat.slug)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Age Slider */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ display: 'flex', justifyContent: 'space-between', mb: 1.5, fontWeight: 600 }}>
|
||||
<span>Age Range</span>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{filters.ageMin ?? 18} - {filters.ageMax ?? 50}
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Slider
|
||||
value={[filters.ageMin ?? 18, filters.ageMax ?? 50]}
|
||||
onChange={handleAgeChange}
|
||||
min={18}
|
||||
max={50}
|
||||
valueLabelDisplay="auto"
|
||||
color="secondary"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Price Slider */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ display: 'flex', justifyContent: 'space-between', mb: 1.5, fontWeight: 600 }}>
|
||||
<span>Hourly Price ($)</span>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
${filters.priceMin ?? 100} - ${filters.priceMax ?? 2000}
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Slider
|
||||
value={[filters.priceMin ?? 100, filters.priceMax ?? 2000]}
|
||||
onChange={handlePriceChange}
|
||||
min={100}
|
||||
max={2000}
|
||||
step={50}
|
||||
valueLabelDisplay="auto"
|
||||
color="secondary"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Spoken Languages */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5, fontWeight: 600 }}>
|
||||
Languages Spoken
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{AVAILABLE_LANGUAGES.map((lang) => {
|
||||
const selected = filters.languages?.includes(lang);
|
||||
return (
|
||||
<Chip
|
||||
key={lang}
|
||||
label={lang}
|
||||
size="small"
|
||||
clickable
|
||||
variant={selected ? 'filled' : 'outlined'}
|
||||
color={selected ? 'primary' : 'default'}
|
||||
onClick={() => handleLanguageToggle(lang)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchFilterPanel;
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SearchOffIcon from '@mui/icons-material/SearchOff';
|
||||
import { motion } from 'framer-motion';
|
||||
import LuxeButton from './LuxeButton';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title = 'Nothing here yet',
|
||||
description = 'Check back soon for new content.',
|
||||
actionLabel,
|
||||
onAction,
|
||||
icon,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
py: 8,
|
||||
px: 3,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
mb: 3,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{icon ?? <SearchOffIcon sx={{ fontSize: 36 }} />}
|
||||
</Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 360, mb: 3 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
{actionLabel && onAction && (
|
||||
<LuxeButton variant="outlined" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</LuxeButton>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmptyState;
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import Card, { type CardProps } from '@mui/material/Card';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
const StyledCard = styled(Card)(({ theme }) => ({
|
||||
background: theme.palette.glass.background,
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
border: `1px solid ${theme.palette.glass.border}`,
|
||||
borderRadius: theme.customRadius?.lg ?? 16,
|
||||
boxShadow: theme.customShadows?.glass ?? theme.shadows[4],
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||
overflow: 'hidden',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: theme.customShadows?.lg,
|
||||
},
|
||||
}));
|
||||
|
||||
export type GlassCardProps = CardProps;
|
||||
|
||||
export function GlassCard(props: GlassCardProps) {
|
||||
return <StyledCard {...props} />;
|
||||
}
|
||||
|
||||
export default GlassCard;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message?: string;
|
||||
size?: number;
|
||||
fullScreen?: boolean;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({
|
||||
message,
|
||||
size = 40,
|
||||
fullScreen = false,
|
||||
}: LoadingSpinnerProps) {
|
||||
const content = (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
...(fullScreen && { minHeight: '60vh' }),
|
||||
}}
|
||||
role="status"
|
||||
aria-label={message ?? 'Loading'}
|
||||
>
|
||||
<CircularProgress size={size} thickness={3} sx={{ color: 'gold.main' }} />
|
||||
{message && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export default LoadingSpinner;
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { GradientText } from './styled';
|
||||
import { APP_NAME } from '@/constants';
|
||||
|
||||
interface LogoProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
const sizes = { sm: 28, md: 36, lg: 48 };
|
||||
|
||||
export function Logo({ size = 'md', showText = true }: LogoProps) {
|
||||
const px = sizes[size];
|
||||
|
||||
return (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to="/"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
aria-label={`${APP_NAME} home`}
|
||||
>
|
||||
<Box
|
||||
component="svg"
|
||||
width={px}
|
||||
height={px}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<rect width="64" height="64" rx="16" fill="currentColor" opacity="0.1" />
|
||||
<path
|
||||
d="M32 12L38 26H52L41 35L45 50L32 41L19 50L23 35L12 26H26L32 12Z"
|
||||
fill="currentColor"
|
||||
style={{ color: '#C9A962' }}
|
||||
/>
|
||||
</Box>
|
||||
{showText && (
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontFamily: '"Playfair Display", serif',
|
||||
fontWeight: 700,
|
||||
fontSize: size === 'lg' ? '1.75rem' : size === 'sm' ? '1rem' : '1.25rem',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
<GradientText>{APP_NAME}</GradientText>
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Logo;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import Button, { type ButtonProps } from '@mui/material/Button';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { gradients } from '@/theme/tokens';
|
||||
|
||||
const StyledButton = styled(Button)(({ theme }) => ({
|
||||
borderRadius: theme.customRadius?.md ?? 12,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.02em',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&.MuiButton-containedPrimary': {
|
||||
background: gradients.gold,
|
||||
color: theme.palette.mode === 'dark' ? '#0A0A0B' : '#FFFFFF',
|
||||
'&:hover': {
|
||||
background: gradients.gold,
|
||||
filter: 'brightness(1.1)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: theme.customShadows?.gold,
|
||||
},
|
||||
},
|
||||
'&.MuiButton-outlinedPrimary': {
|
||||
borderColor: theme.palette.gold.main,
|
||||
color: theme.palette.gold.main,
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.gold.light,
|
||||
backgroundColor: `${theme.palette.gold.main}14`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export interface LuxeButtonProps extends ButtonProps {
|
||||
component?: React.ElementType;
|
||||
to?: string;
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function LuxeButton(props: LuxeButtonProps) {
|
||||
return <StyledButton {...props} />;
|
||||
}
|
||||
|
||||
export default LuxeButton;
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Rating from '@mui/material/Rating';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GlassCard } from './GlassCard';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import { formatCurrency } from '@/utils';
|
||||
import type { Profile } from '@/types';
|
||||
|
||||
interface ProfileCardProps {
|
||||
profile: Profile;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export function ProfileCard({ profile, index = 0 }: ProfileCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.4, delay: index * 0.05 }}
|
||||
>
|
||||
<GlassCard
|
||||
sx={{ cursor: 'pointer', '&:hover': { '& .profile-image': { transform: 'scale(1.05)' } } }}
|
||||
>
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/profile/${profile.slug}`}
|
||||
sx={{ textDecoration: 'none', color: 'inherit', display: 'block' }}
|
||||
>
|
||||
<Box sx={{ position: 'relative', overflow: 'hidden', height: 280 }}>
|
||||
<Box
|
||||
className="profile-image"
|
||||
component="img"
|
||||
src={profile.avatar}
|
||||
alt={profile.name}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transition: 'transform 0.5s ease',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(180deg, transparent 50%, rgba(0,0,0,0.7) 100%)',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ position: 'absolute', top: 12, left: 12, display: 'flex', gap: 0.5 }}>
|
||||
{profile.isVerified && <StatusBadge badgeType="verified" />}
|
||||
{profile.isPremium && <StatusBadge badgeType="premium" />}
|
||||
{profile.isOnline && <StatusBadge badgeType="online" />}
|
||||
</Box>
|
||||
<Box sx={{ position: 'absolute', bottom: 12, left: 12, right: 12 }}>
|
||||
<Typography variant="h6" sx={{ color: '#fff', fontWeight: 600 }}>
|
||||
{profile.name}, {profile.age}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)' }}>
|
||||
{profile.city}{profile.area ? ` · ${profile.area}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Rating value={profile.rating} precision={0.1} size="small" readOnly />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
({profile.reviewCount})
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="subtitle2" color="gold.main" fontWeight={700}>
|
||||
From {formatCurrency(profile.priceFrom, profile.currency)}
|
||||
</Typography>
|
||||
{profile.tagline && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
{profile.tagline}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileCardCompact({ profile }: ProfileCardProps) {
|
||||
return (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/profile/${profile.slug}`}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
transition: 'background 0.2s',
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
<Avatar src={profile.avatar} alt={profile.name} sx={{ width: 48, height: 48 }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" noWrap>
|
||||
{profile.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{profile.city}
|
||||
</Typography>
|
||||
</Box>
|
||||
{profile.isOnline && <StatusBadge badgeType="online" />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileCard;
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { GradientText } from './styled';
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
align?: 'left' | 'center';
|
||||
action?: React.ReactNode;
|
||||
gradient?: boolean;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
subtitle,
|
||||
align = 'left',
|
||||
action,
|
||||
gradient = false,
|
||||
}: SectionHeaderProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
alignItems: align === 'center' ? 'center' : { xs: 'flex-start', sm: 'flex-end' },
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
mb: { xs: 4, md: 5 },
|
||||
textAlign: align,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h2"
|
||||
sx={{ fontSize: { xs: '1.75rem', md: '2.25rem' }, mb: subtitle ? 1 : 0 }}
|
||||
>
|
||||
{gradient ? <GradientText>{title}</GradientText> : title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{action}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionHeader;
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import Box from '@mui/material/Box';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import { GlassCard } from './GlassCard';
|
||||
|
||||
interface ProfileCardSkeletonProps {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export function ProfileCardSkeleton({ count = 1 }: ProfileCardSkeletonProps) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<GlassCard key={i} sx={{ p: 0 }}>
|
||||
<Skeleton variant="rectangular" height={280} animation="wave" />
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Skeleton width="60%" height={24} animation="wave" />
|
||||
<Skeleton width="40%" height={18} sx={{ mt: 1 }} animation="wave" />
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
|
||||
<Skeleton width={60} height={24} animation="wave" />
|
||||
<Skeleton width={60} height={24} animation="wave" />
|
||||
</Box>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionSkeleton() {
|
||||
return (
|
||||
<Box>
|
||||
<Skeleton width={240} height={40} sx={{ mb: 1 }} animation="wave" />
|
||||
<Skeleton width={400} height={24} sx={{ mb: 4 }} animation="wave" />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' },
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<ProfileCardSkeleton count={4} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileCardSkeleton;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import Chip, { type ChipProps } from '@mui/material/Chip';
|
||||
import VerifiedIcon from '@mui/icons-material/Verified';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import CircleIcon from '@mui/icons-material/Circle';
|
||||
|
||||
type BadgeType = 'verified' | 'premium' | 'online' | 'default';
|
||||
|
||||
interface StatusBadgeProps extends Omit<ChipProps, 'color' | 'variant'> {
|
||||
badgeType?: BadgeType;
|
||||
}
|
||||
|
||||
const badgeConfig: Record<BadgeType, { label: string; color: ChipProps['color']; icon?: React.ReactElement }> = {
|
||||
verified: { label: 'Verified', color: 'success', icon: <VerifiedIcon /> },
|
||||
premium: { label: 'Premium', color: 'secondary', icon: <StarIcon /> },
|
||||
online: { label: 'Online', color: 'success', icon: <CircleIcon sx={{ fontSize: 10 }} /> },
|
||||
default: { label: '', color: 'default' },
|
||||
};
|
||||
|
||||
export function StatusBadge({ badgeType = 'default', label, ...props }: StatusBadgeProps) {
|
||||
const config = badgeConfig[badgeType];
|
||||
return (
|
||||
<Chip
|
||||
size="small"
|
||||
label={label ?? config.label}
|
||||
color={config.color}
|
||||
icon={config.icon}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
height: 24,
|
||||
...(badgeType === 'premium' && {
|
||||
background: (theme) =>
|
||||
`linear-gradient(135deg, ${theme.palette.gold.main}, ${theme.palette.gold.dark})`,
|
||||
color: '#0A0A0B',
|
||||
}),
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatusBadge;
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import IconButton from '@mui/material/IconButton';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import SettingsBrightnessIcon from '@mui/icons-material/SettingsBrightness';
|
||||
import { useState } from 'react';
|
||||
import { useThemeStore } from '@/store';
|
||||
import type { ThemeMode } from '@/theme';
|
||||
|
||||
const modes: { value: ThemeMode; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: 'light', label: 'Light', icon: <LightModeIcon fontSize="small" /> },
|
||||
{ value: 'dark', label: 'Dark', icon: <DarkModeIcon fontSize="small" /> },
|
||||
{ value: 'system', label: 'System', icon: <SettingsBrightnessIcon fontSize="small" /> },
|
||||
];
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { mode, setMode } = useThemeStore();
|
||||
const [anchor, setAnchor] = useState<null | HTMLElement>(null);
|
||||
|
||||
const current = modes.find((m) => m.value === mode) ?? modes[2]!;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={(e) => setAnchor(e.currentTarget)}
|
||||
aria-label="Toggle theme"
|
||||
size="small"
|
||||
sx={{ color: 'text.primary' }}
|
||||
>
|
||||
{current.icon}
|
||||
</IconButton>
|
||||
<Menu anchorEl={anchor} open={Boolean(anchor)} onClose={() => setAnchor(null)}>
|
||||
{modes.map((m) => (
|
||||
<MenuItem
|
||||
key={m.value}
|
||||
selected={mode === m.value}
|
||||
onClick={() => {
|
||||
setMode(m.value);
|
||||
setAnchor(null);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>{m.icon}</ListItemIcon>
|
||||
<ListItemText>{m.label}</ListItemText>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeToggle;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export { LuxeButton } from './LuxeButton';
|
||||
export { GlassCard } from './GlassCard';
|
||||
export { SectionHeader } from './SectionHeader';
|
||||
export { ProfileCard, ProfileCardCompact } from './ProfileCard';
|
||||
export { StatusBadge } from './StatusBadge';
|
||||
export { EmptyState } from './EmptyState';
|
||||
export { LoadingSpinner } from './LoadingSpinner';
|
||||
export { ProfileCardSkeleton, SectionSkeleton } from './SkeletonLoader';
|
||||
export { ThemeToggle } from './ThemeToggle';
|
||||
export { Logo } from './Logo';
|
||||
export { PageContainer, SectionWrapper, GlassSurface, GlassForm, GradientText } from './styled';
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { styled } from '@mui/material/styles';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
export const PageContainer = styled(Box)(({ theme }) => ({
|
||||
width: '100%',
|
||||
maxWidth: 1440,
|
||||
margin: '0 auto',
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(2),
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
paddingLeft: theme.spacing(3),
|
||||
paddingRight: theme.spacing(3),
|
||||
},
|
||||
[theme.breakpoints.up('md')]: {
|
||||
paddingLeft: theme.spacing(4),
|
||||
paddingRight: theme.spacing(4),
|
||||
},
|
||||
}));
|
||||
|
||||
export const SectionWrapper = styled(Box)(({ theme }) => ({
|
||||
paddingTop: theme.spacing(8),
|
||||
paddingBottom: theme.spacing(8),
|
||||
[theme.breakpoints.up('md')]: {
|
||||
paddingTop: theme.spacing(12),
|
||||
paddingBottom: theme.spacing(12),
|
||||
},
|
||||
}));
|
||||
|
||||
export const GlassSurface = styled(Box)(({ theme }) => ({
|
||||
background: theme.palette.glass.background,
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
border: `1px solid ${theme.palette.glass.border}`,
|
||||
borderRadius: theme.customRadius?.lg ?? 16,
|
||||
}));
|
||||
|
||||
export const GlassForm = styled('form')(({ theme }) => ({
|
||||
background: theme.palette.glass.background,
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
border: `1px solid ${theme.palette.glass.border}`,
|
||||
borderRadius: theme.customRadius?.lg ?? 16,
|
||||
}));
|
||||
|
||||
export const GradientText = styled('span')(({ theme }) => ({
|
||||
background: `linear-gradient(135deg, ${theme.palette.gold.main} 0%, ${theme.palette.secondary.light} 100%)`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}));
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import en from './locales/en.json';
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: { en: { translation: en } },
|
||||
fallbackLng: 'en',
|
||||
interpolation: { escapeValue: false },
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"common": {
|
||||
"search": "Search",
|
||||
"login": "Sign In",
|
||||
"register": "Create Account",
|
||||
"logout": "Sign Out",
|
||||
"viewAll": "View All",
|
||||
"learnMore": "Learn More",
|
||||
"loading": "Loading…",
|
||||
"error": "Something went wrong",
|
||||
"retry": "Try Again",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Submit",
|
||||
"verified": "Verified",
|
||||
"premium": "Premium",
|
||||
"online": "Online"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"search": "Search",
|
||||
"cities": "Cities",
|
||||
"categories": "Categories",
|
||||
"blog": "Blog",
|
||||
"forum": "Community",
|
||||
"advertise": "Advertise",
|
||||
"dashboard": "Dashboard"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Discover Premium Companions",
|
||||
"subtitle": "Curated profiles. Verified identities. Unmatched elegance.",
|
||||
"cta": "Explore Profiles",
|
||||
"secondaryCta": "List Your Profile"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search by name, city, or category…",
|
||||
"location": "Location",
|
||||
"category": "Category",
|
||||
"filters": "Filters",
|
||||
"results": "{{count}} profiles found"
|
||||
},
|
||||
"sections": {
|
||||
"trending": "Trending Now",
|
||||
"featured": "Featured Profiles",
|
||||
"latest": "Latest Arrivals",
|
||||
"verified": "Verified Profiles",
|
||||
"premium": "Premium Advertisers",
|
||||
"cities": "Popular Cities",
|
||||
"categories": "Browse Categories",
|
||||
"nearby": "Nearby Listings",
|
||||
"blogs": "Recent Articles",
|
||||
"popularBlogs": "Popular Reads",
|
||||
"forum": "Community Discussions",
|
||||
"testimonials": "What Members Say",
|
||||
"stats": "Platform at a Glance",
|
||||
"faq": "Frequently Asked Questions",
|
||||
"newsletter": "Stay in the Loop"
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "The premier platform for premium companion services worldwide.",
|
||||
"company": "Company",
|
||||
"support": "Support",
|
||||
"legal": "Legal",
|
||||
"social": "Follow Us",
|
||||
"copyright": "© {{year}} Luxe. All rights reserved."
|
||||
},
|
||||
"auth": {
|
||||
"welcomeBack": "Welcome Back",
|
||||
"createAccount": "Create Your Account",
|
||||
"email": "Email Address",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"noAccount": "Don't have an account?",
|
||||
"hasAccount": "Already have an account?",
|
||||
"orContinueWith": "Or continue with"
|
||||
},
|
||||
"newsletter": {
|
||||
"title": "Exclusive Updates",
|
||||
"subtitle": "Be the first to know about new profiles, features, and premium offers.",
|
||||
"placeholder": "Enter your email",
|
||||
"button": "Subscribe",
|
||||
"success": "You're subscribed!"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
export const APP_NAME = import.meta.env.VITE_APP_NAME ?? 'Luxe';
|
||||
export const APP_URL = import.meta.env.VITE_APP_URL ?? 'http://localhost:5173';
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api';
|
||||
export const SOCKET_URL = import.meta.env.VITE_SOCKET_URL ?? 'http://localhost:3000';
|
||||
export const CDN_URL = import.meta.env.VITE_CDN_URL ?? '';
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
THEME_MODE: 'luxe-theme-mode',
|
||||
AUTH_TOKEN: 'luxe-auth-token',
|
||||
REFRESH_TOKEN: 'luxe-refresh-token',
|
||||
LOCALE: 'luxe-locale',
|
||||
SEARCH_FILTERS: 'luxe-search-filters',
|
||||
RECENT_SEARCHES: 'luxe-recent-searches',
|
||||
} as const;
|
||||
|
||||
export const ROUTES = {
|
||||
HOME: '/',
|
||||
SEARCH: '/search',
|
||||
PROFILE: '/profile/:slug',
|
||||
BLOG: '/blog',
|
||||
BLOG_POST: '/blog/:slug',
|
||||
FORUM: '/forum',
|
||||
CITIES: '/cities',
|
||||
CITY: '/cities/:slug',
|
||||
CATEGORIES: '/categories',
|
||||
CATEGORY: '/categories/:slug',
|
||||
AUTH: {
|
||||
LOGIN: '/auth/login',
|
||||
REGISTER: '/auth/register',
|
||||
FORGOT_PASSWORD: '/auth/forgot-password',
|
||||
RESET_PASSWORD: '/auth/reset-password',
|
||||
VERIFY_OTP: '/auth/verify-otp',
|
||||
},
|
||||
DASHBOARD: {
|
||||
ROOT: '/dashboard',
|
||||
PROFILE: '/dashboard/profile',
|
||||
SAVED: '/dashboard/saved',
|
||||
MESSAGES: '/dashboard/messages',
|
||||
SETTINGS: '/dashboard/settings',
|
||||
},
|
||||
ADVERTISER: {
|
||||
ROOT: '/advertiser',
|
||||
LISTINGS: '/advertiser/listings',
|
||||
ANALYTICS: '/advertiser/analytics',
|
||||
},
|
||||
ADMIN: {
|
||||
ROOT: '/admin',
|
||||
},
|
||||
LEGAL: {
|
||||
PRIVACY: '/privacy',
|
||||
TERMS: '/terms',
|
||||
COOKIES: '/cookies',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const BREAKPOINTS = {
|
||||
xs: 0,
|
||||
sm: 600,
|
||||
md: 900,
|
||||
lg: 1200,
|
||||
xl: 1536,
|
||||
} as const;
|
||||
|
||||
export const ANIMATION = {
|
||||
duration: {
|
||||
fast: 0.15,
|
||||
normal: 0.3,
|
||||
slow: 0.5,
|
||||
},
|
||||
easing: {
|
||||
smooth: [0.4, 0, 0.2, 1] as const,
|
||||
bounce: [0.34, 1.56, 0.64, 1] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const QUERY_KEYS = {
|
||||
PROFILES: 'profiles',
|
||||
FEATURED: 'featured-profiles',
|
||||
TRENDING: 'trending-profiles',
|
||||
CITIES: 'cities',
|
||||
CATEGORIES: 'categories',
|
||||
BLOGS: 'blogs',
|
||||
FORUM: 'forum',
|
||||
USER: 'user',
|
||||
NOTIFICATIONS: 'notifications',
|
||||
} as const;
|
||||
|
||||
export const USER_ROLES = {
|
||||
VISITOR: 'visitor',
|
||||
USER: 'user',
|
||||
ADVERTISER: 'advertiser',
|
||||
AGENCY: 'agency',
|
||||
MODERATOR: 'moderator',
|
||||
SUPPORT: 'support',
|
||||
ADMIN: 'admin',
|
||||
SUPER_ADMIN: 'super_admin',
|
||||
} as const;
|
||||
|
||||
export type UserRole = (typeof USER_ROLES)[keyof typeof USER_ROLES];
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import type { Profile, City, Category, BlogPost, ForumTopic, Testimonial, Statistic, FAQItem } from '@/types';
|
||||
|
||||
const avatar = (seed: number) =>
|
||||
`https://images.unsplash.com/photo-${1544005313 + seed}-94ddf0286df2?w=400&h=500&fit=crop&crop=face`;
|
||||
|
||||
const cover = (seed: number) =>
|
||||
`https://images.unsplash.com/photo-${1517841905 + seed}-976672fecd0?w=800&h=600&fit=crop`;
|
||||
|
||||
export const mockProfiles: Profile[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `profile-${i + 1}`,
|
||||
slug: `companion-${i + 1}`,
|
||||
name: ['Sophia', 'Isabella', 'Olivia', 'Emma', 'Ava', 'Mia', 'Charlotte', 'Amelia', 'Harper', 'Evelyn', 'Abigail', 'Emily'][i] ?? 'Profile',
|
||||
age: 22 + (i % 8),
|
||||
city: ['New York', 'London', 'Paris', 'Dubai', 'Singapore', 'Tokyo', 'Milan', 'Barcelona'][i % 8] ?? 'City',
|
||||
area: ['Manhattan', 'Mayfair', 'Le Marais', 'Marina', 'Orchard', 'Shibuya', 'Brera', 'Eixample'][i % 8],
|
||||
avatar: avatar(i),
|
||||
coverImage: cover(i),
|
||||
rating: 4.5 + (i % 5) * 0.1,
|
||||
reviewCount: 12 + i * 7,
|
||||
isVerified: i % 3 !== 0,
|
||||
isPremium: i % 2 === 0,
|
||||
isOnline: i % 4 === 0,
|
||||
priceFrom: 200 + i * 50,
|
||||
currency: 'USD',
|
||||
categories: [['Elite', 'Dinner Date'], ['Travel', 'Events'], ['VIP', 'Corporate']][i % 3] ?? [],
|
||||
languages: [['English', 'French'], ['English', 'Spanish'], ['English', 'Italian']][i % 3] ?? ['English'],
|
||||
height: `${165 + (i % 15)} cm`,
|
||||
tagline: 'Elegant companion for discerning gentlemen',
|
||||
}));
|
||||
|
||||
export const mockCities: City[] = [
|
||||
{ id: '1', slug: 'new-york', name: 'New York', country: 'USA', profileCount: 342, image: 'https://images.unsplash.com/photo-1496442226666-8d4d0e62e6e9?w=600&h=400&fit=crop' },
|
||||
{ id: '2', slug: 'london', name: 'London', country: 'UK', profileCount: 289, image: 'https://images.unsplash.com/photo-1513635269975-59663e0ac1ad?w=600&h=400&fit=crop' },
|
||||
{ id: '3', slug: 'paris', name: 'Paris', country: 'France', profileCount: 256, image: 'https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=600&h=400&fit=crop' },
|
||||
{ id: '4', slug: 'dubai', name: 'Dubai', country: 'UAE', profileCount: 198, image: 'https://images.unsplash.com/photo-1512453979798-5ea266f8880c?w=600&h=400&fit=crop' },
|
||||
{ id: '5', slug: 'singapore', name: 'Singapore', country: 'Singapore', profileCount: 167, image: 'https://images.unsplash.com/photo-1525620190502-4bb1c0d4a8b0?w=600&h=400&fit=crop' },
|
||||
{ id: '6', slug: 'tokyo', name: 'Tokyo', country: 'Japan', profileCount: 143, image: 'https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?w=600&h=400&fit=crop' },
|
||||
];
|
||||
|
||||
export const mockCategories: Category[] = [
|
||||
{ id: '1', slug: 'elite', name: 'Elite Companions', icon: 'Diamond', profileCount: 456, description: 'Premium verified companions' },
|
||||
{ id: '2', slug: 'dinner-date', name: 'Dinner Dates', icon: 'Restaurant', profileCount: 312, description: 'Sophisticated dining companions' },
|
||||
{ id: '3', slug: 'travel', name: 'Travel Companions', icon: 'Flight', profileCount: 234, description: 'Travel the world in style' },
|
||||
{ id: '4', slug: 'events', name: 'Event Escorts', icon: 'Event', profileCount: 189, description: 'Corporate and social events' },
|
||||
{ id: '5', slug: 'vip', name: 'VIP Services', icon: 'Star', profileCount: 167, description: 'Exclusive VIP experiences' },
|
||||
{ id: '6', slug: 'massage', name: 'Wellness', icon: 'Spa', profileCount: 278, description: 'Relaxation and wellness' },
|
||||
];
|
||||
|
||||
export const mockBlogPosts: BlogPost[] = [
|
||||
{ id: '1', slug: 'etiquette-guide', title: 'The Modern Gentleman\'s Guide to Companion Etiquette', excerpt: 'Essential tips for a refined and respectful experience.', coverImage: 'https://images.unsplash.com/photo-1515377905703-c4788e51af15?w=800&h=500&fit=crop', author: { name: 'Editorial Team', avatar: avatar(20) }, category: 'Lifestyle', tags: ['Etiquette', 'Guide'], readingTime: 8, publishedAt: '2026-07-15', likes: 234 },
|
||||
{ id: '2', slug: 'safety-first', title: 'Safety & Verification: Why It Matters', excerpt: 'How our verification process protects both parties.', coverImage: 'https://images.unsplash.com/photo-1563013544-824ae1b704d3?w=800&h=500&fit=crop', author: { name: 'Security Team', avatar: avatar(21) }, category: 'Safety', tags: ['Security', 'Trust'], readingTime: 5, publishedAt: '2026-07-10', likes: 189 },
|
||||
{ id: '3', slug: 'top-cities', title: 'Top 10 Cities for Premium Companionship', excerpt: 'Discover the world\'s most sought-after destinations.', coverImage: 'https://images.unsplash.com/photo-1488646953014-85cb44e25828?w=800&h=500&fit=crop', author: { name: 'Travel Editor', avatar: avatar(22) }, category: 'Travel', tags: ['Cities', 'Travel'], readingTime: 12, publishedAt: '2026-07-05', likes: 412 },
|
||||
];
|
||||
|
||||
export const mockForumTopics: ForumTopic[] = [
|
||||
{ id: '1', slug: 'first-time-tips', title: 'Tips for first-time users?', excerpt: 'Looking for advice on making the most of the platform…', author: { name: 'Member_42', avatar: avatar(30) }, category: 'General', replies: 28, likes: 45, isPinned: true, createdAt: '2026-07-18' },
|
||||
{ id: '2', slug: 'verification-process', title: 'How long does verification take?', excerpt: 'Submitted my documents 3 days ago…', author: { name: 'NewAdvertiser', avatar: avatar(31) }, category: 'Support', replies: 12, likes: 8, isPinned: false, createdAt: '2026-07-17' },
|
||||
{ id: '3', slug: 'best-cities', title: 'Which cities have the best selection?', excerpt: 'Planning to travel and want recommendations…', author: { name: 'Traveler99', avatar: avatar(32) }, category: 'Discussion', replies: 56, likes: 89, isPinned: false, createdAt: '2026-07-16' },
|
||||
];
|
||||
|
||||
export const mockTestimonials: Testimonial[] = [
|
||||
{ id: '1', name: 'James R.', role: 'Member since 2024', avatar: avatar(40), content: 'The verification process gave me complete confidence. Every profile I\'ve connected with has exceeded expectations.', rating: 5 },
|
||||
{ id: '2', name: 'Michael T.', role: 'Premium Member', avatar: avatar(41), content: 'Finally a platform that treats discretion and quality as priorities. The interface is stunning and intuitive.', rating: 5 },
|
||||
{ id: '3', name: 'David L.', role: 'Advertiser', avatar: avatar(42), content: 'As an advertiser, the dashboard analytics and lead management tools are enterprise-grade. Highly recommended.', rating: 5 },
|
||||
];
|
||||
|
||||
export const mockStatistics: Statistic[] = [
|
||||
{ label: 'Verified Profiles', value: '12,500+', icon: 'VerifiedUser' },
|
||||
{ label: 'Global Cities', value: '180+', icon: 'LocationCity' },
|
||||
{ label: 'Happy Members', value: '50K+', icon: 'People' },
|
||||
{ label: 'Average Rating', value: '4.9', icon: 'Star' },
|
||||
];
|
||||
|
||||
export const mockFAQ: FAQItem[] = [
|
||||
{ id: '1', question: 'How does profile verification work?', answer: 'Our multi-step verification includes identity document review, photo verification, and optional video confirmation. Verified profiles display a trust badge visible to all members.' },
|
||||
{ id: '2', question: 'Is my privacy protected?', answer: 'Absolutely. We use enterprise-grade encryption, never share personal data with third parties, and offer discreet billing options. Your browsing activity is never logged or sold.' },
|
||||
{ id: '3', question: 'How do I list my profile as an advertiser?', answer: 'Create an advertiser account, complete verification, upload your gallery, set your availability and pricing, and submit for review. Most profiles go live within 24–48 hours.' },
|
||||
{ id: '4', question: 'What payment methods are accepted?', answer: 'We support major credit cards, digital wallets, and cryptocurrency. All transactions are processed through PCI-compliant payment gateways.' },
|
||||
{ id: '5', question: 'Can I save searches and favorites?', answer: 'Yes. Registered members can save profiles, create custom search filters, and receive notifications when new matching profiles are listed.' },
|
||||
];
|
||||
|
||||
export const navItems = [
|
||||
{ label: 'nav.home', path: '/' },
|
||||
{ label: 'nav.search', path: '/search' },
|
||||
{ label: 'nav.cities', path: '/cities' },
|
||||
{ label: 'nav.categories', path: '/categories' },
|
||||
{ label: 'nav.blog', path: '/blog' },
|
||||
{ label: 'nav.forum', path: '/forum' },
|
||||
];
|
||||
|
||||
export const footerLinks = {
|
||||
company: [
|
||||
{ label: 'About Us', path: '/about' },
|
||||
{ label: 'Careers', path: '/careers' },
|
||||
{ label: 'Press', path: '/press' },
|
||||
{ label: 'Advertise', path: '/advertise' },
|
||||
],
|
||||
support: [
|
||||
{ label: 'Help Center', path: '/help' },
|
||||
{ label: 'Contact Us', path: '/contact' },
|
||||
{ label: 'Safety', path: '/safety' },
|
||||
{ label: 'Verification', path: '/verification' },
|
||||
],
|
||||
legal: [
|
||||
{ label: 'Privacy Policy', path: '/privacy' },
|
||||
{ label: 'Terms of Service', path: '/terms' },
|
||||
{ label: 'Cookie Policy', path: '/cookies' },
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { useMemo, useEffect } from 'react';
|
||||
import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { lightTheme, darkTheme } from '@/theme';
|
||||
import { useResolvedTheme } from '@/hooks';
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const resolved = useResolvedTheme();
|
||||
const theme = useMemo(() => (resolved === 'dark' ? darkTheme : lightTheme), [resolved]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
}, [resolved]);
|
||||
|
||||
return (
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</MuiThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeProvider;
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageContainer, SectionWrapper, SectionHeader, GlassCard, LuxeButton } from '@/components/ui';
|
||||
import { mockCities, mockCategories } from '@/constants/mockData';
|
||||
import { formatNumber } from '@/utils';
|
||||
|
||||
export function CitiesSection() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionWrapper sx={{ bgcolor: 'action.hover' }}>
|
||||
<PageContainer>
|
||||
<SectionHeader title={t('sections.cities')} gradient />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(3, 1fr)', lg: 'repeat(6, 1fr)' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{mockCities.map((city, i) => (
|
||||
<motion.div
|
||||
key={city.id}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
>
|
||||
<GlassCard sx={{ cursor: 'pointer', '&:hover': { transform: 'translateY(-4px)' } }}>
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/cities/${city.slug}`}
|
||||
sx={{ textDecoration: 'none', color: 'inherit', display: 'block' }}
|
||||
>
|
||||
<Box sx={{ position: 'relative', height: 140, overflow: 'hidden' }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={city.image}
|
||||
alt={city.name}
|
||||
loading="lazy"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(180deg, transparent 40%, rgba(0,0,0,0.75) 100%)',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ position: 'absolute', bottom: 12, left: 12 }}>
|
||||
<Typography variant="subtitle1" sx={{ color: '#fff', fontWeight: 600 }}>
|
||||
{city.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.8)' }}>
|
||||
{formatNumber(city.profileCount)} profiles
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
))}
|
||||
</Box>
|
||||
</PageContainer>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function CategoriesSection() {
|
||||
const { t } = useTranslation();
|
||||
const icons = ['💎', '🍽️', '✈️', '🎭', '⭐', '🧘'];
|
||||
|
||||
return (
|
||||
<SectionWrapper>
|
||||
<PageContainer>
|
||||
<SectionHeader
|
||||
title={t('sections.categories')}
|
||||
action={
|
||||
<LuxeButton component={RouterLink} to="/categories" variant="outlined" size="small">
|
||||
{t('common.viewAll')}
|
||||
</LuxeButton>
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr 1fr', sm: 'repeat(3, 1fr)', lg: 'repeat(6, 1fr)' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{mockCategories.map((cat, i) => (
|
||||
<GlassCard key={cat.id} sx={{ p: 0 }}>
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/categories/${cat.slug}`}
|
||||
sx={{
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '2rem', mb: 1 }}>{icons[i] ?? '•'}</Typography>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
{cat.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatNumber(cat.profileCount)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
))}
|
||||
</Box>
|
||||
</PageContainer>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default CitiesSection;
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Rating from '@mui/material/Rating';
|
||||
import Accordion from '@mui/material/Accordion';
|
||||
import AccordionSummary from '@mui/material/AccordionSummary';
|
||||
import AccordionDetails from '@mui/material/AccordionDetails';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Pagination, Autoplay } from 'swiper/modules';
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/pagination';
|
||||
import {
|
||||
PageContainer,
|
||||
SectionWrapper,
|
||||
SectionHeader,
|
||||
GlassCard,
|
||||
LuxeButton,
|
||||
GradientText,
|
||||
} from '@/components/ui';
|
||||
import { mockBlogPosts, mockForumTopics, mockTestimonials, mockStatistics, mockFAQ } from '@/constants/mockData';
|
||||
import { newsletterSchema, type NewsletterFormData } from '@/validators/auth';
|
||||
import { gradients } from '@/theme/tokens';
|
||||
|
||||
export function BlogSection({ titleKey, posts }: { titleKey: string; posts: typeof mockBlogPosts }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionWrapper>
|
||||
<PageContainer>
|
||||
<SectionHeader
|
||||
title={t(titleKey)}
|
||||
action={<LuxeButton component={RouterLink} to="/blog" variant="outlined" size="small">{t('common.viewAll')}</LuxeButton>}
|
||||
/>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 3 }}>
|
||||
{posts.map((post, i) => (
|
||||
<motion.div
|
||||
key={post.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
>
|
||||
<GlassCard>
|
||||
<Box component={RouterLink} to={`/blog/${post.slug}`} sx={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Box component="img" src={post.coverImage} alt={post.title} loading="lazy" sx={{ width: '100%', height: 180, objectFit: 'cover' }} />
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Chip label={post.category} size="small" sx={{ mb: 1.5 }} />
|
||||
<Typography variant="h6" sx={{ fontSize: '1rem', mb: 1, lineHeight: 1.4 }}>
|
||||
{post.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{post.excerpt}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar src={post.author.avatar} sx={{ width: 24, height: 24 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{post.readingTime} min read
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
))}
|
||||
</Box>
|
||||
</PageContainer>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForumSection() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionWrapper sx={{ bgcolor: 'action.hover' }}>
|
||||
<PageContainer>
|
||||
<SectionHeader title={t('sections.forum')} action={<LuxeButton component={RouterLink} to="/forum" variant="outlined" size="small">{t('common.viewAll')}</LuxeButton>} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{mockForumTopics.map((topic) => (
|
||||
<GlassCard key={topic.id} sx={{ p: 2.5 }}>
|
||||
<Box component={RouterLink} to={`/forum/${topic.slug}`} sx={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Avatar src={topic.author.avatar} sx={{ width: 40, height: 40 }} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
{topic.isPinned && <Chip label="Pinned" size="small" color="secondary" />}
|
||||
<Chip label={topic.category} size="small" variant="outlined" />
|
||||
</Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>{topic.title}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{topic.excerpt}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{topic.replies} replies · {topic.likes} likes
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
))}
|
||||
</Box>
|
||||
</PageContainer>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function TestimonialsSection() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionWrapper>
|
||||
<PageContainer>
|
||||
<SectionHeader title={t('sections.testimonials')} align="center" gradient />
|
||||
<Swiper modules={[Pagination, Autoplay]} spaceBetween={24} slidesPerView={1} pagination={{ clickable: true }} autoplay={{ delay: 5000 }} breakpoints={{ 768: { slidesPerView: 2 }, 1024: { slidesPerView: 3 } }}>
|
||||
{mockTestimonials.map((item) => (
|
||||
<SwiperSlide key={item.id}>
|
||||
<GlassCard sx={{ p: 3, height: '100%' }}>
|
||||
<Rating value={item.rating} readOnly size="small" sx={{ mb: 2 }} />
|
||||
<Typography variant="body1" sx={{ mb: 3, lineHeight: 1.7, fontStyle: 'italic' }}>
|
||||
"{item.content}"
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Avatar src={item.avatar} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2">{item.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{item.role}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</PageContainer>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatisticsSection() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionWrapper sx={{ background: gradients.purple, color: '#fff' }}>
|
||||
<PageContainer>
|
||||
<SectionHeader title={t('sections.stats')} align="center" />
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(4, 1fr)' }, gap: 4, textAlign: 'center' }}>
|
||||
{mockStatistics.map((stat, i) => (
|
||||
<Box key={stat.label} component={motion.div} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ delay: i * 0.1 }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 700, mb: 1 }}>{stat.value}</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.85 }}>{stat.label}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</PageContainer>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function FAQSection() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionWrapper>
|
||||
<PageContainer sx={{ maxWidth: 800 }}>
|
||||
<SectionHeader title={t('sections.faq')} align="center" />
|
||||
{mockFAQ.map((item) => (
|
||||
<Accordion key={item.id} disableGutters elevation={0} sx={{ bgcolor: 'transparent', '&::before': { display: 'none' }, mb: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>{item.question}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography color="text.secondary" lineHeight={1.7}>{item.answer}</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</PageContainer>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function NewsletterSection() {
|
||||
const { t } = useTranslation();
|
||||
const { register, handleSubmit, formState: { errors, isSubmitSuccessful }, reset } = useForm<NewsletterFormData>({
|
||||
resolver: zodResolver(newsletterSchema),
|
||||
});
|
||||
|
||||
const onSubmit = () => {
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper sx={{ bgcolor: 'action.hover' }}>
|
||||
<PageContainer sx={{ maxWidth: 640, textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ mb: 1 }}>
|
||||
<GradientText>{t('newsletter.title')}</GradientText>
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||
{t('newsletter.subtitle')}
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ display: 'flex', gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
|
||||
<TextField
|
||||
{...register('email')}
|
||||
fullWidth
|
||||
placeholder={t('newsletter.placeholder')}
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
/>
|
||||
<LuxeButton type="submit" variant="contained" sx={{ minWidth: 140, whiteSpace: 'nowrap' }}>
|
||||
{isSubmitSuccessful ? t('newsletter.success') : t('newsletter.button')}
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
</PageContainer>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentBlogsSection() {
|
||||
return <BlogSection titleKey="sections.blogs" posts={mockBlogPosts} />;
|
||||
}
|
||||
|
||||
export function PopularBlogsSection() {
|
||||
return <BlogSection titleKey="sections.popularBlogs" posts={[...mockBlogPosts].sort((a, b) => b.likes - a.likes)} />;
|
||||
}
|
||||
|
||||
export default BlogSection;
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import LocationOnIcon from '@mui/icons-material/LocationOn';
|
||||
import CategoryIcon from '@mui/icons-material/Category';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { LuxeButton, PageContainer, GradientText, GlassForm } from '@/components/ui';
|
||||
import { searchSchema, type SearchFormData } from '@/validators/auth';
|
||||
import { mockCategories, mockCities } from '@/constants/mockData';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { gradients } from '@/theme/tokens';
|
||||
|
||||
export function HeroSection() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { control, handleSubmit } = useForm<SearchFormData>({
|
||||
resolver: zodResolver(searchSchema),
|
||||
defaultValues: { query: '', city: '', category: '' },
|
||||
});
|
||||
|
||||
const onSubmit = (data: SearchFormData) => {
|
||||
const params = new URLSearchParams();
|
||||
if (data.query) params.set('q', data.query);
|
||||
if (data.city) params.set('city', data.city);
|
||||
if (data.category) params.set('category', data.category);
|
||||
navigate(`${ROUTES.SEARCH}?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
minHeight: { xs: '85vh', md: '90vh' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: 'url(https://images.unsplash.com/photo-1515377905703-c4788e51af15?w=1920&h=1080&fit=crop)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? 'linear-gradient(180deg, rgba(10,10,11,0.4) 0%, rgba(10,10,11,0.92) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(250,250,250,0.3) 0%, rgba(250,250,250,0.95) 100%)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '20%',
|
||||
right: '-10%',
|
||||
width: '50%',
|
||||
height: '60%',
|
||||
background: gradients.purple,
|
||||
opacity: 0.15,
|
||||
filter: 'blur(100px)',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
/>
|
||||
|
||||
<PageContainer sx={{ position: 'relative', zIndex: 1, py: { xs: 8, md: 12 } }}>
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
sx={{ maxWidth: 800 }}
|
||||
>
|
||||
<Typography
|
||||
variant="h1"
|
||||
sx={{
|
||||
fontSize: { xs: '2.5rem', sm: '3.5rem', md: '4.5rem' },
|
||||
lineHeight: 1.1,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<GradientText>{t('hero.title')}</GradientText>
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 5, fontWeight: 400, maxWidth: 560, lineHeight: 1.6 }}
|
||||
>
|
||||
{t('hero.subtitle')}
|
||||
</Typography>
|
||||
|
||||
<GlassForm
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
sx={{ p: { xs: 2, md: 3 }, maxWidth: 720 }}
|
||||
>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 5 }}>
|
||||
<Controller
|
||||
name="query"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fullWidth
|
||||
placeholder={t('search.placeholder')}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Controller
|
||||
name="city"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
select
|
||||
fullWidth
|
||||
label={t('search.location')}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<LocationOnIcon color="action" fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">All Cities</MenuItem>
|
||||
{mockCities.map((city) => (
|
||||
<MenuItem key={city.id} value={city.slug}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
|
||||
<Controller
|
||||
name="category"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
select
|
||||
fullWidth
|
||||
label={t('search.category')}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<CategoryIcon color="action" fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{mockCategories.map((cat) => (
|
||||
<MenuItem key={cat.id} value={cat.slug}>
|
||||
{cat.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 2 }}>
|
||||
<LuxeButton type="submit" variant="contained" fullWidth sx={{ height: '100%', minHeight: 56 }}>
|
||||
{t('common.search')}
|
||||
</LuxeButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</GlassForm>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 4, flexWrap: 'wrap' }}>
|
||||
<LuxeButton variant="contained" size="large" onClick={() => navigate(ROUTES.SEARCH)}>
|
||||
{t('hero.cta')}
|
||||
</LuxeButton>
|
||||
<LuxeButton variant="outlined" size="large" onClick={() => navigate(ROUTES.AUTH.REGISTER)}>
|
||||
{t('hero.secondaryCta')}
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeroSection;
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import Box from '@mui/material/Box';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageContainer, SectionWrapper, SectionHeader, ProfileCard } from '@/components/ui';
|
||||
import { mockProfiles } from '@/constants/mockData';
|
||||
import LuxeButton from '@/components/ui/LuxeButton';
|
||||
|
||||
interface ProfileGridSectionProps {
|
||||
titleKey: string;
|
||||
subtitle?: string;
|
||||
profiles: typeof mockProfiles;
|
||||
filter?: (p: (typeof mockProfiles)[0]) => boolean;
|
||||
viewAllPath?: string;
|
||||
gradient?: boolean;
|
||||
}
|
||||
|
||||
export function ProfileGridSection({
|
||||
titleKey,
|
||||
subtitle,
|
||||
profiles,
|
||||
filter,
|
||||
viewAllPath = '/search',
|
||||
gradient = false,
|
||||
}: ProfileGridSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const data = filter ? profiles.filter(filter) : profiles;
|
||||
|
||||
return (
|
||||
<SectionWrapper>
|
||||
<PageContainer>
|
||||
<SectionHeader
|
||||
title={t(titleKey)}
|
||||
subtitle={subtitle}
|
||||
gradient={gradient}
|
||||
action={
|
||||
<LuxeButton component={RouterLink} to={viewAllPath} variant="outlined" size="small">
|
||||
{t('common.viewAll')}
|
||||
</LuxeButton>
|
||||
}
|
||||
/>
|
||||
<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"
|
||||
profiles={mockProfiles}
|
||||
filter={(p) => p.isOnline || p.rating >= 4.7}
|
||||
gradient
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeaturedProfilesSection() {
|
||||
return (
|
||||
<ProfileGridSection
|
||||
titleKey="sections.featured"
|
||||
subtitle="Hand-picked profiles showcasing exceptional quality and service."
|
||||
profiles={mockProfiles}
|
||||
filter={(p) => p.isPremium}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LatestProfilesSection() {
|
||||
return <ProfileGridSection titleKey="sections.latest" profiles={[...mockProfiles].reverse()} />;
|
||||
}
|
||||
|
||||
export function VerifiedProfilesSection() {
|
||||
return (
|
||||
<ProfileGridSection
|
||||
titleKey="sections.verified"
|
||||
subtitle="Identity-verified companions you can trust."
|
||||
profiles={mockProfiles}
|
||||
filter={(p) => p.isVerified}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PremiumAdvertisersSection() {
|
||||
return (
|
||||
<ProfileGridSection
|
||||
titleKey="sections.premium"
|
||||
profiles={mockProfiles}
|
||||
filter={(p) => p.isPremium}
|
||||
gradient
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NearbyListingsSection() {
|
||||
return (
|
||||
<ProfileGridSection
|
||||
titleKey="sections.nearby"
|
||||
subtitle="Discover companions in your area."
|
||||
profiles={mockProfiles.slice(0, 4)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileGridSection;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
export { HeroSection } from './HeroSection';
|
||||
export {
|
||||
TrendingProfilesSection,
|
||||
FeaturedProfilesSection,
|
||||
LatestProfilesSection,
|
||||
VerifiedProfilesSection,
|
||||
PremiumAdvertisersSection,
|
||||
NearbyListingsSection,
|
||||
} from './ProfileSections';
|
||||
export { CitiesSection, CategoriesSection } from './CitiesCategoriesSection';
|
||||
export {
|
||||
RecentBlogsSection,
|
||||
PopularBlogsSection,
|
||||
ForumSection,
|
||||
TestimonialsSection,
|
||||
StatisticsSection,
|
||||
FAQSection,
|
||||
NewsletterSection,
|
||||
} from './ContentSections';
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useThemeStore } from '@/store';
|
||||
import { resolveThemeMode } from '@/theme';
|
||||
|
||||
export function useResolvedTheme() {
|
||||
const mode = useThemeStore((s) => s.mode);
|
||||
const [resolved, setResolved] = useState<'light' | 'dark'>(() => resolveThemeMode(mode));
|
||||
|
||||
useEffect(() => {
|
||||
setResolved(resolveThemeMode(mode));
|
||||
|
||||
if (mode !== 'system') return;
|
||||
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => setResolved(resolveThemeMode('system'));
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, [mode]);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() =>
|
||||
typeof window !== 'undefined' ? window.matchMedia(query).matches : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(query);
|
||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
setMatches(mq.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function useScrollPosition(threshold = 50): boolean {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setScrolled(window.scrollY > threshold);
|
||||
handler();
|
||||
window.addEventListener('scroll', handler, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handler);
|
||||
}, [threshold]);
|
||||
|
||||
return scrolled;
|
||||
}
|
||||
|
||||
export function useDebounce<T>(value: T, delay = 300): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { Outlet, Link as RouterLink } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Logo } from '@/components/ui';
|
||||
import { gradients } from '@/theme/tokens';
|
||||
|
||||
export function AuthLayout() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
sx={{
|
||||
display: { xs: 'none', lg: 'flex' },
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
p: 6,
|
||||
background: gradients.purple,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'radial-gradient(circle at 30% 50%, rgba(201,169,98,0.15) 0%, transparent 60%)',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ position: 'relative', zIndex: 1, textAlign: 'center', maxWidth: 420 }}>
|
||||
<Logo size="lg" />
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{ color: '#fff', mt: 4, mb: 2, fontFamily: '"Playfair Display", serif' }}
|
||||
>
|
||||
Welcome to Luxe
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: 'rgba(255,255,255,0.8)', lineHeight: 1.8 }}>
|
||||
The premier platform for premium companion services. Join thousands of verified members worldwide.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
p: { xs: 3, sm: 6 },
|
||||
bgcolor: 'background.default',
|
||||
}}
|
||||
>
|
||||
<Box component={RouterLink} to="/" sx={{ display: { xs: 'block', lg: 'none' }, mb: 4, textDecoration: 'none' }}>
|
||||
<Logo />
|
||||
</Box>
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
sx={{ width: '100%', maxWidth: 420 }}
|
||||
>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthLayout;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Outlet } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import { Header } from '@/components/layouts/Header';
|
||||
import { Footer } from '@/components/layouts/Footer';
|
||||
|
||||
export function MainLayout() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<Header />
|
||||
<Box component="main" sx={{ flex: 1 }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
<Footer />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default MainLayout;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from '@/app/App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { Helmet } from 'react-helmet-async';
|
||||
import {
|
||||
HeroSection,
|
||||
TrendingProfilesSection,
|
||||
FeaturedProfilesSection,
|
||||
LatestProfilesSection,
|
||||
VerifiedProfilesSection,
|
||||
PremiumAdvertisersSection,
|
||||
CitiesSection,
|
||||
CategoriesSection,
|
||||
NearbyListingsSection,
|
||||
RecentBlogsSection,
|
||||
PopularBlogsSection,
|
||||
ForumSection,
|
||||
TestimonialsSection,
|
||||
StatisticsSection,
|
||||
FAQSection,
|
||||
NewsletterSection,
|
||||
} from '@/features/home/sections';
|
||||
import { APP_NAME, APP_URL } from '@/constants';
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{APP_NAME} — Premium Companion Directory</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Discover verified premium companions worldwide. Curated profiles, trusted verification, and an elegant experience."
|
||||
/>
|
||||
<link rel="canonical" href={APP_URL} />
|
||||
<meta property="og:title" content={`${APP_NAME} — Premium Companion Directory`} />
|
||||
<meta property="og:description" content="Discover verified premium companions worldwide." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={APP_URL} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: APP_NAME,
|
||||
url: APP_URL,
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${APP_URL}/search?q={search_term_string}`,
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</Helmet>
|
||||
|
||||
<HeroSection />
|
||||
<TrendingProfilesSection />
|
||||
<FeaturedProfilesSection />
|
||||
<LatestProfilesSection />
|
||||
<VerifiedProfilesSection />
|
||||
<PremiumAdvertisersSection />
|
||||
<CitiesSection />
|
||||
<CategoriesSection />
|
||||
<NearbyListingsSection />
|
||||
<RecentBlogsSection />
|
||||
<PopularBlogsSection />
|
||||
<ForumSection />
|
||||
<TestimonialsSection />
|
||||
<StatisticsSection />
|
||||
<FAQSection />
|
||||
<NewsletterSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default HomePage;
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { LuxeButton } from '@/components/ui';
|
||||
import { ROUTES } from '@/constants';
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '60vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
px: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h1" sx={{ fontSize: '6rem', fontWeight: 700, color: 'gold.main', lineHeight: 1 }}>
|
||||
404
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ mt: 2, mb: 1 }}>
|
||||
Page not found
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 400 }}>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</Typography>
|
||||
<LuxeButton component={RouterLink} to={ROUTES.HOME} variant="contained">
|
||||
Back to Home
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFoundPage;
|
||||
|
|
@ -0,0 +1,717 @@
|
|||
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';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Rating from '@mui/material/Rating';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
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 IconButton from '@mui/material/IconButton';
|
||||
import PhoneIcon from '@mui/icons-material/Phone';
|
||||
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
import FileCopyIcon from '@mui/icons-material/FileCopy';
|
||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import VerifiedIcon from '@mui/icons-material/Verified';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
|
||||
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Navigation, Pagination } from 'swiper/modules';
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/navigation';
|
||||
import 'swiper/css/pagination';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
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 { ROUTES } from '@/constants';
|
||||
import { useMediaQuery } from '@/hooks';
|
||||
|
||||
// Form validation schema for Reviews
|
||||
const reviewSchema = z.object({
|
||||
reviewerName: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
rating: z.number().min(1, 'Please select a rating').max(5),
|
||||
comment: z.string().min(10, 'Review comment must be at least 10 characters'),
|
||||
});
|
||||
|
||||
type ReviewFormData = z.infer<typeof reviewSchema>;
|
||||
|
||||
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 [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [activePhotoIdx, setActivePhotoIdx] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [qrOpen, setQrOpen] = useState(false);
|
||||
const [verifyOpen, setVerifyOpen] = useState(false);
|
||||
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<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>({
|
||||
resolver: zodResolver(reviewSchema),
|
||||
defaultValues: { reviewerName: '', rating: 5, comment: '' },
|
||||
});
|
||||
|
||||
const ratingVal = watch('rating');
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<SectionWrapper>
|
||||
<PageContainer sx={{ textAlign: 'center', py: 12 }}>
|
||||
<Typography variant="h4" fontWeight={700} gutterBottom>Companion Profile Not Found</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 4 }}>The profile you are looking for does not exist or has been removed.</Typography>
|
||||
<LuxeButton variant="contained" component={RouterLink} to={ROUTES.SEARCH}>
|
||||
Return to Directory
|
||||
</LuxeButton>
|
||||
</PageContainer>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 = [
|
||||
{ duration: '1 Hour', price: profile.priceFrom },
|
||||
{ duration: '2 Hours', price: Math.round(profile.priceFrom * 1.7) },
|
||||
{ duration: '3 Hours', price: Math.round(profile.priceFrom * 2.4) },
|
||||
{ duration: 'Dinner Date (4 hrs)', price: Math.round(profile.priceFrom * 3.0) },
|
||||
{ duration: 'Overnight (12 hrs)', price: Math.round(profile.priceFrom * 6.5) },
|
||||
];
|
||||
|
||||
const outcallRates = [
|
||||
{ duration: '1 Hour', price: Math.round(profile.priceFrom * 1.25) },
|
||||
{ duration: '2 Hours', price: Math.round(profile.priceFrom * 2.1) },
|
||||
{ duration: '3 Hours', price: Math.round(profile.priceFrom * 3.0) },
|
||||
{ duration: 'Dinner Date (4 hrs)', price: Math.round(profile.priceFrom * 3.8) },
|
||||
{ duration: 'Overnight (12 hrs)', price: Math.round(profile.priceFrom * 8.0) },
|
||||
];
|
||||
|
||||
// Contact integration numbers (mock)
|
||||
const phoneNum = '+1 (555) 123-4567';
|
||||
const whatsappUrl = `https://wa.me/15551234567?text=Hello%20${profile.name},%20I%20found%20your%20profile%20on%20Luxe%20and%20would%20love%20to%20inquire%20about%20your%20availability.`;
|
||||
const telegramUrl = `https://t.me/luxe_companion_bot`;
|
||||
|
||||
const handleCopyNumber = () => {
|
||||
navigator.clipboard.writeText(phoneNum);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleReviewSubmit = (data: ReviewFormData) => {
|
||||
const newRev = {
|
||||
id: reviewsList.length + 1,
|
||||
name: data.reviewerName,
|
||||
rating: data.rating,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
comment: data.comment,
|
||||
};
|
||||
setReviewsList([newRev, ...reviewsList]);
|
||||
reset();
|
||||
};
|
||||
|
||||
// Mock schedule grid
|
||||
const daysOfWeek = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const timeSlots = ['Morning (9am - 12pm)', 'Afternoon (12pm - 5pm)', 'Evening (5pm - 10pm)', 'Night (10pm - 2am)'];
|
||||
const mockSchedule: Record<string, string[]> = {
|
||||
Mon: ['Available', 'Available', 'Booked', 'Unavailable'],
|
||||
Tue: ['Available', 'Available', 'Available', 'Booked'],
|
||||
Wed: ['Unavailable', 'Available', 'Booked', 'Booked'],
|
||||
Thu: ['Available', 'Available', 'Available', 'Available'],
|
||||
Fri: ['Booked', 'Available', 'Booked', 'Booked'],
|
||||
Sat: ['Unavailable', 'Booked', 'Booked', 'Booked'],
|
||||
Sun: ['Available', 'Available', 'Unavailable', 'Unavailable'],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{profile.name} — Premium Companion in {profile.city} | Luxe</title>
|
||||
<meta name="description" content={`View high-end verified companion ${profile.name} in ${profile.city}. High resolution photos, booking calendar, verified rates.`} />
|
||||
{/* OpenGraph */}
|
||||
<meta property="og:title" content={`${profile.name} — Premium Companion in ${profile.city}`} />
|
||||
<meta property="og:description" content={`Verified photos and rates for ${profile.name} in ${profile.city}.`} />
|
||||
<meta property="og:image" content={profile.avatar} />
|
||||
</Helmet>
|
||||
|
||||
<SectionWrapper sx={{ pt: 3, pb: 10 }}>
|
||||
<PageContainer>
|
||||
{/* Back button */}
|
||||
<LuxeButton
|
||||
variant="text"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 3, color: 'text.secondary' }}
|
||||
>
|
||||
Back to listings
|
||||
</LuxeButton>
|
||||
|
||||
<Grid container spacing={5}>
|
||||
{/* Left Column: Gallery & Verification CTA */}
|
||||
<Grid size={{ xs: 12, md: 5.5, lg: 5 }}>
|
||||
<Box sx={{ position: 'relative', borderRadius: 4, overflow: 'hidden', mb: 2, height: { xs: 400, sm: 500, md: 550 } }}>
|
||||
<Swiper
|
||||
modules={[Navigation, Pagination]}
|
||||
navigation={isSmUp}
|
||||
pagination={{ clickable: true }}
|
||||
style={{ height: '100%', '--swiper-theme-color': '#C9A962' } as any}
|
||||
onSlideChange={(swiper) => setActivePhotoIdx(swiper.activeIndex)}
|
||||
>
|
||||
{photos.map((src, idx) => (
|
||||
<SwiperSlide key={idx} onClick={() => setLightboxOpen(true)} style={{ cursor: 'zoom-in' }}>
|
||||
<Box component="img" src={src} alt={`${profile.name} Photo ${idx + 1}`} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
{/* Badge Overlay */}
|
||||
<Box sx={{ position: 'absolute', top: 16, left: 16, display: 'flex', gap: 1, zIndex: 10 }}>
|
||||
{profile.isVerified && <StatusBadge badgeType="verified" />}
|
||||
{profile.isPremium && <StatusBadge badgeType="premium" />}
|
||||
{profile.isOnline && <StatusBadge badgeType="online" />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Photo Mini Grids */}
|
||||
<Grid container spacing={1.5} sx={{ mb: 4 }}>
|
||||
{photos.map((src, idx) => (
|
||||
<Grid key={idx} size={3}>
|
||||
<Box
|
||||
component="img"
|
||||
src={src}
|
||||
alt="Thumbnail"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 70,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 2,
|
||||
cursor: 'pointer',
|
||||
border: activePhotoIdx === idx ? '2px solid #C9A962' : '2px solid transparent',
|
||||
transition: 'border 0.2s',
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Verification & Trust Alert */}
|
||||
<GlassCard sx={{ p: 2.5, mb: 4, display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<Avatar sx={{ bgcolor: 'secondary.main', width: 44, height: 44 }}>
|
||||
<VerifiedIcon sx={{ color: '#0A0A0B' }} />
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" fontWeight={700}>Identity Verified</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
This client has successfully completed biometric verification.
|
||||
</Typography>
|
||||
</Box>
|
||||
<LuxeButton variant="outlined" size="small" onClick={() => setVerifyOpen(true)}>
|
||||
Verify Yours
|
||||
</LuxeButton>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
|
||||
{/* Right Column: Information, Contact, Rates */}
|
||||
<Grid size={{ xs: 12, md: 6.5, lg: 7 }}>
|
||||
{/* Profile Title Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h3" fontWeight={700} sx={{ fontFamily: '"Playfair Display", serif', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{profile.name}
|
||||
{profile.isVerified && <CheckCircleIcon color="secondary" fontSize="medium" />}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" fontWeight={400} sx={{ mt: 0.5 }}>
|
||||
{profile.age} years old · {profile.city} {profile.area ? `(${profile.area})` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: 'action.hover', px: 1.5, py: 0.75, borderRadius: 2 }}>
|
||||
<StarIcon sx={{ color: 'amber.500', fontSize: 18 }} />
|
||||
<Typography fontWeight={700} variant="body2">{profile.rating}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">({profile.reviewCount})</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle1" color="secondary.light" fontWeight={600} sx={{ mb: 3 }}>
|
||||
From {formatCurrency(profile.priceFrom, profile.currency)} / hour
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 1.5 }}>
|
||||
Connect Instantly
|
||||
</Typography>
|
||||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<LuxeButton
|
||||
variant="contained"
|
||||
fullWidth
|
||||
component="a"
|
||||
href={whatsappUrl}
|
||||
target="_blank"
|
||||
startIcon={<WhatsAppIcon />}
|
||||
sx={{ bgcolor: '#25D366', color: '#fff', '&:hover': { bgcolor: '#128C7E' } }}
|
||||
>
|
||||
WhatsApp
|
||||
</LuxeButton>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<LuxeButton
|
||||
variant="contained"
|
||||
fullWidth
|
||||
component="a"
|
||||
href={telegramUrl}
|
||||
target="_blank"
|
||||
startIcon={<TelegramIcon />}
|
||||
sx={{ bgcolor: '#0088cc', color: '#fff', '&:hover': { bgcolor: '#006699' } }}
|
||||
>
|
||||
Telegram
|
||||
</LuxeButton>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<LuxeButton
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
component="a"
|
||||
href={`tel:${phoneNum}`}
|
||||
startIcon={<PhoneIcon />}
|
||||
>
|
||||
Call Host
|
||||
</LuxeButton>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 6 }}>
|
||||
<LuxeButton
|
||||
variant="text"
|
||||
fullWidth
|
||||
startIcon={<FileCopyIcon />}
|
||||
onClick={handleCopyNumber}
|
||||
sx={{ color: 'text.secondary' }}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy Number'}
|
||||
</LuxeButton>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 6 }}>
|
||||
<LuxeButton
|
||||
variant="text"
|
||||
fullWidth
|
||||
startIcon={<QrCodeIcon />}
|
||||
onClick={() => setQrOpen(true)}
|
||||
sx={{ color: 'text.secondary' }}
|
||||
>
|
||||
QR Contact
|
||||
</LuxeButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Biography Section */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" fontWeight={700} sx={{ mb: 1.5, fontFamily: '"Playfair Display", serif' }}>
|
||||
About {profile.name}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ lineHeight: 1.8, whiteSpace: 'pre-line' }}>
|
||||
Elegant and highly professional companion for executive dinner dates, travel arrangements, and wellness companionship. I prioritize mutual respect, privacy, and creating an upscale, relaxed experience.
|
||||
{"\n\n"}
|
||||
Whether you require an elegant partner for a corporate function, a luxurious dinner companion, or looking for premium wellness therapy, I tailor our sessions to suit your exact requirements. Discretion is guaranteed.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Key Attributes */}
|
||||
<Grid container spacing={2} sx={{ mb: 5, bgcolor: 'action.hover', p: 2.5, borderRadius: 3 }}>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Height</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>{profile.height ?? '168 cm'}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Age</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>{profile.age} yrs</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Languages</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>{profile.languages.join(', ')}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Categories</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>{profile.categories.join(', ')}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Services Rate Table */}
|
||||
<Box sx={{ mb: 5 }}>
|
||||
<Typography variant="h6" fontWeight={700} sx={{ mb: 2, fontFamily: '"Playfair Display", serif' }}>
|
||||
Rates and Services
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TableContainer component={Paper} elevation={0} sx={{ border: 1, borderColor: 'divider', bgcolor: 'transparent' }}>
|
||||
<Table size="small">
|
||||
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} align="center"><Typography fontWeight={700}>Incall Rates</Typography></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{incallRates.map((rate) => (
|
||||
<TableRow key={rate.duration}>
|
||||
<TableCell>{rate.duration}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, color: 'secondary.light' }}>${rate.price}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TableContainer component={Paper} elevation={0} sx={{ border: 1, borderColor: 'divider', bgcolor: 'transparent' }}>
|
||||
<Table size="small">
|
||||
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} align="center"><Typography fontWeight={700}>Outcall Rates</Typography></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{outcallRates.map((rate) => (
|
||||
<TableRow key={rate.duration}>
|
||||
<TableCell>{rate.duration}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, color: 'secondary.light' }}>${rate.price}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Availability Calendar */}
|
||||
<Box sx={{ mb: 5 }}>
|
||||
<Typography variant="h6" fontWeight={700} sx={{ mb: 2, fontFamily: '"Playfair Display", serif', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CalendarMonthIcon /> Availability Calendar
|
||||
</Typography>
|
||||
<TableContainer component={Paper} elevation={0} sx={{ border: 1, borderColor: 'divider', bgcolor: 'transparent' }}>
|
||||
<Table size="small">
|
||||
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableRow>
|
||||
<TableCell><Typography fontWeight={700}>Day</Typography></TableCell>
|
||||
{timeSlots.map((slot) => (
|
||||
<TableCell key={slot} align="center"><Typography variant="caption" fontWeight={700}>{slot.split(' ')[0]}</Typography></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{daysOfWeek.map((day) => (
|
||||
<TableRow key={day}>
|
||||
<TableCell sx={{ fontWeight: 600 }}>{day}</TableCell>
|
||||
{timeSlots.map((_, idx) => {
|
||||
const status = mockSchedule[day]?.[idx] ?? 'Unavailable';
|
||||
const color = status === 'Available' ? '#25D366' : status === 'Booked' ? '#d32f2f' : 'text.disabled';
|
||||
return (
|
||||
<TableCell key={idx} align="center">
|
||||
<Chip
|
||||
label={status}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: status === 'Available' ? 'rgba(37, 211, 102, 0.15)' : status === 'Booked' ? 'rgba(211, 47, 47, 0.15)' : 'rgba(255,255,255,0.05)',
|
||||
color: color,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
borderRadius: 1.5,
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
|
||||
{/* Reviews Section */}
|
||||
<Box sx={{ mb: 5 }}>
|
||||
<Typography variant="h6" fontWeight={700} sx={{ mb: 2.5, fontFamily: '"Playfair Display", serif' }}>
|
||||
Client Reviews ({reviewsList.length})
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, mb: 4 }}>
|
||||
{reviewsList.map((rev) => (
|
||||
<GlassCard key={rev.id} sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle2" fontWeight={600}>{rev.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{rev.date}</Typography>
|
||||
</Box>
|
||||
<Rating value={rev.rating} size="small" readOnly sx={{ mb: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary" lineHeight={1.6}>
|
||||
{rev.comment}
|
||||
</Typography>
|
||||
</GlassCard>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Review Form */}
|
||||
<GlassCard sx={{ p: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight={700} sx={{ mb: 2 }}>Leave a Review</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit(handleReviewSubmit)} noValidate sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
{...register('reviewerName')}
|
||||
fullWidth
|
||||
label="Your Name"
|
||||
size="small"
|
||||
error={!!errors.reviewerName}
|
||||
helperText={errors.reviewerName?.message}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography variant="body2" color="text.secondary">Your Rating:</Typography>
|
||||
<Rating
|
||||
name="rating"
|
||||
value={ratingVal}
|
||||
onChange={(_, val) => setValue('rating', val ?? 5)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<TextField
|
||||
{...register('comment')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
label="Your Experience"
|
||||
placeholder="Write your review here... minimum 10 characters."
|
||||
error={!!errors.comment}
|
||||
helperText={errors.comment?.message}
|
||||
/>
|
||||
|
||||
<LuxeButton type="submit" variant="contained" sx={{ alignSelf: 'flex-start' }}>
|
||||
Submit Review
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</PageContainer>
|
||||
</SectionWrapper>
|
||||
|
||||
{/* Fullscreen Slider Lightbox */}
|
||||
<Dialog
|
||||
fullScreen
|
||||
open={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
slotProps={{
|
||||
backdrop: { sx: { bgcolor: 'rgba(0,0,0,0.95)' } }
|
||||
}}
|
||||
PaperProps={{
|
||||
sx: { bgcolor: 'rgba(0,0,0,0.95)', color: '#fff', overflow: 'hidden', display: 'flex', flexDirection: 'column', justifyContent: 'center' }
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }}>
|
||||
<IconButton onClick={() => setLightboxOpen(false)} sx={{ color: '#fff' }} aria-label="Close lightbox">
|
||||
<CloseIcon fontSize="large" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', height: '100%' }}>
|
||||
<Swiper
|
||||
modules={[Navigation, Pagination]}
|
||||
navigation
|
||||
pagination={{ type: 'fraction' }}
|
||||
initialSlide={activePhotoIdx}
|
||||
style={{ width: '100%', height: '80vh' }}
|
||||
>
|
||||
{photos.map((src, idx) => (
|
||||
<SwiperSlide key={idx} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={src}
|
||||
alt={`${profile.name} Large Photo ${idx + 1}`}
|
||||
sx={{ maxHeight: '100%', maxWidth: '100%', objectFit: 'contain', margin: 'auto' }}
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</Box>
|
||||
</Dialog>
|
||||
|
||||
{/* QR Code Dialog */}
|
||||
<Dialog open={qrOpen} onClose={() => setQrOpen(false)} PaperProps={{ sx: { p: 4, textAlign: 'center', borderRadius: 3 } }}>
|
||||
<Typography variant="h6" fontWeight={700} gutterBottom>Scan to Save Contact</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>Use your smartphone camera to scan this QR code and save {profile.name}'s contact details instantly.</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(`TEL:${phoneNum}`)}`}
|
||||
alt="Contact QR Code"
|
||||
sx={{ width: 180, height: 180, margin: '0 auto', border: '8px solid white', borderRadius: 2, boxShadow: 2 }}
|
||||
/>
|
||||
<LuxeButton onClick={() => setQrOpen(false)} sx={{ mt: 3 }} variant="outlined">Close</LuxeButton>
|
||||
</Dialog>
|
||||
|
||||
{/* Selfie Verification Modal */}
|
||||
<Dialog
|
||||
open={verifyOpen}
|
||||
onClose={() => setVerifyOpen(false)}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
PaperProps={{ sx: { p: 4, borderRadius: 3 } }}
|
||||
>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CameraAltIcon color="secondary" /> Identity Verification
|
||||
</Typography>
|
||||
|
||||
{verificationStep === 1 && (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, lineHeight: 1.6 }}>
|
||||
Luxe maintains the highest safety and security standards. Verified badges are awarded to companions and members who complete our real-time verification process.
|
||||
</Typography>
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
You will need a valid government-issued ID (Passport, Driver's license) and a device with a working camera to take a real-time selfie.
|
||||
</Alert>
|
||||
<LuxeButton variant="contained" fullWidth onClick={() => setVerificationStep(2)}>
|
||||
Get Started
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{verificationStep === 2 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 1 }}>Step 1: Upload Identity Document</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Please upload a clear, high-resolution photo of your Passport, Driver's License, or National ID card.
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px dashed rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
bgcolor: 'action.hover',
|
||||
'&:hover': { borderColor: 'secondary.main' },
|
||||
mb: 3,
|
||||
}}
|
||||
onClick={() => {
|
||||
// simulate file upload
|
||||
setUploadedDoc(new File([''], 'id_passport_mock.jpg'));
|
||||
}}
|
||||
>
|
||||
{uploadedDoc ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<CheckIcon color="success" />
|
||||
<Typography variant="body2" fontWeight={600}>{uploadedDoc.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Click to re-upload</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">Drag & drop your ID file here, or click to upload</Typography>
|
||||
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1 }}>PNG, JPG or PDF up to 5MB</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<LuxeButton variant="outlined" fullWidth onClick={() => setVerificationStep(1)}>Back</LuxeButton>
|
||||
<LuxeButton variant="contained" fullWidth disabled={!uploadedDoc} onClick={() => setVerificationStep(3)}>Next Step</LuxeButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{verificationStep === 3 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 1 }}>Step 2: Upload Biometric Selfie</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Upload a clear photo of yourself holding your ID card next to your face, matching the name and face details on your uploaded document.
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px dashed rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
bgcolor: 'action.hover',
|
||||
'&:hover': { borderColor: 'secondary.main' },
|
||||
mb: 3,
|
||||
}}
|
||||
onClick={() => {
|
||||
setUploadedSelfie(new File([''], 'selfie_verify_mock.jpg'));
|
||||
}}
|
||||
>
|
||||
{uploadedSelfie ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<CheckIcon color="success" />
|
||||
<Typography variant="body2" fontWeight={600}>{uploadedSelfie.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Click to re-upload</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">Take/Upload selfie holding ID card</Typography>
|
||||
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1 }}>Ensure your face and ID details are clearly visible</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<LuxeButton variant="outlined" fullWidth onClick={() => setVerificationStep(2)}>Back</LuxeButton>
|
||||
<LuxeButton variant="contained" fullWidth disabled={!uploadedSelfie} onClick={() => setVerificationStep(4)}>Submit Verification</LuxeButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{verificationStep === 4 && (
|
||||
<Box sx={{ textAlign: 'center', py: 2 }}>
|
||||
<Avatar sx={{ bgcolor: 'success.main', width: 56, height: 56, margin: '0 auto 16px' }}>
|
||||
<CheckIcon sx={{ color: '#fff', fontSize: 32 }} />
|
||||
</Avatar>
|
||||
<Typography variant="h6" fontWeight={700} gutterBottom>Verification Submitted</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 4, px: 2 }}>
|
||||
Your verification documents and selfie have been uploaded successfully. Our moderators will review your application within 24-48 hours.
|
||||
</Typography>
|
||||
<LuxeButton variant="contained" fullWidth onClick={() => { setVerifyOpen(false); setVerificationStep(1); setUploadedDoc(null); setUploadedSelfie(null); }}>
|
||||
Finished
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfilePage;
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
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 { 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';
|
||||
|
||||
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]);
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
// Apply sorting
|
||||
const sortedResults = [...results].sort((a, b) => {
|
||||
const sortBy = filters.sortBy ?? 'relevance';
|
||||
if (sortBy === 'price_asc') return a.priceFrom - b.priceFrom;
|
||||
if (sortBy === 'price_desc') return b.priceFrom - a.priceFrom;
|
||||
if (sortBy === 'rating') return b.rating - a.rating;
|
||||
if (sortBy === 'newest') return b.id.localeCompare(a.id);
|
||||
|
||||
// Relevance: Premium first, then verified first
|
||||
if (a.isPremium && !b.isPremium) return -1;
|
||||
if (!a.isPremium && b.isPremium) return 1;
|
||||
if (a.isVerified && !b.isVerified) return -1;
|
||||
if (!a.isVerified && b.isVerified) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
{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;
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import { LuxeButton } from '@/components/ui';
|
||||
import { forgotPasswordSchema, type ForgotPasswordFormData } from '@/validators/auth';
|
||||
import { ROUTES } from '@/constants';
|
||||
|
||||
export function ForgotPasswordPage() {
|
||||
const { t } = useTranslation();
|
||||
const [sent, setSent] = useState(false);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ForgotPasswordFormData>({
|
||||
resolver: zodResolver(forgotPasswordSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (_data: ForgotPasswordFormData) => {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
setSent(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
component={RouterLink}
|
||||
to={ROUTES.AUTH.LOGIN}
|
||||
variant="body2"
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, color: 'text.secondary', textDecoration: 'none', mb: 3 }}
|
||||
>
|
||||
<ArrowBackIcon fontSize="small" /> Back to sign in
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h4" sx={{ mb: 1, fontFamily: '"Playfair Display", serif' }}>
|
||||
Reset Password
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
|
||||
Enter your email and we'll send you a link to reset your password.
|
||||
</Typography>
|
||||
|
||||
{sent ? (
|
||||
<Alert severity="success">
|
||||
Check your inbox for password reset instructions.
|
||||
</Alert>
|
||||
) : (
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||
<TextField
|
||||
{...register('email')}
|
||||
label={t('auth.email')}
|
||||
type="email"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
/>
|
||||
<LuxeButton type="submit" variant="contained" fullWidth sx={{ mt: 3, py: 1.5 }} disabled={isSubmitting}>
|
||||
{isSubmitting ? t('common.loading') : 'Send Reset Link'}
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default ForgotPasswordPage;
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import AppleIcon from '@mui/icons-material/Apple';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LuxeButton } from '@/components/ui';
|
||||
import { loginSchema, type LoginFormData } from '@/validators/auth';
|
||||
import { ROUTES } from '@/constants';
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: { rememberMe: false },
|
||||
});
|
||||
|
||||
const onSubmit = async (_data: LoginFormData) => {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ mb: 1, fontFamily: '"Playfair Display", serif' }}>
|
||||
{t('auth.welcomeBack')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
|
||||
Sign in to access your dashboard and saved profiles.
|
||||
</Typography>
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||
<TextField
|
||||
{...register('email')}
|
||||
label={t('auth.email')}
|
||||
type="email"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
autoComplete="email"
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
/>
|
||||
<TextField
|
||||
{...register('password')}
|
||||
label={t('auth.password')}
|
||||
type="password"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
autoComplete="current-password"
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox {...register('rememberMe')} size="small" />}
|
||||
label={<Typography variant="body2">Remember me</Typography>}
|
||||
/>
|
||||
<Typography
|
||||
component={RouterLink}
|
||||
to={ROUTES.AUTH.FORGOT_PASSWORD}
|
||||
variant="body2"
|
||||
sx={{ color: 'gold.main', textDecoration: 'none' }}
|
||||
>
|
||||
{t('auth.forgotPassword')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<LuxeButton type="submit" variant="contained" fullWidth sx={{ mt: 3, py: 1.5 }} disabled={isSubmitting}>
|
||||
{isSubmitting ? t('common.loading') : t('common.login')}
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('auth.orContinueWith')}
|
||||
</Typography>
|
||||
</Divider>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<LuxeButton variant="outlined" fullWidth startIcon={<GoogleIcon />} aria-label="Sign in with Google">
|
||||
Google
|
||||
</LuxeButton>
|
||||
<LuxeButton variant="outlined" fullWidth startIcon={<AppleIcon />} aria-label="Sign in with Apple">
|
||||
Apple
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 4, textAlign: 'center' }}>
|
||||
{t('auth.noAccount')}{' '}
|
||||
<Typography component={RouterLink} to={ROUTES.AUTH.REGISTER} variant="body2" sx={{ color: 'gold.main', fontWeight: 600 }}>
|
||||
{t('common.register')}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import AppleIcon from '@mui/icons-material/Apple';
|
||||
import { useForm } from 'react-hook-form';
|
||||
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 } from '@/constants';
|
||||
|
||||
export function RegisterPage() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: { acceptTerms: false },
|
||||
});
|
||||
|
||||
const onSubmit = async (_data: RegisterFormData) => {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ mb: 1, fontFamily: '"Playfair Display", serif' }}>
|
||||
{t('auth.createAccount')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
|
||||
Join the premier platform for premium companionship.
|
||||
</Typography>
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||
<TextField
|
||||
{...register('displayName')}
|
||||
label="Display Name"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
error={!!errors.displayName}
|
||||
helperText={errors.displayName?.message}
|
||||
/>
|
||||
<TextField
|
||||
{...register('email')}
|
||||
label={t('auth.email')}
|
||||
type="email"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
/>
|
||||
<TextField
|
||||
{...register('password')}
|
||||
label={t('auth.password')}
|
||||
type="password"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
/>
|
||||
<TextField
|
||||
{...register('confirmPassword')}
|
||||
label={t('auth.confirmPassword')}
|
||||
type="password"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
error={!!errors.confirmPassword}
|
||||
helperText={errors.confirmPassword?.message}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox {...register('acceptTerms')} size="small" />}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
I agree to the{' '}
|
||||
<Typography component={RouterLink} to="/terms" variant="body2" sx={{ color: 'gold.main' }}>
|
||||
Terms of Service
|
||||
</Typography>{' '}
|
||||
and{' '}
|
||||
<Typography component={RouterLink} to="/privacy" variant="body2" sx={{ color: 'gold.main' }}>
|
||||
Privacy Policy
|
||||
</Typography>
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
{errors.acceptTerms && (
|
||||
<Typography variant="caption" color="error" sx={{ display: 'block', ml: 4 }}>
|
||||
{errors.acceptTerms.message}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<LuxeButton type="submit" variant="contained" fullWidth sx={{ mt: 3, py: 1.5 }} disabled={isSubmitting}>
|
||||
{isSubmitting ? t('common.loading') : t('common.register')}
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('auth.orContinueWith')}
|
||||
</Typography>
|
||||
</Divider>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<LuxeButton variant="outlined" fullWidth startIcon={<GoogleIcon />}>Google</LuxeButton>
|
||||
<LuxeButton variant="outlined" fullWidth startIcon={<AppleIcon />}>Apple</LuxeButton>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 4, textAlign: 'center' }}>
|
||||
{t('auth.hasAccount')}{' '}
|
||||
<Typography component={RouterLink} to={ROUTES.AUTH.LOGIN} variant="body2" sx={{ color: 'gold.main', fontWeight: 600 }}>
|
||||
{t('common.login')}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default RegisterPage;
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
import { useState } from 'react';
|
||||
import { Link as RouterLink } 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 Chip from '@mui/material/Chip';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import ThumbUpOutlinedIcon from '@mui/icons-material/ThumbUpOutlined';
|
||||
|
||||
import { PageContainer, SectionWrapper, GlassCard, LuxeButton, SectionHeader } from '@/components/ui';
|
||||
import { mockBlogPosts } from '@/constants/mockData';
|
||||
|
||||
const BLOG_CATEGORIES = ['All', 'Lifestyle', 'Safety', 'Travel', 'Wellness'];
|
||||
|
||||
export function BlogListPage() {
|
||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Filter posts
|
||||
const filteredPosts = mockBlogPosts.filter((post) => {
|
||||
const matchesCategory = selectedCategory === 'All' || post.category.toLowerCase() === selectedCategory.toLowerCase();
|
||||
const matchesSearch = post.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
post.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
const featuredPost = mockBlogPosts[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Insights & Lifestyle Blog — Luxe</title>
|
||||
<meta name="description" content="Read companion etiquette guides, safety guidelines, travel tips, and premium lifestyle articles from the Luxe editorial team." />
|
||||
</Helmet>
|
||||
|
||||
<SectionWrapper sx={{ pt: 5, pb: 10 }}>
|
||||
<PageContainer>
|
||||
{/* Header */}
|
||||
<SectionHeader
|
||||
title="Luxe Insights"
|
||||
subtitle="Your premium guide to companion etiquette, safety, travel, and luxury lifestyle."
|
||||
/>
|
||||
|
||||
{/* Top Categories & Search Bar */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 5, flexWrap: 'wrap', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{BLOG_CATEGORIES.map((cat) => (
|
||||
<Chip
|
||||
key={cat}
|
||||
label={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
color={selectedCategory === cat ? 'primary' : 'default'}
|
||||
variant={selectedCategory === cat ? 'filled' : 'outlined'}
|
||||
sx={{ borderRadius: 2, px: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search articles..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
sx={{ minWidth: 260, '& .MuiOutlinedInput-root': { borderRadius: 3 } }}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon color="action" fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Featured Post (Only show if search/category is not active) */}
|
||||
{selectedCategory === 'All' && !searchQuery && featuredPost && (
|
||||
<Box sx={{ mb: 6 }}>
|
||||
<Typography variant="h6" fontWeight={700} sx={{ mb: 2, textTransform: 'uppercase', letterSpacing: '0.1em', fontSize: '0.8rem', color: 'secondary.light' }}>
|
||||
Featured Article
|
||||
</Typography>
|
||||
<GlassCard sx={{ overflow: 'hidden' }}>
|
||||
<Grid container>
|
||||
<Grid size={{ xs: 12, md: 7 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={featuredPost.coverImage}
|
||||
alt={featuredPost.title}
|
||||
sx={{ width: '100%', height: { xs: 260, md: 420 }, objectFit: 'cover' }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 5 }} sx={{ p: { xs: 3, md: 5 }, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<Chip label={featuredPost.category} size="small" color="secondary" />
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h4"
|
||||
fontWeight={700}
|
||||
sx={{ fontFamily: '"Playfair Display", serif', mb: 2, lineHeight: 1.3, cursor: 'pointer', '&:hover': { color: 'secondary.light' } }}
|
||||
component={RouterLink}
|
||||
to={`/blog/${featuredPost.slug}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
{featuredPost.title}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3, lineHeight: 1.6 }}>
|
||||
{featuredPost.excerpt}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Avatar src={featuredPost.author.avatar} alt={featuredPost.author.name} sx={{ width: 36, height: 36 }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={600}>{featuredPost.author.name}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, color: 'text.secondary', fontSize: '0.75rem', alignItems: 'center', mt: 0.25 }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}><CalendarTodayIcon sx={{ fontSize: 12 }} /> {featuredPost.publishedAt}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}><AccessTimeIcon sx={{ fontSize: 12 }} /> {featuredPost.readingTime} min read</span>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<LuxeButton variant="contained" component={RouterLink} to={`/blog/${featuredPost.slug}`} sx={{ alignSelf: 'flex-start' }}>
|
||||
Read Article
|
||||
</LuxeButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</GlassCard>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Articles Grid */}
|
||||
<Typography variant="h6" fontWeight={700} sx={{ mb: 3, fontFamily: '"Playfair Display", serif' }}>
|
||||
{selectedCategory !== 'All' || searchQuery ? 'Search Results' : 'All Articles'} ({filteredPosts.length})
|
||||
</Typography>
|
||||
|
||||
{filteredPosts.length > 0 ? (
|
||||
<Grid container spacing={4}>
|
||||
{filteredPosts.map((post) => (
|
||||
<Grid key={post.id} size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<GlassCard sx={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
loading="lazy"
|
||||
sx={{ width: '100%', height: 200, objectFit: 'cover' }}
|
||||
/>
|
||||
<Box sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Chip label={post.category} size="small" variant="outlined" />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<AccessTimeIcon sx={{ fontSize: 12 }} /> {post.readingTime} min
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={600}
|
||||
sx={{ mb: 1.5, fontFamily: '"Playfair Display", serif', lineHeight: 1.4, cursor: 'pointer', '&:hover': { color: 'secondary.light' } }}
|
||||
component={RouterLink}
|
||||
to={`/blog/${post.slug}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
{post.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1, lineHeight: 1.6 }}>
|
||||
{post.excerpt}
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar src={post.author.avatar} alt={post.author.name} sx={{ width: 28, height: 28 }} />
|
||||
<Typography variant="caption" fontWeight={600}>{post.author.name}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'text.secondary' }}>
|
||||
<ThumbUpOutlinedIcon sx={{ fontSize: 14 }} />
|
||||
<Typography variant="caption">{post.likes}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Box sx={{ py: 8, textAlign: 'center', border: '1px dashed rgba(255,255,255,0.1)', borderRadius: 4 }}>
|
||||
<Typography variant="body1" color="text.secondary">No articles found matching your search options.</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</PageContainer>
|
||||
</SectionWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlogListPage;
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
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';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
||||
import ShareIcon from '@mui/icons-material/Share';
|
||||
import BookmarkIcon from '@mui/icons-material/Bookmark';
|
||||
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
|
||||
|
||||
import { PageContainer, SectionWrapper, GlassCard, LuxeButton } from '@/components/ui';
|
||||
import { mockBlogPosts } from '@/constants/mockData';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
const commentFormSchema = z.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
commentText: z.string().min(5, 'Comment must be at least 5 characters'),
|
||||
});
|
||||
|
||||
type CommentFormData = z.infer<typeof commentFormSchema>;
|
||||
|
||||
export function BlogPostPage() {
|
||||
const { slug } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const post = mockBlogPosts.find((p) => p.slug === slug);
|
||||
|
||||
// States
|
||||
const [likes, setLikes] = useState(post?.likes ?? 0);
|
||||
const [isLiked, setIsLiked] = useState(false);
|
||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||
const [comments, setComments] = useState<any[]>([
|
||||
{ id: 1, name: 'Arthur Pendragon', date: '2026-07-16', text: 'This was extremely helpful. The details on communication etiquette were spot on.' },
|
||||
{ id: 2, name: 'Sonia G.', date: '2026-07-17', text: 'Very nice article! It really outlines the safety indicators we should look for on profiles.' },
|
||||
]);
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<CommentFormData>({
|
||||
resolver: zodResolver(commentFormSchema),
|
||||
defaultValues: { name: '', commentText: '' },
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<SectionWrapper>
|
||||
<PageContainer sx={{ textAlign: 'center', py: 12 }}>
|
||||
<Typography variant="h4" fontWeight={700} gutterBottom>Article Not Found</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 4 }}>The blog article you are looking for does not exist or has been removed.</Typography>
|
||||
<LuxeButton variant="contained" component={RouterLink} to="/blog">
|
||||
Back to Blog
|
||||
</LuxeButton>
|
||||
</PageContainer>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const handleLikeToggle = () => {
|
||||
if (isLiked) {
|
||||
setLikes(likes - 1);
|
||||
setIsLiked(false);
|
||||
} else {
|
||||
setLikes(likes + 1);
|
||||
setIsLiked(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommentSubmit = (data: CommentFormData) => {
|
||||
const newComment = {
|
||||
id: comments.length + 1,
|
||||
name: data.name,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
text: data.commentText,
|
||||
};
|
||||
setComments([...comments, newComment]);
|
||||
reset();
|
||||
};
|
||||
|
||||
// Get related posts (exclude current)
|
||||
const relatedPosts = mockBlogPosts.filter((p) => p.id !== post.id).slice(0, 3);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{post.title} — Luxe Insights</title>
|
||||
<meta name="description" content={post.excerpt} />
|
||||
{/* OpenGraph */}
|
||||
<meta property="og:title" content={`${post.title} — Luxe Insights`} />
|
||||
<meta property="og:description" content={post.excerpt} />
|
||||
<meta property="og:image" content={post.coverImage} />
|
||||
</Helmet>
|
||||
|
||||
<SectionWrapper sx={{ pt: 3, pb: 10 }}>
|
||||
<PageContainer sx={{ maxWidth: 840 }}>
|
||||
{/* Back to Blog */}
|
||||
<LuxeButton
|
||||
variant="text"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate('/blog')}
|
||||
sx={{ mb: 3, color: 'text.secondary' }}
|
||||
>
|
||||
Back to Insights
|
||||
</LuxeButton>
|
||||
|
||||
{/* Category & Metadata */}
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', mb: 2 }}>
|
||||
<Chip label={post.category} color="secondary" size="small" />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CalendarTodayIcon sx={{ fontSize: 12 }} /> {post.publishedAt}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<AccessTimeIcon sx={{ fontSize: 12 }} /> {post.readingTime} min read
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Title */}
|
||||
<Typography
|
||||
variant="h2"
|
||||
fontWeight={700}
|
||||
sx={{ fontFamily: '"Playfair Display", serif', mb: 4, lineHeight: 1.25, fontSize: { xs: '2rem', md: '2.75rem' } }}
|
||||
>
|
||||
{post.title}
|
||||
</Typography>
|
||||
|
||||
{/* Author Block */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4, flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Avatar src={post.author.avatar} alt={post.author.name} sx={{ width: 48, height: 48 }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={600}>{post.author.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Luxe Contributor</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Interaction Tools */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<LuxeButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={isLiked ? <FavoriteIcon color="error" /> : <FavoriteBorderIcon />}
|
||||
onClick={handleLikeToggle}
|
||||
>
|
||||
{likes}
|
||||
</LuxeButton>
|
||||
<LuxeButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setIsBookmarked(!isBookmarked)}
|
||||
>
|
||||
{isBookmarked ? <BookmarkIcon color="primary" /> : <BookmarkBorderIcon />}
|
||||
</LuxeButton>
|
||||
<LuxeButton variant="outlined" size="small">
|
||||
<ShareIcon fontSize="small" />
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Cover Image */}
|
||||
<Box
|
||||
component="img"
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
sx={{ width: '100%', height: { xs: 260, md: 450 }, objectFit: 'cover', borderRadius: 4, mb: 5 }}
|
||||
/>
|
||||
|
||||
{/* Article Contents */}
|
||||
<Box sx={{ color: 'text.secondary', fontSize: '1.1rem', lineHeight: 1.8, mb: 6 }}>
|
||||
<Typography variant="body1" paragraph sx={{ fontSize: 'inherit', lineHeight: 'inherit', mb: 3 }}>
|
||||
When engaging with premium companion services, establishing a foundation of clear, polite, and respectful communication is key. Our platform is dedicated to connecting clients with high-end therapists and dining hosts under the premise of absolute discretion and mutual trust.
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph sx={{ fontSize: 'inherit', lineHeight: 'inherit', mb: 3 }}>
|
||||
This editorial guideline offers a comprehensive set of steps to ensure a flawless experience, covering basic etiquette, booking confirmations, cancelation courtesy, and the values we uphold.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ bgcolor: 'action.hover', p: 4, borderRadius: 3, borderLeft: '4px solid #C9A962', my: 4, fontStyle: 'italic' }}>
|
||||
"Discretion is the cornerstone of luxury companionships. Ensuring you represent your intentions politely during initial bookings forms the backbone of a successful date."
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" color="text.primary" fontWeight={700} sx={{ mt: 5, mb: 2, fontFamily: '"Playfair Display", serif' }}>
|
||||
1. Discretion and Professionalism
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph sx={{ fontSize: 'inherit', lineHeight: 'inherit', mb: 3 }}>
|
||||
Professional companions are independent advertisers or associated with boutique agencies. In both scenarios, their scheduling calendar represents a strict business environment. Always confirm dates, times, and rates explicitly before requesting a booking, and avoid discussing private matters over unencrypted communication channels.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h5" color="text.primary" fontWeight={700} sx={{ mt: 5, mb: 2, fontFamily: '"Playfair Display", serif' }}>
|
||||
2. Honoring Scheduling Calendars
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph sx={{ fontSize: 'inherit', lineHeight: 'inherit', mb: 3 }}>
|
||||
Late cancelations disrupt business operations. If you need to rearrange your scheduled session, ensure you provide a minimum of 24 hours warning. Respecting the companion's time builds immediate long-term rapport, paving the way for elite premium dates in the future.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Tags */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 5 }}>
|
||||
{post.tags.map((tag) => (
|
||||
<Chip key={tag} label={`#${tag}`} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
{/* Comments Feed */}
|
||||
<Box sx={{ mb: 6 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ mb: 3, fontFamily: '"Playfair Display", serif' }}>
|
||||
Discussion ({comments.length})
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 4 }}>
|
||||
{comments.map((c) => (
|
||||
<GlassCard key={c.id} sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle2" fontWeight={600}>{c.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{c.date}</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6 }}>
|
||||
{c.text}
|
||||
</Typography>
|
||||
</GlassCard>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Comment Input Form */}
|
||||
<GlassCard sx={{ p: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight={700} sx={{ mb: 2 }}>Leave a Comment</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit(handleCommentSubmit)} noValidate sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
<TextField
|
||||
{...register('name')}
|
||||
label="Your Name"
|
||||
size="small"
|
||||
fullWidth
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
/>
|
||||
<TextField
|
||||
{...register('commentText')}
|
||||
label="Join the discussion..."
|
||||
placeholder="Write your comment here..."
|
||||
size="small"
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
error={!!errors.commentText}
|
||||
helperText={errors.commentText?.message}
|
||||
/>
|
||||
<LuxeButton type="submit" variant="contained" sx={{ alignSelf: 'flex-start' }}>
|
||||
Post Comment
|
||||
</LuxeButton>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
{/* Related Articles */}
|
||||
<Box>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ mb: 3, fontFamily: '"Playfair Display", serif' }}>
|
||||
Related Articles
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
{relatedPosts.map((rp) => (
|
||||
<Grid key={rp.id} size={{ xs: 12, sm: 4 }}>
|
||||
<GlassCard sx={{ height: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={rp.coverImage}
|
||||
alt={rp.title}
|
||||
sx={{ width: '100%', height: 120, objectFit: 'cover' }}
|
||||
/>
|
||||
<Box sx={{ p: 2, flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
fontFamily: '"Playfair Display", serif',
|
||||
lineHeight: 1.3,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { color: 'secondary.light' },
|
||||
mb: 2,
|
||||
}}
|
||||
component={RouterLink}
|
||||
to={`/blog/${rp.slug}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
{rp.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{rp.readingTime} min read
|
||||
</Typography>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
</PageContainer>
|
||||
</SectionWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlogPostPage;
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { ThemeMode } from '@/theme';
|
||||
import { STORAGE_KEYS } from '@/constants';
|
||||
import type { User, SearchFilters } from '@/types';
|
||||
|
||||
interface ThemeState {
|
||||
mode: ThemeMode;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
mode: 'system',
|
||||
setMode: (mode) => set({ mode }),
|
||||
}),
|
||||
{ name: STORAGE_KEYS.THEME_MODE },
|
||||
),
|
||||
);
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
setUser: (user: User | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
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;
|
||||
recentSearches: string[];
|
||||
isSearchOpen: boolean;
|
||||
setFilters: (filters: Partial<SearchFilters>) => void;
|
||||
resetFilters: () => void;
|
||||
addRecentSearch: (query: string) => void;
|
||||
setSearchOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSearchStore = create<SearchState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
filters: {},
|
||||
recentSearches: [],
|
||||
isSearchOpen: false,
|
||||
setFilters: (filters) => set({ filters: { ...get().filters, ...filters } }),
|
||||
resetFilters: () => set({ filters: {} }),
|
||||
addRecentSearch: (query) => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
const recent = [trimmed, ...get().recentSearches.filter((s) => s !== trimmed)].slice(0, 8);
|
||||
set({ recentSearches: recent });
|
||||
},
|
||||
setSearchOpen: (isSearchOpen) => set({ isSearchOpen }),
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEYS.SEARCH_FILTERS,
|
||||
partialize: (state) => ({
|
||||
filters: state.filters,
|
||||
recentSearches: state.recentSearches,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
interface UIState {
|
||||
isMobileNavOpen: boolean;
|
||||
setMobileNavOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>()((set) => ({
|
||||
isMobileNavOpen: false,
|
||||
setMobileNavOpen: (isMobileNavOpen) => set({ isMobileNavOpen }),
|
||||
}));
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap');
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(201, 169, 98, 0.3);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(201, 169, 98, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(201, 169, 98, 0.5);
|
||||
}
|
||||
|
||||
.swiper-pagination-bullet-active {
|
||||
background: #c9a962 !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import { createTheme, type ThemeOptions } from '@mui/material/styles';
|
||||
import { palette, typography, shadows, borderRadius } from './tokens';
|
||||
|
||||
declare module '@mui/material/styles' {
|
||||
interface Palette {
|
||||
gold: Palette['primary'];
|
||||
glass: {
|
||||
background: string;
|
||||
border: string;
|
||||
};
|
||||
}
|
||||
interface PaletteOptions {
|
||||
gold?: PaletteOptions['primary'];
|
||||
glass?: {
|
||||
background: string;
|
||||
border: string;
|
||||
};
|
||||
}
|
||||
interface Theme {
|
||||
customShadows: typeof shadows;
|
||||
customRadius: typeof borderRadius;
|
||||
}
|
||||
interface ThemeOptions {
|
||||
customShadows?: typeof shadows;
|
||||
customRadius?: typeof borderRadius;
|
||||
}
|
||||
}
|
||||
|
||||
const baseTypography = {
|
||||
fontFamily: typography.fontFamily.body,
|
||||
h1: {
|
||||
fontFamily: typography.fontFamily.display,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
},
|
||||
h2: {
|
||||
fontFamily: typography.fontFamily.display,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.01em',
|
||||
},
|
||||
h3: {
|
||||
fontFamily: typography.fontFamily.display,
|
||||
fontWeight: 600,
|
||||
},
|
||||
h4: { fontWeight: 600 },
|
||||
h5: { fontWeight: 600 },
|
||||
h6: { fontWeight: 600 },
|
||||
button: { textTransform: 'none' as const, fontWeight: 600 },
|
||||
};
|
||||
|
||||
const sharedComponents: ThemeOptions['components'] = {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
html: { scrollBehavior: 'smooth' },
|
||||
body: { overflowX: 'hidden' },
|
||||
'*:focus-visible': {
|
||||
outline: `2px solid ${palette.gold[500]}`,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
'*, *::before, *::after': {
|
||||
animationDuration: '0.01ms !important',
|
||||
animationIterationCount: '1 !important',
|
||||
transitionDuration: '0.01ms !important',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
defaultProps: { disableElevation: true },
|
||||
styleOverrides: {
|
||||
root: { borderRadius: borderRadius.md, padding: '10px 24px' },
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: borderRadius.lg },
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
defaultProps: { variant: 'outlined' },
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: borderRadius.sm },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const lightTheme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: { main: palette.purple[700], light: palette.purple[500], dark: palette.purple[900] },
|
||||
secondary: { main: palette.gold[500], light: palette.gold[300], dark: palette.gold[700] },
|
||||
gold: { main: palette.gold[500], light: palette.gold[300], dark: palette.gold[700] },
|
||||
background: { default: palette.neutral[50], paper: palette.neutral[0] },
|
||||
text: { primary: palette.neutral[900], secondary: palette.neutral[600] },
|
||||
divider: palette.neutral[200],
|
||||
glass: {
|
||||
background: 'rgba(255, 255, 255, 0.72)',
|
||||
border: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
},
|
||||
typography: baseTypography,
|
||||
shape: { borderRadius: borderRadius.md },
|
||||
customShadows: shadows,
|
||||
customRadius: borderRadius,
|
||||
components: {
|
||||
...sharedComponents,
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderBottom: `1px solid ${palette.neutral[200]}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const darkTheme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: { main: palette.gold[500], light: palette.gold[300], dark: palette.gold[700] },
|
||||
secondary: { main: palette.purple[500], light: palette.purple[300], dark: palette.purple[700] },
|
||||
gold: { main: palette.gold[500], light: palette.gold[300], dark: palette.gold[700] },
|
||||
background: { default: palette.neutral[950], paper: palette.neutral[900] },
|
||||
text: { primary: palette.neutral[50], secondary: palette.neutral[400] },
|
||||
divider: 'rgba(255, 255, 255, 0.08)',
|
||||
glass: {
|
||||
background: 'rgba(26, 26, 28, 0.72)',
|
||||
border: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
},
|
||||
typography: baseTypography,
|
||||
shape: { borderRadius: borderRadius.md },
|
||||
customShadows: shadows,
|
||||
customRadius: borderRadius,
|
||||
components: {
|
||||
...sharedComponents,
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: 'rgba(10, 10, 11, 0.85)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
export function resolveThemeMode(mode: ThemeMode): 'light' | 'dark' {
|
||||
if (mode === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
export const palette = {
|
||||
gold: {
|
||||
50: '#FBF8F0',
|
||||
100: '#F5EDD6',
|
||||
200: '#EBD9AD',
|
||||
300: '#DFC584',
|
||||
400: '#D4AF37',
|
||||
500: '#C9A962',
|
||||
600: '#A88B4A',
|
||||
700: '#876D38',
|
||||
800: '#665026',
|
||||
900: '#453314',
|
||||
},
|
||||
purple: {
|
||||
50: '#F3EEF8',
|
||||
100: '#E0D4F0',
|
||||
200: '#C1A9E1',
|
||||
300: '#A27ED2',
|
||||
400: '#8353C3',
|
||||
500: '#4A2C7A',
|
||||
600: '#3D2466',
|
||||
700: '#2D1B4E',
|
||||
800: '#1E1235',
|
||||
900: '#0F091C',
|
||||
},
|
||||
neutral: {
|
||||
0: '#FFFFFF',
|
||||
50: '#FAFAFA',
|
||||
100: '#F5F5F5',
|
||||
200: '#E5E5E5',
|
||||
300: '#D4D4D4',
|
||||
400: '#A3A3A3',
|
||||
500: '#737373',
|
||||
600: '#525252',
|
||||
700: '#404040',
|
||||
800: '#262626',
|
||||
900: '#171717',
|
||||
950: '#0A0A0B',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const gradients = {
|
||||
gold: 'linear-gradient(135deg, #C9A962 0%, #D4AF37 50%, #A88B4A 100%)',
|
||||
purple: 'linear-gradient(135deg, #2D1B4E 0%, #4A2C7A 50%, #8353C3 100%)',
|
||||
hero: 'linear-gradient(180deg, rgba(10,10,11,0) 0%, rgba(10,10,11,0.85) 100%)',
|
||||
heroLight: 'linear-gradient(180deg, rgba(250,250,250,0) 0%, rgba(250,250,250,0.9) 100%)',
|
||||
glass: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
card: 'linear-gradient(145deg, rgba(201,169,98,0.08) 0%, rgba(45,27,78,0.04) 100%)',
|
||||
} as const;
|
||||
|
||||
export const shadows = {
|
||||
sm: '0 1px 2px rgba(0,0,0,0.05)',
|
||||
md: '0 4px 12px rgba(0,0,0,0.08)',
|
||||
lg: '0 8px 32px rgba(0,0,0,0.12)',
|
||||
xl: '0 16px 48px rgba(0,0,0,0.16)',
|
||||
gold: '0 4px 24px rgba(201,169,98,0.25)',
|
||||
purple: '0 4px 24px rgba(45,27,78,0.2)',
|
||||
glass: '0 8px 32px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.1)',
|
||||
} as const;
|
||||
|
||||
export const typography = {
|
||||
fontFamily: {
|
||||
display: '"Playfair Display", Georgia, serif',
|
||||
body: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
mono: '"JetBrains Mono", "Fira Code", monospace',
|
||||
},
|
||||
fontSize: {
|
||||
xs: '0.75rem',
|
||||
sm: '0.875rem',
|
||||
base: '1rem',
|
||||
lg: '1.125rem',
|
||||
xl: '1.25rem',
|
||||
'2xl': '1.5rem',
|
||||
'3xl': '1.875rem',
|
||||
'4xl': '2.25rem',
|
||||
'5xl': '3rem',
|
||||
'6xl': '3.75rem',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const spacing = {
|
||||
section: { xs: 6, md: 10, lg: 14 },
|
||||
container: { xs: 2, sm: 3, md: 4 },
|
||||
} as const;
|
||||
|
||||
export const borderRadius = {
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 24,
|
||||
full: 9999,
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatar?: string;
|
||||
role: import('@/constants').UserRole;
|
||||
isVerified: boolean;
|
||||
isPremium: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
age: number;
|
||||
city: string;
|
||||
area?: string;
|
||||
avatar: string;
|
||||
coverImage?: string;
|
||||
rating: number;
|
||||
reviewCount: number;
|
||||
isVerified: boolean;
|
||||
isPremium: boolean;
|
||||
isOnline: boolean;
|
||||
priceFrom: number;
|
||||
currency: string;
|
||||
categories: string[];
|
||||
languages: string[];
|
||||
height?: string;
|
||||
tagline?: string;
|
||||
}
|
||||
|
||||
export interface City {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
country: string;
|
||||
profileCount: number;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
profileCount: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
coverImage: string;
|
||||
author: { name: string; avatar: string };
|
||||
category: string;
|
||||
tags: string[];
|
||||
readingTime: number;
|
||||
publishedAt: string;
|
||||
likes: number;
|
||||
}
|
||||
|
||||
export interface ForumTopic {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
author: { name: string; avatar: string };
|
||||
category: string;
|
||||
replies: number;
|
||||
likes: number;
|
||||
isPinned: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Testimonial {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
avatar: string;
|
||||
content: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface Statistic {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface FAQItem {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
query?: string;
|
||||
city?: string;
|
||||
area?: string;
|
||||
categories?: string[];
|
||||
ageMin?: number;
|
||||
ageMax?: number;
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
verified?: boolean;
|
||||
online?: boolean;
|
||||
premium?: boolean;
|
||||
rating?: number;
|
||||
languages?: string[];
|
||||
sortBy?: 'relevance' | 'rating' | 'price_asc' | 'price_desc' | 'newest';
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
label: string;
|
||||
path: string;
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error';
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
link?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { STORAGE_KEYS } from '@/constants';
|
||||
|
||||
export function getStorageItem<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? (JSON.parse(item) as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function setStorageItem<T>(key: string, value: T): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// Storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function removeStorageItem(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function getSecureToken(): string | null {
|
||||
return sessionStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
}
|
||||
|
||||
export function setSecureToken(token: string): void {
|
||||
sessionStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, token);
|
||||
}
|
||||
|
||||
export function clearSecureToken(): void {
|
||||
sessionStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number, currency = 'USD', locale = 'en-US'): string {
|
||||
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount);
|
||||
}
|
||||
|
||||
export function formatNumber(value: number, locale = 'en-US'): string {
|
||||
return new Intl.NumberFormat(locale).format(value);
|
||||
}
|
||||
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength).trim()}…`;
|
||||
}
|
||||
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export function cn(...classes: (string | false | undefined | null)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: Parameters<T>) => void>(
|
||||
fn: T,
|
||||
delay: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
export function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
export function getImageUrl(path: string, width?: number): string {
|
||||
const cdn = import.meta.env.VITE_CDN_URL;
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
const base = cdn || '';
|
||||
const params = width ? `?w=${width}&fm=webp` : '';
|
||||
return `${base}${path}${params}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
rememberMe: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const registerSchema = z
|
||||
.object({
|
||||
displayName: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Must contain an uppercase letter')
|
||||
.regex(/[0-9]/, 'Must contain a number'),
|
||||
confirmPassword: z.string(),
|
||||
acceptTerms: z.boolean().refine((v) => v, 'You must accept the terms'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
});
|
||||
|
||||
export const newsletterSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
});
|
||||
|
||||
export const searchSchema = z.object({
|
||||
query: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
});
|
||||
|
||||
export type LoginFormData = z.infer<typeof loginSchema>;
|
||||
export type RegisterFormData = z.infer<typeof registerSchema>;
|
||||
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
|
||||
export type NewsletterFormData = z.infer<typeof newsletterSchema>;
|
||||
export type SearchFormData = z.infer<typeof searchSchema>;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
readonly VITE_APP_NAME: string;
|
||||
readonly VITE_APP_URL: string;
|
||||
readonly VITE_SOCKET_URL: string;
|
||||
readonly VITE_CDN_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"],
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg', 'robots.txt'],
|
||||
manifest: {
|
||||
name: 'Luxe Directory',
|
||||
short_name: 'Luxe',
|
||||
description: 'Premium companion directory platform',
|
||||
theme_color: '#0A0A0B',
|
||||
background_color: '#0A0A0B',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/api\./i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: { maxEntries: 100, maxAgeSeconds: 86400 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('@mui') || id.includes('@emotion')) return 'mui';
|
||||
if (id.includes('framer-motion')) return 'motion';
|
||||
if (id.includes('@tanstack/react-query')) return 'query';
|
||||
if (id.includes('react') || id.includes('react-dom') || id.includes('react-router')) return 'vendor';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 600,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
open: false,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue