Separate CP admin panel into independent Vite project at /cp/
- New /cp/ Vite project with own package.json, Dockerfile, tailwind config - Migrated CpLoginPage, CpLayout, AdminDashboard → cp/src/ (self-contained) - cp/src/lib/utils.ts has only what CP needs (cn, PLAN_LABELS, PLAN_COLORS) - cp/src/data/mockData.ts has Hotel/User types and mock data - cp/ builds to nginx static container (hotelsync-cp) - docker-compose.yml: added cp service - nginx: added cp.hotelsync.ru → hotelsync-cp:80 (SSL block ready for certbot) - deploy.yml: builds hotelsync-cp:latest and restarts container on push - Cleaned src/App.tsx: removed IS_CP branch, CpLoginPage/CpLayout/AdminDashboard imports Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,11 +20,12 @@ jobs:
|
||||
- name: Copy env
|
||||
run: cp /opt/hotelsync/.env /opt/hotelsync/app/.env
|
||||
|
||||
- name: Build Docker image
|
||||
- name: Build Docker images
|
||||
run: |
|
||||
cd /opt/hotelsync/app
|
||||
docker build -t hotelsync-frontend:latest .
|
||||
echo "✅ Image built"
|
||||
docker build -t hotelsync-cp:latest ./cp
|
||||
echo "✅ Images built"
|
||||
|
||||
- name: Restart frontend container
|
||||
run: |
|
||||
@@ -35,7 +36,18 @@ jobs:
|
||||
--network hotelsync_hotelsync-net \
|
||||
--restart unless-stopped \
|
||||
hotelsync-frontend:latest
|
||||
echo "✅ Container started"
|
||||
echo "✅ Frontend started"
|
||||
|
||||
- name: Restart CP container
|
||||
run: |
|
||||
docker stop hotelsync-cp 2>/dev/null || true
|
||||
docker rm hotelsync-cp 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name hotelsync-cp \
|
||||
--network hotelsync_hotelsync-net \
|
||||
--restart unless-stopped \
|
||||
hotelsync-cp:latest
|
||||
echo "✅ CP started"
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
|
||||
14
cp/Dockerfile
Normal file
14
cp/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
# ── Stage 1: Build ──────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: Serve ──────────────────────────────────────────────────────────
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
16
cp/index.html
Normal file
16
cp/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru" class="dark">
|
||||
<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" />
|
||||
<title>HotelSync · Control Panel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
cp/nginx.conf
Normal file
19
cp/nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# React SPA — все пути отдают index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Кэшировать статику
|
||||
location ~* \.(js|css|png|svg|ico|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
||||
}
|
||||
29
cp/package.json
Normal file
29
cp/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "hotelsync-cp",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"lucide-react": "^0.446.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.45",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
6
cp/postcss.config.js
Normal file
6
cp/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
31
cp/src/App.tsx
Normal file
31
cp/src/App.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { CpLayout } from './layouts/CpLayout'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
|
||||
function CpGuard({ children }: { children: React.ReactNode }) {
|
||||
const auth = localStorage.getItem('cpAuth')
|
||||
if (!auth) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
<Route element={<CpGuard><CpLayout /></CpGuard>}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/hotels" element={<Dashboard />} />
|
||||
<Route path="/cp-users" element={<Dashboard />} />
|
||||
<Route path="/billing" element={<Dashboard />} />
|
||||
<Route path="/monitoring" element={<Dashboard />} />
|
||||
<Route path="/cp-settings" element={<Dashboard />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
14
cp/src/components/ui/Badge.tsx
Normal file
14
cp/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Badge({ children, className }: BadgeProps) {
|
||||
return (
|
||||
<span className={cn('badge', className)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
80
cp/src/data/mockData.ts
Normal file
80
cp/src/data/mockData.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { HotelPlan } from '../lib/utils'
|
||||
|
||||
export interface Hotel {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
address: string
|
||||
plan: HotelPlan
|
||||
isActive: boolean
|
||||
roomCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
hotelId: string | null
|
||||
hotelName?: string
|
||||
}
|
||||
|
||||
export const MOCK_HOTELS: Hotel[] = [
|
||||
{
|
||||
id: 'hotel-1',
|
||||
name: 'Grand Palace Hotel',
|
||||
slug: 'grand-palace',
|
||||
address: 'ул. Тверская 1, Москва',
|
||||
plan: 'pro',
|
||||
isActive: true,
|
||||
roomCount: 12,
|
||||
createdAt: '2024-01-15',
|
||||
},
|
||||
{
|
||||
id: 'hotel-2',
|
||||
name: 'Sea Breeze Resort',
|
||||
slug: 'sea-breeze',
|
||||
address: 'ул. Набережная 12, Сочи',
|
||||
plan: 'enterprise',
|
||||
isActive: true,
|
||||
roomCount: 48,
|
||||
createdAt: '2024-03-22',
|
||||
},
|
||||
{
|
||||
id: 'hotel-3',
|
||||
name: 'City Inn Express',
|
||||
slug: 'city-inn',
|
||||
address: 'пр. Невский 88, Санкт-Петербург',
|
||||
plan: 'starter',
|
||||
isActive: false,
|
||||
roomCount: 20,
|
||||
createdAt: '2024-06-01',
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_USERS: User[] = [
|
||||
{
|
||||
id: 'user-admin',
|
||||
email: 'admin@hotelsync.io',
|
||||
name: 'Александр Петров',
|
||||
role: 'super_admin',
|
||||
hotelId: null,
|
||||
},
|
||||
{
|
||||
id: 'user-manager-1',
|
||||
email: 'manager@grand-palace.ru',
|
||||
name: 'Елена Смирнова',
|
||||
role: 'hotel_manager',
|
||||
hotelId: 'hotel-1',
|
||||
hotelName: 'Grand Palace Hotel',
|
||||
},
|
||||
{
|
||||
id: 'user-housekeeper-1',
|
||||
email: 'cleaner@grand-palace.ru',
|
||||
name: 'Мария Иванова',
|
||||
role: 'housekeeper',
|
||||
hotelId: 'hotel-1',
|
||||
hotelName: 'Grand Palace Hotel',
|
||||
},
|
||||
]
|
||||
53
cp/src/index.css
Normal file
53
cp/src/index.css
Normal file
@@ -0,0 +1,53 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-950 text-slate-100 font-sans;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-slate-700 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-slate-600;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-slate-900 border border-slate-800 rounded-xl;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg
|
||||
bg-brand-600 hover:bg-brand-500
|
||||
text-white text-sm font-medium
|
||||
transition-colors duration-150
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
139
cp/src/layouts/CpLayout.tsx
Normal file
139
cp/src/layouts/CpLayout.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Building2, Users, BarChart2, CreditCard, Settings,
|
||||
Shield, LogOut, Menu, X, Bell, Activity,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', icon: BarChart2, label: 'Обзор' },
|
||||
{ to: '/hotels', icon: Building2, label: 'Отели' },
|
||||
{ to: '/cp-users', icon: Users, label: 'Пользователи' },
|
||||
{ to: '/billing', icon: CreditCard, label: 'Тарифы и оплата' },
|
||||
{ to: '/monitoring', icon: Activity, label: 'Мониторинг' },
|
||||
{ to: '/cp-settings', icon: Settings, label: 'Настройки' },
|
||||
]
|
||||
|
||||
export function CpLayout() {
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const stored = localStorage.getItem('cpAuth')
|
||||
const admin = stored ? JSON.parse(stored) : null
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('cpAuth')
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-950 text-slate-100">
|
||||
{/* Mobile overlay */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 lg:hidden"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={cn(
|
||||
'fixed left-0 top-0 bottom-0 z-50 w-60 flex flex-col bg-slate-900 border-r border-slate-800 transition-transform duration-200',
|
||||
'lg:translate-x-0 lg:static lg:z-auto',
|
||||
open ? 'translate-x-0' : '-translate-x-full',
|
||||
)}>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-slate-800">
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-600 flex items-center justify-center shrink-0">
|
||||
<Shield size={16} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white leading-none">HotelSync</p>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">Control Panel</p>
|
||||
</div>
|
||||
<button onClick={() => setOpen(false)} className="ml-auto lg:hidden text-slate-500 hover:text-slate-300">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
{NAV.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
onClick={() => setOpen(false)}
|
||||
className={({ isActive }) => cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-brand-600/20 text-brand-400'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200',
|
||||
)}
|
||||
>
|
||||
<item.icon size={16} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User footer */}
|
||||
<div className="px-3 pb-4 border-t border-slate-800 pt-3">
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-700 flex items-center justify-center shrink-0">
|
||||
<Shield size={14} className="text-brand-300" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-slate-200 truncate">{admin?.email ?? 'admin'}</p>
|
||||
<p className="text-[10px] text-slate-500">Super Admin</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
title="Выйти"
|
||||
className="text-slate-600 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<LogOut size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* Topbar */}
|
||||
<header className="flex items-center gap-4 px-4 md:px-6 py-3 border-b border-slate-800 bg-slate-900 shrink-0">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="lg:hidden text-slate-400 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<span className="text-slate-400 font-medium">cp.hotelsync.ru</span>
|
||||
<ChevronRight size={12} />
|
||||
<span>Control Panel</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/10 border border-emerald-500/20">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="text-[11px] text-emerald-400 font-medium">Система в норме</span>
|
||||
</div>
|
||||
|
||||
<button className="relative text-slate-400 hover:text-slate-200 transition-colors p-1.5">
|
||||
<Bell size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto bg-slate-950">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
cp/src/lib/utils.ts
Normal file
20
cp/src/lib/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export type HotelPlan = 'starter' | 'pro' | 'enterprise'
|
||||
|
||||
export const PLAN_LABELS: Record<HotelPlan, string> = {
|
||||
starter: 'Starter',
|
||||
pro: 'Pro',
|
||||
enterprise: 'Enterprise',
|
||||
}
|
||||
|
||||
export const PLAN_COLORS: Record<HotelPlan, string> = {
|
||||
starter: 'bg-slate-700 text-slate-300',
|
||||
pro: 'bg-brand-900/30 text-brand-300',
|
||||
enterprise: 'bg-amber-900/30 text-amber-300',
|
||||
}
|
||||
10
cp/src/main.tsx
Normal file
10
cp/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
119
cp/src/pages/Dashboard.tsx
Normal file
119
cp/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Building2, Users, TrendingUp, CheckCircle2, Plus } from 'lucide-react'
|
||||
import { MOCK_HOTELS, MOCK_USERS } from '../data/mockData'
|
||||
import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
|
||||
export function Dashboard() {
|
||||
const activeHotels = MOCK_HOTELS.filter(h => h.isActive).length
|
||||
const totalRooms = MOCK_HOTELS.reduce((s, h) => s + h.roomCount, 0)
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-100">Панель администратора</h1>
|
||||
<p className="text-sm text-slate-500">HotelSync SaaS · Обзор платформы</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Всего отелей', value: MOCK_HOTELS.length, icon: Building2, color: 'text-brand-400', bg: 'bg-brand-900/20' },
|
||||
{ label: 'Активных', value: activeHotels, icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-900/20' },
|
||||
{ label: 'Всего номеров', value: totalRooms, icon: TrendingUp, color: 'text-amber-400', bg: 'bg-amber-900/20' },
|
||||
{ label: 'Пользователей', value: MOCK_USERS.length, icon: Users, color: 'text-violet-400', bg: 'bg-violet-900/20' },
|
||||
].map(s => {
|
||||
const Icon = s.icon
|
||||
return (
|
||||
<div key={s.label} className={cn('card p-4', s.bg)}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Icon size={18} className={s.color} />
|
||||
</div>
|
||||
<p className={cn('text-2xl font-bold', s.color)}>{s.value}</p>
|
||||
<p className="text-sm text-slate-400">{s.label}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Hotels + Plan stats */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-slate-100">Отели</h3>
|
||||
<button className="btn-primary py-1.5 text-xs">
|
||||
<Plus size={13} />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{MOCK_HOTELS.map(hotel => (
|
||||
<div key={hotel.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-800/50 transition-colors cursor-pointer">
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold',
|
||||
hotel.isActive
|
||||
? 'bg-brand-900/30 text-brand-300'
|
||||
: 'bg-slate-700 text-slate-500',
|
||||
)}>
|
||||
{hotel.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-100 truncate">{hotel.name}</p>
|
||||
<p className="text-xs text-slate-500">{hotel.address} · {hotel.roomCount} номеров</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge className={PLAN_COLORS[hotel.plan]}>{PLAN_LABELS[hotel.plan]}</Badge>
|
||||
<div className={cn('w-2 h-2 rounded-full', hotel.isActive ? 'bg-emerald-500' : 'bg-slate-600')} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-5">
|
||||
<h3 className="font-semibold text-slate-100 mb-4">Статистика по тарифам</h3>
|
||||
<div className="space-y-3">
|
||||
{(['starter', 'pro', 'enterprise'] as const).map(plan => {
|
||||
const count = MOCK_HOTELS.filter(h => h.plan === plan).length
|
||||
const pct = Math.round((count / MOCK_HOTELS.length) * 100)
|
||||
return (
|
||||
<div key={plan}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Badge className={PLAN_COLORS[plan]}>{PLAN_LABELS[plan]}</Badge>
|
||||
<span className="text-sm font-medium text-slate-300">{count} отелей ({pct}%)</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-700">
|
||||
<div
|
||||
className="h-2 rounded-full bg-brand-500 transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-5 border-t border-slate-800">
|
||||
<h4 className="text-sm font-semibold text-slate-300 mb-3">Пользователи</h4>
|
||||
<div className="space-y-2">
|
||||
{MOCK_USERS.map(u => (
|
||||
<div key={u.id} className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-600 flex items-center justify-center text-white text-sm font-semibold shrink-0">
|
||||
{u.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-100">{u.name}</p>
|
||||
<p className="text-xs text-slate-500">{u.email}</p>
|
||||
</div>
|
||||
<Badge className="bg-slate-700 text-slate-300 text-[10px]">
|
||||
{u.role}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
cp/src/pages/LoginPage.tsx
Normal file
144
cp/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState, FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Shield, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const ADMIN_EMAIL = 'admin@hotelsync.io'
|
||||
const ADMIN_PASSWORD = 'demo'
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPass, setShowPass] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
|
||||
if (email.trim() === ADMIN_EMAIL && password === ADMIN_PASSWORD) {
|
||||
localStorage.setItem('cpAuth', JSON.stringify({ email, ts: Date.now() }))
|
||||
navigate('/', { replace: true })
|
||||
} else {
|
||||
setError('Неверный email или пароль')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
|
||||
{/* Subtle grid background */}
|
||||
<div
|
||||
className="fixed inset-0 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)',
|
||||
backgroundSize: '40px 40px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-brand-600 mb-4">
|
||||
<Shield size={26} className="text-white" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-white tracking-tight">HotelSync</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Control Panel · Только для команды</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wide">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
className={cn(
|
||||
'w-full px-4 py-2.5 rounded-xl text-sm bg-slate-800 border text-slate-100 placeholder-slate-600',
|
||||
'focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors',
|
||||
error ? 'border-red-500/60' : 'border-slate-700',
|
||||
)}
|
||||
placeholder="admin@hotelsync.io"
|
||||
value={email}
|
||||
onChange={e => { setEmail(e.target.value); setError('') }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wide">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPass ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
className={cn(
|
||||
'w-full px-4 py-2.5 pr-10 rounded-xl text-sm bg-slate-800 border text-slate-100 placeholder-slate-600',
|
||||
'focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors',
|
||||
error ? 'border-red-500/60' : 'border-slate-700',
|
||||
)}
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={e => { setPassword(e.target.value); setError('') }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass(v => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
{showPass ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<AlertCircle size={14} className="text-red-400 shrink-0" />
|
||||
<p className="text-xs text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !email || !password}
|
||||
className={cn(
|
||||
'w-full py-2.5 rounded-xl text-sm font-semibold transition-all mt-2',
|
||||
loading || !email || !password
|
||||
? 'bg-slate-700 text-slate-500 cursor-not-allowed'
|
||||
: 'bg-brand-600 hover:bg-brand-500 text-white shadow-lg shadow-brand-900/40',
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
Вход...
|
||||
</span>
|
||||
) : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-slate-700 mt-6">
|
||||
Доступ только для сотрудников HotelSync Inc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
cp/tailwind.config.ts
Normal file
34
cp/tailwind.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
300: '#a5b4fc',
|
||||
400: '#818cf8',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
boxShadow: {
|
||||
'card': '0 1px 3px 0 rgb(0 0 0 / 0.07), 0 1px 2px -1px rgb(0 0 0 / 0.07)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config
|
||||
20
cp/tsconfig.json
Normal file
20
cp/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
6
cp/vite.config.ts
Normal file
6
cp/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
@@ -18,7 +18,7 @@ server {
|
||||
# ── Редирект HTTP → HTTPS ─────────────────────────────────────────────────
|
||||
server {
|
||||
listen 80;
|
||||
server_name hotelsync.ru www.hotelsync.ru app.hotelsync.ru api.hotelsync.ru git.hotelsync.ru;
|
||||
server_name hotelsync.ru www.hotelsync.ru app.hotelsync.ru api.hotelsync.ru git.hotelsync.ru cp.hotelsync.ru;
|
||||
|
||||
# Certbot challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
@@ -64,6 +64,25 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# ── cp.hotelsync.ru — Control Panel ──────────────────────────────────────
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name cp.hotelsync.ru;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/cp.hotelsync.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/cp.hotelsync.ru/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location / {
|
||||
proxy_pass http://cp:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# ── api.hotelsync.ru — API Backend ────────────────────────────────────────
|
||||
server {
|
||||
listen 443 ssl;
|
||||
|
||||
@@ -2,7 +2,7 @@ version: '3.9'
|
||||
|
||||
services:
|
||||
|
||||
# ── Frontend (React) ───────────────────────────────────────────────────────
|
||||
# ── Frontend (React PMS) ───────────────────────────────────────────────────
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
@@ -12,6 +12,16 @@ services:
|
||||
networks:
|
||||
- hotelsync-net
|
||||
|
||||
# ── CP (Control Panel) ─────────────────────────────────────────────────────
|
||||
cp:
|
||||
build:
|
||||
context: ./cp
|
||||
dockerfile: Dockerfile
|
||||
container_name: hotelsync-cp
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- hotelsync-net
|
||||
|
||||
# ── API (Node.js + Fastify + PostgreSQL) ──────────────────────────────────
|
||||
api:
|
||||
build:
|
||||
@@ -75,6 +85,7 @@ services:
|
||||
- certbot_certs:/etc/letsencrypt:ro
|
||||
depends_on:
|
||||
- frontend
|
||||
- cp
|
||||
- api
|
||||
networks:
|
||||
- hotelsync-net
|
||||
|
||||
38
src/App.tsx
38
src/App.tsx
@@ -5,9 +5,7 @@ import { ModulesProvider } from './contexts/ModulesContext'
|
||||
import { AmenitiesProvider } from './contexts/AmenitiesContext'
|
||||
import { NotificationsProvider } from './contexts/NotificationsContext'
|
||||
import { AppLayout } from './layouts/AppLayout'
|
||||
import { CpLayout } from './layouts/CpLayout'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { CpLoginPage } from './pages/CpLoginPage'
|
||||
import { CalendarPage } from './pages/CalendarPage'
|
||||
import { BookingsPage } from './pages/BookingsPage'
|
||||
import { RoomsPage } from './pages/RoomsPage'
|
||||
@@ -21,7 +19,6 @@ import { WebsitePage } from './pages/WebsitePage'
|
||||
import { BookingWidgetPage } from './pages/BookingWidgetPage'
|
||||
import { AvailabilityPage } from './pages/AvailabilityPage'
|
||||
import { FloorMapPage } from './pages/FloorMapPage'
|
||||
import { AdminDashboard } from './pages/AdminDashboard'
|
||||
import { MigrationPage } from './pages/MigrationPage'
|
||||
import { PosPage } from './pages/PosPage'
|
||||
import { ReviewsPage } from './pages/ReviewsPage'
|
||||
@@ -39,42 +36,7 @@ import { DiscountsPage } from './pages/DiscountsPage'
|
||||
import { GuestReviewPage } from './pages/GuestReviewPage'
|
||||
import { GuestRoomServicePage } from './pages/GuestRoomServicePage'
|
||||
|
||||
// Определяем на каком домене работаем
|
||||
const IS_CP = window.location.hostname === 'cp.hotelsync.ru'
|
||||
|
||||
// ── Guard для CP: если не авторизован → /login ────────────────────────────
|
||||
function CpGuard({ children }: { children: React.ReactNode }) {
|
||||
const auth = localStorage.getItem('cpAuth')
|
||||
if (!auth) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// ── Control Panel (cp.hotelsync.ru) ──────────────────────────────────────
|
||||
if (IS_CP) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<CpLoginPage />} />
|
||||
|
||||
<Route element={<CpGuard><CpLayout /></CpGuard>}>
|
||||
<Route path="/" element={<AdminDashboard />} />
|
||||
<Route path="/hotels" element={<AdminDashboard />} />
|
||||
<Route path="/cp-users" element={<AdminDashboard />} />
|
||||
<Route path="/billing" element={<AdminDashboard />} />
|
||||
<Route path="/monitoring" element={<AdminDashboard />} />
|
||||
<Route path="/cp-settings" element={<AdminDashboard />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ── PMS App (app.hotelsync.ru) ────────────────────────────────────────────
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
|
||||
Reference in New Issue
Block a user