`AppFooter` — версия инстанса из публичного `/api/health` (не требует авторизации, поэтому работает и на экранах входа; поля `version` может не быть — тогда версия не показывается) плюс копирайт с ТЕКУЩИМ годом и ссылкой на профиль автора. Подключён во все раскладки: `page-shell` (календарь, админка, профиль, мои конференции), `AuthLayout` (вход, регистрация, подтверждение email), `join-shell` и `lobby-shell`. В комнате конференции футера нет — там полноэкранное видео. Прежний футер админки (`.admin-footer`) удалён вместе со стилем — дубля не осталось. В брендовой панели экранов auth строка «© 2026 VidConf · self-hosted» ужата до «self-hosted»: год там был захардкожен, а два копирайта на одном экране — дубль.
417 lines
18 KiB
TypeScript
417 lines
18 KiB
TypeScript
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||
import { AlertCircle, ArrowRight, CheckCircle2, Eye, EyeOff, Hash, Link2, Lock, Mail, User, Video } from 'lucide-react'
|
||
import {
|
||
guestJoinConference,
|
||
joinConference,
|
||
resolveConference,
|
||
type ConferenceJoinData,
|
||
} from '@/api/conferences'
|
||
import { ApiError, errorDetail } from '@/api/client'
|
||
import { useAuth } from '@/auth/useAuth'
|
||
import { AppFooter } from '@/components/layout/AppFooter'
|
||
import { LogoMark } from '@/components/ui/LogoMark'
|
||
import { ThemeToggle } from '@/components/ui/ThemeToggle'
|
||
import { parseJoinQuery } from '@/lib/parseJoinQuery'
|
||
import '@/styles/join.css'
|
||
|
||
type Step = 'input' | 'guest-info' | 'password' | 'connecting' | 'ended'
|
||
|
||
interface ResolvedTarget {
|
||
id: string
|
||
title: string | null
|
||
isClosed: boolean
|
||
requiresPassword: boolean
|
||
/** Нормализованный запрос пользователя (slug/номер) — используется как параметр `/room/:slug`. */
|
||
query: string
|
||
}
|
||
|
||
/**
|
||
* Подключение к конференции (см. design/mockups/join.html) — три состояния
|
||
* одного флоу: (а) ввод ссылки/номера, (б) «представиться» для гостя,
|
||
* (в) пароль закрытой конференции. Экран публичный — доступен без сессии
|
||
* (гость входит без аккаунта), поэтому не завёрнут в `RequireAuth`.
|
||
*
|
||
* Маршруты: `/join` (ручной ввод, опционально `?q=` — например, после
|
||
* редиректа из RoomPage при потере join-состояния) и `/j/:slug` (прямая
|
||
* ссылка-приглашение — резолвится автоматически при заходе).
|
||
*
|
||
* Авторизованный пользователь пропускает «представиться»: после резолва либо
|
||
* сразу входит (если конференция открыта), либо сразу видит экран пароля
|
||
* (если закрыта). Гость всегда проходит «представиться»; если конференция
|
||
* закрыта, экран пароля показывается вторым шагом, после чего оба поля
|
||
* (имя/email + пароль) уходят одним запросом `guest-join`.
|
||
*/
|
||
export function JoinPage() {
|
||
const navigate = useNavigate()
|
||
const { slug: slugParam } = useParams<{ slug?: string }>()
|
||
const [searchParams] = useSearchParams()
|
||
const { status } = useAuth()
|
||
const isAuthenticated = status === 'authenticated'
|
||
|
||
const initialQuery = slugParam ?? searchParams.get('q') ?? ''
|
||
const [query, setQuery] = useState(initialQuery)
|
||
const [resolving, setResolving] = useState(false)
|
||
const [resolveError, setResolveError] = useState<string | null>(null)
|
||
|
||
const [step, setStep] = useState<Step>('input')
|
||
const [target, setTarget] = useState<ResolvedTarget | null>(null)
|
||
|
||
const [guestName, setGuestName] = useState('')
|
||
const [guestEmail, setGuestEmail] = useState('')
|
||
|
||
const [password, setPassword] = useState('')
|
||
const [showPassword, setShowPassword] = useState(false)
|
||
const [invalidPassword, setInvalidPassword] = useState(false)
|
||
const [joining, setJoining] = useState(false)
|
||
|
||
const autoResolvedRef = useRef(false)
|
||
|
||
// Прямая ссылка (/j/:slug) или ?q= из редиректа — резолвим сразу, не
|
||
// дожидаясь клика по «Продолжить», но только после того как известен
|
||
// статус сессии (нужен для выбора «гость / авторизованный» флоу).
|
||
useEffect(() => {
|
||
if (status === 'loading' || autoResolvedRef.current || !initialQuery) return
|
||
autoResolvedRef.current = true
|
||
void handleResolve(initialQuery)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [status])
|
||
|
||
async function handleResolve(rawQuery: string) {
|
||
const normalized = parseJoinQuery(rawQuery)
|
||
if (!normalized) return
|
||
setResolveError(null)
|
||
setResolving(true)
|
||
try {
|
||
const info = await resolveConference(normalized)
|
||
// Завершённая конференция (ADR-001) — backend отдаёт минимальный ответ
|
||
// без is_closed/requires_password: сразу показываем «завершена», не
|
||
// дожидаясь шага «представиться»/пароль и попытки join (которая всё
|
||
// равно вернёт 410).
|
||
if (info.status === 'ended') {
|
||
setTarget({ id: info.id, title: info.title, isClosed: false, requiresPassword: false, query: normalized })
|
||
setStep('ended')
|
||
return
|
||
}
|
||
const nextTarget: ResolvedTarget = {
|
||
id: info.id,
|
||
title: info.title,
|
||
isClosed: info.is_closed ?? false,
|
||
requiresPassword: info.requires_password ?? false,
|
||
query: normalized,
|
||
}
|
||
setTarget(nextTarget)
|
||
if (nextTarget.requiresPassword) {
|
||
setStep('password')
|
||
} else if (isAuthenticated) {
|
||
setStep('connecting')
|
||
await performJoin(nextTarget, {})
|
||
} else {
|
||
setStep('guest-info')
|
||
}
|
||
} catch (err) {
|
||
if (err instanceof ApiError && err.status === 404) {
|
||
setResolveError('Конференция не найдена — проверьте ссылку или номер')
|
||
} else {
|
||
setResolveError('Не удалось найти конференцию. Попробуйте ещё раз')
|
||
}
|
||
setStep('input')
|
||
} finally {
|
||
setResolving(false)
|
||
}
|
||
}
|
||
|
||
async function performJoin(
|
||
resolved: ResolvedTarget,
|
||
opts: { password?: string; guestName?: string; guestEmail?: string },
|
||
) {
|
||
setJoining(true)
|
||
setInvalidPassword(false)
|
||
try {
|
||
let data: ConferenceJoinData
|
||
if (isAuthenticated) {
|
||
data = await joinConference(resolved.id, opts.password ? { password: opts.password } : undefined)
|
||
} else {
|
||
data = await guestJoinConference(resolved.id, {
|
||
display_name: opts.guestName ?? guestName.trim(),
|
||
email: (opts.guestEmail ?? guestEmail).trim() || undefined,
|
||
password: opts.password,
|
||
})
|
||
}
|
||
navigate(`/room/${encodeURIComponent(resolved.query)}`, {
|
||
replace: true,
|
||
state: {
|
||
livekitUrl: data.livekit_url,
|
||
token: data.token,
|
||
roomName: data.room_name,
|
||
conferenceId: data.conference_id,
|
||
chatEnabled: data.chat_enabled,
|
||
},
|
||
})
|
||
} catch (err) {
|
||
if (err instanceof ApiError && err.status === 403 && errorDetail(err) === 'password_required') {
|
||
setStep('password')
|
||
} else if (err instanceof ApiError && err.status === 403 && errorDetail(err) === 'invalid_password') {
|
||
setInvalidPassword(true)
|
||
setStep('password')
|
||
} else if (err instanceof ApiError && err.status === 410) {
|
||
setStep('ended')
|
||
} else {
|
||
setResolveError('Не удалось подключиться к конференции. Попробуйте ещё раз позже')
|
||
setStep('input')
|
||
}
|
||
} finally {
|
||
setJoining(false)
|
||
}
|
||
}
|
||
|
||
function handleInputSubmit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault()
|
||
void handleResolve(query)
|
||
}
|
||
|
||
function handleGuestInfoSubmit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault()
|
||
if (!target || !guestName.trim()) return
|
||
if (target.requiresPassword) {
|
||
setStep('password')
|
||
} else {
|
||
void performJoin(target, {})
|
||
}
|
||
}
|
||
|
||
function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault()
|
||
if (!target || !password) return
|
||
void performJoin(target, { password })
|
||
}
|
||
|
||
function resetToInput() {
|
||
setResolveError(null)
|
||
setInvalidPassword(false)
|
||
setStep('input')
|
||
}
|
||
|
||
if (step === 'connecting') {
|
||
return (
|
||
<div className="app-loader" role="status" aria-live="polite">
|
||
Подключаемся к конференции…
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="join-shell">
|
||
<header className="join-topbar">
|
||
<Link to="/lobby" className="brand-mark">
|
||
<LogoMark /> VidConf
|
||
</Link>
|
||
<ThemeToggle />
|
||
</header>
|
||
|
||
<main className="join-main">
|
||
{step === 'input' && (
|
||
<section className="screen-block">
|
||
<div className="flow-card">
|
||
<div className="flow-icon" aria-hidden="true">
|
||
<Link2 className="icon" aria-hidden="true" />
|
||
</div>
|
||
<h1>Подключиться к конференции</h1>
|
||
<p className="flow-sub">Вставьте ссылку-приглашение или введите номер конференции</p>
|
||
|
||
<form onSubmit={handleInputSubmit}>
|
||
<div className={`field${resolveError ? ' has-error' : ''}`}>
|
||
<label htmlFor="join-input">Ссылка или номер</label>
|
||
<div className="input-wrap">
|
||
<Hash className="icon" aria-hidden="true" />
|
||
<input
|
||
id="join-input"
|
||
type="text"
|
||
placeholder="vidconf.ru/j/... или 8842 104"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
{resolveError ? (
|
||
<p className="error-msg">
|
||
<AlertCircle style={{ width: 16, height: 16 }} aria-hidden="true" /> {resolveError}
|
||
</p>
|
||
) : (
|
||
<p className="field-hint">Найдите номер или ссылку в приглашении организатора</p>
|
||
)}
|
||
</div>
|
||
<button type="submit" className="btn btn-primary" disabled={!query.trim() || resolving}>
|
||
{resolving ? 'Ищем конференцию…' : 'Продолжить'}
|
||
{!resolving && <ArrowRight style={{ width: 18, height: 18 }} aria-hidden="true" />}
|
||
</button>
|
||
</form>
|
||
<Link to="/lobby" className="link-back">
|
||
Назад в лобби
|
||
</Link>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{step === 'guest-info' && target && (
|
||
<section className="screen-block">
|
||
<div className="flow-card">
|
||
<div className="target-conf">
|
||
<span className="icon-wrap" aria-hidden="true">
|
||
{target.isClosed ? <Lock className="icon" aria-hidden="true" /> : <Video className="icon" aria-hidden="true" />}
|
||
</span>
|
||
<div>
|
||
<strong>{target.title ?? 'Конференция без названия'}</strong>
|
||
<span>№ {target.query}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flow-icon" aria-hidden="true">
|
||
<User className="icon" aria-hidden="true" />
|
||
</div>
|
||
<h1>Как вас зовут?</h1>
|
||
<p className="flow-sub">У вас нет аккаунта VidConf — представьтесь, чтобы войти как гость</p>
|
||
|
||
<form onSubmit={handleGuestInfoSubmit}>
|
||
<div className="field">
|
||
<label htmlFor="guest-name">
|
||
Имя<span className="field-optional">· обязательно</span>
|
||
</label>
|
||
<div className="input-wrap">
|
||
<User className="icon" aria-hidden="true" />
|
||
<input
|
||
id="guest-name"
|
||
type="text"
|
||
placeholder="Как к вам обращаться"
|
||
value={guestName}
|
||
onChange={(e) => setGuestName(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="guest-email">
|
||
Email<span className="field-optional">· необязательно</span>
|
||
</label>
|
||
<div className="input-wrap">
|
||
<Mail className="icon" aria-hidden="true" />
|
||
<input
|
||
id="guest-email"
|
||
type="email"
|
||
placeholder="you@mail.ru"
|
||
value={guestEmail}
|
||
onChange={(e) => setGuestEmail(e.target.value)}
|
||
/>
|
||
</div>
|
||
<p className="field-hint">Укажите, если хотите получить саммари конференции на почту</p>
|
||
</div>
|
||
<button type="submit" className="btn btn-primary" disabled={!guestName.trim() || joining}>
|
||
{joining ? 'Входим…' : 'Войти в конференцию'}
|
||
{!joining && <ArrowRight style={{ width: 18, height: 18 }} aria-hidden="true" />}
|
||
</button>
|
||
</form>
|
||
<button type="button" className="link-back" onClick={resetToInput}>
|
||
Назад
|
||
</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{step === 'password' && target && (
|
||
<section className="screen-block">
|
||
<div className="flow-card">
|
||
<div className="target-conf">
|
||
<span className="icon-wrap" aria-hidden="true">
|
||
<Lock className="icon" aria-hidden="true" />
|
||
</span>
|
||
<div>
|
||
<strong>{target.title ?? 'Конференция без названия'}</strong>
|
||
<span>№ {target.query} · закрытая</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flow-icon" aria-hidden="true">
|
||
<Lock className="icon" aria-hidden="true" />
|
||
</div>
|
||
<h1>Введите пароль</h1>
|
||
<p className="flow-sub">Эта конференция закрыта — доступ только по паролю от организатора</p>
|
||
|
||
<form onSubmit={handlePasswordSubmit}>
|
||
<div className={`field${invalidPassword ? ' has-error' : ''}`}>
|
||
<label htmlFor="join-pass">Пароль конференции</label>
|
||
<div className="input-wrap">
|
||
<Lock className="icon" aria-hidden="true" />
|
||
<input
|
||
id="join-pass"
|
||
type={showPassword ? 'text' : 'password'}
|
||
placeholder="••••••••"
|
||
value={password}
|
||
onChange={(e) => {
|
||
setPassword(e.target.value)
|
||
setInvalidPassword(false)
|
||
}}
|
||
autoFocus
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="toggle-eye"
|
||
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
|
||
onClick={() => setShowPassword((v) => !v)}
|
||
>
|
||
{showPassword ? <EyeOff className="icon" aria-hidden="true" /> : <Eye className="icon" aria-hidden="true" />}
|
||
</button>
|
||
</div>
|
||
{invalidPassword && (
|
||
<p className="error-msg">
|
||
<AlertCircle style={{ width: 16, height: 16 }} aria-hidden="true" /> Неверный пароль, попробуйте ещё раз
|
||
</p>
|
||
)}
|
||
</div>
|
||
<button type="submit" className="btn btn-primary" disabled={!password || joining}>
|
||
{joining ? 'Входим…' : 'Войти'}
|
||
{!joining && <ArrowRight style={{ width: 18, height: 18 }} aria-hidden="true" />}
|
||
</button>
|
||
</form>
|
||
<button type="button" className="link-back" onClick={resetToInput}>
|
||
Назад
|
||
</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{step === 'ended' && (
|
||
<section className="screen-block">
|
||
<div className="flow-card">
|
||
{target && (
|
||
<div className="target-conf">
|
||
<span className="icon-wrap" aria-hidden="true">
|
||
<CheckCircle2 className="icon" aria-hidden="true" />
|
||
</span>
|
||
<div>
|
||
<strong>{target.title ?? 'Конференция без названия'}</strong>
|
||
<span>№ {target.query} · завершена</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flow-icon" aria-hidden="true">
|
||
<CheckCircle2 className="icon" aria-hidden="true" />
|
||
</div>
|
||
<h1>Конференция завершена</h1>
|
||
<p className="flow-sub">Эта конференция уже завершилась и недоступна для повторного входа</p>
|
||
|
||
<button type="button" className="btn btn-primary" onClick={resetToInput}>
|
||
Ввести другую ссылку или номер
|
||
</button>
|
||
<Link to="/lobby" className="link-back">
|
||
Назад в лобби
|
||
</Link>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</main>
|
||
<AppFooter />
|
||
</div>
|
||
)
|
||
}
|