Files
dashboards/apps/customer/src/pages/LoginPage.tsx
T
Alireza HassaniandCursor 15aad9f9d8 Use compact unlabeled filter rows across Super Admin lists.
Also ship pending add-customer name flexibility, store-gated e-payment settings, and logo-only customer login branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 12:32:18 +03:30

991 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User, X } from 'lucide-react'
import {
useAuth,
CUSTOMER_ACCESS_MESSAGE,
} from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { ApiError } from '../lib/api'
import { toE164CellNumber } from '../lib/cellNumber'
import { getTenantDomain } from '../lib/config'
import {
logout as logoutRequest,
register,
resetPassword,
sendOtp,
} from '../services/authService'
import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong, useLocale, useToast } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { safeRedirectPath } from '../lib/redirect'
import { BackgroundOrbs } from '../components/BackgroundOrbs'
import styles from './LoginPage.module.css'
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
type SmsStep = 'phone' | 'code'
const MESHKEE_APP_MARKER = '__MESHKEE_APP__'
const MESHKEE_SITE_URL = 'https://meshkee.com'
function parseCellExistsSites(message: string): string | null {
if (message.startsWith('CELL_EXISTS_OTHER_SITE:')) {
return message.slice('CELL_EXISTS_OTHER_SITE:'.length).trim()
}
if (message.includes('Cell number exists on another account')) {
return ''
}
return null
}
function renderWithMeshkeeLink(message: string, linkClassName: string): ReactNode {
const idx = message.indexOf(MESHKEE_APP_MARKER)
if (idx === -1) return message
return (
<>
{message.slice(0, idx)}
<a
href={MESHKEE_SITE_URL}
target="_blank"
rel="noopener noreferrer"
className={linkClassName}
>
Meshkee.app
</a>
{message.slice(idx + MESHKEE_APP_MARKER.length)}
</>
)
}
export function LoginPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const redirectTo = safeRedirectPath(searchParams.get('redirect'))
const { login, loginWithOtp } = useAuth()
const { businessName, businessNameEn, logoUrl } = useTenantBranding()
const tenantDomain = getTenantDomain()
const t = useT()
const { showToast } = useToast()
const { locale, dir } = useLocale()
const [view, setView] = useState<AuthView>('login')
const [smsStep, setSmsStep] = useState<SmsStep>('phone')
const [showPassword, setShowPassword] = useState(false)
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [smsCode, setSmsCode] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmNewPassword, setConfirmNewPassword] = useState('')
const [codeSent, setCodeSent] = useState(false)
const [countdown, setCountdown] = useState(0)
const [error, setError] = useState('')
const [info, setInfo] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
/** `null` closed; `''` generic notice; otherwise site names */
const [cellExistsSites, setCellExistsSites] = useState<string | null>(null)
function clearMessages() {
setError('')
setInfo('')
}
function resetForm() {
setPhone('')
setPassword('')
setConfirmPassword('')
setFirstName('')
setLastName('')
setSmsCode('')
setNewPassword('')
setConfirmNewPassword('')
setSmsStep('phone')
setCodeSent(false)
setShowPassword(false)
setCellExistsSites(null)
clearMessages()
}
function switchView(next: AuthView) {
resetForm()
setView(next)
}
function startCountdown() {
setCountdown(60)
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
}
function handleApiError(err: unknown, fallback: string) {
if (err instanceof ApiError) {
if (
err.message.includes('E.164') ||
err.message.includes('cellNumber must be')
) {
setError(t('login.error.cellEnglishDigits'))
} else if (
err.message.includes('SMS provider is not configured') ||
err.message.includes('SMS provider is unreachable') ||
err.message.includes('SMS provider returned') ||
err.message.includes('Unable to send SMS')
) {
setError(t('login.error.smsProvider'))
} else if (
err.message.includes('Invalid cell number or password')
) {
setError(t('login.error.invalidCredentials'))
} else if (
err.message.includes('Cell number is not verified') ||
err.message.includes('complete OTP verification')
) {
setError(t('login.error.cellNotVerified'))
} else {
setError(err.message)
}
} else {
setError(fallback)
}
}
function resolveCellNumber(): string | null {
if (/[۰-۹٠-٩]/.test(phone)) {
setError(t('login.error.cellEnglishDigits'))
return null
}
const cellNumber = toE164CellNumber(phone)
if (!/^\+[1-9]\d{6,14}$/.test(cellNumber)) {
setError(t('login.error.cellEnglishDigits'))
return null
}
return cellNumber
}
async function handleSendCode() {
clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true)
try {
const result = await sendOtp(cellNumber, tenantDomain)
if (!result.enabled) {
setInfo(result.message)
}
setCodeSent(true)
setSmsStep('code')
startCountdown()
} catch (err) {
handleApiError(err, t('login.error.sendCode'))
} finally {
setIsSubmitting(false)
}
}
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true)
try {
await login(cellNumber, password)
navigate(redirectTo)
} catch (err) {
handleApiError(err, t('login.error.signIn'))
} finally {
setIsSubmitting(false)
}
}
async function runSignup(acknowledgeExistingAccount = false) {
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true)
try {
const data = await register({
cellNumber,
password,
firstName: firstName.trim(),
lastName: lastName.trim(),
domain: tenantDomain,
...(acknowledgeExistingAccount ? { acknowledgeExistingAccount: true } : {}),
})
setCellExistsSites(null)
// Password is applied only after OTP when passwordPending; toast then.
if (data.passwordUpdated) {
showToast(t('signup.toast.passwordUpdated'), 'info')
}
if (data.requiresOtp || data.passwordPending || !data.user.cellVerifiedAt) {
if (data.accessToken) {
logoutRequest()
}
await continueToSmsVerify(
cellNumber,
data.passwordPending
? t('signup.info.verifyExistingPasswordUpdated')
: t('signup.info.verify'),
)
return
}
if (data.user.customerBusinesses.length === 0) {
logoutRequest()
setError(CUSTOMER_ACCESS_MESSAGE)
return
}
await login(cellNumber, password)
navigate(redirectTo)
} catch (err) {
if (!acknowledgeExistingAccount && err instanceof ApiError) {
const sites = parseCellExistsSites(err.message)
if (sites !== null) {
setCellExistsSites(sites)
return
}
}
setCellExistsSites(null)
handleApiError(err, t('signup.error.create'))
} finally {
setIsSubmitting(false)
}
}
async function continueToSmsVerify(cellNumber: string, infoMessage?: string) {
setView('otp')
setSmsStep('phone')
setPassword('')
setConfirmPassword('')
setSmsCode('')
setInfo(infoMessage ?? t('signup.info.verify'))
try {
const otpResult = await sendOtp(cellNumber, tenantDomain)
if (!otpResult.enabled) {
setInfo(otpResult.message)
}
setCodeSent(true)
setSmsStep('code')
startCountdown()
} catch (otpErr) {
handleApiError(otpErr, t('login.error.sendCode'))
}
}
async function handleSignup(e: React.FormEvent) {
e.preventDefault()
clearMessages()
setCellExistsSites(null)
if (password !== confirmPassword) {
setError(t('signup.error.match'))
return
}
if (!isPasswordStrong(password)) {
setError(t('forgot.error.strength'))
return
}
await runSignup()
}
/**
* Existing Meshkee account on another site: user admits the notice, then we link
* them with the new password and prove phone ownership via SMS.
*/
async function handleAdmitCellExists() {
clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) {
setCellExistsSites(null)
return
}
await runSignup(true)
}
async function handleResetPassword(e: React.FormEvent) {
e.preventDefault()
clearMessages()
if (!isPasswordStrong(newPassword)) {
setError(t('forgot.error.strength'))
return
}
if (newPassword !== confirmNewPassword) {
setError(t('forgot.error.match'))
return
}
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true)
try {
await resetPassword(cellNumber, smsCode, newPassword)
setInfo(t('forgot.info.success'))
setTimeout(() => switchView('login'), 2000)
} catch (err) {
handleApiError(err, t('forgot.error.verify'))
} finally {
setIsSubmitting(false)
}
}
async function handleOtpLogin(e: React.FormEvent) {
e.preventDefault()
clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true)
try {
const result = await loginWithOtp(cellNumber, smsCode)
if (result.passwordUpdated) {
showToast(t('signup.toast.passwordUpdated'), 'info')
}
navigate(redirectTo)
} catch (err) {
handleApiError(err, t('otp.error.signIn'))
} finally {
setIsSubmitting(false)
}
}
const heading =
view === 'login'
? { title: t('login.welcome'), subtitle: t('login.subtitle') }
: view === 'signup'
? { title: t('signup.title'), subtitle: null }
: view === 'forgot'
? {
title: t('forgot.title'),
subtitle: smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode'),
}
: {
title: t('otp.title'),
subtitle: smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode'),
}
return (
<div className={styles.page}>
<BackgroundOrbs />
<div className={styles.brand}>
{logoUrl ? <img src={logoUrl} alt="" className={styles.logo} /> : null}
<div className={styles.brandText}>
<span className={styles.domain} lang={locale === 'fa' ? 'fa' : undefined}>
{businessName || tenantDomain}
</span>
<span className={styles.appName} lang="en" dir="ltr">
{businessNameEn || tenantDomain}
</span>
</div>
</div>
<div className={styles.card}>
{(view === 'forgot' || view === 'otp') && (
<button
type="button"
className={styles.backBtn}
onClick={() => switchView('login')}
disabled={isSubmitting}
>
<ArrowLeft size={18} />
{view === 'forgot' ? t('forgot.back') : t('otp.back')}
</button>
)}
<div className={styles.cardHeader}>
<div className={styles.heading}>
<h1 className={styles.title}>{heading.title}</h1>
{heading.subtitle ? <p className={styles.subtitle}>{heading.subtitle}</p> : null}
</div>
<div className={styles.langSelect}>
<LanguageSelect />
</div>
</div>
{view === 'login' && (
<>
<form className={styles.form} onSubmit={handleLogin}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{info && <div className={styles.info}>{info}</div>}
<div className={styles.field}>
<label htmlFor="login-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
id="login-phone"
type="tel"
inputMode="tel"
placeholder="09122222222"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="login-password">{t('login.password')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="login-password"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('login.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
<button
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div className={styles.formActions}>
<button
type="button"
className={styles.linkBtn}
onClick={() => switchView('forgot')}
disabled={isSubmitting}
>
{t('login.forgot')}
</button>
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? t('login.signingIn') : t('login.signIn')}
</button>
</form>
<div className={styles.divider}>
<span>{t('login.or')}</span>
</div>
<button
type="button"
className={styles.secondaryBtn}
onClick={() => switchView('otp')}
disabled={isSubmitting}
>
<KeyRound size={18} />
{t('login.otp')}
</button>
<p className={styles.footerText}>
<span>{t('login.noAccount')}</span>
<button
type="button"
className={styles.linkBtn}
onClick={() => switchView('signup')}
disabled={isSubmitting}
>
{t('login.signUp')}
</button>
</p>
</>
)}
{view === 'signup' && (
<>
<form className={styles.form} onSubmit={handleSignup}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{info && <div className={styles.info}>{info}</div>}
<div className={styles.fieldRow}>
<div className={styles.field}>
<label htmlFor="signup-first">{t('signup.firstName')}</label>
<div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} />
<input
id="signup-first"
type="text"
placeholder={t('signup.firstName')}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
minLength={2}
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-last">{t('signup.lastName')}</label>
<div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} />
<input
id="signup-last"
type="text"
placeholder={t('signup.lastName')}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
minLength={2}
disabled={isSubmitting}
/>
</div>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
id="signup-phone"
type="tel"
inputMode="tel"
placeholder="09123456789"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-password">{t('login.password')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="signup-password"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('signup.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
<button
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-confirm">{t('signup.confirm')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="signup-confirm"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('signup.confirmPlaceholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
aria-describedby="signup-password-strength signup-password-strength-rules"
/>
</div>
<PasswordStrengthMeter
password={password}
confirmPassword={confirmPassword}
id="signup-password-strength"
labels={{
title: t('password.strength'),
empty: t('password.strength.empty'),
weak: t('password.strength.weak'),
fair: t('password.strength.fair'),
good: t('password.strength.good'),
strong: t('password.strength.strong'),
rules: {
length: t('password.rule.length'),
upper: t('password.rule.upper'),
number: t('password.rule.number'),
match: t('password.rule.match'),
},
}}
/>
</div>
<button
type="submit"
className={styles.submitBtn}
disabled={
isSubmitting || !isPasswordStrong(password) || password !== confirmPassword
}
>
{isSubmitting ? t('signup.creating') : t('signup.create')}
</button>
</form>
<p className={styles.footerText}>
<span>{t('signup.hasAccount')}</span>
<button
type="button"
className={styles.linkBtn}
onClick={() => switchView('login')}
disabled={isSubmitting}
>
{t('signup.signIn')}
</button>
</p>
</>
)}
{view === 'forgot' && (
<>
<form className={styles.form} onSubmit={handleResetPassword}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{info && <div className={styles.info}>{info}</div>}
{smsStep === 'phone' ? (
<>
<div className={styles.field}>
<label htmlFor="forgot-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
id="forgot-phone"
type="tel"
inputMode="tel"
placeholder="09122222222"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
<button
type="button"
className={styles.submitBtn}
onClick={() => void handleSendCode()}
disabled={isSubmitting}
>
{isSubmitting ? t('forgot.sending') : t('forgot.sendCode')}
</button>
</>
) : (
<>
{codeSent && (
<p className={styles.codeHint}>
{t('common.codeSent', { phone })}
</p>
)}
<div className={styles.field}>
<label htmlFor="forgot-code">{t('forgot.code')}</label>
<div className={styles.inputWrap}>
<KeyRound size={18} className={styles.inputIcon} />
<input
id="forgot-code"
type="text"
inputMode="numeric"
placeholder="123456"
maxLength={6}
value={smsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
required
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="forgot-new-password">{t('forgot.newPassword')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="forgot-new-password"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('forgot.newPasswordPlaceholder')}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
aria-describedby="forgot-password-strength"
/>
<button
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div className={styles.field}>
<label htmlFor="forgot-confirm-password">{t('forgot.confirm')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="forgot-confirm-password"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('forgot.confirmPlaceholder')}
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
aria-describedby="forgot-password-strength"
/>
</div>
<PasswordStrengthMeter
password={newPassword}
confirmPassword={confirmNewPassword}
id="forgot-password-strength"
labels={{
title: t('password.strength'),
empty: t('password.strength.empty'),
weak: t('password.strength.weak'),
fair: t('password.strength.fair'),
good: t('password.strength.good'),
strong: t('password.strength.strong'),
rules: {
length: t('password.rule.length'),
upper: t('password.rule.upper'),
number: t('password.rule.number'),
match: t('password.rule.match'),
},
}}
/>
</div>
<div className={styles.resendRow}>
{countdown > 0 ? (
<span className={styles.countdown}>{t('common.resendIn', { seconds: countdown })}</span>
) : (
<button
type="button"
className={styles.linkBtn}
onClick={() => void handleSendCode()}
disabled={isSubmitting}
>
{t('common.resend')}
</button>
)}
</div>
<button
type="submit"
className={styles.submitBtn}
disabled={isSubmitting || !isPasswordStrong(newPassword) || newPassword !== confirmNewPassword}
>
{isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
</button>
</>
)}
</form>
</>
)}
{view === 'otp' && (
<>
<form className={styles.form} onSubmit={handleOtpLogin}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{info && <div className={styles.info}>{info}</div>}
{smsStep === 'phone' ? (
<>
<div className={styles.field}>
<label htmlFor="otp-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
id="otp-phone"
type="tel"
inputMode="tel"
placeholder="09122222222"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
<button
type="button"
className={styles.submitBtn}
onClick={() => void handleSendCode()}
disabled={isSubmitting}
>
{isSubmitting ? t('otp.sending') : t('otp.sendCode')}
</button>
</>
) : (
<>
{codeSent && (
<p className={styles.codeHint}>
{t('common.codeSent', { phone })}
</p>
)}
<div className={styles.field}>
<label htmlFor="otp-code">{t('otp.code')}</label>
<div className={styles.inputWrap}>
<KeyRound size={18} className={styles.inputIcon} />
<input
id="otp-code"
type="text"
inputMode="numeric"
placeholder="123456"
maxLength={6}
value={smsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
required
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.resendRow}>
{countdown > 0 ? (
<span className={styles.countdown}>{t('common.resendIn', { seconds: countdown })}</span>
) : (
<button
type="button"
className={styles.linkBtn}
onClick={() => void handleSendCode()}
disabled={isSubmitting}
>
{t('common.resend')}
</button>
)}
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? t('otp.signingIn') : t('otp.signIn')}
</button>
</>
)}
</form>
</>
)}
</div>
<a
className={styles.poweredBy}
href="https://meshkee.com"
target="_blank"
rel="noopener noreferrer"
>
Powered by Meshkee.app
</a>
{cellExistsSites !== null &&
createPortal(
<div
className={styles.noticeOverlay}
onClick={() => !isSubmitting && setCellExistsSites(null)}
>
<div
className={styles.noticeModal}
role="dialog"
aria-modal="true"
aria-labelledby="cell-exists-title"
lang={locale}
dir={dir}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.noticeHeader}>
<h2 id="cell-exists-title" className={styles.noticeTitle}>
{t('signup.cellExists.title')}
</h2>
<button
type="button"
className={styles.noticeClose}
onClick={() => setCellExistsSites(null)}
disabled={isSubmitting}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<p className={styles.noticeBody}>
{renderWithMeshkeeLink(
cellExistsSites
? t('signup.error.cellExists', { sites: cellExistsSites })
: t('signup.error.cellExistsGeneric'),
styles.noticeLink,
)}
</p>
<button
type="button"
className={styles.noticeAdmit}
onClick={() => void handleAdmitCellExists()}
disabled={isSubmitting}
>
{isSubmitting ? t('signup.creating') : t('signup.cellExists.admit')}
</button>
</div>
</div>,
document.body,
)}
</div>
)
}