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:
207
src/pages/LoginPage.tsx
Normal file
207
src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user