Initial commit: HotelSync PMS v0.1.0

- React 18 + TypeScript + Vite + Tailwind CSS
- Шахматка бронирований (drag-to-book)
- Страницы: Calendar, Bookings, Rooms, Housekeeping, Channels, API Docs, Settings
- Роли: super_admin, hotel_manager, housekeeper
- Светлая/тёмная тема
- Docker + Nginx конфигурация
- Лендинг hotelsync.ru
This commit is contained in:
2026-03-10 20:38:32 +03:00
commit 420d55d57e
45 changed files with 7274 additions and 0 deletions

207
src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,207 @@
import { useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { Hotel, Eye, EyeOff, Sun, Moon, AlertCircle } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
import { cn } from '../lib/utils'
const DEMO_ACCOUNTS = [
{ label: 'Менеджер отеля', email: 'manager@grand-palace.ru', role: 'hotel_manager' },
{ label: 'Горничная', email: 'cleaner@grand-palace.ru', role: 'housekeeper' },
{ label: 'Супер-администратор', email: 'admin@hotelsync.io', role: 'super_admin' },
]
export function LoginPage() {
const { user, login } = useAuth()
const { theme, toggle } = useTheme()
const navigate = useNavigate()
const [email, setEmail] = useState('manager@grand-palace.ru')
const [password, setPassword] = useState('demo')
const [showPass, setShowPass] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
if (user) {
if (user.role === 'super_admin') return <Navigate to="/admin" replace />
return <Navigate to="/grand-palace/calendar" replace />
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
const ok = await login(email, password)
setLoading(false)
if (!ok) {
setError('Неверный email или пароль')
return
}
// Redirect based on role
const user = DEMO_ACCOUNTS.find(a => a.email === email)
if (user?.role === 'super_admin') navigate('/admin')
else navigate('/grand-palace/calendar')
}
const fillDemo = (acc: typeof DEMO_ACCOUNTS[number]) => {
setEmail(acc.email)
setPassword('demo')
setError('')
}
return (
<div className="min-h-screen bg-gradient-to-br from-brand-50 via-white to-slate-100 dark:from-slate-900 dark:via-slate-900 dark:to-brand-950 flex">
{/* Left panel — branding */}
<div className="hidden lg:flex flex-col justify-between w-1/2 bg-brand-600 dark:bg-brand-800 p-12">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
<Hotel size={22} className="text-white" />
</div>
<span className="text-2xl font-bold text-white">HotelSync</span>
</div>
<div>
<h1 className="text-4xl font-bold text-white leading-tight mb-4">
Современная PMS система<br />для вашего отеля
</h1>
<p className="text-brand-200 text-lg leading-relaxed">
Управляйте бронированиями, номерным фондом и уборкой в одном месте.
Синхронизация с Booking.com, Airbnb и другими каналами.
</p>
<div className="mt-10 grid grid-cols-3 gap-6">
{[
{ value: '500+', label: 'Отелей' },
{ value: '1M+', label: 'Бронирований' },
{ value: '99.9%', label: 'Аптайм' },
].map(s => (
<div key={s.label}>
<p className="text-3xl font-bold text-white">{s.value}</p>
<p className="text-brand-200 text-sm">{s.label}</p>
</div>
))}
</div>
</div>
<p className="text-brand-200 text-sm">
© 2026 HotelSync · SaaS PMS Platform
</p>
</div>
{/* Right panel — login form */}
<div className="flex-1 flex flex-col">
{/* Theme toggle */}
<div className="flex justify-end p-4">
<button onClick={toggle} className="btn-ghost p-2">
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
</button>
</div>
<div className="flex-1 flex items-center justify-center px-6">
<div className="w-full max-w-md">
{/* Mobile logo */}
<div className="lg:hidden flex items-center gap-2.5 mb-8">
<div className="w-9 h-9 rounded-xl bg-brand-600 flex items-center justify-center">
<Hotel size={18} className="text-white" />
</div>
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
Hotel<span className="text-brand-600">Next</span>
</span>
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-1">
Добро пожаловать
</h2>
<p className="text-slate-500 dark:text-slate-400 mb-8">
Войдите в свой аккаунт для доступа к панели управления
</p>
{/* Demo account chips */}
<div className="mb-5">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
Демо-аккаунты:
</p>
<div className="flex flex-wrap gap-2">
{DEMO_ACCOUNTS.map(acc => (
<button
key={acc.email}
onClick={() => fillDemo(acc)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
email === acc.email
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
)}
>
{acc.label}
</button>
))}
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Email
</label>
<input
type="email"
className="input"
placeholder="email@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Пароль
</label>
<div className="relative">
<input
type={showPass ? 'text' : 'password'}
className="input pr-10"
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
<button
type="button"
onClick={() => setShowPass(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{error && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
<AlertCircle size={15} />
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center py-2.5"
>
{loading ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : 'Войти'}
</button>
</form>
<p className="text-center text-xs text-slate-400 dark:text-slate-500 mt-6">
Пароль для демо: <code className="bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded">demo</code>
</p>
</div>
</div>
</div>
</div>
)
}