first commit
This commit is contained in:
commit
498bd34178
|
|
@ -0,0 +1,5 @@
|
||||||
|
VITE_API_BASE_URL=http://localhost:3000/api
|
||||||
|
VITE_APP_NAME=Luxe
|
||||||
|
VITE_APP_URL=http://localhost:5173
|
||||||
|
VITE_SOCKET_URL=http://localhost:3000
|
||||||
|
VITE_CDN_URL=
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Luxe — Premium Companion Directory Platform
|
||||||
|
|
||||||
|
Enterprise-grade React frontend built for scale, performance, and premium UX.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- React 19 + TypeScript + Vite
|
||||||
|
- Material UI v7 (custom luxury theme)
|
||||||
|
- TanStack Query · Zustand · React Hook Form · Zod
|
||||||
|
- Framer Motion · i18next · React Helmet Async
|
||||||
|
- PWA-ready with service worker
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:5173](http://localhost:5173).
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `npm run dev` | Start development server |
|
||||||
|
| `npm run build` | Production build |
|
||||||
|
| `npm run preview` | Preview production build |
|
||||||
|
| `npm run lint` | Run ESLint |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # App shell, providers, router
|
||||||
|
├── components/ # Shared UI (design system, layouts, search)
|
||||||
|
├── features/ # Feature modules (home sections, etc.)
|
||||||
|
├── pages/ # Route-level page components
|
||||||
|
├── layouts/ # Main, Auth, Dashboard layouts
|
||||||
|
├── theme/ # MUI theme, design tokens
|
||||||
|
├── store/ # Zustand stores
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
├── api/ # Axios client & interceptors
|
||||||
|
├── services/ # API service layer
|
||||||
|
├── validators/ # Zod schemas
|
||||||
|
├── types/ # TypeScript definitions
|
||||||
|
├── constants/ # App constants & mock data
|
||||||
|
├── config/ # i18n, env config
|
||||||
|
├── utils/ # Utility functions
|
||||||
|
└── styles/ # Global CSS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 1 (Complete)
|
||||||
|
|
||||||
|
- Enterprise folder structure
|
||||||
|
- Luxury design system (dark/light/auto themes)
|
||||||
|
- Responsive header with mobile drawer
|
||||||
|
- Global search with recent queries
|
||||||
|
- Premium landing page (all sections)
|
||||||
|
- Auth layout (login, register, forgot password)
|
||||||
|
- SEO metadata & structured data
|
||||||
|
- PWA manifest
|
||||||
|
|
||||||
|
## Phases Roadmap
|
||||||
|
|
||||||
|
- **Phase 2** — User-facing pages (profiles, cities, categories, blog)
|
||||||
|
- **Phase 3** — Advertiser dashboard
|
||||||
|
- **Phase 4** — Admin dashboard
|
||||||
|
- **Phase 5** — Blog & forum
|
||||||
|
- **Phase 6** — Wallet, ads, notifications, verification, analytics
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Private — All rights reserved.
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0A0A0B" />
|
||||||
|
<meta name="description" content="Luxe — Premium companion directory platform. Verified profiles, elegant experience." />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<title>Luxe — Premium Companion Directory</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
"name": "luxe",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint src",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@mui/icons-material": "^7.3.11",
|
||||||
|
"@mui/material": "^7.3.11",
|
||||||
|
"@tanstack/react-query": "^5.101.4",
|
||||||
|
"@tanstack/react-virtual": "^3.14.8",
|
||||||
|
"axios": "^1.18.1",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
|
"framer-motion": "^12.42.2",
|
||||||
|
"i18next": "^26.3.6",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"react-dropzone": "^19.1.1",
|
||||||
|
"react-helmet-async": "^3.0.0",
|
||||||
|
"react-hook-form": "^7.82.0",
|
||||||
|
"react-i18next": "^17.0.10",
|
||||||
|
"react-router-dom": "^7.18.1",
|
||||||
|
"recharts": "^3.10.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
|
"swiper": "^14.0.6",
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"eslint": "^10.7.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1",
|
||||||
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<rect width="64" height="64" rx="16" fill="#0A0A0B"/>
|
||||||
|
<path d="M32 12L38 26H52L41 35L45 50L32 41L19 50L23 35L12 26H26L32 12Z" fill="#C9A962"/>
|
||||||
|
<circle cx="32" cy="32" r="28" stroke="#C9A962" stroke-width="1.5" stroke-opacity="0.3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 319 B |
|
|
@ -0,0 +1,24 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -0,0 +1,7 @@
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /dashboard/
|
||||||
|
Disallow: /admin/
|
||||||
|
Disallow: /auth/
|
||||||
|
|
||||||
|
Sitemap: /sitemap.xml
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import { API_BASE_URL, STORAGE_KEYS } from '@/constants';
|
||||||
|
import { getStorageItem, removeStorageItem } from '@/utils';
|
||||||
|
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = getStorageItem<string | null>(STORAGE_KEYS.AUTH_TOKEN, null);
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
removeStorageItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||||
|
removeStorageItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { Suspense, lazy } from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import { HelmetProvider } from 'react-helmet-async';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { ThemeProvider } from '@/contexts/ThemeProvider';
|
||||||
|
import { MainLayout } from '@/layouts/MainLayout';
|
||||||
|
import { AuthLayout } from '@/layouts/AuthLayout';
|
||||||
|
import { LoadingSpinner } from '@/components/ui';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
import '@/config/i18n';
|
||||||
|
import '@/styles/global.css';
|
||||||
|
|
||||||
|
const HomePage = lazy(() => import('@/pages/HomePage'));
|
||||||
|
const LoginPage = lazy(() => import('@/pages/auth/LoginPage'));
|
||||||
|
const RegisterPage = lazy(() => import('@/pages/auth/RegisterPage'));
|
||||||
|
const ForgotPasswordPage = lazy(() => import('@/pages/auth/ForgotPasswordPage'));
|
||||||
|
const SearchPage = lazy(() => import('@/pages/SearchPage'));
|
||||||
|
const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
|
||||||
|
const BlogListPage = lazy(() => import('@/pages/blog/BlogListPage'));
|
||||||
|
const BlogPostPage = lazy(() => import('@/pages/blog/BlogPostPage'));
|
||||||
|
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'));
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function PageLoader() {
|
||||||
|
return <LoadingSpinner fullScreen message="Loading…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<HelmetProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider>{children}</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</HelmetProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppRouter() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<MainLayout />}>
|
||||||
|
<Route index element={<HomePage />} />
|
||||||
|
<Route path={ROUTES.SEARCH} element={<SearchPage />} />
|
||||||
|
<Route path={ROUTES.PROFILE} element={<ProfilePage />} />
|
||||||
|
<Route path={ROUTES.BLOG} element={<BlogListPage />} />
|
||||||
|
<Route path={ROUTES.BLOG_POST} element={<BlogPostPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route element={<AuthLayout />}>
|
||||||
|
<Route path={ROUTES.AUTH.LOGIN} element={<LoginPage />} />
|
||||||
|
<Route path={ROUTES.AUTH.REGISTER} element={<RegisterPage />} />
|
||||||
|
<Route path={ROUTES.AUTH.FORGOT_PASSWORD} element={<ForgotPasswordPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AppProviders>
|
||||||
|
<AppRouter />
|
||||||
|
</AppProviders>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -0,0 +1,102 @@
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Link from '@mui/material/Link';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import Grid from '@mui/material/Grid';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import TwitterIcon from '@mui/icons-material/Twitter';
|
||||||
|
import InstagramIcon from '@mui/icons-material/Instagram';
|
||||||
|
import LinkedInIcon from '@mui/icons-material/LinkedIn';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Logo, PageContainer } from '@/components/ui';
|
||||||
|
import { footerLinks } from '@/constants/mockData';
|
||||||
|
import { APP_NAME } from '@/constants';
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="footer"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
pt: { xs: 6, md: 10 },
|
||||||
|
pb: 4,
|
||||||
|
mt: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PageContainer>
|
||||||
|
<Grid container spacing={4}>
|
||||||
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
<Logo size="md" />
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 2, maxWidth: 320 }}>
|
||||||
|
{t('footer.tagline')}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mt: 3 }}>
|
||||||
|
{[TwitterIcon, InstagramIcon, LinkedInIcon].map((Icon, i) => (
|
||||||
|
<IconButton key={i} size="small" aria-label="Social link" sx={{ color: 'text.secondary' }}>
|
||||||
|
<Icon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{Object.entries(footerLinks).map(([section, links]) => (
|
||||||
|
<Grid key={section} size={{ xs: 6, sm: 4, md: 2.5 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 2, textTransform: 'capitalize' }}>
|
||||||
|
{t(`footer.${section}`)}
|
||||||
|
</Typography>
|
||||||
|
<Box component="nav" sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{links.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.path}
|
||||||
|
component={RouterLink}
|
||||||
|
to={link.path}
|
||||||
|
underline="hover"
|
||||||
|
color="text.secondary"
|
||||||
|
variant="body2"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t('footer.copyright', { year, app: APP_NAME })}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||||
|
<Link component={RouterLink} to="/privacy" variant="caption" color="text.secondary">
|
||||||
|
Privacy
|
||||||
|
</Link>
|
||||||
|
<Link component={RouterLink} to="/terms" variant="caption" color="text.secondary">
|
||||||
|
Terms
|
||||||
|
</Link>
|
||||||
|
<Link component={RouterLink} to="/cookies" variant="caption" color="text.secondary">
|
||||||
|
Cookies
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageContainer>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link as RouterLink, useLocation } from 'react-router-dom';
|
||||||
|
import AppBar from '@mui/material/AppBar';
|
||||||
|
import Toolbar from '@mui/material/Toolbar';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Drawer from '@mui/material/Drawer';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Logo, LuxeButton, ThemeToggle } from '@/components/ui';
|
||||||
|
import { GlobalSearch } from '@/components/search/GlobalSearch';
|
||||||
|
import { useAuthStore, useSearchStore } from '@/store';
|
||||||
|
import { navItems } from '@/constants/mockData';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
import { useScrollPosition } from '@/hooks';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
const location = useLocation();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const scrolled = useScrollPosition(20);
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
const { isSearchOpen, setSearchOpen } = useSearchStore();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
const isAuthPage = location.pathname.startsWith('/auth');
|
||||||
|
|
||||||
|
if (isAuthPage) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBar
|
||||||
|
position="sticky"
|
||||||
|
elevation={scrolled ? 2 : 0}
|
||||||
|
component={motion.header}
|
||||||
|
initial={{ y: -20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
sx={{
|
||||||
|
bgcolor: scrolled ? undefined : 'transparent',
|
||||||
|
backgroundImage: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar sx={{ minHeight: { xs: 64, md: 72 }, gap: 2 }}>
|
||||||
|
<Logo size="sm" />
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<Box component="nav" sx={{ display: 'flex', gap: 0.5, ml: 4, flex: 1 }} aria-label="Main navigation">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.path}
|
||||||
|
component={RouterLink}
|
||||||
|
to={item.path}
|
||||||
|
sx={{
|
||||||
|
color: location.pathname === item.path ? 'gold.main' : 'text.primary',
|
||||||
|
fontWeight: location.pathname === item.path ? 600 : 500,
|
||||||
|
px: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(item.label)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ flex: isMobile ? 1 : 0 }} />
|
||||||
|
|
||||||
|
<IconButton onClick={() => setSearchOpen(true)} aria-label="Open search" sx={{ color: 'text.primary' }}>
|
||||||
|
<SearchIcon />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<LuxeButton component={RouterLink} to={ROUTES.DASHBOARD.ROOT} variant="outlined" size="small">
|
||||||
|
{t('nav.dashboard')}
|
||||||
|
</LuxeButton>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button component={RouterLink} to={ROUTES.AUTH.LOGIN} sx={{ fontWeight: 600 }}>
|
||||||
|
{t('common.login')}
|
||||||
|
</Button>
|
||||||
|
<LuxeButton component={RouterLink} to={ROUTES.AUTH.REGISTER} variant="contained" size="small">
|
||||||
|
{t('common.register')}
|
||||||
|
</LuxeButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<IconButton onClick={() => setMobileOpen(true)} aria-label="Open menu" edge="end">
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
anchor="right"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={() => setMobileOpen(false)}
|
||||||
|
PaperProps={{ sx: { width: 300, pt: 2 } }}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', px: 2, mb: 1 }}>
|
||||||
|
<IconButton onClick={() => setMobileOpen(false)} aria-label="Close menu">
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<List>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={item.path}
|
||||||
|
component={RouterLink}
|
||||||
|
to={item.path}
|
||||||
|
selected={location.pathname === item.path}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
>
|
||||||
|
<ListItemText primary={t(item.label)} />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Box sx={{ px: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
<LuxeButton
|
||||||
|
fullWidth
|
||||||
|
component={RouterLink}
|
||||||
|
to={ROUTES.AUTH.LOGIN}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
>
|
||||||
|
{t('common.login')}
|
||||||
|
</LuxeButton>
|
||||||
|
<LuxeButton
|
||||||
|
fullWidth
|
||||||
|
component={RouterLink}
|
||||||
|
to={ROUTES.AUTH.REGISTER}
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
>
|
||||||
|
{t('common.register')}
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<GlobalSearch open={isSearchOpen} onClose={() => setSearchOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
|
import LocationOnIcon from '@mui/icons-material/LocationOn';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSearchStore } from '@/store';
|
||||||
|
import { useDebounce } from '@/hooks';
|
||||||
|
import { mockProfiles, mockCities } from '@/constants/mockData';
|
||||||
|
import { ProfileCardCompact } from '@/components/ui';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
|
||||||
|
interface GlobalSearchProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalSearch({ open, onClose }: GlobalSearchProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { recentSearches, addRecentSearch } = useSearchStore();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const debouncedQuery = useDebounce(query, 200);
|
||||||
|
|
||||||
|
const results = debouncedQuery.trim()
|
||||||
|
? mockProfiles.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(debouncedQuery.toLowerCase()) ||
|
||||||
|
p.city.toLowerCase().includes(debouncedQuery.toLowerCase()),
|
||||||
|
).slice(0, 5)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleSearch = useCallback(
|
||||||
|
(q: string) => {
|
||||||
|
const trimmed = q.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
addRecentSearch(trimmed);
|
||||||
|
onClose();
|
||||||
|
navigate(`${ROUTES.SEARCH}?q=${encodeURIComponent(trimmed)}`);
|
||||||
|
},
|
||||||
|
[addRecentSearch, navigate, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') handleSearch(query);
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="md"
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 3,
|
||||||
|
mt: { xs: 2, md: 8 },
|
||||||
|
mx: 2,
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
slotProps={{ backdrop: { sx: { backdropFilter: 'blur(8px)' } } }}
|
||||||
|
>
|
||||||
|
<DialogContent sx={{ p: 0 }}>
|
||||||
|
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
placeholder={t('search.placeholder')}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: query && (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton size="small" onClick={() => setQuery('')} aria-label="Clear search">
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 2, maxHeight: 400, overflow: 'auto' }}>
|
||||||
|
{!debouncedQuery && recentSearches.length > 0 && (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
|
||||||
|
<HistoryIcon fontSize="inherit" /> Recent
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{recentSearches.map((s) => (
|
||||||
|
<Chip key={s} label={s} size="small" onClick={() => handleSearch(s)} clickable />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!debouncedQuery && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
|
||||||
|
<LocationOnIcon fontSize="inherit" /> Popular Cities
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{mockCities.slice(0, 6).map((city) => (
|
||||||
|
<Chip
|
||||||
|
key={city.id}
|
||||||
|
label={city.name}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => handleSearch(city.name)}
|
||||||
|
clickable
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{results.length > 0 && (
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||||
|
{t('search.results', { count: results.length })}
|
||||||
|
</Typography>
|
||||||
|
<List disablePadding>
|
||||||
|
{results.map((profile) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={profile.id}
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
navigate(`/profile/${profile.slug}`);
|
||||||
|
}}
|
||||||
|
sx={{ borderRadius: 2, mb: 0.5 }}
|
||||||
|
>
|
||||||
|
<ProfileCardCompact profile={profile} />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{debouncedQuery && results.length === 0 && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
No profiles found for "{debouncedQuery}"
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalSearch;
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Slider from '@mui/material/Slider';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
import Switch from '@mui/material/Switch';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import { useSearchStore } from '@/store';
|
||||||
|
import { LuxeButton, GlassCard } from '@/components/ui';
|
||||||
|
import { mockCities, mockCategories } from '@/constants/mockData';
|
||||||
|
|
||||||
|
const AVAILABLE_LANGUAGES = ['English', 'French', 'Spanish', 'Italian', 'German', 'Russian', 'Japanese'];
|
||||||
|
|
||||||
|
export function SearchFilterPanel() {
|
||||||
|
const { filters, setFilters, resetFilters } = useSearchStore();
|
||||||
|
|
||||||
|
const handleCityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFilters({ city: e.target.value || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgeChange = (_event: Event, value: number | number[]) => {
|
||||||
|
const [min, max] = value as number[];
|
||||||
|
setFilters({ ageMin: min, ageMax: max });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePriceChange = (_event: Event, value: number | number[]) => {
|
||||||
|
const [min, max] = value as number[];
|
||||||
|
setFilters({ priceMin: min, priceMax: max });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (key: 'verified' | 'online' | 'premium') => {
|
||||||
|
setFilters({ [key]: !filters[key] || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryToggle = (slug: string) => {
|
||||||
|
const current = filters.categories ?? [];
|
||||||
|
const next = current.includes(slug)
|
||||||
|
? current.filter((c) => c !== slug)
|
||||||
|
: [...current, slug];
|
||||||
|
setFilters({ categories: next.length > 0 ? next : undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLanguageToggle = (lang: string) => {
|
||||||
|
const current = filters.languages ?? [];
|
||||||
|
const next = current.includes(lang)
|
||||||
|
? current.filter((l) => l !== lang)
|
||||||
|
: [...current, lang];
|
||||||
|
setFilters({ languages: next.length > 0 ? next : undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassCard sx={{ p: 3, height: 'fit-content' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||||
|
<Typography variant="h6" fontWeight={700} sx={{ fontFamily: '"Playfair Display", serif' }}>
|
||||||
|
Filters
|
||||||
|
</Typography>
|
||||||
|
<LuxeButton variant="text" size="small" onClick={resetFilters} sx={{ color: 'text.secondary', p: 0, minWidth: 'auto' }}>
|
||||||
|
Clear All
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{/* Toggle Switches */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={!!filters.verified} onChange={() => handleToggle('verified')} color="secondary" />}
|
||||||
|
label={<Typography variant="body2">Verified Only</Typography>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={!!filters.online} onChange={() => handleToggle('online')} color="secondary" />}
|
||||||
|
label={<Typography variant="body2">Online Now</Typography>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={!!filters.premium} onChange={() => handleToggle('premium')} color="secondary" />}
|
||||||
|
label={<Typography variant="body2">Premium Showcase</Typography>}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Location Dropdown */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5, fontWeight: 600 }}>
|
||||||
|
Location
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
value={filters.city ?? ''}
|
||||||
|
onChange={handleCityChange}
|
||||||
|
slotProps={{
|
||||||
|
select: {
|
||||||
|
displayEmpty: true,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All Cities</MenuItem>
|
||||||
|
{mockCities.map((city) => (
|
||||||
|
<MenuItem key={city.id} value={city.name}>
|
||||||
|
{city.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Categories Section */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5, fontWeight: 600 }}>
|
||||||
|
Categories
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{mockCategories.map((cat) => {
|
||||||
|
const selected = filters.categories?.includes(cat.slug);
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={cat.id}
|
||||||
|
label={cat.name}
|
||||||
|
size="small"
|
||||||
|
clickable
|
||||||
|
variant={selected ? 'filled' : 'outlined'}
|
||||||
|
color={selected ? 'primary' : 'default'}
|
||||||
|
onClick={() => handleCategoryToggle(cat.slug)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Age Slider */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ display: 'flex', justifyContent: 'space-between', mb: 1.5, fontWeight: 600 }}>
|
||||||
|
<span>Age Range</span>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{filters.ageMin ?? 18} - {filters.ageMax ?? 50}
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={[filters.ageMin ?? 18, filters.ageMax ?? 50]}
|
||||||
|
onChange={handleAgeChange}
|
||||||
|
min={18}
|
||||||
|
max={50}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
color="secondary"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Price Slider */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ display: 'flex', justifyContent: 'space-between', mb: 1.5, fontWeight: 600 }}>
|
||||||
|
<span>Hourly Price ($)</span>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
${filters.priceMin ?? 100} - ${filters.priceMax ?? 2000}
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={[filters.priceMin ?? 100, filters.priceMax ?? 2000]}
|
||||||
|
onChange={handlePriceChange}
|
||||||
|
min={100}
|
||||||
|
max={2000}
|
||||||
|
step={50}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
color="secondary"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Spoken Languages */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5, fontWeight: 600 }}>
|
||||||
|
Languages Spoken
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{AVAILABLE_LANGUAGES.map((lang) => {
|
||||||
|
const selected = filters.languages?.includes(lang);
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={lang}
|
||||||
|
label={lang}
|
||||||
|
size="small"
|
||||||
|
clickable
|
||||||
|
variant={selected ? 'filled' : 'outlined'}
|
||||||
|
color={selected ? 'primary' : 'default'}
|
||||||
|
onClick={() => handleLanguageToggle(lang)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchFilterPanel;
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import SearchOffIcon from '@mui/icons-material/SearchOff';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import LuxeButton from './LuxeButton';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
title = 'Nothing here yet',
|
||||||
|
description = 'Check back soon for new content.',
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
icon,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component={motion.div}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
py: 8,
|
||||||
|
px: 3,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
mb: 3,
|
||||||
|
color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon ?? <SearchOffIcon sx={{ fontSize: 36 }} />}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 360, mb: 3 }}>
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<LuxeButton variant="outlined" onClick={onAction}>
|
||||||
|
{actionLabel}
|
||||||
|
</LuxeButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmptyState;
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import Card, { type CardProps } from '@mui/material/Card';
|
||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
|
||||||
|
const StyledCard = styled(Card)(({ theme }) => ({
|
||||||
|
background: theme.palette.glass.background,
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
WebkitBackdropFilter: 'blur(20px)',
|
||||||
|
border: `1px solid ${theme.palette.glass.border}`,
|
||||||
|
borderRadius: theme.customRadius?.lg ?? 16,
|
||||||
|
boxShadow: theme.customShadows?.glass ?? theme.shadows[4],
|
||||||
|
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||||
|
overflow: 'hidden',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-4px)',
|
||||||
|
boxShadow: theme.customShadows?.lg,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export type GlassCardProps = CardProps;
|
||||||
|
|
||||||
|
export function GlassCard(props: GlassCardProps) {
|
||||||
|
return <StyledCard {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlassCard;
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
message?: string;
|
||||||
|
size?: number;
|
||||||
|
fullScreen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingSpinner({
|
||||||
|
message,
|
||||||
|
size = 40,
|
||||||
|
fullScreen = false,
|
||||||
|
}: LoadingSpinnerProps) {
|
||||||
|
const content = (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
|
...(fullScreen && { minHeight: '60vh' }),
|
||||||
|
}}
|
||||||
|
role="status"
|
||||||
|
aria-label={message ?? 'Loading'}
|
||||||
|
>
|
||||||
|
<CircularProgress size={size} thickness={3} sx={{ color: 'gold.main' }} />
|
||||||
|
{message && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoadingSpinner;
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import { GradientText } from './styled';
|
||||||
|
import { APP_NAME } from '@/constants';
|
||||||
|
|
||||||
|
interface LogoProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showText?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = { sm: 28, md: 36, lg: 48 };
|
||||||
|
|
||||||
|
export function Logo({ size = 'md', showText = true }: LogoProps) {
|
||||||
|
const px = sizes[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component={RouterLink}
|
||||||
|
to="/"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
}}
|
||||||
|
aria-label={`${APP_NAME} home`}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="svg"
|
||||||
|
width={px}
|
||||||
|
height={px}
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<rect width="64" height="64" rx="16" fill="currentColor" opacity="0.1" />
|
||||||
|
<path
|
||||||
|
d="M32 12L38 26H52L41 35L45 50L32 41L19 50L23 35L12 26H26L32 12Z"
|
||||||
|
fill="currentColor"
|
||||||
|
style={{ color: '#C9A962' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{showText && (
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
fontFamily: '"Playfair Display", serif',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: size === 'lg' ? '1.75rem' : size === 'sm' ? '1rem' : '1.25rem',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GradientText>{APP_NAME}</GradientText>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Logo;
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import Button, { type ButtonProps } from '@mui/material/Button';
|
||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
import { gradients } from '@/theme/tokens';
|
||||||
|
|
||||||
|
const StyledButton = styled(Button)(({ theme }) => ({
|
||||||
|
borderRadius: theme.customRadius?.md ?? 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'&.MuiButton-containedPrimary': {
|
||||||
|
background: gradients.gold,
|
||||||
|
color: theme.palette.mode === 'dark' ? '#0A0A0B' : '#FFFFFF',
|
||||||
|
'&:hover': {
|
||||||
|
background: gradients.gold,
|
||||||
|
filter: 'brightness(1.1)',
|
||||||
|
transform: 'translateY(-1px)',
|
||||||
|
boxShadow: theme.customShadows?.gold,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'&.MuiButton-outlinedPrimary': {
|
||||||
|
borderColor: theme.palette.gold.main,
|
||||||
|
color: theme.palette.gold.main,
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: theme.palette.gold.light,
|
||||||
|
backgroundColor: `${theme.palette.gold.main}14`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface LuxeButtonProps extends ButtonProps {
|
||||||
|
component?: React.ElementType;
|
||||||
|
to?: string;
|
||||||
|
href?: string;
|
||||||
|
target?: string;
|
||||||
|
rel?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LuxeButton(props: LuxeButtonProps) {
|
||||||
|
return <StyledButton {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LuxeButton;
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Rating from '@mui/material/Rating';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { GlassCard } from './GlassCard';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
import { formatCurrency } from '@/utils';
|
||||||
|
import type { Profile } from '@/types';
|
||||||
|
|
||||||
|
interface ProfileCardProps {
|
||||||
|
profile: Profile;
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCard({ profile, index = 0 }: ProfileCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: '-50px' }}
|
||||||
|
transition={{ duration: 0.4, delay: index * 0.05 }}
|
||||||
|
>
|
||||||
|
<GlassCard
|
||||||
|
sx={{ cursor: 'pointer', '&:hover': { '& .profile-image': { transform: 'scale(1.05)' } } }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/profile/${profile.slug}`}
|
||||||
|
sx={{ textDecoration: 'none', color: 'inherit', display: 'block' }}
|
||||||
|
>
|
||||||
|
<Box sx={{ position: 'relative', overflow: 'hidden', height: 280 }}>
|
||||||
|
<Box
|
||||||
|
className="profile-image"
|
||||||
|
component="img"
|
||||||
|
src={profile.avatar}
|
||||||
|
alt={profile.name}
|
||||||
|
loading="lazy"
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
transition: 'transform 0.5s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'linear-gradient(180deg, transparent 50%, rgba(0,0,0,0.7) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box sx={{ position: 'absolute', top: 12, left: 12, display: 'flex', gap: 0.5 }}>
|
||||||
|
{profile.isVerified && <StatusBadge badgeType="verified" />}
|
||||||
|
{profile.isPremium && <StatusBadge badgeType="premium" />}
|
||||||
|
{profile.isOnline && <StatusBadge badgeType="online" />}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ position: 'absolute', bottom: 12, left: 12, right: 12 }}>
|
||||||
|
<Typography variant="h6" sx={{ color: '#fff', fontWeight: 600 }}>
|
||||||
|
{profile.name}, {profile.age}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)' }}>
|
||||||
|
{profile.city}{profile.area ? ` · ${profile.area}` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
<Rating value={profile.rating} precision={0.1} size="small" readOnly />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
({profile.reviewCount})
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="subtitle2" color="gold.main" fontWeight={700}>
|
||||||
|
From {formatCurrency(profile.priceFrom, profile.currency)}
|
||||||
|
</Typography>
|
||||||
|
{profile.tagline && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||||
|
{profile.tagline}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</GlassCard>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCardCompact({ profile }: ProfileCardProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/profile/${profile.slug}`}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar src={profile.avatar} alt={profile.name} sx={{ width: 48, height: 48 }} />
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="subtitle2" noWrap>
|
||||||
|
{profile.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{profile.city}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{profile.isOnline && <StatusBadge badgeType="online" />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileCard;
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { GradientText } from './styled';
|
||||||
|
|
||||||
|
interface SectionHeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
align?: 'left' | 'center';
|
||||||
|
action?: React.ReactNode;
|
||||||
|
gradient?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
align = 'left',
|
||||||
|
action,
|
||||||
|
gradient = false,
|
||||||
|
}: SectionHeaderProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
alignItems: align === 'center' ? 'center' : { xs: 'flex-start', sm: 'flex-end' },
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 2,
|
||||||
|
mb: { xs: 4, md: 5 },
|
||||||
|
textAlign: align,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="h3"
|
||||||
|
component="h2"
|
||||||
|
sx={{ fontSize: { xs: '1.75rem', md: '2.25rem' }, mb: subtitle ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
{gradient ? <GradientText>{title}</GradientText> : title}
|
||||||
|
</Typography>
|
||||||
|
{subtitle && (
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{action}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SectionHeader;
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Skeleton from '@mui/material/Skeleton';
|
||||||
|
import { GlassCard } from './GlassCard';
|
||||||
|
|
||||||
|
interface ProfileCardSkeletonProps {
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCardSkeleton({ count = 1 }: ProfileCardSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<GlassCard key={i} sx={{ p: 0 }}>
|
||||||
|
<Skeleton variant="rectangular" height={280} animation="wave" />
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Skeleton width="60%" height={24} animation="wave" />
|
||||||
|
<Skeleton width="40%" height={18} sx={{ mt: 1 }} animation="wave" />
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
|
||||||
|
<Skeleton width={60} height={24} animation="wave" />
|
||||||
|
<Skeleton width={60} height={24} animation="wave" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionSkeleton() {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Skeleton width={240} height={40} sx={{ mb: 1 }} animation="wave" />
|
||||||
|
<Skeleton width={400} height={24} sx={{ mb: 4 }} animation="wave" />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' },
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProfileCardSkeleton count={4} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileCardSkeleton;
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import Chip, { type ChipProps } from '@mui/material/Chip';
|
||||||
|
import VerifiedIcon from '@mui/icons-material/Verified';
|
||||||
|
import StarIcon from '@mui/icons-material/Star';
|
||||||
|
import CircleIcon from '@mui/icons-material/Circle';
|
||||||
|
|
||||||
|
type BadgeType = 'verified' | 'premium' | 'online' | 'default';
|
||||||
|
|
||||||
|
interface StatusBadgeProps extends Omit<ChipProps, 'color' | 'variant'> {
|
||||||
|
badgeType?: BadgeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeConfig: Record<BadgeType, { label: string; color: ChipProps['color']; icon?: React.ReactElement }> = {
|
||||||
|
verified: { label: 'Verified', color: 'success', icon: <VerifiedIcon /> },
|
||||||
|
premium: { label: 'Premium', color: 'secondary', icon: <StarIcon /> },
|
||||||
|
online: { label: 'Online', color: 'success', icon: <CircleIcon sx={{ fontSize: 10 }} /> },
|
||||||
|
default: { label: '', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ badgeType = 'default', label, ...props }: StatusBadgeProps) {
|
||||||
|
const config = badgeConfig[badgeType];
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={label ?? config.label}
|
||||||
|
color={config.color}
|
||||||
|
icon={config.icon}
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
height: 24,
|
||||||
|
...(badgeType === 'premium' && {
|
||||||
|
background: (theme) =>
|
||||||
|
`linear-gradient(135deg, ${theme.palette.gold.main}, ${theme.palette.gold.dark})`,
|
||||||
|
color: '#0A0A0B',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusBadge;
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Menu from '@mui/material/Menu';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||||
|
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||||
|
import SettingsBrightnessIcon from '@mui/icons-material/SettingsBrightness';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useThemeStore } from '@/store';
|
||||||
|
import type { ThemeMode } from '@/theme';
|
||||||
|
|
||||||
|
const modes: { value: ThemeMode; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ value: 'light', label: 'Light', icon: <LightModeIcon fontSize="small" /> },
|
||||||
|
{ value: 'dark', label: 'Dark', icon: <DarkModeIcon fontSize="small" /> },
|
||||||
|
{ value: 'system', label: 'System', icon: <SettingsBrightnessIcon fontSize="small" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { mode, setMode } = useThemeStore();
|
||||||
|
const [anchor, setAnchor] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
|
const current = modes.find((m) => m.value === mode) ?? modes[2]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
onClick={(e) => setAnchor(e.currentTarget)}
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
size="small"
|
||||||
|
sx={{ color: 'text.primary' }}
|
||||||
|
>
|
||||||
|
{current.icon}
|
||||||
|
</IconButton>
|
||||||
|
<Menu anchorEl={anchor} open={Boolean(anchor)} onClose={() => setAnchor(null)}>
|
||||||
|
{modes.map((m) => (
|
||||||
|
<MenuItem
|
||||||
|
key={m.value}
|
||||||
|
selected={mode === m.value}
|
||||||
|
onClick={() => {
|
||||||
|
setMode(m.value);
|
||||||
|
setAnchor(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>{m.icon}</ListItemIcon>
|
||||||
|
<ListItemText>{m.label}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeToggle;
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export { LuxeButton } from './LuxeButton';
|
||||||
|
export { GlassCard } from './GlassCard';
|
||||||
|
export { SectionHeader } from './SectionHeader';
|
||||||
|
export { ProfileCard, ProfileCardCompact } from './ProfileCard';
|
||||||
|
export { StatusBadge } from './StatusBadge';
|
||||||
|
export { EmptyState } from './EmptyState';
|
||||||
|
export { LoadingSpinner } from './LoadingSpinner';
|
||||||
|
export { ProfileCardSkeleton, SectionSkeleton } from './SkeletonLoader';
|
||||||
|
export { ThemeToggle } from './ThemeToggle';
|
||||||
|
export { Logo } from './Logo';
|
||||||
|
export { PageContainer, SectionWrapper, GlassSurface, GlassForm, GradientText } from './styled';
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
|
export const PageContainer = styled(Box)(({ theme }) => ({
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 1440,
|
||||||
|
margin: '0 auto',
|
||||||
|
paddingLeft: theme.spacing(2),
|
||||||
|
paddingRight: theme.spacing(2),
|
||||||
|
[theme.breakpoints.up('sm')]: {
|
||||||
|
paddingLeft: theme.spacing(3),
|
||||||
|
paddingRight: theme.spacing(3),
|
||||||
|
},
|
||||||
|
[theme.breakpoints.up('md')]: {
|
||||||
|
paddingLeft: theme.spacing(4),
|
||||||
|
paddingRight: theme.spacing(4),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const SectionWrapper = styled(Box)(({ theme }) => ({
|
||||||
|
paddingTop: theme.spacing(8),
|
||||||
|
paddingBottom: theme.spacing(8),
|
||||||
|
[theme.breakpoints.up('md')]: {
|
||||||
|
paddingTop: theme.spacing(12),
|
||||||
|
paddingBottom: theme.spacing(12),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const GlassSurface = styled(Box)(({ theme }) => ({
|
||||||
|
background: theme.palette.glass.background,
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
WebkitBackdropFilter: 'blur(20px)',
|
||||||
|
border: `1px solid ${theme.palette.glass.border}`,
|
||||||
|
borderRadius: theme.customRadius?.lg ?? 16,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const GlassForm = styled('form')(({ theme }) => ({
|
||||||
|
background: theme.palette.glass.background,
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
WebkitBackdropFilter: 'blur(20px)',
|
||||||
|
border: `1px solid ${theme.palette.glass.border}`,
|
||||||
|
borderRadius: theme.customRadius?.lg ?? 16,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const GradientText = styled('span')(({ theme }) => ({
|
||||||
|
background: `linear-gradient(135deg, ${theme.palette.gold.main} 0%, ${theme.palette.secondary.light} 100%)`,
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
}));
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
import en from './locales/en.json';
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources: { en: { translation: en } },
|
||||||
|
fallbackLng: 'en',
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
detection: {
|
||||||
|
order: ['localStorage', 'navigator'],
|
||||||
|
caches: ['localStorage'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"search": "Search",
|
||||||
|
"login": "Sign In",
|
||||||
|
"register": "Create Account",
|
||||||
|
"logout": "Sign Out",
|
||||||
|
"viewAll": "View All",
|
||||||
|
"learnMore": "Learn More",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"error": "Something went wrong",
|
||||||
|
"retry": "Try Again",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"submit": "Submit",
|
||||||
|
"verified": "Verified",
|
||||||
|
"premium": "Premium",
|
||||||
|
"online": "Online"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"search": "Search",
|
||||||
|
"cities": "Cities",
|
||||||
|
"categories": "Categories",
|
||||||
|
"blog": "Blog",
|
||||||
|
"forum": "Community",
|
||||||
|
"advertise": "Advertise",
|
||||||
|
"dashboard": "Dashboard"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"title": "Discover Premium Companions",
|
||||||
|
"subtitle": "Curated profiles. Verified identities. Unmatched elegance.",
|
||||||
|
"cta": "Explore Profiles",
|
||||||
|
"secondaryCta": "List Your Profile"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Search by name, city, or category…",
|
||||||
|
"location": "Location",
|
||||||
|
"category": "Category",
|
||||||
|
"filters": "Filters",
|
||||||
|
"results": "{{count}} profiles found"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"trending": "Trending Now",
|
||||||
|
"featured": "Featured Profiles",
|
||||||
|
"latest": "Latest Arrivals",
|
||||||
|
"verified": "Verified Profiles",
|
||||||
|
"premium": "Premium Advertisers",
|
||||||
|
"cities": "Popular Cities",
|
||||||
|
"categories": "Browse Categories",
|
||||||
|
"nearby": "Nearby Listings",
|
||||||
|
"blogs": "Recent Articles",
|
||||||
|
"popularBlogs": "Popular Reads",
|
||||||
|
"forum": "Community Discussions",
|
||||||
|
"testimonials": "What Members Say",
|
||||||
|
"stats": "Platform at a Glance",
|
||||||
|
"faq": "Frequently Asked Questions",
|
||||||
|
"newsletter": "Stay in the Loop"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"tagline": "The premier platform for premium companion services worldwide.",
|
||||||
|
"company": "Company",
|
||||||
|
"support": "Support",
|
||||||
|
"legal": "Legal",
|
||||||
|
"social": "Follow Us",
|
||||||
|
"copyright": "© {{year}} Luxe. All rights reserved."
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"welcomeBack": "Welcome Back",
|
||||||
|
"createAccount": "Create Your Account",
|
||||||
|
"email": "Email Address",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Confirm Password",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"hasAccount": "Already have an account?",
|
||||||
|
"orContinueWith": "Or continue with"
|
||||||
|
},
|
||||||
|
"newsletter": {
|
||||||
|
"title": "Exclusive Updates",
|
||||||
|
"subtitle": "Be the first to know about new profiles, features, and premium offers.",
|
||||||
|
"placeholder": "Enter your email",
|
||||||
|
"button": "Subscribe",
|
||||||
|
"success": "You're subscribed!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
export const APP_NAME = import.meta.env.VITE_APP_NAME ?? 'Luxe';
|
||||||
|
export const APP_URL = import.meta.env.VITE_APP_URL ?? 'http://localhost:5173';
|
||||||
|
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api';
|
||||||
|
export const SOCKET_URL = import.meta.env.VITE_SOCKET_URL ?? 'http://localhost:3000';
|
||||||
|
export const CDN_URL = import.meta.env.VITE_CDN_URL ?? '';
|
||||||
|
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
THEME_MODE: 'luxe-theme-mode',
|
||||||
|
AUTH_TOKEN: 'luxe-auth-token',
|
||||||
|
REFRESH_TOKEN: 'luxe-refresh-token',
|
||||||
|
LOCALE: 'luxe-locale',
|
||||||
|
SEARCH_FILTERS: 'luxe-search-filters',
|
||||||
|
RECENT_SEARCHES: 'luxe-recent-searches',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ROUTES = {
|
||||||
|
HOME: '/',
|
||||||
|
SEARCH: '/search',
|
||||||
|
PROFILE: '/profile/:slug',
|
||||||
|
BLOG: '/blog',
|
||||||
|
BLOG_POST: '/blog/:slug',
|
||||||
|
FORUM: '/forum',
|
||||||
|
CITIES: '/cities',
|
||||||
|
CITY: '/cities/:slug',
|
||||||
|
CATEGORIES: '/categories',
|
||||||
|
CATEGORY: '/categories/:slug',
|
||||||
|
AUTH: {
|
||||||
|
LOGIN: '/auth/login',
|
||||||
|
REGISTER: '/auth/register',
|
||||||
|
FORGOT_PASSWORD: '/auth/forgot-password',
|
||||||
|
RESET_PASSWORD: '/auth/reset-password',
|
||||||
|
VERIFY_OTP: '/auth/verify-otp',
|
||||||
|
},
|
||||||
|
DASHBOARD: {
|
||||||
|
ROOT: '/dashboard',
|
||||||
|
PROFILE: '/dashboard/profile',
|
||||||
|
SAVED: '/dashboard/saved',
|
||||||
|
MESSAGES: '/dashboard/messages',
|
||||||
|
SETTINGS: '/dashboard/settings',
|
||||||
|
},
|
||||||
|
ADVERTISER: {
|
||||||
|
ROOT: '/advertiser',
|
||||||
|
LISTINGS: '/advertiser/listings',
|
||||||
|
ANALYTICS: '/advertiser/analytics',
|
||||||
|
},
|
||||||
|
ADMIN: {
|
||||||
|
ROOT: '/admin',
|
||||||
|
},
|
||||||
|
LEGAL: {
|
||||||
|
PRIVACY: '/privacy',
|
||||||
|
TERMS: '/terms',
|
||||||
|
COOKIES: '/cookies',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const BREAKPOINTS = {
|
||||||
|
xs: 0,
|
||||||
|
sm: 600,
|
||||||
|
md: 900,
|
||||||
|
lg: 1200,
|
||||||
|
xl: 1536,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ANIMATION = {
|
||||||
|
duration: {
|
||||||
|
fast: 0.15,
|
||||||
|
normal: 0.3,
|
||||||
|
slow: 0.5,
|
||||||
|
},
|
||||||
|
easing: {
|
||||||
|
smooth: [0.4, 0, 0.2, 1] as const,
|
||||||
|
bounce: [0.34, 1.56, 0.64, 1] as const,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const QUERY_KEYS = {
|
||||||
|
PROFILES: 'profiles',
|
||||||
|
FEATURED: 'featured-profiles',
|
||||||
|
TRENDING: 'trending-profiles',
|
||||||
|
CITIES: 'cities',
|
||||||
|
CATEGORIES: 'categories',
|
||||||
|
BLOGS: 'blogs',
|
||||||
|
FORUM: 'forum',
|
||||||
|
USER: 'user',
|
||||||
|
NOTIFICATIONS: 'notifications',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const USER_ROLES = {
|
||||||
|
VISITOR: 'visitor',
|
||||||
|
USER: 'user',
|
||||||
|
ADVERTISER: 'advertiser',
|
||||||
|
AGENCY: 'agency',
|
||||||
|
MODERATOR: 'moderator',
|
||||||
|
SUPPORT: 'support',
|
||||||
|
ADMIN: 'admin',
|
||||||
|
SUPER_ADMIN: 'super_admin',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UserRole = (typeof USER_ROLES)[keyof typeof USER_ROLES];
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import type { Profile, City, Category, BlogPost, ForumTopic, Testimonial, Statistic, FAQItem } from '@/types';
|
||||||
|
|
||||||
|
const avatar = (seed: number) =>
|
||||||
|
`https://images.unsplash.com/photo-${1544005313 + seed}-94ddf0286df2?w=400&h=500&fit=crop&crop=face`;
|
||||||
|
|
||||||
|
const cover = (seed: number) =>
|
||||||
|
`https://images.unsplash.com/photo-${1517841905 + seed}-976672fecd0?w=800&h=600&fit=crop`;
|
||||||
|
|
||||||
|
export const mockProfiles: Profile[] = Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
id: `profile-${i + 1}`,
|
||||||
|
slug: `companion-${i + 1}`,
|
||||||
|
name: ['Sophia', 'Isabella', 'Olivia', 'Emma', 'Ava', 'Mia', 'Charlotte', 'Amelia', 'Harper', 'Evelyn', 'Abigail', 'Emily'][i] ?? 'Profile',
|
||||||
|
age: 22 + (i % 8),
|
||||||
|
city: ['New York', 'London', 'Paris', 'Dubai', 'Singapore', 'Tokyo', 'Milan', 'Barcelona'][i % 8] ?? 'City',
|
||||||
|
area: ['Manhattan', 'Mayfair', 'Le Marais', 'Marina', 'Orchard', 'Shibuya', 'Brera', 'Eixample'][i % 8],
|
||||||
|
avatar: avatar(i),
|
||||||
|
coverImage: cover(i),
|
||||||
|
rating: 4.5 + (i % 5) * 0.1,
|
||||||
|
reviewCount: 12 + i * 7,
|
||||||
|
isVerified: i % 3 !== 0,
|
||||||
|
isPremium: i % 2 === 0,
|
||||||
|
isOnline: i % 4 === 0,
|
||||||
|
priceFrom: 200 + i * 50,
|
||||||
|
currency: 'USD',
|
||||||
|
categories: [['Elite', 'Dinner Date'], ['Travel', 'Events'], ['VIP', 'Corporate']][i % 3] ?? [],
|
||||||
|
languages: [['English', 'French'], ['English', 'Spanish'], ['English', 'Italian']][i % 3] ?? ['English'],
|
||||||
|
height: `${165 + (i % 15)} cm`,
|
||||||
|
tagline: 'Elegant companion for discerning gentlemen',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const mockCities: City[] = [
|
||||||
|
{ id: '1', slug: 'new-york', name: 'New York', country: 'USA', profileCount: 342, image: 'https://images.unsplash.com/photo-1496442226666-8d4d0e62e6e9?w=600&h=400&fit=crop' },
|
||||||
|
{ id: '2', slug: 'london', name: 'London', country: 'UK', profileCount: 289, image: 'https://images.unsplash.com/photo-1513635269975-59663e0ac1ad?w=600&h=400&fit=crop' },
|
||||||
|
{ id: '3', slug: 'paris', name: 'Paris', country: 'France', profileCount: 256, image: 'https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=600&h=400&fit=crop' },
|
||||||
|
{ id: '4', slug: 'dubai', name: 'Dubai', country: 'UAE', profileCount: 198, image: 'https://images.unsplash.com/photo-1512453979798-5ea266f8880c?w=600&h=400&fit=crop' },
|
||||||
|
{ id: '5', slug: 'singapore', name: 'Singapore', country: 'Singapore', profileCount: 167, image: 'https://images.unsplash.com/photo-1525620190502-4bb1c0d4a8b0?w=600&h=400&fit=crop' },
|
||||||
|
{ id: '6', slug: 'tokyo', name: 'Tokyo', country: 'Japan', profileCount: 143, image: 'https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?w=600&h=400&fit=crop' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockCategories: Category[] = [
|
||||||
|
{ id: '1', slug: 'elite', name: 'Elite Companions', icon: 'Diamond', profileCount: 456, description: 'Premium verified companions' },
|
||||||
|
{ id: '2', slug: 'dinner-date', name: 'Dinner Dates', icon: 'Restaurant', profileCount: 312, description: 'Sophisticated dining companions' },
|
||||||
|
{ id: '3', slug: 'travel', name: 'Travel Companions', icon: 'Flight', profileCount: 234, description: 'Travel the world in style' },
|
||||||
|
{ id: '4', slug: 'events', name: 'Event Escorts', icon: 'Event', profileCount: 189, description: 'Corporate and social events' },
|
||||||
|
{ id: '5', slug: 'vip', name: 'VIP Services', icon: 'Star', profileCount: 167, description: 'Exclusive VIP experiences' },
|
||||||
|
{ id: '6', slug: 'massage', name: 'Wellness', icon: 'Spa', profileCount: 278, description: 'Relaxation and wellness' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockBlogPosts: BlogPost[] = [
|
||||||
|
{ id: '1', slug: 'etiquette-guide', title: 'The Modern Gentleman\'s Guide to Companion Etiquette', excerpt: 'Essential tips for a refined and respectful experience.', coverImage: 'https://images.unsplash.com/photo-1515377905703-c4788e51af15?w=800&h=500&fit=crop', author: { name: 'Editorial Team', avatar: avatar(20) }, category: 'Lifestyle', tags: ['Etiquette', 'Guide'], readingTime: 8, publishedAt: '2026-07-15', likes: 234 },
|
||||||
|
{ id: '2', slug: 'safety-first', title: 'Safety & Verification: Why It Matters', excerpt: 'How our verification process protects both parties.', coverImage: 'https://images.unsplash.com/photo-1563013544-824ae1b704d3?w=800&h=500&fit=crop', author: { name: 'Security Team', avatar: avatar(21) }, category: 'Safety', tags: ['Security', 'Trust'], readingTime: 5, publishedAt: '2026-07-10', likes: 189 },
|
||||||
|
{ id: '3', slug: 'top-cities', title: 'Top 10 Cities for Premium Companionship', excerpt: 'Discover the world\'s most sought-after destinations.', coverImage: 'https://images.unsplash.com/photo-1488646953014-85cb44e25828?w=800&h=500&fit=crop', author: { name: 'Travel Editor', avatar: avatar(22) }, category: 'Travel', tags: ['Cities', 'Travel'], readingTime: 12, publishedAt: '2026-07-05', likes: 412 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockForumTopics: ForumTopic[] = [
|
||||||
|
{ id: '1', slug: 'first-time-tips', title: 'Tips for first-time users?', excerpt: 'Looking for advice on making the most of the platform…', author: { name: 'Member_42', avatar: avatar(30) }, category: 'General', replies: 28, likes: 45, isPinned: true, createdAt: '2026-07-18' },
|
||||||
|
{ id: '2', slug: 'verification-process', title: 'How long does verification take?', excerpt: 'Submitted my documents 3 days ago…', author: { name: 'NewAdvertiser', avatar: avatar(31) }, category: 'Support', replies: 12, likes: 8, isPinned: false, createdAt: '2026-07-17' },
|
||||||
|
{ id: '3', slug: 'best-cities', title: 'Which cities have the best selection?', excerpt: 'Planning to travel and want recommendations…', author: { name: 'Traveler99', avatar: avatar(32) }, category: 'Discussion', replies: 56, likes: 89, isPinned: false, createdAt: '2026-07-16' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockTestimonials: Testimonial[] = [
|
||||||
|
{ id: '1', name: 'James R.', role: 'Member since 2024', avatar: avatar(40), content: 'The verification process gave me complete confidence. Every profile I\'ve connected with has exceeded expectations.', rating: 5 },
|
||||||
|
{ id: '2', name: 'Michael T.', role: 'Premium Member', avatar: avatar(41), content: 'Finally a platform that treats discretion and quality as priorities. The interface is stunning and intuitive.', rating: 5 },
|
||||||
|
{ id: '3', name: 'David L.', role: 'Advertiser', avatar: avatar(42), content: 'As an advertiser, the dashboard analytics and lead management tools are enterprise-grade. Highly recommended.', rating: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockStatistics: Statistic[] = [
|
||||||
|
{ label: 'Verified Profiles', value: '12,500+', icon: 'VerifiedUser' },
|
||||||
|
{ label: 'Global Cities', value: '180+', icon: 'LocationCity' },
|
||||||
|
{ label: 'Happy Members', value: '50K+', icon: 'People' },
|
||||||
|
{ label: 'Average Rating', value: '4.9', icon: 'Star' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockFAQ: FAQItem[] = [
|
||||||
|
{ id: '1', question: 'How does profile verification work?', answer: 'Our multi-step verification includes identity document review, photo verification, and optional video confirmation. Verified profiles display a trust badge visible to all members.' },
|
||||||
|
{ id: '2', question: 'Is my privacy protected?', answer: 'Absolutely. We use enterprise-grade encryption, never share personal data with third parties, and offer discreet billing options. Your browsing activity is never logged or sold.' },
|
||||||
|
{ id: '3', question: 'How do I list my profile as an advertiser?', answer: 'Create an advertiser account, complete verification, upload your gallery, set your availability and pricing, and submit for review. Most profiles go live within 24–48 hours.' },
|
||||||
|
{ id: '4', question: 'What payment methods are accepted?', answer: 'We support major credit cards, digital wallets, and cryptocurrency. All transactions are processed through PCI-compliant payment gateways.' },
|
||||||
|
{ id: '5', question: 'Can I save searches and favorites?', answer: 'Yes. Registered members can save profiles, create custom search filters, and receive notifications when new matching profiles are listed.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const navItems = [
|
||||||
|
{ label: 'nav.home', path: '/' },
|
||||||
|
{ label: 'nav.search', path: '/search' },
|
||||||
|
{ label: 'nav.cities', path: '/cities' },
|
||||||
|
{ label: 'nav.categories', path: '/categories' },
|
||||||
|
{ label: 'nav.blog', path: '/blog' },
|
||||||
|
{ label: 'nav.forum', path: '/forum' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const footerLinks = {
|
||||||
|
company: [
|
||||||
|
{ label: 'About Us', path: '/about' },
|
||||||
|
{ label: 'Careers', path: '/careers' },
|
||||||
|
{ label: 'Press', path: '/press' },
|
||||||
|
{ label: 'Advertise', path: '/advertise' },
|
||||||
|
],
|
||||||
|
support: [
|
||||||
|
{ label: 'Help Center', path: '/help' },
|
||||||
|
{ label: 'Contact Us', path: '/contact' },
|
||||||
|
{ label: 'Safety', path: '/safety' },
|
||||||
|
{ label: 'Verification', path: '/verification' },
|
||||||
|
],
|
||||||
|
legal: [
|
||||||
|
{ label: 'Privacy Policy', path: '/privacy' },
|
||||||
|
{ label: 'Terms of Service', path: '/terms' },
|
||||||
|
{ label: 'Cookie Policy', path: '/cookies' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useMemo, useEffect } from 'react';
|
||||||
|
import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material';
|
||||||
|
import { lightTheme, darkTheme } from '@/theme';
|
||||||
|
import { useResolvedTheme } from '@/hooks';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const resolved = useResolvedTheme();
|
||||||
|
const theme = useMemo(() => (resolved === 'dark' ? darkTheme : lightTheme), [resolved]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', resolved);
|
||||||
|
document.documentElement.style.colorScheme = resolved;
|
||||||
|
}, [resolved]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MuiThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
{children}
|
||||||
|
</MuiThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeProvider;
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { PageContainer, SectionWrapper, SectionHeader, GlassCard, LuxeButton } from '@/components/ui';
|
||||||
|
import { mockCities, mockCategories } from '@/constants/mockData';
|
||||||
|
import { formatNumber } from '@/utils';
|
||||||
|
|
||||||
|
export function CitiesSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper sx={{ bgcolor: 'action.hover' }}>
|
||||||
|
<PageContainer>
|
||||||
|
<SectionHeader title={t('sections.cities')} gradient />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(3, 1fr)', lg: 'repeat(6, 1fr)' },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mockCities.map((city, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={city.id}
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
whileInView={{ opacity: 1, scale: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: i * 0.05 }}
|
||||||
|
>
|
||||||
|
<GlassCard sx={{ cursor: 'pointer', '&:hover': { transform: 'translateY(-4px)' } }}>
|
||||||
|
<Box
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/cities/${city.slug}`}
|
||||||
|
sx={{ textDecoration: 'none', color: 'inherit', display: 'block' }}
|
||||||
|
>
|
||||||
|
<Box sx={{ position: 'relative', height: 140, overflow: 'hidden' }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={city.image}
|
||||||
|
alt={city.name}
|
||||||
|
loading="lazy"
|
||||||
|
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'linear-gradient(180deg, transparent 40%, rgba(0,0,0,0.75) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box sx={{ position: 'absolute', bottom: 12, left: 12 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ color: '#fff', fontWeight: 600 }}>
|
||||||
|
{city.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.8)' }}>
|
||||||
|
{formatNumber(city.profileCount)} profiles
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</GlassCard>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoriesSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const icons = ['💎', '🍽️', '✈️', '🎭', '⭐', '🧘'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper>
|
||||||
|
<PageContainer>
|
||||||
|
<SectionHeader
|
||||||
|
title={t('sections.categories')}
|
||||||
|
action={
|
||||||
|
<LuxeButton component={RouterLink} to="/categories" variant="outlined" size="small">
|
||||||
|
{t('common.viewAll')}
|
||||||
|
</LuxeButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr 1fr', sm: 'repeat(3, 1fr)', lg: 'repeat(6, 1fr)' },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mockCategories.map((cat, i) => (
|
||||||
|
<GlassCard key={cat.id} sx={{ p: 0 }}>
|
||||||
|
<Box
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/categories/${cat.slug}`}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
textAlign: 'center',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontSize: '2rem', mb: 1 }}>{icons[i] ?? '•'}</Typography>
|
||||||
|
<Typography variant="subtitle2" fontWeight={600}>
|
||||||
|
{cat.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{formatNumber(cat.profileCount)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CitiesSection;
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Rating from '@mui/material/Rating';
|
||||||
|
import Accordion from '@mui/material/Accordion';
|
||||||
|
import AccordionSummary from '@mui/material/AccordionSummary';
|
||||||
|
import AccordionDetails from '@mui/material/AccordionDetails';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
|
import { Pagination, Autoplay } from 'swiper/modules';
|
||||||
|
import 'swiper/css';
|
||||||
|
import 'swiper/css/pagination';
|
||||||
|
import {
|
||||||
|
PageContainer,
|
||||||
|
SectionWrapper,
|
||||||
|
SectionHeader,
|
||||||
|
GlassCard,
|
||||||
|
LuxeButton,
|
||||||
|
GradientText,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import { mockBlogPosts, mockForumTopics, mockTestimonials, mockStatistics, mockFAQ } from '@/constants/mockData';
|
||||||
|
import { newsletterSchema, type NewsletterFormData } from '@/validators/auth';
|
||||||
|
import { gradients } from '@/theme/tokens';
|
||||||
|
|
||||||
|
export function BlogSection({ titleKey, posts }: { titleKey: string; posts: typeof mockBlogPosts }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper>
|
||||||
|
<PageContainer>
|
||||||
|
<SectionHeader
|
||||||
|
title={t(titleKey)}
|
||||||
|
action={<LuxeButton component={RouterLink} to="/blog" variant="outlined" size="small">{t('common.viewAll')}</LuxeButton>}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 3 }}>
|
||||||
|
{posts.map((post, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={post.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: i * 0.1 }}
|
||||||
|
>
|
||||||
|
<GlassCard>
|
||||||
|
<Box component={RouterLink} to={`/blog/${post.slug}`} sx={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<Box component="img" src={post.coverImage} alt={post.title} loading="lazy" sx={{ width: '100%', height: 180, objectFit: 'cover' }} />
|
||||||
|
<Box sx={{ p: 2.5 }}>
|
||||||
|
<Chip label={post.category} size="small" sx={{ mb: 1.5 }} />
|
||||||
|
<Typography variant="h6" sx={{ fontSize: '1rem', mb: 1, lineHeight: 1.4 }}>
|
||||||
|
{post.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
{post.excerpt}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Avatar src={post.author.avatar} sx={{ width: 24, height: 24 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{post.readingTime} min read
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</GlassCard>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForumSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper sx={{ bgcolor: 'action.hover' }}>
|
||||||
|
<PageContainer>
|
||||||
|
<SectionHeader title={t('sections.forum')} action={<LuxeButton component={RouterLink} to="/forum" variant="outlined" size="small">{t('common.viewAll')}</LuxeButton>} />
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{mockForumTopics.map((topic) => (
|
||||||
|
<GlassCard key={topic.id} sx={{ p: 2.5 }}>
|
||||||
|
<Box component={RouterLink} to={`/forum/${topic.slug}`} sx={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||||
|
<Avatar src={topic.author.avatar} sx={{ width: 40, height: 40 }} />
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
|
{topic.isPinned && <Chip label="Pinned" size="small" color="secondary" />}
|
||||||
|
<Chip label={topic.category} size="small" variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600}>{topic.title}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{topic.excerpt}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||||
|
{topic.replies} replies · {topic.likes} likes
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TestimonialsSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper>
|
||||||
|
<PageContainer>
|
||||||
|
<SectionHeader title={t('sections.testimonials')} align="center" gradient />
|
||||||
|
<Swiper modules={[Pagination, Autoplay]} spaceBetween={24} slidesPerView={1} pagination={{ clickable: true }} autoplay={{ delay: 5000 }} breakpoints={{ 768: { slidesPerView: 2 }, 1024: { slidesPerView: 3 } }}>
|
||||||
|
{mockTestimonials.map((item) => (
|
||||||
|
<SwiperSlide key={item.id}>
|
||||||
|
<GlassCard sx={{ p: 3, height: '100%' }}>
|
||||||
|
<Rating value={item.rating} readOnly size="small" sx={{ mb: 2 }} />
|
||||||
|
<Typography variant="body1" sx={{ mb: 3, lineHeight: 1.7, fontStyle: 'italic' }}>
|
||||||
|
"{item.content}"
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Avatar src={item.avatar} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2">{item.name}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">{item.role}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</GlassCard>
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatisticsSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper sx={{ background: gradients.purple, color: '#fff' }}>
|
||||||
|
<PageContainer>
|
||||||
|
<SectionHeader title={t('sections.stats')} align="center" />
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(4, 1fr)' }, gap: 4, textAlign: 'center' }}>
|
||||||
|
{mockStatistics.map((stat, i) => (
|
||||||
|
<Box key={stat.label} component={motion.div} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ delay: i * 0.1 }}>
|
||||||
|
<Typography variant="h3" sx={{ fontWeight: 700, mb: 1 }}>{stat.value}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.85 }}>{stat.label}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FAQSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper>
|
||||||
|
<PageContainer sx={{ maxWidth: 800 }}>
|
||||||
|
<SectionHeader title={t('sections.faq')} align="center" />
|
||||||
|
{mockFAQ.map((item) => (
|
||||||
|
<Accordion key={item.id} disableGutters elevation={0} sx={{ bgcolor: 'transparent', '&::before': { display: 'none' }, mb: 1 }}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography fontWeight={600}>{item.question}</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Typography color="text.secondary" lineHeight={1.7}>{item.answer}</Typography>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
))}
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewsletterSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { register, handleSubmit, formState: { errors, isSubmitSuccessful }, reset } = useForm<NewsletterFormData>({
|
||||||
|
resolver: zodResolver(newsletterSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper sx={{ bgcolor: 'action.hover' }}>
|
||||||
|
<PageContainer sx={{ maxWidth: 640, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h4" sx={{ mb: 1 }}>
|
||||||
|
<GradientText>{t('newsletter.title')}</GradientText>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||||
|
{t('newsletter.subtitle')}
|
||||||
|
</Typography>
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ display: 'flex', gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
|
||||||
|
<TextField
|
||||||
|
{...register('email')}
|
||||||
|
fullWidth
|
||||||
|
placeholder={t('newsletter.placeholder')}
|
||||||
|
error={!!errors.email}
|
||||||
|
helperText={errors.email?.message}
|
||||||
|
/>
|
||||||
|
<LuxeButton type="submit" variant="contained" sx={{ minWidth: 140, whiteSpace: 'nowrap' }}>
|
||||||
|
{isSubmitSuccessful ? t('newsletter.success') : t('newsletter.button')}
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentBlogsSection() {
|
||||||
|
return <BlogSection titleKey="sections.blogs" posts={mockBlogPosts} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopularBlogsSection() {
|
||||||
|
return <BlogSection titleKey="sections.popularBlogs" posts={[...mockBlogPosts].sort((a, b) => b.likes - a.likes)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlogSection;
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import Grid from '@mui/material/Grid';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import LocationOnIcon from '@mui/icons-material/LocationOn';
|
||||||
|
import CategoryIcon from '@mui/icons-material/Category';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { LuxeButton, PageContainer, GradientText, GlassForm } from '@/components/ui';
|
||||||
|
import { searchSchema, type SearchFormData } from '@/validators/auth';
|
||||||
|
import { mockCategories, mockCities } from '@/constants/mockData';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
import { gradients } from '@/theme/tokens';
|
||||||
|
|
||||||
|
export function HeroSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { control, handleSubmit } = useForm<SearchFormData>({
|
||||||
|
resolver: zodResolver(searchSchema),
|
||||||
|
defaultValues: { query: '', city: '', category: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: SearchFormData) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (data.query) params.set('q', data.query);
|
||||||
|
if (data.city) params.set('city', data.city);
|
||||||
|
if (data.category) params.set('category', data.category);
|
||||||
|
navigate(`${ROUTES.SEARCH}?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
minHeight: { xs: '85vh', md: '90vh' },
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: 'url(https://images.unsplash.com/photo-1515377905703-c4788e51af15?w=1920&h=1080&fit=crop)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
'&::after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: (theme) =>
|
||||||
|
theme.palette.mode === 'dark'
|
||||||
|
? 'linear-gradient(180deg, rgba(10,10,11,0.4) 0%, rgba(10,10,11,0.92) 100%)'
|
||||||
|
: 'linear-gradient(180deg, rgba(250,250,250,0.3) 0%, rgba(250,250,250,0.95) 100%)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '20%',
|
||||||
|
right: '-10%',
|
||||||
|
width: '50%',
|
||||||
|
height: '60%',
|
||||||
|
background: gradients.purple,
|
||||||
|
opacity: 0.15,
|
||||||
|
filter: 'blur(100px)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageContainer sx={{ position: 'relative', zIndex: 1, py: { xs: 8, md: 12 } }}>
|
||||||
|
<Box
|
||||||
|
component={motion.div}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
sx={{ maxWidth: 800 }}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="h1"
|
||||||
|
sx={{
|
||||||
|
fontSize: { xs: '2.5rem', sm: '3.5rem', md: '4.5rem' },
|
||||||
|
lineHeight: 1.1,
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GradientText>{t('hero.title')}</GradientText>
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ mb: 5, fontWeight: 400, maxWidth: 560, lineHeight: 1.6 }}
|
||||||
|
>
|
||||||
|
{t('hero.subtitle')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<GlassForm
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
sx={{ p: { xs: 2, md: 3 }, maxWidth: 720 }}
|
||||||
|
>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, md: 5 }}>
|
||||||
|
<Controller
|
||||||
|
name="query"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fullWidth
|
||||||
|
placeholder={t('search.placeholder')}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<Controller
|
||||||
|
name="city"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label={t('search.location')}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<LocationOnIcon color="action" fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All Cities</MenuItem>
|
||||||
|
{mockCities.map((city) => (
|
||||||
|
<MenuItem key={city.id} value={city.slug}>
|
||||||
|
{city.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
|
||||||
|
<Controller
|
||||||
|
name="category"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label={t('search.category')}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<CategoryIcon color="action" fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
{mockCategories.map((cat) => (
|
||||||
|
<MenuItem key={cat.id} value={cat.slug}>
|
||||||
|
{cat.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 2 }}>
|
||||||
|
<LuxeButton type="submit" variant="contained" fullWidth sx={{ height: '100%', minHeight: 56 }}>
|
||||||
|
{t('common.search')}
|
||||||
|
</LuxeButton>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</GlassForm>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mt: 4, flexWrap: 'wrap' }}>
|
||||||
|
<LuxeButton variant="contained" size="large" onClick={() => navigate(ROUTES.SEARCH)}>
|
||||||
|
{t('hero.cta')}
|
||||||
|
</LuxeButton>
|
||||||
|
<LuxeButton variant="outlined" size="large" onClick={() => navigate(ROUTES.AUTH.REGISTER)}>
|
||||||
|
{t('hero.secondaryCta')}
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageContainer>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HeroSection;
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { PageContainer, SectionWrapper, SectionHeader, ProfileCard } from '@/components/ui';
|
||||||
|
import { mockProfiles } from '@/constants/mockData';
|
||||||
|
import LuxeButton from '@/components/ui/LuxeButton';
|
||||||
|
|
||||||
|
interface ProfileGridSectionProps {
|
||||||
|
titleKey: string;
|
||||||
|
subtitle?: string;
|
||||||
|
profiles: typeof mockProfiles;
|
||||||
|
filter?: (p: (typeof mockProfiles)[0]) => boolean;
|
||||||
|
viewAllPath?: string;
|
||||||
|
gradient?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileGridSection({
|
||||||
|
titleKey,
|
||||||
|
subtitle,
|
||||||
|
profiles,
|
||||||
|
filter,
|
||||||
|
viewAllPath = '/search',
|
||||||
|
gradient = false,
|
||||||
|
}: ProfileGridSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const data = filter ? profiles.filter(filter) : profiles;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper>
|
||||||
|
<PageContainer>
|
||||||
|
<SectionHeader
|
||||||
|
title={t(titleKey)}
|
||||||
|
subtitle={subtitle}
|
||||||
|
gradient={gradient}
|
||||||
|
action={
|
||||||
|
<LuxeButton component={RouterLink} to={viewAllPath} variant="outlined" size="small">
|
||||||
|
{t('common.viewAll')}
|
||||||
|
</LuxeButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: '1fr',
|
||||||
|
sm: 'repeat(2, 1fr)',
|
||||||
|
md: 'repeat(3, 1fr)',
|
||||||
|
lg: 'repeat(4, 1fr)',
|
||||||
|
},
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.slice(0, 8).map((profile, i) => (
|
||||||
|
<ProfileCard key={profile.id} profile={profile} index={i} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrendingProfilesSection() {
|
||||||
|
return (
|
||||||
|
<ProfileGridSection
|
||||||
|
titleKey="sections.trending"
|
||||||
|
profiles={mockProfiles}
|
||||||
|
filter={(p) => p.isOnline || p.rating >= 4.7}
|
||||||
|
gradient
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeaturedProfilesSection() {
|
||||||
|
return (
|
||||||
|
<ProfileGridSection
|
||||||
|
titleKey="sections.featured"
|
||||||
|
subtitle="Hand-picked profiles showcasing exceptional quality and service."
|
||||||
|
profiles={mockProfiles}
|
||||||
|
filter={(p) => p.isPremium}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LatestProfilesSection() {
|
||||||
|
return <ProfileGridSection titleKey="sections.latest" profiles={[...mockProfiles].reverse()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerifiedProfilesSection() {
|
||||||
|
return (
|
||||||
|
<ProfileGridSection
|
||||||
|
titleKey="sections.verified"
|
||||||
|
subtitle="Identity-verified companions you can trust."
|
||||||
|
profiles={mockProfiles}
|
||||||
|
filter={(p) => p.isVerified}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PremiumAdvertisersSection() {
|
||||||
|
return (
|
||||||
|
<ProfileGridSection
|
||||||
|
titleKey="sections.premium"
|
||||||
|
profiles={mockProfiles}
|
||||||
|
filter={(p) => p.isPremium}
|
||||||
|
gradient
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NearbyListingsSection() {
|
||||||
|
return (
|
||||||
|
<ProfileGridSection
|
||||||
|
titleKey="sections.nearby"
|
||||||
|
subtitle="Discover companions in your area."
|
||||||
|
profiles={mockProfiles.slice(0, 4)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileGridSection;
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
export { HeroSection } from './HeroSection';
|
||||||
|
export {
|
||||||
|
TrendingProfilesSection,
|
||||||
|
FeaturedProfilesSection,
|
||||||
|
LatestProfilesSection,
|
||||||
|
VerifiedProfilesSection,
|
||||||
|
PremiumAdvertisersSection,
|
||||||
|
NearbyListingsSection,
|
||||||
|
} from './ProfileSections';
|
||||||
|
export { CitiesSection, CategoriesSection } from './CitiesCategoriesSection';
|
||||||
|
export {
|
||||||
|
RecentBlogsSection,
|
||||||
|
PopularBlogsSection,
|
||||||
|
ForumSection,
|
||||||
|
TestimonialsSection,
|
||||||
|
StatisticsSection,
|
||||||
|
FAQSection,
|
||||||
|
NewsletterSection,
|
||||||
|
} from './ContentSections';
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useThemeStore } from '@/store';
|
||||||
|
import { resolveThemeMode } from '@/theme';
|
||||||
|
|
||||||
|
export function useResolvedTheme() {
|
||||||
|
const mode = useThemeStore((s) => s.mode);
|
||||||
|
const [resolved, setResolved] = useState<'light' | 'dark'>(() => resolveThemeMode(mode));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setResolved(resolveThemeMode(mode));
|
||||||
|
|
||||||
|
if (mode !== 'system') return;
|
||||||
|
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handler = () => setResolved(resolveThemeMode('system'));
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(() =>
|
||||||
|
typeof window !== 'undefined' ? window.matchMedia(query).matches : false,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia(query);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||||
|
setMatches(mq.matches);
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollPosition(threshold = 50): boolean {
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setScrolled(window.scrollY > threshold);
|
||||||
|
handler();
|
||||||
|
window.addEventListener('scroll', handler, { passive: true });
|
||||||
|
return () => window.removeEventListener('scroll', handler);
|
||||||
|
}, [threshold]);
|
||||||
|
|
||||||
|
return scrolled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay = 300): T {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebounced(value), delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
:root {
|
||||||
|
--text: #6b6375;
|
||||||
|
--text-h: #08060d;
|
||||||
|
--bg: #fff;
|
||||||
|
--border: #e5e4e7;
|
||||||
|
--code-bg: #f4f3ec;
|
||||||
|
--accent: #aa3bff;
|
||||||
|
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||||
|
--accent-border: rgba(170, 59, 255, 0.5);
|
||||||
|
--social-bg: rgba(244, 243, 236, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||||
|
|
||||||
|
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--mono: ui-monospace, Consolas, monospace;
|
||||||
|
|
||||||
|
font: 18px/145% var(--sans);
|
||||||
|
letter-spacing: 0.18px;
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--text: #9ca3af;
|
||||||
|
--text-h: #f3f4f6;
|
||||||
|
--bg: #16171d;
|
||||||
|
--border: #2e303a;
|
||||||
|
--code-bg: #1f2028;
|
||||||
|
--accent: #c084fc;
|
||||||
|
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||||
|
--accent-border: rgba(192, 132, 252, 0.5);
|
||||||
|
--social-bg: rgba(47, 48, 58, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#social .button-icon {
|
||||||
|
filter: invert(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 1126px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
border-inline: 1px solid var(--border);
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-family: var(--heading);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 56px;
|
||||||
|
letter-spacing: -1.68px;
|
||||||
|
margin: 32px 0;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 118%;
|
||||||
|
letter-spacing: -0.24px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
.counter {
|
||||||
|
font-family: var(--mono);
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 135%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { Outlet, Link as RouterLink } from 'react-router-dom';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Logo } from '@/components/ui';
|
||||||
|
import { gradients } from '@/theme/tokens';
|
||||||
|
|
||||||
|
export function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component={motion.div}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'none', lg: 'flex' },
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: 6,
|
||||||
|
background: gradients.purple,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'radial-gradient(circle at 30% 50%, rgba(201,169,98,0.15) 0%, transparent 60%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box sx={{ position: 'relative', zIndex: 1, textAlign: 'center', maxWidth: 420 }}>
|
||||||
|
<Logo size="lg" />
|
||||||
|
<Typography
|
||||||
|
variant="h3"
|
||||||
|
sx={{ color: '#fff', mt: 4, mb: 2, fontFamily: '"Playfair Display", serif' }}
|
||||||
|
>
|
||||||
|
Welcome to Luxe
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ color: 'rgba(255,255,255,0.8)', lineHeight: 1.8 }}>
|
||||||
|
The premier platform for premium companion services. Join thousands of verified members worldwide.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: { xs: 3, sm: 6 },
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box component={RouterLink} to="/" sx={{ display: { xs: 'block', lg: 'none' }, mb: 4, textDecoration: 'none' }}>
|
||||||
|
<Logo />
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
component={motion.div}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.1 }}
|
||||||
|
sx={{ width: '100%', maxWidth: 420 }}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthLayout;
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { Header } from '@/components/layouts/Header';
|
||||||
|
import { Footer } from '@/components/layouts/Footer';
|
||||||
|
|
||||||
|
export function MainLayout() {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||||
|
<Header />
|
||||||
|
<Box component="main" sx={{ flex: 1 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
<Footer />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MainLayout;
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from '@/app/App';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import {
|
||||||
|
HeroSection,
|
||||||
|
TrendingProfilesSection,
|
||||||
|
FeaturedProfilesSection,
|
||||||
|
LatestProfilesSection,
|
||||||
|
VerifiedProfilesSection,
|
||||||
|
PremiumAdvertisersSection,
|
||||||
|
CitiesSection,
|
||||||
|
CategoriesSection,
|
||||||
|
NearbyListingsSection,
|
||||||
|
RecentBlogsSection,
|
||||||
|
PopularBlogsSection,
|
||||||
|
ForumSection,
|
||||||
|
TestimonialsSection,
|
||||||
|
StatisticsSection,
|
||||||
|
FAQSection,
|
||||||
|
NewsletterSection,
|
||||||
|
} from '@/features/home/sections';
|
||||||
|
import { APP_NAME, APP_URL } from '@/constants';
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>{APP_NAME} — Premium Companion Directory</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Discover verified premium companions worldwide. Curated profiles, trusted verification, and an elegant experience."
|
||||||
|
/>
|
||||||
|
<link rel="canonical" href={APP_URL} />
|
||||||
|
<meta property="og:title" content={`${APP_NAME} — Premium Companion Directory`} />
|
||||||
|
<meta property="og:description" content="Discover verified premium companions worldwide." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content={APP_URL} />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: APP_NAME,
|
||||||
|
url: APP_URL,
|
||||||
|
potentialAction: {
|
||||||
|
'@type': 'SearchAction',
|
||||||
|
target: `${APP_URL}/search?q={search_term_string}`,
|
||||||
|
'query-input': 'required name=search_term_string',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</script>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<HeroSection />
|
||||||
|
<TrendingProfilesSection />
|
||||||
|
<FeaturedProfilesSection />
|
||||||
|
<LatestProfilesSection />
|
||||||
|
<VerifiedProfilesSection />
|
||||||
|
<PremiumAdvertisersSection />
|
||||||
|
<CitiesSection />
|
||||||
|
<CategoriesSection />
|
||||||
|
<NearbyListingsSection />
|
||||||
|
<RecentBlogsSection />
|
||||||
|
<PopularBlogsSection />
|
||||||
|
<ForumSection />
|
||||||
|
<TestimonialsSection />
|
||||||
|
<StatisticsSection />
|
||||||
|
<FAQSection />
|
||||||
|
<NewsletterSection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { LuxeButton } from '@/components/ui';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
|
||||||
|
export function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: '60vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
px: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h1" sx={{ fontSize: '6rem', fontWeight: 700, color: 'gold.main', lineHeight: 1 }}>
|
||||||
|
404
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" sx={{ mt: 2, mb: 1 }}>
|
||||||
|
Page not found
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 400 }}>
|
||||||
|
The page you're looking for doesn't exist or has been moved.
|
||||||
|
</Typography>
|
||||||
|
<LuxeButton component={RouterLink} to={ROUTES.HOME} variant="contained">
|
||||||
|
Back to Home
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotFoundPage;
|
||||||
|
|
@ -0,0 +1,717 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Grid from '@mui/material/Grid';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Rating from '@mui/material/Rating';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Table from '@mui/material/Table';
|
||||||
|
import TableBody from '@mui/material/TableBody';
|
||||||
|
import TableCell from '@mui/material/TableCell';
|
||||||
|
import TableContainer from '@mui/material/TableContainer';
|
||||||
|
import TableHead from '@mui/material/TableHead';
|
||||||
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import PhoneIcon from '@mui/icons-material/Phone';
|
||||||
|
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
|
||||||
|
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||||
|
import FileCopyIcon from '@mui/icons-material/FileCopy';
|
||||||
|
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import VerifiedIcon from '@mui/icons-material/Verified';
|
||||||
|
import StarIcon from '@mui/icons-material/Star';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
|
||||||
|
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
||||||
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
|
import { Navigation, Pagination } from 'swiper/modules';
|
||||||
|
import 'swiper/css';
|
||||||
|
import 'swiper/css/navigation';
|
||||||
|
import 'swiper/css/pagination';
|
||||||
|
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { PageContainer, SectionWrapper, GlassCard, LuxeButton, StatusBadge } from '@/components/ui';
|
||||||
|
import { mockProfiles } from '@/constants/mockData';
|
||||||
|
import { formatCurrency } from '@/utils';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
import { useMediaQuery } from '@/hooks';
|
||||||
|
|
||||||
|
// Form validation schema for Reviews
|
||||||
|
const reviewSchema = z.object({
|
||||||
|
reviewerName: z.string().min(2, 'Name must be at least 2 characters'),
|
||||||
|
rating: z.number().min(1, 'Please select a rating').max(5),
|
||||||
|
comment: z.string().min(10, 'Review comment must be at least 10 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ReviewFormData = z.infer<typeof reviewSchema>;
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const { slug } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isSmUp = useMediaQuery('(min-width:600px)');
|
||||||
|
|
||||||
|
const profile = mockProfiles.find((p) => p.slug === slug);
|
||||||
|
|
||||||
|
// States
|
||||||
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
const [activePhotoIdx, setActivePhotoIdx] = useState(0);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [qrOpen, setQrOpen] = useState(false);
|
||||||
|
const [verifyOpen, setVerifyOpen] = useState(false);
|
||||||
|
const [verificationStep, setVerificationStep] = useState(1); // 1: Info, 2: Document, 3: Selfie, 4: Submitted
|
||||||
|
const [uploadedDoc, setUploadedDoc] = useState<File | null>(null);
|
||||||
|
const [uploadedSelfie, setUploadedSelfie] = useState<File | null>(null);
|
||||||
|
const [reviewsList, setReviewsList] = useState<any[]>([
|
||||||
|
{ id: 1, name: 'Alex M.', rating: 5, date: '2026-07-10', comment: 'Absolutely stunning and highly professional companion. Discretion was top tier, and we had an incredible dinner date. Highly recommend!' },
|
||||||
|
{ id: 2, name: 'Julian F.', rating: 4, date: '2026-07-02', comment: 'Very pleasant wellness companion. Speaks perfect French and English. Will definitely book again.' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// React Hook Form for review
|
||||||
|
const { register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<ReviewFormData>({
|
||||||
|
resolver: zodResolver(reviewSchema),
|
||||||
|
defaultValues: { reviewerName: '', rating: 5, comment: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ratingVal = watch('rating');
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return (
|
||||||
|
<SectionWrapper>
|
||||||
|
<PageContainer sx={{ textAlign: 'center', py: 12 }}>
|
||||||
|
<Typography variant="h4" fontWeight={700} gutterBottom>Companion Profile Not Found</Typography>
|
||||||
|
<Typography color="text.secondary" sx={{ mb: 4 }}>The profile you are looking for does not exist or has been removed.</Typography>
|
||||||
|
<LuxeButton variant="contained" component={RouterLink} to={ROUTES.SEARCH}>
|
||||||
|
Return to Directory
|
||||||
|
</LuxeButton>
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample photos for slider
|
||||||
|
const photos = [
|
||||||
|
profile.avatar,
|
||||||
|
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=600&h=800&fit=crop',
|
||||||
|
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=600&h=800&fit=crop',
|
||||||
|
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=600&h=800&fit=crop',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Rates calculation
|
||||||
|
const incallRates = [
|
||||||
|
{ duration: '1 Hour', price: profile.priceFrom },
|
||||||
|
{ duration: '2 Hours', price: Math.round(profile.priceFrom * 1.7) },
|
||||||
|
{ duration: '3 Hours', price: Math.round(profile.priceFrom * 2.4) },
|
||||||
|
{ duration: 'Dinner Date (4 hrs)', price: Math.round(profile.priceFrom * 3.0) },
|
||||||
|
{ duration: 'Overnight (12 hrs)', price: Math.round(profile.priceFrom * 6.5) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const outcallRates = [
|
||||||
|
{ duration: '1 Hour', price: Math.round(profile.priceFrom * 1.25) },
|
||||||
|
{ duration: '2 Hours', price: Math.round(profile.priceFrom * 2.1) },
|
||||||
|
{ duration: '3 Hours', price: Math.round(profile.priceFrom * 3.0) },
|
||||||
|
{ duration: 'Dinner Date (4 hrs)', price: Math.round(profile.priceFrom * 3.8) },
|
||||||
|
{ duration: 'Overnight (12 hrs)', price: Math.round(profile.priceFrom * 8.0) },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Contact integration numbers (mock)
|
||||||
|
const phoneNum = '+1 (555) 123-4567';
|
||||||
|
const whatsappUrl = `https://wa.me/15551234567?text=Hello%20${profile.name},%20I%20found%20your%20profile%20on%20Luxe%20and%20would%20love%20to%20inquire%20about%20your%20availability.`;
|
||||||
|
const telegramUrl = `https://t.me/luxe_companion_bot`;
|
||||||
|
|
||||||
|
const handleCopyNumber = () => {
|
||||||
|
navigator.clipboard.writeText(phoneNum);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReviewSubmit = (data: ReviewFormData) => {
|
||||||
|
const newRev = {
|
||||||
|
id: reviewsList.length + 1,
|
||||||
|
name: data.reviewerName,
|
||||||
|
rating: data.rating,
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
comment: data.comment,
|
||||||
|
};
|
||||||
|
setReviewsList([newRev, ...reviewsList]);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock schedule grid
|
||||||
|
const daysOfWeek = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
const timeSlots = ['Morning (9am - 12pm)', 'Afternoon (12pm - 5pm)', 'Evening (5pm - 10pm)', 'Night (10pm - 2am)'];
|
||||||
|
const mockSchedule: Record<string, string[]> = {
|
||||||
|
Mon: ['Available', 'Available', 'Booked', 'Unavailable'],
|
||||||
|
Tue: ['Available', 'Available', 'Available', 'Booked'],
|
||||||
|
Wed: ['Unavailable', 'Available', 'Booked', 'Booked'],
|
||||||
|
Thu: ['Available', 'Available', 'Available', 'Available'],
|
||||||
|
Fri: ['Booked', 'Available', 'Booked', 'Booked'],
|
||||||
|
Sat: ['Unavailable', 'Booked', 'Booked', 'Booked'],
|
||||||
|
Sun: ['Available', 'Available', 'Unavailable', 'Unavailable'],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>{profile.name} — Premium Companion in {profile.city} | Luxe</title>
|
||||||
|
<meta name="description" content={`View high-end verified companion ${profile.name} in ${profile.city}. High resolution photos, booking calendar, verified rates.`} />
|
||||||
|
{/* OpenGraph */}
|
||||||
|
<meta property="og:title" content={`${profile.name} — Premium Companion in ${profile.city}`} />
|
||||||
|
<meta property="og:description" content={`Verified photos and rates for ${profile.name} in ${profile.city}.`} />
|
||||||
|
<meta property="og:image" content={profile.avatar} />
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<SectionWrapper sx={{ pt: 3, pb: 10 }}>
|
||||||
|
<PageContainer>
|
||||||
|
{/* Back button */}
|
||||||
|
<LuxeButton
|
||||||
|
variant="text"
|
||||||
|
startIcon={<ArrowBackIcon />}
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
sx={{ mb: 3, color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
Back to listings
|
||||||
|
</LuxeButton>
|
||||||
|
|
||||||
|
<Grid container spacing={5}>
|
||||||
|
{/* Left Column: Gallery & Verification CTA */}
|
||||||
|
<Grid size={{ xs: 12, md: 5.5, lg: 5 }}>
|
||||||
|
<Box sx={{ position: 'relative', borderRadius: 4, overflow: 'hidden', mb: 2, height: { xs: 400, sm: 500, md: 550 } }}>
|
||||||
|
<Swiper
|
||||||
|
modules={[Navigation, Pagination]}
|
||||||
|
navigation={isSmUp}
|
||||||
|
pagination={{ clickable: true }}
|
||||||
|
style={{ height: '100%', '--swiper-theme-color': '#C9A962' } as any}
|
||||||
|
onSlideChange={(swiper) => setActivePhotoIdx(swiper.activeIndex)}
|
||||||
|
>
|
||||||
|
{photos.map((src, idx) => (
|
||||||
|
<SwiperSlide key={idx} onClick={() => setLightboxOpen(true)} style={{ cursor: 'zoom-in' }}>
|
||||||
|
<Box component="img" src={src} alt={`${profile.name} Photo ${idx + 1}`} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
|
||||||
|
{/* Badge Overlay */}
|
||||||
|
<Box sx={{ position: 'absolute', top: 16, left: 16, display: 'flex', gap: 1, zIndex: 10 }}>
|
||||||
|
{profile.isVerified && <StatusBadge badgeType="verified" />}
|
||||||
|
{profile.isPremium && <StatusBadge badgeType="premium" />}
|
||||||
|
{profile.isOnline && <StatusBadge badgeType="online" />}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Photo Mini Grids */}
|
||||||
|
<Grid container spacing={1.5} sx={{ mb: 4 }}>
|
||||||
|
{photos.map((src, idx) => (
|
||||||
|
<Grid key={idx} size={3}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={src}
|
||||||
|
alt="Thumbnail"
|
||||||
|
onClick={() => setLightboxOpen(true)}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: 70,
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: 2,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: activePhotoIdx === idx ? '2px solid #C9A962' : '2px solid transparent',
|
||||||
|
transition: 'border 0.2s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Verification & Trust Alert */}
|
||||||
|
<GlassCard sx={{ p: 2.5, mb: 4, display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
|
<Avatar sx={{ bgcolor: 'secondary.main', width: 44, height: 44 }}>
|
||||||
|
<VerifiedIcon sx={{ color: '#0A0A0B' }} />
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={700}>Identity Verified</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||||
|
This client has successfully completed biometric verification.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<LuxeButton variant="outlined" size="small" onClick={() => setVerifyOpen(true)}>
|
||||||
|
Verify Yours
|
||||||
|
</LuxeButton>
|
||||||
|
</GlassCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Right Column: Information, Contact, Rates */}
|
||||||
|
<Grid size={{ xs: 12, md: 6.5, lg: 7 }}>
|
||||||
|
{/* Profile Title Header */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h3" fontWeight={700} sx={{ fontFamily: '"Playfair Display", serif', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{profile.name}
|
||||||
|
{profile.isVerified && <CheckCircleIcon color="secondary" fontSize="medium" />}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" color="text.secondary" fontWeight={400} sx={{ mt: 0.5 }}>
|
||||||
|
{profile.age} years old · {profile.city} {profile.area ? `(${profile.area})` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: 'action.hover', px: 1.5, py: 0.75, borderRadius: 2 }}>
|
||||||
|
<StarIcon sx={{ color: 'amber.500', fontSize: 18 }} />
|
||||||
|
<Typography fontWeight={700} variant="body2">{profile.rating}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">({profile.reviewCount})</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" color="secondary.light" fontWeight={600} sx={{ mb: 3 }}>
|
||||||
|
From {formatCurrency(profile.priceFrom, profile.currency)} / hour
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 1.5 }}>
|
||||||
|
Connect Instantly
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<LuxeButton
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
component="a"
|
||||||
|
href={whatsappUrl}
|
||||||
|
target="_blank"
|
||||||
|
startIcon={<WhatsAppIcon />}
|
||||||
|
sx={{ bgcolor: '#25D366', color: '#fff', '&:hover': { bgcolor: '#128C7E' } }}
|
||||||
|
>
|
||||||
|
WhatsApp
|
||||||
|
</LuxeButton>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<LuxeButton
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
component="a"
|
||||||
|
href={telegramUrl}
|
||||||
|
target="_blank"
|
||||||
|
startIcon={<TelegramIcon />}
|
||||||
|
sx={{ bgcolor: '#0088cc', color: '#fff', '&:hover': { bgcolor: '#006699' } }}
|
||||||
|
>
|
||||||
|
Telegram
|
||||||
|
</LuxeButton>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<LuxeButton
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
component="a"
|
||||||
|
href={`tel:${phoneNum}`}
|
||||||
|
startIcon={<PhoneIcon />}
|
||||||
|
>
|
||||||
|
Call Host
|
||||||
|
</LuxeButton>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 6 }}>
|
||||||
|
<LuxeButton
|
||||||
|
variant="text"
|
||||||
|
fullWidth
|
||||||
|
startIcon={<FileCopyIcon />}
|
||||||
|
onClick={handleCopyNumber}
|
||||||
|
sx={{ color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy Number'}
|
||||||
|
</LuxeButton>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 6 }}>
|
||||||
|
<LuxeButton
|
||||||
|
variant="text"
|
||||||
|
fullWidth
|
||||||
|
startIcon={<QrCodeIcon />}
|
||||||
|
onClick={() => setQrOpen(true)}
|
||||||
|
sx={{ color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
QR Contact
|
||||||
|
</LuxeButton>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Biography Section */}
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<Typography variant="h6" fontWeight={700} sx={{ mb: 1.5, fontFamily: '"Playfair Display", serif' }}>
|
||||||
|
About {profile.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ lineHeight: 1.8, whiteSpace: 'pre-line' }}>
|
||||||
|
Elegant and highly professional companion for executive dinner dates, travel arrangements, and wellness companionship. I prioritize mutual respect, privacy, and creating an upscale, relaxed experience.
|
||||||
|
{"\n\n"}
|
||||||
|
Whether you require an elegant partner for a corporate function, a luxurious dinner companion, or looking for premium wellness therapy, I tailor our sessions to suit your exact requirements. Discretion is guaranteed.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Key Attributes */}
|
||||||
|
<Grid container spacing={2} sx={{ mb: 5, bgcolor: 'action.hover', p: 2.5, borderRadius: 3 }}>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Height</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>{profile.height ?? '168 cm'}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Age</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>{profile.age} yrs</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Languages</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>{profile.languages.join(', ')}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Categories</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>{profile.categories.join(', ')}</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Services Rate Table */}
|
||||||
|
<Box sx={{ mb: 5 }}>
|
||||||
|
<Typography variant="h6" fontWeight={700} sx={{ mb: 2, fontFamily: '"Playfair Display", serif' }}>
|
||||||
|
Rates and Services
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TableContainer component={Paper} elevation={0} sx={{ border: 1, borderColor: 'divider', bgcolor: 'transparent' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2} align="center"><Typography fontWeight={700}>Incall Rates</Typography></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{incallRates.map((rate) => (
|
||||||
|
<TableRow key={rate.duration}>
|
||||||
|
<TableCell>{rate.duration}</TableCell>
|
||||||
|
<TableCell align="right" sx={{ fontWeight: 600, color: 'secondary.light' }}>${rate.price}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TableContainer component={Paper} elevation={0} sx={{ border: 1, borderColor: 'divider', bgcolor: 'transparent' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2} align="center"><Typography fontWeight={700}>Outcall Rates</Typography></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{outcallRates.map((rate) => (
|
||||||
|
<TableRow key={rate.duration}>
|
||||||
|
<TableCell>{rate.duration}</TableCell>
|
||||||
|
<TableCell align="right" sx={{ fontWeight: 600, color: 'secondary.light' }}>${rate.price}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Availability Calendar */}
|
||||||
|
<Box sx={{ mb: 5 }}>
|
||||||
|
<Typography variant="h6" fontWeight={700} sx={{ mb: 2, fontFamily: '"Playfair Display", serif', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<CalendarMonthIcon /> Availability Calendar
|
||||||
|
</Typography>
|
||||||
|
<TableContainer component={Paper} elevation={0} sx={{ border: 1, borderColor: 'divider', bgcolor: 'transparent' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell><Typography fontWeight={700}>Day</Typography></TableCell>
|
||||||
|
{timeSlots.map((slot) => (
|
||||||
|
<TableCell key={slot} align="center"><Typography variant="caption" fontWeight={700}>{slot.split(' ')[0]}</Typography></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{daysOfWeek.map((day) => (
|
||||||
|
<TableRow key={day}>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>{day}</TableCell>
|
||||||
|
{timeSlots.map((_, idx) => {
|
||||||
|
const status = mockSchedule[day]?.[idx] ?? 'Unavailable';
|
||||||
|
const color = status === 'Available' ? '#25D366' : status === 'Booked' ? '#d32f2f' : 'text.disabled';
|
||||||
|
return (
|
||||||
|
<TableCell key={idx} align="center">
|
||||||
|
<Chip
|
||||||
|
label={status}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: status === 'Available' ? 'rgba(37, 211, 102, 0.15)' : status === 'Booked' ? 'rgba(211, 47, 47, 0.15)' : 'rgba(255,255,255,0.05)',
|
||||||
|
color: color,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Reviews Section */}
|
||||||
|
<Box sx={{ mb: 5 }}>
|
||||||
|
<Typography variant="h6" fontWeight={700} sx={{ mb: 2.5, fontFamily: '"Playfair Display", serif' }}>
|
||||||
|
Client Reviews ({reviewsList.length})
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, mb: 4 }}>
|
||||||
|
{reviewsList.map((rev) => (
|
||||||
|
<GlassCard key={rev.id} sx={{ p: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={600}>{rev.name}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">{rev.date}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Rating value={rev.rating} size="small" readOnly sx={{ mb: 1 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary" lineHeight={1.6}>
|
||||||
|
{rev.comment}
|
||||||
|
</Typography>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Review Form */}
|
||||||
|
<GlassCard sx={{ p: 3 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={700} sx={{ mb: 2 }}>Leave a Review</Typography>
|
||||||
|
<Box component="form" onSubmit={handleSubmit(handleReviewSubmit)} noValidate sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
{...register('reviewerName')}
|
||||||
|
fullWidth
|
||||||
|
label="Your Name"
|
||||||
|
size="small"
|
||||||
|
error={!!errors.reviewerName}
|
||||||
|
helperText={errors.reviewerName?.message}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">Your Rating:</Typography>
|
||||||
|
<Rating
|
||||||
|
name="rating"
|
||||||
|
value={ratingVal}
|
||||||
|
onChange={(_, val) => setValue('rating', val ?? 5)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
{...register('comment')}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
label="Your Experience"
|
||||||
|
placeholder="Write your review here... minimum 10 characters."
|
||||||
|
error={!!errors.comment}
|
||||||
|
helperText={errors.comment?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LuxeButton type="submit" variant="contained" sx={{ alignSelf: 'flex-start' }}>
|
||||||
|
Submit Review
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
</GlassCard>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
|
||||||
|
{/* Fullscreen Slider Lightbox */}
|
||||||
|
<Dialog
|
||||||
|
fullScreen
|
||||||
|
open={lightboxOpen}
|
||||||
|
onClose={() => setLightboxOpen(false)}
|
||||||
|
slotProps={{
|
||||||
|
backdrop: { sx: { bgcolor: 'rgba(0,0,0,0.95)' } }
|
||||||
|
}}
|
||||||
|
PaperProps={{
|
||||||
|
sx: { bgcolor: 'rgba(0,0,0,0.95)', color: '#fff', overflow: 'hidden', display: 'flex', flexDirection: 'column', justifyContent: 'center' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }}>
|
||||||
|
<IconButton onClick={() => setLightboxOpen(false)} sx={{ color: '#fff' }} aria-label="Close lightbox">
|
||||||
|
<CloseIcon fontSize="large" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', height: '100%' }}>
|
||||||
|
<Swiper
|
||||||
|
modules={[Navigation, Pagination]}
|
||||||
|
navigation
|
||||||
|
pagination={{ type: 'fraction' }}
|
||||||
|
initialSlide={activePhotoIdx}
|
||||||
|
style={{ width: '100%', height: '80vh' }}
|
||||||
|
>
|
||||||
|
{photos.map((src, idx) => (
|
||||||
|
<SwiperSlide key={idx} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={src}
|
||||||
|
alt={`${profile.name} Large Photo ${idx + 1}`}
|
||||||
|
sx={{ maxHeight: '100%', maxWidth: '100%', objectFit: 'contain', margin: 'auto' }}
|
||||||
|
/>
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* QR Code Dialog */}
|
||||||
|
<Dialog open={qrOpen} onClose={() => setQrOpen(false)} PaperProps={{ sx: { p: 4, textAlign: 'center', borderRadius: 3 } }}>
|
||||||
|
<Typography variant="h6" fontWeight={700} gutterBottom>Scan to Save Contact</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>Use your smartphone camera to scan this QR code and save {profile.name}'s contact details instantly.</Typography>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(`TEL:${phoneNum}`)}`}
|
||||||
|
alt="Contact QR Code"
|
||||||
|
sx={{ width: 180, height: 180, margin: '0 auto', border: '8px solid white', borderRadius: 2, boxShadow: 2 }}
|
||||||
|
/>
|
||||||
|
<LuxeButton onClick={() => setQrOpen(false)} sx={{ mt: 3 }} variant="outlined">Close</LuxeButton>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Selfie Verification Modal */}
|
||||||
|
<Dialog
|
||||||
|
open={verifyOpen}
|
||||||
|
onClose={() => setVerifyOpen(false)}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="sm"
|
||||||
|
PaperProps={{ sx: { p: 4, borderRadius: 3 } }}
|
||||||
|
>
|
||||||
|
<Typography variant="h5" fontWeight={700} sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<CameraAltIcon color="secondary" /> Identity Verification
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{verificationStep === 1 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, lineHeight: 1.6 }}>
|
||||||
|
Luxe maintains the highest safety and security standards. Verified badges are awarded to companions and members who complete our real-time verification process.
|
||||||
|
</Typography>
|
||||||
|
<Alert severity="info" sx={{ mb: 3 }}>
|
||||||
|
You will need a valid government-issued ID (Passport, Driver's license) and a device with a working camera to take a real-time selfie.
|
||||||
|
</Alert>
|
||||||
|
<LuxeButton variant="contained" fullWidth onClick={() => setVerificationStep(2)}>
|
||||||
|
Get Started
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verificationStep === 2 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 1 }}>Step 1: Upload Identity Document</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Please upload a clear, high-resolution photo of your Passport, Driver's License, or National ID card.
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: '2px dashed rgba(255, 255, 255, 0.2)',
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
'&:hover': { borderColor: 'secondary.main' },
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
// simulate file upload
|
||||||
|
setUploadedDoc(new File([''], 'id_passport_mock.jpg'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploadedDoc ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||||
|
<CheckIcon color="success" />
|
||||||
|
<Typography variant="body2" fontWeight={600}>{uploadedDoc.name}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Click to re-upload</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">Drag & drop your ID file here, or click to upload</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1 }}>PNG, JPG or PDF up to 5MB</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
|
<LuxeButton variant="outlined" fullWidth onClick={() => setVerificationStep(1)}>Back</LuxeButton>
|
||||||
|
<LuxeButton variant="contained" fullWidth disabled={!uploadedDoc} onClick={() => setVerificationStep(3)}>Next Step</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verificationStep === 3 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 1 }}>Step 2: Upload Biometric Selfie</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Upload a clear photo of yourself holding your ID card next to your face, matching the name and face details on your uploaded document.
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: '2px dashed rgba(255, 255, 255, 0.2)',
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
'&:hover': { borderColor: 'secondary.main' },
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setUploadedSelfie(new File([''], 'selfie_verify_mock.jpg'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploadedSelfie ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||||
|
<CheckIcon color="success" />
|
||||||
|
<Typography variant="body2" fontWeight={600}>{uploadedSelfie.name}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Click to re-upload</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">Take/Upload selfie holding ID card</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1 }}>Ensure your face and ID details are clearly visible</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
|
<LuxeButton variant="outlined" fullWidth onClick={() => setVerificationStep(2)}>Back</LuxeButton>
|
||||||
|
<LuxeButton variant="contained" fullWidth disabled={!uploadedSelfie} onClick={() => setVerificationStep(4)}>Submit Verification</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verificationStep === 4 && (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 2 }}>
|
||||||
|
<Avatar sx={{ bgcolor: 'success.main', width: 56, height: 56, margin: '0 auto 16px' }}>
|
||||||
|
<CheckIcon sx={{ color: '#fff', fontSize: 32 }} />
|
||||||
|
</Avatar>
|
||||||
|
<Typography variant="h6" fontWeight={700} gutterBottom>Verification Submitted</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 4, px: 2 }}>
|
||||||
|
Your verification documents and selfie have been uploaded successfully. Our moderators will review your application within 24-48 hours.
|
||||||
|
</Typography>
|
||||||
|
<LuxeButton variant="contained" fullWidth onClick={() => { setVerifyOpen(false); setVerificationStep(1); setUploadedDoc(null); setUploadedSelfie(null); }}>
|
||||||
|
Finished
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfilePage;
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Grid from '@mui/material/Grid';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Drawer from '@mui/material/Drawer';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import { PageContainer, SectionWrapper, ProfileCard, SectionHeader, LuxeButton } from '@/components/ui';
|
||||||
|
import { SearchFilterPanel } from '@/components/search/SearchFilterPanel';
|
||||||
|
import { mockProfiles } from '@/constants/mockData';
|
||||||
|
import { useSearchStore } from '@/store';
|
||||||
|
import { useMediaQuery } from '@/hooks';
|
||||||
|
|
||||||
|
export function SearchPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { filters, setFilters, resetFilters } = useSearchStore();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const isMdUp = useMediaQuery('(min-width:900px)');
|
||||||
|
|
||||||
|
// Sync URL query param to search store on load
|
||||||
|
const queryParam = searchParams.get('q') ?? '';
|
||||||
|
const cityParam = searchParams.get('city') ?? '';
|
||||||
|
const categoryParam = searchParams.get('category') ?? '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initialFilters: any = {};
|
||||||
|
if (queryParam) initialFilters.query = queryParam;
|
||||||
|
if (cityParam) initialFilters.city = cityParam;
|
||||||
|
if (categoryParam) initialFilters.categories = [categoryParam];
|
||||||
|
if (Object.keys(initialFilters).length > 0) {
|
||||||
|
setFilters(initialFilters);
|
||||||
|
}
|
||||||
|
}, [queryParam, cityParam, categoryParam, setFilters]);
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
const results = mockProfiles.filter((p) => {
|
||||||
|
if (filters.query &&
|
||||||
|
!p.name.toLowerCase().includes(filters.query.toLowerCase()) &&
|
||||||
|
!p.city.toLowerCase().includes(filters.query.toLowerCase()) &&
|
||||||
|
!(p.tagline ?? '').toLowerCase().includes(filters.query.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filters.city && p.city.toLowerCase() !== filters.city.toLowerCase()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filters.categories && filters.categories.length > 0) {
|
||||||
|
const matchesCategory = p.categories.some((cat) =>
|
||||||
|
filters.categories!.some((fc) => fc.toLowerCase() === cat.toLowerCase())
|
||||||
|
);
|
||||||
|
if (!matchesCategory) return false;
|
||||||
|
}
|
||||||
|
if (filters.ageMin !== undefined && p.age < filters.ageMin) return false;
|
||||||
|
if (filters.ageMax !== undefined && p.age > filters.ageMax) return false;
|
||||||
|
if (filters.priceMin !== undefined && p.priceFrom < filters.priceMin) return false;
|
||||||
|
if (filters.priceMax !== undefined && p.priceFrom > filters.priceMax) return false;
|
||||||
|
if (filters.verified && !p.isVerified) return false;
|
||||||
|
if (filters.online && !p.isOnline) return false;
|
||||||
|
if (filters.premium && !p.isPremium) return false;
|
||||||
|
if (filters.languages && filters.languages.length > 0) {
|
||||||
|
const matchesLanguage = p.languages.some((lang) =>
|
||||||
|
filters.languages!.some((fl) => fl.toLowerCase() === lang.toLowerCase())
|
||||||
|
);
|
||||||
|
if (!matchesLanguage) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
const sortedResults = [...results].sort((a, b) => {
|
||||||
|
const sortBy = filters.sortBy ?? 'relevance';
|
||||||
|
if (sortBy === 'price_asc') return a.priceFrom - b.priceFrom;
|
||||||
|
if (sortBy === 'price_desc') return b.priceFrom - a.priceFrom;
|
||||||
|
if (sortBy === 'rating') return b.rating - a.rating;
|
||||||
|
if (sortBy === 'newest') return b.id.localeCompare(a.id);
|
||||||
|
|
||||||
|
// Relevance: Premium first, then verified first
|
||||||
|
if (a.isPremium && !b.isPremium) return -1;
|
||||||
|
if (!a.isPremium && b.isPremium) return 1;
|
||||||
|
if (a.isVerified && !b.isVerified) return -1;
|
||||||
|
if (!a.isVerified && b.isVerified) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearchInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setFilters({ query: val || undefined });
|
||||||
|
|
||||||
|
// Update URL params
|
||||||
|
const nextParams = new URLSearchParams(searchParams);
|
||||||
|
if (val) {
|
||||||
|
nextParams.set('q', val);
|
||||||
|
} else {
|
||||||
|
nextParams.delete('q');
|
||||||
|
}
|
||||||
|
setSearchParams(nextParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFilters({ sortBy: e.target.value as any });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFilter = (key: keyof typeof filters, value?: any) => {
|
||||||
|
if (key === 'categories' && value) {
|
||||||
|
setFilters({ categories: filters.categories?.filter((c) => c !== value) });
|
||||||
|
} else if (key === 'languages' && value) {
|
||||||
|
setFilters({ languages: filters.languages?.filter((l) => l !== value) });
|
||||||
|
} else {
|
||||||
|
setFilters({ [key]: undefined });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeFilterChips = [];
|
||||||
|
if (filters.city) activeFilterChips.push({ label: `City: ${filters.city}`, onClick: () => handleRemoveFilter('city') });
|
||||||
|
if (filters.verified) activeFilterChips.push({ label: 'Verified', onClick: () => handleRemoveFilter('verified') });
|
||||||
|
if (filters.online) activeFilterChips.push({ label: 'Online Now', onClick: () => handleRemoveFilter('online') });
|
||||||
|
if (filters.premium) activeFilterChips.push({ label: 'Premium Showcase', onClick: () => handleRemoveFilter('premium') });
|
||||||
|
if (filters.ageMin || filters.ageMax) {
|
||||||
|
activeFilterChips.push({
|
||||||
|
label: `Age: ${filters.ageMin ?? 18}-${filters.ageMax ?? 50}`,
|
||||||
|
onClick: () => { setFilters({ ageMin: undefined, ageMax: undefined }); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (filters.priceMin || filters.priceMax) {
|
||||||
|
activeFilterChips.push({
|
||||||
|
label: `Price: $${filters.priceMin ?? 100}-$${filters.priceMax ?? 2000}`,
|
||||||
|
onClick: () => { setFilters({ priceMin: undefined, priceMax: undefined }); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
filters.categories?.forEach((cat) => {
|
||||||
|
activeFilterChips.push({ label: `Category: ${cat}`, onClick: () => handleRemoveFilter('categories', cat) });
|
||||||
|
});
|
||||||
|
filters.languages?.forEach((lang) => {
|
||||||
|
activeFilterChips.push({ label: `Lang: ${lang}`, onClick: () => handleRemoveFilter('languages', lang) });
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>{filters.query ? `Search: ${filters.query}` : 'Premium Companions Directory'} — Luxe</title>
|
||||||
|
<meta name="description" content="Browse our luxury companion directory with advanced filters to find the perfect wellness or dining partner." />
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<SectionWrapper sx={{ pt: 5, pb: 10 }}>
|
||||||
|
<PageContainer>
|
||||||
|
{/* Header */}
|
||||||
|
<SectionHeader
|
||||||
|
title="Premium Companion Directory"
|
||||||
|
subtitle="Explore high-end companions, verified hosts, and premium wellness providers."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Controls Bar */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 4, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<TextField
|
||||||
|
placeholder="Search by name, city, keyword..."
|
||||||
|
value={filters.query ?? ''}
|
||||||
|
onChange={handleSearchInputChange}
|
||||||
|
size="small"
|
||||||
|
sx={{ flex: 1, minWidth: 260, '& .MuiOutlinedInput-root': { borderRadius: 3 } }}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, width: { xs: '100%', sm: 'auto' }, justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label="Sort By"
|
||||||
|
value={filters.sortBy ?? 'relevance'}
|
||||||
|
onChange={handleSortChange}
|
||||||
|
sx={{ minWidth: 160, '& .MuiOutlinedInput-root': { borderRadius: 3 } }}
|
||||||
|
>
|
||||||
|
<MenuItem value="relevance">Premium First</MenuItem>
|
||||||
|
<MenuItem value="rating">Highest Rated</MenuItem>
|
||||||
|
<MenuItem value="price_asc">Price: Low to High</MenuItem>
|
||||||
|
<MenuItem value="price_desc">Price: High to Low</MenuItem>
|
||||||
|
<MenuItem value="newest">New Listings</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{!isMdUp && (
|
||||||
|
<LuxeButton
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<FilterListIcon />}
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
sx={{ py: 1 }}
|
||||||
|
>
|
||||||
|
Filters
|
||||||
|
</LuxeButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Active Filters Display */}
|
||||||
|
{activeFilterChips.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3, alignItems: 'center' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" fontWeight={600} sx={{ mr: 1 }}>
|
||||||
|
Active Filters:
|
||||||
|
</Typography>
|
||||||
|
{activeFilterChips.map((chip, idx) => (
|
||||||
|
<Chip
|
||||||
|
key={idx}
|
||||||
|
label={chip.label}
|
||||||
|
onDelete={chip.onClick}
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<LuxeButton variant="text" size="small" onClick={resetFilters} sx={{ ml: 'auto', p: 0, minWidth: 'auto' }}>
|
||||||
|
Reset Filters
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Directory Listings and Filter Sidebar Grid */}
|
||||||
|
<Grid container spacing={4}>
|
||||||
|
{/* Desktop Filters Panel */}
|
||||||
|
{isMdUp && (
|
||||||
|
<Grid size={{ md: 3.5, lg: 3 }}>
|
||||||
|
<SearchFilterPanel />
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Profiles Directory */}
|
||||||
|
<Grid size={{ xs: 12, md: 8.5, lg: 9 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
|
Showing {sortedResults.length} premium profile{sortedResults.length !== 1 && 's'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{sortedResults.length > 0 ? (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{sortedResults.map((profile, i) => (
|
||||||
|
<Grid key={profile.id} size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||||
|
<ProfileCard profile={profile} index={i} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
py: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
border: '1px dashed rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" fontWeight={600} gutterBottom>
|
||||||
|
No Companions Found
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
We couldn't find any profiles matching your filters. Try clearing some options or changing your search terms.
|
||||||
|
</Typography>
|
||||||
|
<LuxeButton variant="contained" onClick={resetFilters}>
|
||||||
|
Clear All Filters
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
|
||||||
|
{/* Mobile Drawer Filter Panel */}
|
||||||
|
<Drawer
|
||||||
|
anchor="right"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={() => setMobileOpen(false)}
|
||||||
|
slotProps={{
|
||||||
|
backdrop: { sx: { backdropFilter: 'blur(4px)' } }
|
||||||
|
}}
|
||||||
|
PaperProps={{
|
||||||
|
sx: { width: '100%', maxWidth: 360, bgcolor: 'background.default', p: 0 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Typography variant="h6" fontWeight={700}>Filter Options</Typography>
|
||||||
|
<IconButton onClick={() => setMobileOpen(false)} size="small" aria-label="Close filters">
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ p: 2, overflowY: 'auto', height: 'calc(100% - 64px)' }}>
|
||||||
|
<SearchFilterPanel />
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchPage;
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { LuxeButton } from '@/components/ui';
|
||||||
|
import { forgotPasswordSchema, type ForgotPasswordFormData } from '@/validators/auth';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
|
||||||
|
export function ForgotPasswordPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<ForgotPasswordFormData>({
|
||||||
|
resolver: zodResolver(forgotPasswordSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (_data: ForgotPasswordFormData) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
setSent(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
component={RouterLink}
|
||||||
|
to={ROUTES.AUTH.LOGIN}
|
||||||
|
variant="body2"
|
||||||
|
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, color: 'text.secondary', textDecoration: 'none', mb: 3 }}
|
||||||
|
>
|
||||||
|
<ArrowBackIcon fontSize="small" /> Back to sign in
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="h4" sx={{ mb: 1, fontFamily: '"Playfair Display", serif' }}>
|
||||||
|
Reset Password
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
|
||||||
|
Enter your email and we'll send you a link to reset your password.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{sent ? (
|
||||||
|
<Alert severity="success">
|
||||||
|
Check your inbox for password reset instructions.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||||
|
<TextField
|
||||||
|
{...register('email')}
|
||||||
|
label={t('auth.email')}
|
||||||
|
type="email"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!errors.email}
|
||||||
|
helperText={errors.email?.message}
|
||||||
|
/>
|
||||||
|
<LuxeButton type="submit" variant="contained" fullWidth sx={{ mt: 3, py: 1.5 }} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? t('common.loading') : 'Send Reset Link'}
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ForgotPasswordPage;
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import GoogleIcon from '@mui/icons-material/Google';
|
||||||
|
import AppleIcon from '@mui/icons-material/Apple';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { LuxeButton } from '@/components/ui';
|
||||||
|
import { loginSchema, type LoginFormData } from '@/validators/auth';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<LoginFormData>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
defaultValues: { rememberMe: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (_data: LoginFormData) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" sx={{ mb: 1, fontFamily: '"Playfair Display", serif' }}>
|
||||||
|
{t('auth.welcomeBack')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
|
||||||
|
Sign in to access your dashboard and saved profiles.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||||
|
<TextField
|
||||||
|
{...register('email')}
|
||||||
|
label={t('auth.email')}
|
||||||
|
type="email"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
autoComplete="email"
|
||||||
|
error={!!errors.email}
|
||||||
|
helperText={errors.email?.message}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register('password')}
|
||||||
|
label={t('auth.password')}
|
||||||
|
type="password"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
autoComplete="current-password"
|
||||||
|
error={!!errors.password}
|
||||||
|
helperText={errors.password?.message}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox {...register('rememberMe')} size="small" />}
|
||||||
|
label={<Typography variant="body2">Remember me</Typography>}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
component={RouterLink}
|
||||||
|
to={ROUTES.AUTH.FORGOT_PASSWORD}
|
||||||
|
variant="body2"
|
||||||
|
sx={{ color: 'gold.main', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
{t('auth.forgotPassword')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<LuxeButton type="submit" variant="contained" fullWidth sx={{ mt: 3, py: 1.5 }} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? t('common.loading') : t('common.login')}
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t('auth.orContinueWith')}
|
||||||
|
</Typography>
|
||||||
|
</Divider>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
|
<LuxeButton variant="outlined" fullWidth startIcon={<GoogleIcon />} aria-label="Sign in with Google">
|
||||||
|
Google
|
||||||
|
</LuxeButton>
|
||||||
|
<LuxeButton variant="outlined" fullWidth startIcon={<AppleIcon />} aria-label="Sign in with Apple">
|
||||||
|
Apple
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 4, textAlign: 'center' }}>
|
||||||
|
{t('auth.noAccount')}{' '}
|
||||||
|
<Typography component={RouterLink} to={ROUTES.AUTH.REGISTER} variant="body2" sx={{ color: 'gold.main', fontWeight: 600 }}>
|
||||||
|
{t('common.register')}
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import GoogleIcon from '@mui/icons-material/Google';
|
||||||
|
import AppleIcon from '@mui/icons-material/Apple';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { LuxeButton } from '@/components/ui';
|
||||||
|
import { registerSchema, type RegisterFormData } from '@/validators/auth';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<RegisterFormData>({
|
||||||
|
resolver: zodResolver(registerSchema),
|
||||||
|
defaultValues: { acceptTerms: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (_data: RegisterFormData) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" sx={{ mb: 1, fontFamily: '"Playfair Display", serif' }}>
|
||||||
|
{t('auth.createAccount')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
|
||||||
|
Join the premier platform for premium companionship.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||||
|
<TextField
|
||||||
|
{...register('displayName')}
|
||||||
|
label="Display Name"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!errors.displayName}
|
||||||
|
helperText={errors.displayName?.message}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register('email')}
|
||||||
|
label={t('auth.email')}
|
||||||
|
type="email"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!errors.email}
|
||||||
|
helperText={errors.email?.message}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register('password')}
|
||||||
|
label={t('auth.password')}
|
||||||
|
type="password"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!errors.password}
|
||||||
|
helperText={errors.password?.message}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register('confirmPassword')}
|
||||||
|
label={t('auth.confirmPassword')}
|
||||||
|
type="password"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!errors.confirmPassword}
|
||||||
|
helperText={errors.confirmPassword?.message}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox {...register('acceptTerms')} size="small" />}
|
||||||
|
label={
|
||||||
|
<Typography variant="body2">
|
||||||
|
I agree to the{' '}
|
||||||
|
<Typography component={RouterLink} to="/terms" variant="body2" sx={{ color: 'gold.main' }}>
|
||||||
|
Terms of Service
|
||||||
|
</Typography>{' '}
|
||||||
|
and{' '}
|
||||||
|
<Typography component={RouterLink} to="/privacy" variant="body2" sx={{ color: 'gold.main' }}>
|
||||||
|
Privacy Policy
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
/>
|
||||||
|
{errors.acceptTerms && (
|
||||||
|
<Typography variant="caption" color="error" sx={{ display: 'block', ml: 4 }}>
|
||||||
|
{errors.acceptTerms.message}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LuxeButton type="submit" variant="contained" fullWidth sx={{ mt: 3, py: 1.5 }} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? t('common.loading') : t('common.register')}
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t('auth.orContinueWith')}
|
||||||
|
</Typography>
|
||||||
|
</Divider>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
|
<LuxeButton variant="outlined" fullWidth startIcon={<GoogleIcon />}>Google</LuxeButton>
|
||||||
|
<LuxeButton variant="outlined" fullWidth startIcon={<AppleIcon />}>Apple</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 4, textAlign: 'center' }}>
|
||||||
|
{t('auth.hasAccount')}{' '}
|
||||||
|
<Typography component={RouterLink} to={ROUTES.AUTH.LOGIN} variant="body2" sx={{ color: 'gold.main', fontWeight: 600 }}>
|
||||||
|
{t('common.login')}
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegisterPage;
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Grid from '@mui/material/Grid';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
|
||||||
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
|
import ThumbUpOutlinedIcon from '@mui/icons-material/ThumbUpOutlined';
|
||||||
|
|
||||||
|
import { PageContainer, SectionWrapper, GlassCard, LuxeButton, SectionHeader } from '@/components/ui';
|
||||||
|
import { mockBlogPosts } from '@/constants/mockData';
|
||||||
|
|
||||||
|
const BLOG_CATEGORIES = ['All', 'Lifestyle', 'Safety', 'Travel', 'Wellness'];
|
||||||
|
|
||||||
|
export function BlogListPage() {
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Filter posts
|
||||||
|
const filteredPosts = mockBlogPosts.filter((post) => {
|
||||||
|
const matchesCategory = selectedCategory === 'All' || post.category.toLowerCase() === selectedCategory.toLowerCase();
|
||||||
|
const matchesSearch = post.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
post.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
return matchesCategory && matchesSearch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const featuredPost = mockBlogPosts[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>Insights & Lifestyle Blog — Luxe</title>
|
||||||
|
<meta name="description" content="Read companion etiquette guides, safety guidelines, travel tips, and premium lifestyle articles from the Luxe editorial team." />
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<SectionWrapper sx={{ pt: 5, pb: 10 }}>
|
||||||
|
<PageContainer>
|
||||||
|
{/* Header */}
|
||||||
|
<SectionHeader
|
||||||
|
title="Luxe Insights"
|
||||||
|
subtitle="Your premium guide to companion etiquette, safety, travel, and luxury lifestyle."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Top Categories & Search Bar */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 5, flexWrap: 'wrap', gap: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{BLOG_CATEGORIES.map((cat) => (
|
||||||
|
<Chip
|
||||||
|
key={cat}
|
||||||
|
label={cat}
|
||||||
|
onClick={() => setSelectedCategory(cat)}
|
||||||
|
color={selectedCategory === cat ? 'primary' : 'default'}
|
||||||
|
variant={selectedCategory === cat ? 'filled' : 'outlined'}
|
||||||
|
sx={{ borderRadius: 2, px: 1 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Search articles..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
sx={{ minWidth: 260, '& .MuiOutlinedInput-root': { borderRadius: 3 } }}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon color="action" fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Featured Post (Only show if search/category is not active) */}
|
||||||
|
{selectedCategory === 'All' && !searchQuery && featuredPost && (
|
||||||
|
<Box sx={{ mb: 6 }}>
|
||||||
|
<Typography variant="h6" fontWeight={700} sx={{ mb: 2, textTransform: 'uppercase', letterSpacing: '0.1em', fontSize: '0.8rem', color: 'secondary.light' }}>
|
||||||
|
Featured Article
|
||||||
|
</Typography>
|
||||||
|
<GlassCard sx={{ overflow: 'hidden' }}>
|
||||||
|
<Grid container>
|
||||||
|
<Grid size={{ xs: 12, md: 7 }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={featuredPost.coverImage}
|
||||||
|
alt={featuredPost.title}
|
||||||
|
sx={{ width: '100%', height: { xs: 260, md: 420 }, objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 5 }} sx={{ p: { xs: 3, md: 5 }, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||||
|
<Chip label={featuredPost.category} size="small" color="secondary" />
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
fontWeight={700}
|
||||||
|
sx={{ fontFamily: '"Playfair Display", serif', mb: 2, lineHeight: 1.3, cursor: 'pointer', '&:hover': { color: 'secondary.light' } }}
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/blog/${featuredPost.slug}`}
|
||||||
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
{featuredPost.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 3, lineHeight: 1.6 }}>
|
||||||
|
{featuredPost.excerpt}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||||
|
<Avatar src={featuredPost.author.avatar} alt={featuredPost.author.name} sx={{ width: 36, height: 36 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight={600}>{featuredPost.author.name}</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, color: 'text.secondary', fontSize: '0.75rem', alignItems: 'center', mt: 0.25 }}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}><CalendarTodayIcon sx={{ fontSize: 12 }} /> {featuredPost.publishedAt}</span>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}><AccessTimeIcon sx={{ fontSize: 12 }} /> {featuredPost.readingTime} min read</span>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<LuxeButton variant="contained" component={RouterLink} to={`/blog/${featuredPost.slug}`} sx={{ alignSelf: 'flex-start' }}>
|
||||||
|
Read Article
|
||||||
|
</LuxeButton>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</GlassCard>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Articles Grid */}
|
||||||
|
<Typography variant="h6" fontWeight={700} sx={{ mb: 3, fontFamily: '"Playfair Display", serif' }}>
|
||||||
|
{selectedCategory !== 'All' || searchQuery ? 'Search Results' : 'All Articles'} ({filteredPosts.length})
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{filteredPosts.length > 0 ? (
|
||||||
|
<Grid container spacing={4}>
|
||||||
|
{filteredPosts.map((post) => (
|
||||||
|
<Grid key={post.id} size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<GlassCard sx={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={post.coverImage}
|
||||||
|
alt={post.title}
|
||||||
|
loading="lazy"
|
||||||
|
sx={{ width: '100%', height: 200, objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Chip label={post.category} size="small" variant="outlined" />
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<AccessTimeIcon sx={{ fontSize: 12 }} /> {post.readingTime} min
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{ mb: 1.5, fontFamily: '"Playfair Display", serif', lineHeight: 1.4, cursor: 'pointer', '&:hover': { color: 'secondary.light' } }}
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/blog/${post.slug}`}
|
||||||
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1, lineHeight: 1.6 }}>
|
||||||
|
{post.excerpt}
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Avatar src={post.author.avatar} alt={post.author.name} sx={{ width: 28, height: 28 }} />
|
||||||
|
<Typography variant="caption" fontWeight={600}>{post.author.name}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'text.secondary' }}>
|
||||||
|
<ThumbUpOutlinedIcon sx={{ fontSize: 14 }} />
|
||||||
|
<Typography variant="caption">{post.likes}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</GlassCard>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ py: 8, textAlign: 'center', border: '1px dashed rgba(255,255,255,0.1)', borderRadius: 4 }}>
|
||||||
|
<Typography variant="body1" color="text.secondary">No articles found matching your search options.</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlogListPage;
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Grid from '@mui/material/Grid';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
|
||||||
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
|
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||||
|
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
||||||
|
import ShareIcon from '@mui/icons-material/Share';
|
||||||
|
import BookmarkIcon from '@mui/icons-material/Bookmark';
|
||||||
|
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
|
||||||
|
|
||||||
|
import { PageContainer, SectionWrapper, GlassCard, LuxeButton } from '@/components/ui';
|
||||||
|
import { mockBlogPosts } from '@/constants/mockData';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const commentFormSchema = z.object({
|
||||||
|
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||||
|
commentText: z.string().min(5, 'Comment must be at least 5 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CommentFormData = z.infer<typeof commentFormSchema>;
|
||||||
|
|
||||||
|
export function BlogPostPage() {
|
||||||
|
const { slug } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const post = mockBlogPosts.find((p) => p.slug === slug);
|
||||||
|
|
||||||
|
// States
|
||||||
|
const [likes, setLikes] = useState(post?.likes ?? 0);
|
||||||
|
const [isLiked, setIsLiked] = useState(false);
|
||||||
|
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||||
|
const [comments, setComments] = useState<any[]>([
|
||||||
|
{ id: 1, name: 'Arthur Pendragon', date: '2026-07-16', text: 'This was extremely helpful. The details on communication etiquette were spot on.' },
|
||||||
|
{ id: 2, name: 'Sonia G.', date: '2026-07-17', text: 'Very nice article! It really outlines the safety indicators we should look for on profiles.' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { register, handleSubmit, reset, formState: { errors } } = useForm<CommentFormData>({
|
||||||
|
resolver: zodResolver(commentFormSchema),
|
||||||
|
defaultValues: { name: '', commentText: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return (
|
||||||
|
<SectionWrapper>
|
||||||
|
<PageContainer sx={{ textAlign: 'center', py: 12 }}>
|
||||||
|
<Typography variant="h4" fontWeight={700} gutterBottom>Article Not Found</Typography>
|
||||||
|
<Typography color="text.secondary" sx={{ mb: 4 }}>The blog article you are looking for does not exist or has been removed.</Typography>
|
||||||
|
<LuxeButton variant="contained" component={RouterLink} to="/blog">
|
||||||
|
Back to Blog
|
||||||
|
</LuxeButton>
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLikeToggle = () => {
|
||||||
|
if (isLiked) {
|
||||||
|
setLikes(likes - 1);
|
||||||
|
setIsLiked(false);
|
||||||
|
} else {
|
||||||
|
setLikes(likes + 1);
|
||||||
|
setIsLiked(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommentSubmit = (data: CommentFormData) => {
|
||||||
|
const newComment = {
|
||||||
|
id: comments.length + 1,
|
||||||
|
name: data.name,
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
text: data.commentText,
|
||||||
|
};
|
||||||
|
setComments([...comments, newComment]);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get related posts (exclude current)
|
||||||
|
const relatedPosts = mockBlogPosts.filter((p) => p.id !== post.id).slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>{post.title} — Luxe Insights</title>
|
||||||
|
<meta name="description" content={post.excerpt} />
|
||||||
|
{/* OpenGraph */}
|
||||||
|
<meta property="og:title" content={`${post.title} — Luxe Insights`} />
|
||||||
|
<meta property="og:description" content={post.excerpt} />
|
||||||
|
<meta property="og:image" content={post.coverImage} />
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<SectionWrapper sx={{ pt: 3, pb: 10 }}>
|
||||||
|
<PageContainer sx={{ maxWidth: 840 }}>
|
||||||
|
{/* Back to Blog */}
|
||||||
|
<LuxeButton
|
||||||
|
variant="text"
|
||||||
|
startIcon={<ArrowBackIcon />}
|
||||||
|
onClick={() => navigate('/blog')}
|
||||||
|
sx={{ mb: 3, color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
Back to Insights
|
||||||
|
</LuxeButton>
|
||||||
|
|
||||||
|
{/* Category & Metadata */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', mb: 2 }}>
|
||||||
|
<Chip label={post.category} color="secondary" size="small" />
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<CalendarTodayIcon sx={{ fontSize: 12 }} /> {post.publishedAt}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<AccessTimeIcon sx={{ fontSize: 12 }} /> {post.readingTime} min read
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Typography
|
||||||
|
variant="h2"
|
||||||
|
fontWeight={700}
|
||||||
|
sx={{ fontFamily: '"Playfair Display", serif', mb: 4, lineHeight: 1.25, fontSize: { xs: '2rem', md: '2.75rem' } }}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Author Block */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4, flexWrap: 'wrap', gap: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Avatar src={post.author.avatar} alt={post.author.name} sx={{ width: 48, height: 48 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight={600}>{post.author.name}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Luxe Contributor</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Interaction Tools */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<LuxeButton
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={isLiked ? <FavoriteIcon color="error" /> : <FavoriteBorderIcon />}
|
||||||
|
onClick={handleLikeToggle}
|
||||||
|
>
|
||||||
|
{likes}
|
||||||
|
</LuxeButton>
|
||||||
|
<LuxeButton
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setIsBookmarked(!isBookmarked)}
|
||||||
|
>
|
||||||
|
{isBookmarked ? <BookmarkIcon color="primary" /> : <BookmarkBorderIcon />}
|
||||||
|
</LuxeButton>
|
||||||
|
<LuxeButton variant="outlined" size="small">
|
||||||
|
<ShareIcon fontSize="small" />
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Cover Image */}
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={post.coverImage}
|
||||||
|
alt={post.title}
|
||||||
|
sx={{ width: '100%', height: { xs: 260, md: 450 }, objectFit: 'cover', borderRadius: 4, mb: 5 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Article Contents */}
|
||||||
|
<Box sx={{ color: 'text.secondary', fontSize: '1.1rem', lineHeight: 1.8, mb: 6 }}>
|
||||||
|
<Typography variant="body1" paragraph sx={{ fontSize: 'inherit', lineHeight: 'inherit', mb: 3 }}>
|
||||||
|
When engaging with premium companion services, establishing a foundation of clear, polite, and respectful communication is key. Our platform is dedicated to connecting clients with high-end therapists and dining hosts under the premise of absolute discretion and mutual trust.
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" paragraph sx={{ fontSize: 'inherit', lineHeight: 'inherit', mb: 3 }}>
|
||||||
|
This editorial guideline offers a comprehensive set of steps to ensure a flawless experience, covering basic etiquette, booking confirmations, cancelation courtesy, and the values we uphold.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ bgcolor: 'action.hover', p: 4, borderRadius: 3, borderLeft: '4px solid #C9A962', my: 4, fontStyle: 'italic' }}>
|
||||||
|
"Discretion is the cornerstone of luxury companionships. Ensuring you represent your intentions politely during initial bookings forms the backbone of a successful date."
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h5" color="text.primary" fontWeight={700} sx={{ mt: 5, mb: 2, fontFamily: '"Playfair Display", serif' }}>
|
||||||
|
1. Discretion and Professionalism
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" paragraph sx={{ fontSize: 'inherit', lineHeight: 'inherit', mb: 3 }}>
|
||||||
|
Professional companions are independent advertisers or associated with boutique agencies. In both scenarios, their scheduling calendar represents a strict business environment. Always confirm dates, times, and rates explicitly before requesting a booking, and avoid discussing private matters over unencrypted communication channels.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="h5" color="text.primary" fontWeight={700} sx={{ mt: 5, mb: 2, fontFamily: '"Playfair Display", serif' }}>
|
||||||
|
2. Honoring Scheduling Calendars
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" paragraph sx={{ fontSize: 'inherit', lineHeight: 'inherit', mb: 3 }}>
|
||||||
|
Late cancelations disrupt business operations. If you need to rearrange your scheduled session, ensure you provide a minimum of 24 hours warning. Respecting the companion's time builds immediate long-term rapport, paving the way for elite premium dates in the future.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 5 }}>
|
||||||
|
{post.tags.map((tag) => (
|
||||||
|
<Chip key={tag} label={`#${tag}`} size="small" variant="outlined" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
|
||||||
|
{/* Comments Feed */}
|
||||||
|
<Box sx={{ mb: 6 }}>
|
||||||
|
<Typography variant="h5" fontWeight={700} sx={{ mb: 3, fontFamily: '"Playfair Display", serif' }}>
|
||||||
|
Discussion ({comments.length})
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 4 }}>
|
||||||
|
{comments.map((c) => (
|
||||||
|
<GlassCard key={c.id} sx={{ p: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={600}>{c.name}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">{c.date}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6 }}>
|
||||||
|
{c.text}
|
||||||
|
</Typography>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Comment Input Form */}
|
||||||
|
<GlassCard sx={{ p: 3 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={700} sx={{ mb: 2 }}>Leave a Comment</Typography>
|
||||||
|
<Box component="form" onSubmit={handleSubmit(handleCommentSubmit)} noValidate sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
|
<TextField
|
||||||
|
{...register('name')}
|
||||||
|
label="Your Name"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
error={!!errors.name}
|
||||||
|
helperText={errors.name?.message}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register('commentText')}
|
||||||
|
label="Join the discussion..."
|
||||||
|
placeholder="Write your comment here..."
|
||||||
|
size="small"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
fullWidth
|
||||||
|
error={!!errors.commentText}
|
||||||
|
helperText={errors.commentText?.message}
|
||||||
|
/>
|
||||||
|
<LuxeButton type="submit" variant="contained" sx={{ alignSelf: 'flex-start' }}>
|
||||||
|
Post Comment
|
||||||
|
</LuxeButton>
|
||||||
|
</Box>
|
||||||
|
</GlassCard>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
|
||||||
|
{/* Related Articles */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" fontWeight={700} sx={{ mb: 3, fontFamily: '"Playfair Display", serif' }}>
|
||||||
|
Related Articles
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{relatedPosts.map((rp) => (
|
||||||
|
<Grid key={rp.id} size={{ xs: 12, sm: 4 }}>
|
||||||
|
<GlassCard sx={{ height: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={rp.coverImage}
|
||||||
|
alt={rp.title}
|
||||||
|
sx={{ width: '100%', height: 120, objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ p: 2, flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{
|
||||||
|
fontFamily: '"Playfair Display", serif',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': { color: 'secondary.light' },
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/blog/${rp.slug}`}
|
||||||
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
{rp.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{rp.readingTime} min read
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</GlassCard>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
</PageContainer>
|
||||||
|
</SectionWrapper>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlogPostPage;
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { ThemeMode } from '@/theme';
|
||||||
|
import { STORAGE_KEYS } from '@/constants';
|
||||||
|
import type { User, SearchFilters } from '@/types';
|
||||||
|
|
||||||
|
interface ThemeState {
|
||||||
|
mode: ThemeMode;
|
||||||
|
setMode: (mode: ThemeMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThemeStore = create<ThemeState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
mode: 'system',
|
||||||
|
setMode: (mode) => set({ mode }),
|
||||||
|
}),
|
||||||
|
{ name: STORAGE_KEYS.THEME_MODE },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
setUser: (user: User | null) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()((set) => ({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||||
|
setLoading: (isLoading) => set({ isLoading }),
|
||||||
|
logout: () => set({ user: null, isAuthenticated: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface SearchState {
|
||||||
|
filters: SearchFilters;
|
||||||
|
recentSearches: string[];
|
||||||
|
isSearchOpen: boolean;
|
||||||
|
setFilters: (filters: Partial<SearchFilters>) => void;
|
||||||
|
resetFilters: () => void;
|
||||||
|
addRecentSearch: (query: string) => void;
|
||||||
|
setSearchOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSearchStore = create<SearchState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
filters: {},
|
||||||
|
recentSearches: [],
|
||||||
|
isSearchOpen: false,
|
||||||
|
setFilters: (filters) => set({ filters: { ...get().filters, ...filters } }),
|
||||||
|
resetFilters: () => set({ filters: {} }),
|
||||||
|
addRecentSearch: (query) => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const recent = [trimmed, ...get().recentSearches.filter((s) => s !== trimmed)].slice(0, 8);
|
||||||
|
set({ recentSearches: recent });
|
||||||
|
},
|
||||||
|
setSearchOpen: (isSearchOpen) => set({ isSearchOpen }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: STORAGE_KEYS.SEARCH_FILTERS,
|
||||||
|
partialize: (state) => ({
|
||||||
|
filters: state.filters,
|
||||||
|
recentSearches: state.recentSearches,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
isMobileNavOpen: boolean;
|
||||||
|
setMobileNavOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUIStore = create<UIState>()((set) => ({
|
||||||
|
isMobileNavOpen: false,
|
||||||
|
setMobileNavOpen: (isMobileNavOpen) => set({ isMobileNavOpen }),
|
||||||
|
}));
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap');
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(201, 169, 98, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(201, 169, 98, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(201, 169, 98, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swiper-pagination-bullet-active {
|
||||||
|
background: #c9a962 !important;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { createTheme, type ThemeOptions } from '@mui/material/styles';
|
||||||
|
import { palette, typography, shadows, borderRadius } from './tokens';
|
||||||
|
|
||||||
|
declare module '@mui/material/styles' {
|
||||||
|
interface Palette {
|
||||||
|
gold: Palette['primary'];
|
||||||
|
glass: {
|
||||||
|
background: string;
|
||||||
|
border: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
interface PaletteOptions {
|
||||||
|
gold?: PaletteOptions['primary'];
|
||||||
|
glass?: {
|
||||||
|
background: string;
|
||||||
|
border: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
interface Theme {
|
||||||
|
customShadows: typeof shadows;
|
||||||
|
customRadius: typeof borderRadius;
|
||||||
|
}
|
||||||
|
interface ThemeOptions {
|
||||||
|
customShadows?: typeof shadows;
|
||||||
|
customRadius?: typeof borderRadius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseTypography = {
|
||||||
|
fontFamily: typography.fontFamily.body,
|
||||||
|
h1: {
|
||||||
|
fontFamily: typography.fontFamily.display,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontFamily: typography.fontFamily.display,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontFamily: typography.fontFamily.display,
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h4: { fontWeight: 600 },
|
||||||
|
h5: { fontWeight: 600 },
|
||||||
|
h6: { fontWeight: 600 },
|
||||||
|
button: { textTransform: 'none' as const, fontWeight: 600 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const sharedComponents: ThemeOptions['components'] = {
|
||||||
|
MuiCssBaseline: {
|
||||||
|
styleOverrides: {
|
||||||
|
html: { scrollBehavior: 'smooth' },
|
||||||
|
body: { overflowX: 'hidden' },
|
||||||
|
'*:focus-visible': {
|
||||||
|
outline: `2px solid ${palette.gold[500]}`,
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
'@media (prefers-reduced-motion: reduce)': {
|
||||||
|
'*, *::before, *::after': {
|
||||||
|
animationDuration: '0.01ms !important',
|
||||||
|
animationIterationCount: '1 !important',
|
||||||
|
transitionDuration: '0.01ms !important',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
defaultProps: { disableElevation: true },
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderRadius: borderRadius.md, padding: '10px 24px' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderRadius: borderRadius.lg },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTextField: {
|
||||||
|
defaultProps: { variant: 'outlined' },
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderRadius: borderRadius.sm },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lightTheme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'light',
|
||||||
|
primary: { main: palette.purple[700], light: palette.purple[500], dark: palette.purple[900] },
|
||||||
|
secondary: { main: palette.gold[500], light: palette.gold[300], dark: palette.gold[700] },
|
||||||
|
gold: { main: palette.gold[500], light: palette.gold[300], dark: palette.gold[700] },
|
||||||
|
background: { default: palette.neutral[50], paper: palette.neutral[0] },
|
||||||
|
text: { primary: palette.neutral[900], secondary: palette.neutral[600] },
|
||||||
|
divider: palette.neutral[200],
|
||||||
|
glass: {
|
||||||
|
background: 'rgba(255, 255, 255, 0.72)',
|
||||||
|
border: 'rgba(255, 255, 255, 0.4)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: baseTypography,
|
||||||
|
shape: { borderRadius: borderRadius.md },
|
||||||
|
customShadows: shadows,
|
||||||
|
customRadius: borderRadius,
|
||||||
|
components: {
|
||||||
|
...sharedComponents,
|
||||||
|
MuiAppBar: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
borderBottom: `1px solid ${palette.neutral[200]}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const darkTheme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'dark',
|
||||||
|
primary: { main: palette.gold[500], light: palette.gold[300], dark: palette.gold[700] },
|
||||||
|
secondary: { main: palette.purple[500], light: palette.purple[300], dark: palette.purple[700] },
|
||||||
|
gold: { main: palette.gold[500], light: palette.gold[300], dark: palette.gold[700] },
|
||||||
|
background: { default: palette.neutral[950], paper: palette.neutral[900] },
|
||||||
|
text: { primary: palette.neutral[50], secondary: palette.neutral[400] },
|
||||||
|
divider: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
glass: {
|
||||||
|
background: 'rgba(26, 26, 28, 0.72)',
|
||||||
|
border: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: baseTypography,
|
||||||
|
shape: { borderRadius: borderRadius.md },
|
||||||
|
customShadows: shadows,
|
||||||
|
customRadius: borderRadius,
|
||||||
|
components: {
|
||||||
|
...sharedComponents,
|
||||||
|
MuiAppBar: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: 'rgba(10, 10, 11, 0.85)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
export function resolveThemeMode(mode: ThemeMode): 'light' | 'dark' {
|
||||||
|
if (mode === 'system') {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
export const palette = {
|
||||||
|
gold: {
|
||||||
|
50: '#FBF8F0',
|
||||||
|
100: '#F5EDD6',
|
||||||
|
200: '#EBD9AD',
|
||||||
|
300: '#DFC584',
|
||||||
|
400: '#D4AF37',
|
||||||
|
500: '#C9A962',
|
||||||
|
600: '#A88B4A',
|
||||||
|
700: '#876D38',
|
||||||
|
800: '#665026',
|
||||||
|
900: '#453314',
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
50: '#F3EEF8',
|
||||||
|
100: '#E0D4F0',
|
||||||
|
200: '#C1A9E1',
|
||||||
|
300: '#A27ED2',
|
||||||
|
400: '#8353C3',
|
||||||
|
500: '#4A2C7A',
|
||||||
|
600: '#3D2466',
|
||||||
|
700: '#2D1B4E',
|
||||||
|
800: '#1E1235',
|
||||||
|
900: '#0F091C',
|
||||||
|
},
|
||||||
|
neutral: {
|
||||||
|
0: '#FFFFFF',
|
||||||
|
50: '#FAFAFA',
|
||||||
|
100: '#F5F5F5',
|
||||||
|
200: '#E5E5E5',
|
||||||
|
300: '#D4D4D4',
|
||||||
|
400: '#A3A3A3',
|
||||||
|
500: '#737373',
|
||||||
|
600: '#525252',
|
||||||
|
700: '#404040',
|
||||||
|
800: '#262626',
|
||||||
|
900: '#171717',
|
||||||
|
950: '#0A0A0B',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const gradients = {
|
||||||
|
gold: 'linear-gradient(135deg, #C9A962 0%, #D4AF37 50%, #A88B4A 100%)',
|
||||||
|
purple: 'linear-gradient(135deg, #2D1B4E 0%, #4A2C7A 50%, #8353C3 100%)',
|
||||||
|
hero: 'linear-gradient(180deg, rgba(10,10,11,0) 0%, rgba(10,10,11,0.85) 100%)',
|
||||||
|
heroLight: 'linear-gradient(180deg, rgba(250,250,250,0) 0%, rgba(250,250,250,0.9) 100%)',
|
||||||
|
glass: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||||
|
card: 'linear-gradient(145deg, rgba(201,169,98,0.08) 0%, rgba(45,27,78,0.04) 100%)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const shadows = {
|
||||||
|
sm: '0 1px 2px rgba(0,0,0,0.05)',
|
||||||
|
md: '0 4px 12px rgba(0,0,0,0.08)',
|
||||||
|
lg: '0 8px 32px rgba(0,0,0,0.12)',
|
||||||
|
xl: '0 16px 48px rgba(0,0,0,0.16)',
|
||||||
|
gold: '0 4px 24px rgba(201,169,98,0.25)',
|
||||||
|
purple: '0 4px 24px rgba(45,27,78,0.2)',
|
||||||
|
glass: '0 8px 32px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.1)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const typography = {
|
||||||
|
fontFamily: {
|
||||||
|
display: '"Playfair Display", Georgia, serif',
|
||||||
|
body: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||||
|
mono: '"JetBrains Mono", "Fira Code", monospace',
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
xs: '0.75rem',
|
||||||
|
sm: '0.875rem',
|
||||||
|
base: '1rem',
|
||||||
|
lg: '1.125rem',
|
||||||
|
xl: '1.25rem',
|
||||||
|
'2xl': '1.5rem',
|
||||||
|
'3xl': '1.875rem',
|
||||||
|
'4xl': '2.25rem',
|
||||||
|
'5xl': '3rem',
|
||||||
|
'6xl': '3.75rem',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const spacing = {
|
||||||
|
section: { xs: 6, md: 10, lg: 14 },
|
||||||
|
container: { xs: 2, sm: 3, md: 4 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const borderRadius = {
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16,
|
||||||
|
xl: 24,
|
||||||
|
full: 9999,
|
||||||
|
} as const;
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
avatar?: string;
|
||||||
|
role: import('@/constants').UserRole;
|
||||||
|
isVerified: boolean;
|
||||||
|
isPremium: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthTokens {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
age: number;
|
||||||
|
city: string;
|
||||||
|
area?: string;
|
||||||
|
avatar: string;
|
||||||
|
coverImage?: string;
|
||||||
|
rating: number;
|
||||||
|
reviewCount: number;
|
||||||
|
isVerified: boolean;
|
||||||
|
isPremium: boolean;
|
||||||
|
isOnline: boolean;
|
||||||
|
priceFrom: number;
|
||||||
|
currency: string;
|
||||||
|
categories: string[];
|
||||||
|
languages: string[];
|
||||||
|
height?: string;
|
||||||
|
tagline?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface City {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
profileCount: number;
|
||||||
|
image: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
profileCount: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlogPost {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
coverImage: string;
|
||||||
|
author: { name: string; avatar: string };
|
||||||
|
category: string;
|
||||||
|
tags: string[];
|
||||||
|
readingTime: number;
|
||||||
|
publishedAt: string;
|
||||||
|
likes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForumTopic {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
author: { name: string; avatar: string };
|
||||||
|
category: string;
|
||||||
|
replies: number;
|
||||||
|
likes: number;
|
||||||
|
isPinned: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Testimonial {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
avatar: string;
|
||||||
|
content: string;
|
||||||
|
rating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Statistic {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FAQItem {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchFilters {
|
||||||
|
query?: string;
|
||||||
|
city?: string;
|
||||||
|
area?: string;
|
||||||
|
categories?: string[];
|
||||||
|
ageMin?: number;
|
||||||
|
ageMax?: number;
|
||||||
|
priceMin?: number;
|
||||||
|
priceMax?: number;
|
||||||
|
verified?: boolean;
|
||||||
|
online?: boolean;
|
||||||
|
premium?: boolean;
|
||||||
|
rating?: number;
|
||||||
|
languages?: string[];
|
||||||
|
sortBy?: 'relevance' | 'rating' | 'price_asc' | 'price_desc' | 'newest';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
children?: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
read: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { STORAGE_KEYS } from '@/constants';
|
||||||
|
|
||||||
|
export function getStorageItem<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
return item ? (JSON.parse(item) as T) : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStorageItem<T>(key: string, value: T): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeStorageItem(key: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSecureToken(): string | null {
|
||||||
|
return sessionStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSecureToken(token: string): void {
|
||||||
|
sessionStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSecureToken(): void {
|
||||||
|
sessionStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number, currency = 'USD', locale = 'en-US'): string {
|
||||||
|
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(value: number, locale = 'en-US'): string {
|
||||||
|
return new Intl.NumberFormat(locale).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return `${text.slice(0, maxLength).trim()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cn(...classes: (string | false | undefined | null)[]): string {
|
||||||
|
return classes.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce<T extends (...args: Parameters<T>) => void>(
|
||||||
|
fn: T,
|
||||||
|
delay: number,
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => fn(...args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImageUrl(path: string, width?: number): string {
|
||||||
|
const cdn = import.meta.env.VITE_CDN_URL;
|
||||||
|
if (!path) return '';
|
||||||
|
if (path.startsWith('http')) return path;
|
||||||
|
const base = cdn || '';
|
||||||
|
const params = width ? `?w=${width}&fm=webp` : '';
|
||||||
|
return `${base}${path}${params}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z.string().email('Please enter a valid email address'),
|
||||||
|
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
|
rememberMe: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registerSchema = z
|
||||||
|
.object({
|
||||||
|
displayName: z.string().min(2, 'Name must be at least 2 characters'),
|
||||||
|
email: z.string().email('Please enter a valid email address'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(/[A-Z]/, 'Must contain an uppercase letter')
|
||||||
|
.regex(/[0-9]/, 'Must contain a number'),
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
acceptTerms: z.boolean().refine((v) => v, 'You must accept the terms'),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: 'Passwords do not match',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forgotPasswordSchema = z.object({
|
||||||
|
email: z.string().email('Please enter a valid email address'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const newsletterSchema = z.object({
|
||||||
|
email: z.string().email('Please enter a valid email address'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchSchema = z.object({
|
||||||
|
query: z.string().optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LoginFormData = z.infer<typeof loginSchema>;
|
||||||
|
export type RegisterFormData = z.infer<typeof registerSchema>;
|
||||||
|
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
|
||||||
|
export type NewsletterFormData = z.infer<typeof newsletterSchema>;
|
||||||
|
export type SearchFormData = z.infer<typeof searchSchema>;
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL: string;
|
||||||
|
readonly VITE_APP_NAME: string;
|
||||||
|
readonly VITE_APP_URL: string;
|
||||||
|
readonly VITE_SOCKET_URL: string;
|
||||||
|
readonly VITE_CDN_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.svg', 'robots.txt'],
|
||||||
|
manifest: {
|
||||||
|
name: 'Luxe Directory',
|
||||||
|
short_name: 'Luxe',
|
||||||
|
description: 'Premium companion directory platform',
|
||||||
|
theme_color: '#0A0A0B',
|
||||||
|
background_color: '#0A0A0B',
|
||||||
|
display: 'standalone',
|
||||||
|
icons: [
|
||||||
|
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/api\./i,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-cache',
|
||||||
|
expiration: { maxEntries: 100, maxAgeSeconds: 86400 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (id.includes('node_modules')) {
|
||||||
|
if (id.includes('@mui') || id.includes('@emotion')) return 'mui';
|
||||||
|
if (id.includes('framer-motion')) return 'motion';
|
||||||
|
if (id.includes('@tanstack/react-query')) return 'query';
|
||||||
|
if (id.includes('react') || id.includes('react-dom') || id.includes('react-router')) return 'vendor';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chunkSizeWarningLimit: 600,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
open: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue