- Address and phone now required (frontend + backend) - Phone input mask: +7 (XXX) XXX-XX-XX - Fix Bad Request: register body now sends snake_case keys (req() converts camelCase→snake_case, backend schema updated to match) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
658 lines
31 KiB
TypeScript
658 lines
31 KiB
TypeScript
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>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ── Phone mask helper ──────────────────────────────────────────────────────────
|
||
function formatPhone(raw: string): string {
|
||
const digits = raw.replace(/\D/g, '')
|
||
// Normalize: leading 8 or 7 → keep as +7, otherwise prepend 7
|
||
const d = digits.startsWith('8') ? '7' + digits.slice(1)
|
||
: digits.startsWith('7') ? digits
|
||
: digits.length ? '7' + digits : ''
|
||
let result = '+7'
|
||
if (d.length > 1) result += ' (' + d.slice(1, Math.min(4, d.length))
|
||
if (d.length >= 4) result += ') ' + d.slice(4, Math.min(7, d.length))
|
||
if (d.length >= 7) result += '-' + d.slice(7, Math.min(9, d.length))
|
||
if (d.length >= 9) result += '-' + d.slice(9, Math.min(11, d.length))
|
||
return result
|
||
}
|
||
|
||
// ── 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 handlePhoneChange = (raw: string) => {
|
||
// Allow clearing
|
||
if (raw === '' || raw === '+') { setPhone(''); return }
|
||
setPhone(formatPhone(raw))
|
||
setErrors(prev => ({ ...prev, phone: '' }))
|
||
}
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
const err: Record<string, string> = {}
|
||
if (!hotelName.trim()) err.hotelName = 'Введите название отеля'
|
||
if (!address.trim()) err.address = 'Введите адрес отеля'
|
||
if (!contact.trim()) err.contact = 'Введите контактное лицо'
|
||
if (!email.trim()) err.email = 'Введите email'
|
||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) err.email = 'Некорректный email'
|
||
const phoneDigits = phone.replace(/\D/g, '')
|
||
if (phoneDigits.length < 11) err.phone = 'Введите полный номер телефона'
|
||
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={cn('input', errors.address && 'border-red-400 focus:ring-red-400')}
|
||
placeholder="г. Москва, ул. Примерная, д. 1"
|
||
value={address}
|
||
onChange={e => { setAddress(e.target.value); setErrors(prev => ({ ...prev, address: '' })) }}
|
||
/>
|
||
</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={cn('input', errors.phone && 'border-red-400 focus:ring-red-400')}
|
||
placeholder="+7 (999) 000-00-00"
|
||
value={phone}
|
||
onChange={e => handlePhoneChange(e.target.value)}
|
||
onFocus={() => { if (!phone) setPhone('+7 (') }}
|
||
onBlur={() => { if (phone === '+7 (' || phone === '+7') setPhone('') }}
|
||
inputMode="tel"
|
||
/>
|
||
</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>
|
||
)
|
||
}
|