94 lines
3.8 KiB
TypeScript
94 lines
3.8 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import { Link, useSearchParams } from 'react-router-dom'
|
||
import { AlertTriangle, ArrowRight, CheckCircle2, Loader2 } from 'lucide-react'
|
||
import { AuthLayout } from '@/components/auth/AuthLayout'
|
||
import { verifyEmail } from '@/api/auth'
|
||
|
||
type VerifyState = 'verifying' | 'success' | 'error'
|
||
|
||
/**
|
||
* Кэш промисов подтверждения по токену (модульный, вне компонента) — тот же
|
||
* приём дедупликации, что и у refreshPromise в api/client.ts. Защищает от
|
||
* повторного POST /auth/verify-email при двойном монтировании эффекта в
|
||
* React StrictMode (dev): без кэша второй запрос уходит с уже погашенным
|
||
* токеном и backend отвечает 400 «токен уже использован», хотя первый запрос
|
||
* уже успешно подтвердил почту.
|
||
*/
|
||
const verifyRequests = new Map<string, Promise<void>>()
|
||
|
||
function verifyEmailOnce(token: string): Promise<void> {
|
||
let request = verifyRequests.get(token)
|
||
if (!request) {
|
||
request = verifyEmail(token)
|
||
verifyRequests.set(token, request)
|
||
}
|
||
return request
|
||
}
|
||
|
||
/**
|
||
* Экран подтверждения email по ссылке из письма: /verify-email?token=...
|
||
* (см. design/mockups/auth.html, блок «подтверждение email»).
|
||
*/
|
||
export function VerifyEmailPage() {
|
||
const [searchParams] = useSearchParams()
|
||
const token = searchParams.get('token')
|
||
const [state, setState] = useState<VerifyState>(token ? 'verifying' : 'error')
|
||
|
||
useEffect(() => {
|
||
if (!token) return
|
||
let cancelled = false
|
||
verifyEmailOnce(token)
|
||
.then(() => {
|
||
if (!cancelled) setState('success')
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setState('error')
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [token])
|
||
|
||
return (
|
||
<AuthLayout>
|
||
<section className="screen-block">
|
||
{state === 'verifying' && (
|
||
<div className="confirm-card pending">
|
||
<div className="confirm-icon">
|
||
<Loader2 className="lucide spin" aria-hidden="true" />
|
||
</div>
|
||
<h3>Проверяем ссылку…</h3>
|
||
<p>Подтверждаем вашу почту, это займёт пару секунд.</p>
|
||
</div>
|
||
)}
|
||
|
||
{state === 'success' && (
|
||
<div className="confirm-card">
|
||
<div className="confirm-icon">
|
||
<CheckCircle2 className="lucide" aria-hidden="true" />
|
||
</div>
|
||
<h3>Почта подтверждена</h3>
|
||
<p>Аккаунт активирован. Теперь доступно бронирование переговорных и вход в конференции.</p>
|
||
<Link to="/login" className="btn btn-primary btn-block btn-lg">
|
||
Перейти ко входу <ArrowRight className="lucide" aria-hidden="true" />
|
||
</Link>
|
||
</div>
|
||
)}
|
||
|
||
{state === 'error' && (
|
||
<div className="confirm-card error">
|
||
<div className="confirm-icon">
|
||
<AlertTriangle className="lucide" aria-hidden="true" />
|
||
</div>
|
||
<h3>Ссылка недействительна</h3>
|
||
<p>Срок действия ссылки истёк или она уже была использована. Запросите письмо ещё раз со страницы регистрации.</p>
|
||
<Link to="/register" className="btn btn-secondary btn-block btn-lg">
|
||
Вернуться к регистрации
|
||
</Link>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</AuthLayout>
|
||
)
|
||
}
|