Add separate CP admin panel at cp.hotelsync.ru
- CpLoginPage: dark themed login page with email/password auth, only for cp subdomain - CpLayout: separate dark sidebar layout with Shield icon, nav for Обзор/Отели/Пользователи/Тарифы/Мониторинг/Настройки - App.tsx: detect hostname — cp.hotelsync.ru renders CP app, app.hotelsync.ru renders PMS (no /admin route) - CpGuard: protects CP routes, redirects to /login if not authenticated - Admin panel completely removed from app.hotelsync.ru Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
63
src/App.tsx
63
src/App.tsx
@@ -5,7 +5,9 @@ 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'
|
||||||
@@ -37,7 +39,42 @@ 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>
|
||||||
@@ -48,10 +85,10 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
{/* Public */}
|
{/* Public */}
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/review/:slug" element={<GuestReviewPage />} />
|
<Route path="/review/:slug" element={<GuestReviewPage />} />
|
||||||
<Route path="/room-service/:slug" element={<GuestRoomServicePage />} />
|
<Route path="/room-service/:slug" element={<GuestRoomServicePage />} />
|
||||||
|
|
||||||
{/* App routes */}
|
{/* PMS routes */}
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route path="/calendar" element={<CalendarPage />} />
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
<Route path="/bookings" element={<BookingsPage />} />
|
<Route path="/bookings" element={<BookingsPage />} />
|
||||||
@@ -61,17 +98,16 @@ export default function App() {
|
|||||||
<Route path="/api-docs" element={<ApiDocsPage />} />
|
<Route path="/api-docs" element={<ApiDocsPage />} />
|
||||||
<Route path="/modules" element={<ModulesPage />} />
|
<Route path="/modules" element={<ModulesPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
{/* Module pages */}
|
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/website" element={<WebsitePage />} />
|
<Route path="/website" element={<WebsitePage />} />
|
||||||
<Route path="/booking-widget" element={<BookingWidgetPage />} />
|
<Route path="/booking-widget" element={<BookingWidgetPage />} />
|
||||||
<Route path="/availability" element={<AvailabilityPage />} />
|
<Route path="/availability" element={<AvailabilityPage />} />
|
||||||
<Route path="/floor-map" element={<FloorMapPage />} />
|
<Route path="/floor-map" element={<FloorMapPage />} />
|
||||||
<Route path="/migration" element={<MigrationPage />} />
|
<Route path="/migration" element={<MigrationPage />} />
|
||||||
<Route path="/pos" element={<PosPage />} />
|
<Route path="/pos" element={<PosPage />} />
|
||||||
<Route path="/reviews" element={<ReviewsPage />} />
|
<Route path="/reviews" element={<ReviewsPage />} />
|
||||||
<Route path="/room-service" element={<RoomServicePage />} />
|
<Route path="/room-service" element={<RoomServicePage />} />
|
||||||
<Route path="/rental" element={<RentalPage />} />
|
<Route path="/rental" element={<RentalPage />} />
|
||||||
<Route path="/room-categories" element={<RoomCategoriesPage />} />
|
<Route path="/room-categories" element={<RoomCategoriesPage />} />
|
||||||
<Route path="/documents" element={<DocumentsPage />} />
|
<Route path="/documents" element={<DocumentsPage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
@@ -83,13 +119,6 @@ export default function App() {
|
|||||||
<Route path="/discounts" element={<DiscountsPage />} />
|
<Route path="/discounts" element={<DiscountsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Admin routes */}
|
|
||||||
<Route path="/admin" element={<AppLayout />}>
|
|
||||||
<Route index element={<AdminDashboard />} />
|
|
||||||
<Route path="hotels" element={<AdminDashboard />} />
|
|
||||||
<Route path="users" element={<AdminDashboard />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
141
src/layouts/CpLayout.tsx
Normal file
141
src/layouts/CpLayout.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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>
|
||||||
|
|
||||||
|
{/* Breadcrumb placeholder */}
|
||||||
|
<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">
|
||||||
|
{/* Status indicator */}
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
144
src/pages/CpLoginPage.tsx
Normal file
144
src/pages/CpLoginPage.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 CpLoginPage() {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user