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(null) const [step, setStep] = useState('input') const [target, setTarget] = useState(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, title: resolved.title, conferenceId: data.conference_id, chatEnabled: data.chat_enabled, handQueueEnabled: data.hand_queue_enabled, publishQualityCap: data.publish_quality_cap, stageMaxTiles: data.stage_max_tiles, }, }) } 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) { event.preventDefault() void handleResolve(query) } function handleGuestInfoSubmit(event: FormEvent) { event.preventDefault() if (!target || !guestName.trim()) return if (target.requiresPassword) { setStep('password') } else { void performJoin(target, {}) } } function handlePasswordSubmit(event: FormEvent) { event.preventDefault() if (!target || !password) return void performJoin(target, { password }) } function resetToInput() { setResolveError(null) setInvalidPassword(false) setStep('input') } if (step === 'connecting') { return (
Подключаемся к конференции…
) } return (
VidConf
{step === 'input' && (

Подключиться к конференции

Вставьте ссылку-приглашение или введите номер конференции

{resolveError ? (

) : (

Найдите номер или ссылку в приглашении организатора

)}
Назад в лобби
)} {step === 'guest-info' && target && (
{target.title ?? 'Конференция без названия'} № {target.query}

Как вас зовут?

У вас нет аккаунта VidConf — представьтесь, чтобы войти как гость

Укажите, если хотите получить саммари конференции на почту

)} {step === 'password' && target && (
{target.title ?? 'Конференция без названия'} № {target.query} · закрытая

Введите пароль

Эта конференция закрыта — доступ только по паролю от организатора

{invalidPassword && (

)}
)} {step === 'ended' && (
{target && (
{target.title ?? 'Конференция без названия'} № {target.query} · завершена
)}

Конференция завершена

Эта конференция уже завершилась и недоступна для повторного входа

Назад в лобби
)}
) }