Files
hotelsync/src/pages/LoginPage.tsx
HotelSync 91e58ff994 Fix infinite redirect loop: super_admin no longer redirects to /admin
The /admin route was removed when CP was moved to cp.hotelsync.ru.
Logged-in super_admin sessions were causing a redirect loop to a
non-existent route, resulting in a blank page on app.hotelsync.ru.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:16:29 +03:00

558 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import {
Hotel, Eye, EyeOff, Sun, Moon, AlertCircle,
Building2, ChevronRight, ChevronLeft, CheckCircle2, User,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
import { cn } from '../lib/utils'
const DEMO_EMAILS = [
'manager@grand-palace.ru',
'cleaner@grand-palace.ru',
'admin@hotelsync.io',
]
type LegalType = 'ooo' | 'ip' | 'ao' | 'pao' | 'other'
const LEGAL_TYPES: { id: LegalType; label: string }[] = [
{ id: 'ooo', label: 'ООО' },
{ id: 'ip', label: 'ИП' },
{ id: 'ao', label: 'АО' },
{ id: 'pao', label: 'ПАО' },
{ id: 'other', label: 'Другое'},
]
// ── Login form ─────────────────────────────────────────────────────────────────
function LoginForm({ onSwitch }: { onSwitch: () => void }) {
const { login } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPass, setShowPass] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
const loggedIn = await login(email, password)
setLoading(false)
if (!loggedIn) { setError('Неверный email или пароль'); return }
navigate('/calendar')
}
return (
<>
<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>
<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>
{/* Demo hint */}
<div className="mt-5 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
Демо-аккаунты (пароль: <code className="bg-slate-100 dark:bg-slate-700 px-1 rounded">demo</code>):
</p>
<div className="flex flex-col gap-1">
{DEMO_EMAILS.map(e => (
<button key={e} onClick={() => { setEmail(e); setPassword('demo'); setError('') }}
className={cn(
'text-left text-xs px-2 py-1 rounded transition-colors',
email === e
? 'text-brand-700 dark:text-brand-300 bg-brand-50 dark:bg-brand-900/30 font-medium'
: 'text-slate-500 dark:text-slate-400 hover:text-brand-600 dark:hover:text-brand-400',
)}
>{e}</button>
))}
</div>
</div>
<p className="mt-6 text-center text-sm text-slate-500 dark:text-slate-400">
Нет аккаунта?{' '}
<button onClick={onSwitch} className="text-brand-600 dark:text-brand-400 font-medium hover:underline">
Зарегистрировать отель
</button>
</p>
</>
)
}
// ── Register form ──────────────────────────────────────────────────────────────
function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
const [step, setStep] = useState<1 | 2 | 'done'>(1)
// Step 1 — Hotel + Legal entity
const [hotelName, setHotelName] = useState('')
const [legalType, setLegalType] = useState<LegalType>('ooo')
const [legalName, setLegalName] = useState('')
const [inn, setInn] = useState('')
const [kpp, setKpp] = useState('')
const [address, setAddress] = useState('')
// Step 2 — Account
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [showPass, setShowPass] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const isIp = legalType === 'ip'
const validateStep1 = () => {
const e: Record<string, string> = {}
if (!hotelName.trim()) e.hotelName = 'Введите название отеля'
if (!legalName.trim()) e.legalName = 'Введите наименование юрлица'
if (!inn.trim()) e.inn = 'Введите ИНН'
else if (!/^\d{10,12}$/.test(inn.replace(/\s/g, '')))
e.inn = 'ИНН — 10 цифр (юрлицо) или 12 (ИП)'
if (!isIp && !kpp.trim()) e.kpp = 'Введите КПП'
setErrors(e)
return Object.keys(e).length === 0
}
const validateStep2 = () => {
const e: Record<string, string> = {}
if (!firstName.trim()) e.firstName = 'Введите имя'
if (!lastName.trim()) e.lastName = 'Введите фамилию'
if (!email.trim()) e.email = 'Введите email'
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) e.email = 'Некорректный email'
if (password.length < 8) e.password = 'Минимум 8 символов'
if (password !== confirm) e.confirm = 'Пароли не совпадают'
setErrors(e)
return Object.keys(e).length === 0
}
const nextStep = () => {
if (validateStep1()) setStep(2)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateStep2()) return
setLoading(true)
// Simulate API call
await new Promise(r => setTimeout(r, 1200))
setLoading(false)
setStep('done')
}
const Field = ({
label, error, children, hint,
}: { label: string; error?: string; children: React.ReactNode; hint?: string }) => (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{label}</label>
{children}
{hint && !error && <p className="mt-1 text-xs text-slate-400">{hint}</p>}
{error && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400 flex items-center gap-1">
<AlertCircle size={11} /> {error}
</p>
)}
</div>
)
if (step === 'done') {
return (
<div className="text-center py-6">
<div className="w-16 h-16 rounded-full bg-emerald-100 dark:bg-emerald-900/40 flex items-center justify-center mx-auto mb-5">
<CheckCircle2 size={32} className="text-emerald-600 dark:text-emerald-400" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-2">
Заявка отправлена!
</h2>
<p className="text-slate-500 dark:text-slate-400 mb-2">
Мы получили данные отеля <span className="font-semibold text-slate-700 dark:text-slate-300">{hotelName}</span>.
</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-8">
На адрес <span className="font-medium text-slate-700 dark:text-slate-300">{email}</span> отправлено
письмо с подтверждением. После проверки данных менеджер HotelSync активирует аккаунт в течение одного рабочего дня.
</p>
<button onClick={onSwitch} className="btn-primary w-full justify-center py-2.5">
Перейти к входу
</button>
</div>
)
}
return (
<>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{step === 1 ? 'Регистрация отеля' : 'Данные аккаунта'}
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Шаг {step} из 2</p>
</div>
{/* Step indicator */}
<div className="flex items-center gap-1.5">
{[1, 2].map(s => (
<div key={s} className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all',
step === s
? 'bg-brand-600 text-white'
: s < step
? 'bg-emerald-500 text-white'
: 'bg-slate-200 dark:bg-slate-700 text-slate-500 dark:text-slate-400',
)}>
{s < step ? <CheckCircle2 size={14} /> : s}
</div>
))}
</div>
</div>
{/* ── Step 1: Hotel + Legal entity ── */}
{step === 1 && (
<div className="space-y-4">
<Field label="Название отеля *" error={errors.hotelName}>
<input
type="text" className={cn('input', errors.hotelName && 'border-red-400 focus:ring-red-400')}
placeholder="Grand Palace Hotel"
value={hotelName} onChange={e => setHotelName(e.target.value)}
/>
</Field>
<div className="pt-1 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2 mb-3">
<Building2 size={14} className="text-slate-500" />
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Юридическое лицо</p>
</div>
{/* Legal type selector */}
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Организационно-правовая форма *
</label>
<div className="flex gap-1.5 flex-wrap">
{LEGAL_TYPES.map(lt => (
<button
key={lt.id}
type="button"
onClick={() => setLegalType(lt.id)}
className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
legalType === lt.id
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-brand-400',
)}
>
{lt.label}
</button>
))}
</div>
</div>
<div className="space-y-3">
<Field
label={isIp ? 'ФИО индивидуального предпринимателя *' : 'Полное наименование организации *'}
error={errors.legalName}
>
<input
type="text"
className={cn('input', errors.legalName && 'border-red-400 focus:ring-red-400')}
placeholder={isIp ? 'Иванов Иван Иванович' : 'Общество с ограниченной ответственностью «Название»'}
value={legalName} onChange={e => setLegalName(e.target.value)}
/>
</Field>
<div className={cn('grid gap-3', isIp ? 'grid-cols-1' : 'grid-cols-2')}>
<Field
label="ИНН *"
error={errors.inn}
hint={isIp ? '12 цифр' : '10 цифр'}
>
<input
type="text"
className={cn('input font-mono', errors.inn && 'border-red-400 focus:ring-red-400')}
placeholder={isIp ? '123456789012' : '1234567890'}
maxLength={isIp ? 12 : 10}
value={inn} onChange={e => setInn(e.target.value.replace(/\D/g, ''))}
/>
</Field>
{!isIp && (
<Field label="КПП *" error={errors.kpp} hint="9 цифр">
<input
type="text"
className={cn('input font-mono', errors.kpp && 'border-red-400 focus:ring-red-400')}
placeholder="123456789"
maxLength={9}
value={kpp} onChange={e => setKpp(e.target.value.replace(/\D/g, ''))}
/>
</Field>
)}
</div>
<Field label="Юридический адрес" error={errors.address}>
<input
type="text" className="input"
placeholder="г. Москва, ул. Примерная, д. 1"
value={address} onChange={e => setAddress(e.target.value)}
/>
</Field>
</div>
</div>
<button type="button" onClick={nextStep} className="btn-primary w-full justify-center py-2.5 mt-2">
Далее
<ChevronRight size={16} />
</button>
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
Уже есть аккаунт?{' '}
<button onClick={onSwitch} className="text-brand-600 dark:text-brand-400 font-medium hover:underline">
Войти
</button>
</p>
</div>
)}
{/* ── Step 2: Account ── */}
{step === 2 && (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Field label="Имя *" error={errors.firstName}>
<input
type="text"
className={cn('input', errors.firstName && 'border-red-400 focus:ring-red-400')}
placeholder="Иван"
value={firstName} onChange={e => setFirstName(e.target.value)}
/>
</Field>
<Field label="Фамилия *" error={errors.lastName}>
<input
type="text"
className={cn('input', errors.lastName && 'border-red-400 focus:ring-red-400')}
placeholder="Иванов"
value={lastName} onChange={e => setLastName(e.target.value)}
/>
</Field>
</div>
<Field label="Рабочий email *" error={errors.email}>
<input
type="email"
className={cn('input', errors.email && 'border-red-400 focus:ring-red-400')}
placeholder="director@myhotel.ru"
value={email} onChange={e => setEmail(e.target.value)}
/>
</Field>
<Field label="Телефон" error={errors.phone}>
<input
type="tel" className="input"
placeholder="+7 (999) 000-00-00"
value={phone} onChange={e => setPhone(e.target.value)}
/>
</Field>
<Field label="Пароль *" error={errors.password} hint="Минимум 8 символов">
<div className="relative">
<input
type={showPass ? 'text' : 'password'}
className={cn('input pr-10', errors.password && 'border-red-400 focus:ring-red-400')}
placeholder="••••••••"
value={password} onChange={e => setPassword(e.target.value)}
/>
<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>
{password && (
<div className="mt-1.5 flex gap-1">
{[
password.length >= 8,
/[A-Z]/.test(password),
/[0-9]/.test(password),
].map((ok, i) => (
<div key={i} className={cn('h-1 flex-1 rounded-full transition-colors', ok ? 'bg-emerald-500' : 'bg-slate-200 dark:bg-slate-600')} />
))}
</div>
)}
</Field>
<Field label="Подтверждение пароля *" error={errors.confirm}>
<div className="relative">
<input
type={showConfirm ? 'text' : 'password'}
className={cn('input pr-10', errors.confirm && 'border-red-400 focus:ring-red-400')}
placeholder="••••••••"
value={confirm} onChange={e => setConfirm(e.target.value)}
/>
<button type="button" onClick={() => setShowConfirm(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
{showConfirm ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</Field>
<p className="text-xs text-slate-400 dark:text-slate-500">
Нажимая «Создать аккаунт», вы соглашаетесь с{' '}
<span className="text-brand-600 dark:text-brand-400 cursor-pointer hover:underline">условиями использования</span>{' '}
и{' '}
<span className="text-brand-600 dark:text-brand-400 cursor-pointer hover:underline">политикой конфиденциальности</span>
</p>
<div className="flex gap-3">
<button
type="button"
onClick={() => { setErrors({}); setStep(1) }}
className="btn-secondary flex items-center gap-1.5"
>
<ChevronLeft size={16} />
Назад
</button>
<button type="submit" disabled={loading} className="btn-primary flex-1 justify-center py-2.5">
{loading
? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
: <><User size={15} /> Создать аккаунт</>
}
</button>
</div>
</form>
)}
</>
)
}
// ── Page ───────────────────────────────────────────────────────────────────────
export function LoginPage() {
const { user } = useAuth()
const { theme, toggle } = useTheme()
const [mode, setMode] = useState<'login' | 'register'>('login')
if (user) {
return <Navigate to="/calendar" replace />
}
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>
{/* Registration benefits highlight */}
{mode === 'register' && (
<div className="mt-10 space-y-3">
{[
'Бесплатный период 14 дней без ввода карты',
'Настройка за 15 минут — импорт из любой PMS',
'Поддержка на русском языке 24/7',
].map(b => (
<div key={b} className="flex items-start gap-2.5">
<CheckCircle2 size={16} className="text-brand-200 shrink-0 mt-0.5" />
<p className="text-brand-100 text-sm">{b}</p>
</div>
))}
</div>
)}
</div>
<p className="text-brand-200 text-sm">© 2026 HotelSync · SaaS PMS Platform</p>
</div>
{/* Right panel */}
<div className="flex-1 flex flex-col">
<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 py-6 overflow-y-auto">
<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">Sync</span>
</span>
</div>
{mode === 'login'
? <LoginForm onSwitch={() => setMode('register')} />
: <RegisterForm onSwitch={() => setMode('login')} />
}
</div>
</div>
</div>
</div>
)
}