Первоначальная версия VidConf
This commit is contained in:
414
frontend/src/pages/JoinPage.tsx
Normal file
414
frontend/src/pages/JoinPage.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
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 { 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user