Files
hotelsync/src/pages/LoginPage.tsx

628 lines
30 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, useSearchParams } from 'react-router-dom'
import {
Hotel, Eye, EyeOff, Moon, Sun, AlertCircle,
CheckCircle2, ArrowLeft,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
import { cn } from '../lib/utils'
import { api, ApiError } from '../lib/api'
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: 'Другое'},
]
// suppress unused-import lint in case LEGAL_TYPES is used elsewhere
void LEGAL_TYPES
// ── Field component — defined OUTSIDE RegisterForm to prevent unmount on re-render
interface FieldProps {
label: string
error?: string
children: React.ReactNode
}
function Field({ label, error, children }: FieldProps) {
return (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{label}</label>
{children}
{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>
)
}
// ── Login form ─────────────────────────────────────────────────────────────────
function LoginForm({ onSwitch, onForgot }: { onSwitch: () => void; onForgot: () => 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 [unconfirmedEmail, setUnconfirmedEmail] = useState<string | null>(null)
const [resendLoading, setResendLoading] = useState(false)
const [resendDone, setResendDone] = useState(false)
const [searchParams] = useSearchParams()
const confirmed = searchParams.get('confirmed') === '1'
const resetDone = searchParams.get('reset') === 'success'
const tokenError = searchParams.get('error')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// don't clear error here — clear it onChange; clearing here causes "flash"
setLoading(true)
try {
const loggedIn = await login(email, password)
if (!loggedIn) { setError('Неверный email или пароль'); return }
navigate('/calendar')
} catch (err) {
if (err instanceof ApiError && err.status === 403) {
setUnconfirmedEmail(email)
setResendDone(false)
} else {
const msg = err instanceof Error ? err.message : 'Ошибка соединения'
setError(msg)
}
} finally {
setLoading(false)
}
}
const handleResend = async () => {
if (!unconfirmedEmail) return
setResendLoading(true)
try {
await api.auth.resendConfirmation(unconfirmedEmail)
setResendDone(true)
} catch { /* silent */ } finally {
setResendLoading(false)
}
}
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); setError(''); setUnconfirmedEmail(null) }}
required
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Пароль</label>
<button
type="button"
onClick={onForgot}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
Забыли пароль?
</button>
</div>
<div className="relative">
<input
type={showPass ? 'text' : 'password'} className="input pr-10"
placeholder="••••••••" value={password}
onChange={e => { setPassword(e.target.value); setError(''); setUnconfirmedEmail(null) }}
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>
)}
{unconfirmedEmail && (
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-sm">
<div className="flex items-start gap-2 text-amber-800 dark:text-amber-300 mb-2">
<AlertCircle size={15} className="shrink-0 mt-0.5" />
<span>Email <strong>{unconfirmedEmail}</strong> ещё не подтверждён. Проверьте почту и перейдите по ссылке из письма.</span>
</div>
{resendDone ? (
<div className="flex items-center gap-1.5 text-emerald-700 dark:text-emerald-400 text-xs">
<CheckCircle2 size={13} /> Письмо отправлено повторно
</div>
) : (
<button
type="button"
onClick={handleResend}
disabled={resendLoading}
className="text-xs text-amber-700 dark:text-amber-400 hover:underline font-medium disabled:opacity-50"
>
{resendLoading ? 'Отправляем...' : 'Отправить письмо повторно'}
</button>
)}
</div>
)}
{confirmed && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 text-sm">
<CheckCircle2 size={15} /> Email подтверждён! Теперь вы можете войти.
</div>
)}
{resetDone && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 text-sm">
<CheckCircle2 size={15} /> Пароль успешно изменён. Войдите с новым паролем.
</div>
)}
{tokenError === 'invalid_token' && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 text-sm">
<AlertCircle size={15} /> Ссылка недействительна или уже использована.
</div>
)}
{tokenError === 'token_expired' && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 text-sm">
<AlertCircle size={15} /> Ссылка истекла (24 ч). Пожалуйста, зарегистрируйтесь снова.
</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="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>
</>
)
}
// ── Forgot password form ────────────────────────────────────────────────────────
function ForgotPasswordForm({ onBack }: { onBack: () => void }) {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [done, setDone] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await api.auth.forgotPassword(email)
setDone(true)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка'
setError(msg)
} finally {
setLoading(false)
}
}
if (done) {
return (
<div className="text-center py-4">
<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-xl font-bold text-slate-900 dark:text-slate-100 mb-2">Письмо отправлено</h2>
<p className="text-slate-500 dark:text-slate-400 mb-2 text-sm">
Если аккаунт с адресом <span className="font-medium text-slate-700 dark:text-slate-300">{email}</span> существует,
на него придёт письмо со ссылкой для сброса пароля.
</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-8">Ссылка действительна 1 час.</p>
<button onClick={onBack} className="btn-primary w-full justify-center py-2.5">
Вернуться к входу
</button>
</div>
)
}
return (
<>
<button
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 mb-6 -ml-1"
>
<ArrowLeft size={15} /> Назад
</button>
<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">
Введите email аккаунта мы пришлём ссылку для сброса пароля
</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); setError('') }}
required
autoFocus
/>
</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>
</>
)
}
// ── Register form ──────────────────────────────────────────────────────────────
function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
const [done, setDone] = useState(false)
const [hotelName, setHotelName] = useState('')
const [address, setAddress] = useState('')
const [contact, setContact] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [showPass, setShowPass] = useState(false)
const [agreed, setAgreed] = useState(false)
const [showTerms, setShowTerms] = useState(false)
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const err: Record<string, string> = {}
if (!hotelName.trim()) err.hotelName = 'Введите название отеля'
if (!contact.trim()) err.contact = 'Введите контактное лицо'
if (!email.trim()) err.email = 'Введите email'
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) err.email = 'Некорректный email'
if (password.length < 8) err.password = 'Минимум 8 символов'
else if (!/[a-zA-Zа-яА-ЯёЁ]/.test(password)) err.password = 'Пароль должен содержать хотя бы одну букву'
if (!agreed) err.agreed = 'Необходимо принять пользовательское соглашение'
setErrors(err)
if (Object.keys(err).length > 0) return
setLoading(true)
try {
await api.auth.register({ hotelName, address, contact, email, phone, password })
setDone(true)
} catch (regErr) {
const msg = regErr instanceof Error ? regErr.message : 'Ошибка регистрации'
if (msg.includes('уже существует')) {
setErrors({ email: 'Пользователь с таким email уже существует' })
} else {
setErrors({ general: msg })
}
} finally {
setLoading(false)
}
}
if (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> отправлено
письмо с подтверждением. Перейдите по ссылке в письме, чтобы активировать аккаунт.
</p>
<button onClick={onSwitch} className="btn-primary w-full justify-center py-2.5">
Перейти к входу
</button>
</div>
)
}
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-6">Заполните данные мы активируем аккаунт в течение дня</p>
<form onSubmit={handleSubmit} 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"
autoCapitalize="words"
value={hotelName}
onChange={e => { setHotelName(e.target.value.replace(/(?:^|\s)\S/g, c => c.toUpperCase())); setErrors(prev => ({ ...prev, hotelName: '' })) }}
/>
</Field>
<Field label="Адрес" error={errors.address}>
<input
type="text" className="input"
placeholder="г. Москва, ул. Примерная, д. 1"
value={address}
onChange={e => setAddress(e.target.value)}
/>
</Field>
<Field label="Контактное лицо *" error={errors.contact}>
<input
type="text"
className={cn('input', errors.contact && 'border-red-400 focus:ring-red-400')}
placeholder="Иванов Иван Иванович"
autoCapitalize="words"
value={contact}
onChange={e => { setContact(e.target.value.replace(/(?:^|\s)\S/g, c => c.toUpperCase())); setErrors(prev => ({ ...prev, contact: '' })) }}
/>
</Field>
<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); setErrors(prev => ({ ...prev, email: '' })) }}
/>
</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}>
<div className="relative">
<input
type={showPass ? 'text' : 'password'}
className={cn('input pr-10', errors.password && 'border-red-400 focus:ring-red-400')}
placeholder="Минимум 8 символов"
value={password}
onChange={e => { setPassword(e.target.value); setErrors(prev => ({ ...prev, password: '' })) }}
/>
<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-2 grid grid-cols-2 gap-x-3 gap-y-1">
{[
{ ok: password.length >= 8, label: '≥ 8 символов' },
{ ok: /[a-zA-Zа-яА-ЯёЁ]/.test(password), label: 'Содержит букву' },
{ ok: /[A-ZА-ЯЁ]/.test(password), label: 'Заглавная буква' },
{ ok: /[0-9]/.test(password), label: 'Цифра' },
].map(({ ok, label }) => (
<div key={label} className="flex items-center gap-1.5 text-xs">
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0 transition-colors', ok ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600')} />
<span className={cn('transition-colors', ok ? 'text-emerald-700 dark:text-emerald-400' : 'text-slate-400 dark:text-slate-500')}>{label}</span>
</div>
))}
</div>
)}
</Field>
{/* Согласие с пользовательским соглашением */}
<div>
<label className="flex items-start gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={agreed}
onChange={e => { setAgreed(e.target.checked); setErrors(prev => ({ ...prev, agreed: '' })) }}
className="mt-0.5 w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-brand-600 focus:ring-brand-500 cursor-pointer shrink-0"
/>
<span className="text-sm text-slate-600 dark:text-slate-400">
Я согласен с{' '}
<button
type="button"
onClick={() => setShowTerms(true)}
className="text-brand-600 dark:text-brand-400 hover:underline font-medium"
>
пользовательским соглашением
</button>
</span>
</label>
{errors.agreed && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400 flex items-center gap-1">
<AlertCircle size={11} /> {errors.agreed}
</p>
)}
</div>
{errors.general && (
<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} /> {errors.general}
</div>
)}
<button type="submit" disabled={loading} className="btn-primary w-full justify-center py-2.5 mt-2">
{loading
? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
: 'Зарегистрировать отель'
}
</button>
</form>
<p className="mt-5 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>
{/* Модальное окно — пользовательское соглашение */}
{showTerms && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
onClick={() => setShowTerms(false)}
>
<div
className="w-full max-w-lg bg-white dark:bg-slate-800 rounded-2xl shadow-xl flex flex-col max-h-[80vh]"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">Пользовательское соглашение</h3>
<button
onClick={() => setShowTerms(false)}
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 text-xl leading-none"
>
×
</button>
</div>
<div className="overflow-y-auto px-6 py-4 text-sm text-slate-600 dark:text-slate-400 space-y-3 leading-relaxed">
<p className="text-xs text-slate-400 dark:text-slate-500">Редакция от 1 января 2026 г.</p>
<p>Настоящее Пользовательское соглашение регулирует отношения между ООО «ХотелСинк» (далее Сервис) и пользователем, зарегистрировавшим учётную запись.</p>
<p><strong className="text-slate-700 dark:text-slate-300">1. Предмет соглашения.</strong> Сервис предоставляет доступ к облачной PMS-системе HotelSync для управления гостиничными операциями.</p>
<p><strong className="text-slate-700 dark:text-slate-300">2. Регистрация.</strong> Пользователь обязуется предоставить достоверные сведения при регистрации и нести ответственность за сохранность учётных данных.</p>
<p><strong className="text-slate-700 dark:text-slate-300">3. Персональные данные.</strong> Обработка персональных данных осуществляется в соответствии с Федеральным законом 152-ФЗ «О персональных данных».</p>
<p><strong className="text-slate-700 dark:text-slate-300">4. Ограничение ответственности.</strong> Сервис не несёт ответственности за прямые или косвенные убытки, возникшие вследствие использования или невозможности использования платформы.</p>
<p><strong className="text-slate-700 dark:text-slate-300">5. Изменение условий.</strong> Сервис вправе изменять условия соглашения, уведомив пользователей не менее чем за 7 дней.</p>
<p className="text-xs text-slate-400 dark:text-slate-500">По вопросам: support@hotelsync.ru</p>
</div>
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex gap-3">
<button
onClick={() => { setAgreed(true); setShowTerms(false); setErrors(prev => ({ ...prev, agreed: '' })) }}
className="btn-primary flex-1 justify-center py-2"
>
Принять и закрыть
</button>
<button
onClick={() => setShowTerms(false)}
className="btn-secondary flex-1 justify-center py-2"
>
Закрыть
</button>
</div>
</div>
</div>
)}
</>
)
}
// ── Page ───────────────────────────────────────────────────────────────────────
export function LoginPage() {
const { user } = useAuth()
const { theme, toggle } = useTheme()
const [mode, setMode] = useState<'login' | 'register' | 'forgot'>('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>
{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')} onForgot={() => setMode('forgot')} />}
{mode === 'register' && <RegisterForm onSwitch={() => setMode('login')} />}
{mode === 'forgot' && <ForgotPasswordForm onBack={() => setMode('login')} />}
</div>
</div>
</div>
</div>
)
}