first commit

This commit is contained in:
hardik 2026-07-24 14:48:35 +05:30
commit 498bd34178
68 changed files with 14808 additions and 0 deletions

5
.env.example Normal file
View File

@ -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=

24
.gitignore vendored Normal file
View File

@ -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?

8
.oxlintrc.json Normal file
View File

@ -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 }]
}
}

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always"
}

75
README.md Normal file
View File

@ -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.

17
index.html Normal file
View File

@ -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>

9128
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
package.json Normal file
View File

@ -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"
}
}

5
public/favicon.svg Normal file
View File

@ -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

24
public/icons.svg Normal file
View File

@ -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

7
public/robots.txt Normal file
View File

@ -0,0 +1,7 @@
User-agent: *
Allow: /
Disallow: /dashboard/
Disallow: /admin/
Disallow: /auth/
Sitemap: /sitemap.xml

31
src/api/client.ts Normal file
View File

@ -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;

77
src/app/App.tsx Normal file
View File

@ -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>
);
}

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View File

@ -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

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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';

View File

@ -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',
}));

19
src/config/i18n.ts Normal file
View File

@ -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;

View File

@ -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!"
}
}

99
src/constants/index.ts Normal file
View File

@ -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];

109
src/constants/mockData.ts Normal file
View File

@ -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 2448 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' },
],
};

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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';

61
src/hooks/index.ts Normal file
View File

@ -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;
}

111
src/index.css Normal file
View File

@ -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);
}

View File

@ -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;

View File

@ -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;

9
src/main.tsx Normal file
View File

@ -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>,
);

72
src/pages/HomePage.tsx Normal file
View File

@ -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;

View File

@ -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;

717
src/pages/ProfilePage.tsx Normal file
View File

@ -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;

305
src/pages/SearchPage.tsx Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

84
src/store/index.ts Normal file
View File

@ -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 }),
}));

52
src/styles/global.css Normal file
View File

@ -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;
}

161
src/theme/index.ts Normal file
View File

@ -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;
}

92
src/theme/tokens.ts Normal file
View File

@ -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;

154
src/types/index.ts Normal file
View File

@ -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;
}

92
src/utils/index.ts Normal file
View File

@ -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}`;
}

44
src/validators/auth.ts Normal file
View File

@ -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>;

13
src/vite-env.d.ts vendored Normal file
View File

@ -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;
}

32
tsconfig.app.json Normal file
View File

@ -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"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

23
tsconfig.node.json Normal file
View File

@ -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"]
}

62
vite.config.ts Normal file
View File

@ -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,
},
});