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:
2026-03-17 01:03:28 +03:00
parent 5429b272f9
commit 526a9eefcf
21 changed files with 801 additions and 43 deletions

View File

@@ -20,11 +20,12 @@ jobs:
- name: Copy env - name: Copy env
run: cp /opt/hotelsync/.env /opt/hotelsync/app/.env run: cp /opt/hotelsync/.env /opt/hotelsync/app/.env
- name: Build Docker image - name: Build Docker images
run: | run: |
cd /opt/hotelsync/app cd /opt/hotelsync/app
docker build -t hotelsync-frontend:latest . docker build -t hotelsync-frontend:latest .
echo "✅ Image built" docker build -t hotelsync-cp:latest ./cp
echo "✅ Images built"
- name: Restart frontend container - name: Restart frontend container
run: | run: |
@@ -35,7 +36,18 @@ jobs:
--network hotelsync_hotelsync-net \ --network hotelsync_hotelsync-net \
--restart unless-stopped \ --restart unless-stopped \
hotelsync-frontend:latest 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 - name: Health check
run: | run: |

14
cp/Dockerfile Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

31
cp/src/App.tsx Normal file
View 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>
)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})

View File

@@ -18,7 +18,7 @@ server {
# ── Редирект HTTP → HTTPS ───────────────────────────────────────────────── # ── Редирект HTTP → HTTPS ─────────────────────────────────────────────────
server { server {
listen 80; 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 # Certbot challenge
location /.well-known/acme-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 ──────────────────────────────────────── # ── api.hotelsync.ru — API Backend ────────────────────────────────────────
server { server {
listen 443 ssl; listen 443 ssl;

View File

@@ -2,7 +2,7 @@ version: '3.9'
services: services:
# ── Frontend (React) ─────────────────────────────────────────────────────── # ── Frontend (React PMS) ───────────────────────────────────────────────────
frontend: frontend:
build: build:
context: . context: .
@@ -12,6 +12,16 @@ services:
networks: networks:
- hotelsync-net - 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 (Node.js + Fastify + PostgreSQL) ──────────────────────────────────
api: api:
build: build:
@@ -75,6 +85,7 @@ services:
- certbot_certs:/etc/letsencrypt:ro - certbot_certs:/etc/letsencrypt:ro
depends_on: depends_on:
- frontend - frontend
- cp
- api - api
networks: networks:
- hotelsync-net - hotelsync-net

View File

@@ -5,9 +5,7 @@ import { ModulesProvider } from './contexts/ModulesContext'
import { AmenitiesProvider } from './contexts/AmenitiesContext' import { AmenitiesProvider } from './contexts/AmenitiesContext'
import { NotificationsProvider } from './contexts/NotificationsContext' import { NotificationsProvider } from './contexts/NotificationsContext'
import { AppLayout } from './layouts/AppLayout' import { AppLayout } from './layouts/AppLayout'
import { CpLayout } from './layouts/CpLayout'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { CpLoginPage } from './pages/CpLoginPage'
import { CalendarPage } from './pages/CalendarPage' import { CalendarPage } from './pages/CalendarPage'
import { BookingsPage } from './pages/BookingsPage' import { BookingsPage } from './pages/BookingsPage'
import { RoomsPage } from './pages/RoomsPage' import { RoomsPage } from './pages/RoomsPage'
@@ -21,7 +19,6 @@ import { WebsitePage } from './pages/WebsitePage'
import { BookingWidgetPage } from './pages/BookingWidgetPage' import { BookingWidgetPage } from './pages/BookingWidgetPage'
import { AvailabilityPage } from './pages/AvailabilityPage' import { AvailabilityPage } from './pages/AvailabilityPage'
import { FloorMapPage } from './pages/FloorMapPage' import { FloorMapPage } from './pages/FloorMapPage'
import { AdminDashboard } from './pages/AdminDashboard'
import { MigrationPage } from './pages/MigrationPage' import { MigrationPage } from './pages/MigrationPage'
import { PosPage } from './pages/PosPage' import { PosPage } from './pages/PosPage'
import { ReviewsPage } from './pages/ReviewsPage' import { ReviewsPage } from './pages/ReviewsPage'
@@ -39,42 +36,7 @@ import { DiscountsPage } from './pages/DiscountsPage'
import { GuestReviewPage } from './pages/GuestReviewPage' import { GuestReviewPage } from './pages/GuestReviewPage'
import { GuestRoomServicePage } from './pages/GuestRoomServicePage' 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() { 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 ( return (
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>