feat: compact login page with collapsible email, icon OAuth row, safe areas

- OAuth providers: horizontal icon row with labels instead of stacked full-width buttons
- Email form: collapsed by default behind "Войти по Email" pill toggle with smooth CSS Grid animation
- Telegram safe areas: use useTelegramSDK() for top/bottom insets with env() CSS fallback
- Compact layout: smaller logo (48x48), tighter spacing, fits on one mobile screen
- Language switcher offset by safe area to avoid Telegram header overlap
- 100dvh for proper Telegram Mini App viewport handling
This commit is contained in:
Fringg
2026-02-11 05:29:19 +03:00
parent a744b41910
commit 45cbfb5ecb

View File

@@ -15,7 +15,7 @@ import {
type EmailAuthEnabled, type EmailAuthEnabled,
} from '../api/branding'; } from '../api/branding';
import { getAndClearReturnUrl } from '../utils/token'; import { getAndClearReturnUrl } from '../utils/token';
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK'; import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK';
import LanguageSwitcher from '../components/LanguageSwitcher'; import LanguageSwitcher from '../components/LanguageSwitcher';
import TelegramLoginButton from '../components/TelegramLoginButton'; import TelegramLoginButton from '../components/TelegramLoginButton';
import OAuthProviderIcon from '../components/OAuthProviderIcon'; import OAuthProviderIcon from '../components/OAuthProviderIcon';
@@ -54,6 +54,12 @@ export default function Login() {
const [forgotPasswordSent, setForgotPasswordSent] = useState(false); const [forgotPasswordSent, setForgotPasswordSent] = useState(false);
const [forgotPasswordLoading, setForgotPasswordLoading] = useState(false); const [forgotPasswordLoading, setForgotPasswordLoading] = useState(false);
const [forgotPasswordError, setForgotPasswordError] = useState(''); const [forgotPasswordError, setForgotPasswordError] = useState('');
const [showEmailForm, setShowEmailForm] = useState(() => !!referralCode);
// Telegram safe area insets
const { safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
const safeTop = Math.max(safeAreaInset.top, contentSafeAreaInset.top);
const safeBottom = Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom);
// Получаем URL для возврата после авторизации // Получаем URL для возврата после авторизации
const getReturnUrl = useCallback(() => { const getReturnUrl = useCallback(() => {
@@ -313,27 +319,40 @@ export default function Login() {
}; };
return ( return (
<div className="flex min-h-screen items-center justify-center px-4 py-8 sm:px-6 sm:py-12 lg:px-8"> <div
className="flex min-h-[100dvh] items-center justify-center px-4 sm:px-6 lg:px-8"
style={{
paddingTop:
safeTop > 0 ? `${safeTop + 16}px` : 'calc(1rem + env(safe-area-inset-top, 0px))',
paddingBottom:
safeBottom > 0 ? `${safeBottom + 16}px` : 'calc(1rem + env(safe-area-inset-bottom, 0px))',
}}
>
{/* Background gradient */} {/* Background gradient */}
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" /> <div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" /> <div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />
{/* Language switcher */} {/* Language switcher */}
<div className="fixed right-4 top-4 z-50"> <div
className="fixed right-3 z-50"
style={{
top: safeTop > 0 ? `${safeTop + 12}px` : 'calc(12px + env(safe-area-inset-top, 0px))',
}}
>
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
<div className="relative w-full max-w-md space-y-8"> <div className="relative w-full max-w-md space-y-5">
{/* Logo */} {/* Logo & branding */}
<div className="text-center"> <div className="text-center">
<div className="relative mx-auto mb-6 flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-800/80 shadow-md"> <div className="relative mx-auto mb-3 flex h-12 w-12 items-center justify-center overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800/80 shadow-md">
{/* Always show letter as fallback */} {/* Letter fallback */}
<span <span
className={`absolute text-2xl font-bold text-accent-400 transition-opacity duration-200 ${branding?.has_custom_logo && logoLoaded ? 'opacity-0' : 'opacity-100'}`} className={`absolute text-lg font-bold text-accent-400 transition-opacity duration-200 ${branding?.has_custom_logo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}
> >
{appLogo} {appLogo}
</span> </span>
{/* Logo image with smooth fade-in */} {/* Logo image */}
{branding?.has_custom_logo && logoUrl && ( {branding?.has_custom_logo && logoUrl && (
<img <img
src={logoUrl} src={logoUrl}
@@ -343,15 +362,15 @@ export default function Login() {
/> />
)} )}
</div> </div>
{appName && <h1 className="text-3xl font-bold text-dark-50">{appName}</h1>} {appName && <h1 className="text-2xl font-bold text-dark-50">{appName}</h1>}
<p className="mt-2 text-dark-400">{t('auth.loginSubtitle')}</p> <p className="mt-1 text-sm text-dark-400">{t('auth.loginSubtitle')}</p>
{/* Referral Banner - only show when email auth is enabled */} {/* Referral Banner */}
{referralCode && isEmailAuthEnabled && ( {referralCode && isEmailAuthEnabled && (
<div className="mt-4 rounded-xl border border-accent-500/30 bg-accent-500/10 p-3"> <div className="mt-3 rounded-xl border border-accent-500/30 bg-accent-500/10 p-2.5">
<div className="flex items-center gap-2 text-accent-400"> <div className="flex items-center justify-center gap-2 text-accent-400">
<svg <svg
className="h-5 w-5 flex-shrink-0" className="h-4 w-4 flex-shrink-0"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -363,7 +382,7 @@ export default function Login() {
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/> />
</svg> </svg>
<span className="text-sm font-medium">{t('auth.referralInvite')}</span> <span className="text-xs font-medium">{t('auth.referralInvite')}</span>
</div> </div>
</div> </div>
)} )}
@@ -372,9 +391,9 @@ export default function Login() {
{/* Check Email Screen */} {/* Check Email Screen */}
{registeredEmail ? ( {registeredEmail ? (
<div className="card text-center"> <div className="card text-center">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-success-500/20"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-success-500/20">
<svg <svg
className="h-8 w-8 text-success-400" className="h-7 w-7 text-success-400"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -387,14 +406,14 @@ export default function Login() {
/> />
</svg> </svg>
</div> </div>
<h2 className="mb-2 text-xl font-bold text-dark-50"> <h2 className="mb-2 text-lg font-bold text-dark-50">
{t('auth.checkEmail', 'Check your email')} {t('auth.checkEmail', 'Check your email')}
</h2> </h2>
<p className="mb-4 text-dark-400"> <p className="mb-3 text-sm text-dark-400">
{t('auth.verificationSent', 'We sent a verification link to:')} {t('auth.verificationSent', 'We sent a verification link to:')}
</p> </p>
<p className="mb-6 font-medium text-accent-400">{registeredEmail}</p> <p className="mb-4 text-sm font-medium text-accent-400">{registeredEmail}</p>
<p className="mb-6 text-sm text-dark-500"> <p className="mb-5 text-xs text-dark-500">
{t( {t(
'auth.clickLinkToVerify', 'auth.clickLinkToVerify',
'Click the link in the email to verify your account and log in.', 'Click the link in the email to verify your account and log in.',
@@ -411,31 +430,31 @@ export default function Login() {
</button> </button>
</div> </div>
) : ( ) : (
/* Card */ /* Main auth card */
<div className="card"> <div className="card">
{error && ( {error && (
<div className="mb-6 rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-3 text-sm text-error-400"> <div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-2.5 text-sm text-error-400">
{error} {error}
</div> </div>
)} )}
{/* Telegram auth section */} {/* Telegram auth section */}
<div className="space-y-6"> <div className="space-y-3">
<p className="text-center text-sm text-dark-400">{t('auth.registerHint')}</p> <p className="text-center text-xs text-dark-500">{t('auth.registerHint')}</p>
{isLoading && isTelegramWebApp ? ( {isLoading && isTelegramWebApp ? (
<div className="py-8 text-center"> <div className="py-6 text-center">
<div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" /> <div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
<p className="text-sm text-dark-400">{t('auth.authenticating')}</p> <p className="text-sm text-dark-400">{t('auth.authenticating')}</p>
</div> </div>
) : isTelegramWebApp && error ? ( ) : isTelegramWebApp && error ? (
<div className="space-y-4 text-center"> <div className="space-y-3 text-center">
<button <button
onClick={handleRetryTelegramAuth} onClick={handleRetryTelegramAuth}
className="btn-primary mx-auto flex items-center gap-2 px-6 py-3" className="btn-primary mx-auto flex items-center gap-2 px-5 py-2.5"
> >
<svg <svg
className="h-5 w-5" className="h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -461,49 +480,87 @@ export default function Login() {
)} )}
</div> </div>
{/* OAuth providers section */} {/* OAuth providers - compact icon row */}
{oauthProviders.length > 0 && ( {oauthProviders.length > 0 && (
<> <>
<div className="my-6 flex items-center gap-3"> <div className="my-4 flex items-center gap-3">
<div className="h-px flex-1 bg-dark-700" /> <div className="h-px flex-1 bg-dark-700" />
<span className="text-xs text-dark-500">{t('auth.or', 'or')}</span> <span className="text-xs text-dark-500">{t('auth.or', 'or')}</span>
<div className="h-px flex-1 bg-dark-700" /> <div className="h-px flex-1 bg-dark-700" />
</div> </div>
<div className="space-y-3"> <div className="flex items-stretch gap-2">
{oauthProviders.map((provider) => ( {oauthProviders.map((provider) => (
<button <button
key={provider.name} key={provider.name}
type="button" type="button"
onClick={() => handleOAuthLogin(provider.name)} onClick={() => handleOAuthLogin(provider.name)}
disabled={oauthLoading !== null} disabled={oauthLoading !== null}
className="hover:bg-dark-750 flex w-full items-center justify-center gap-3 rounded-xl border border-dark-700 bg-dark-800 px-4 py-3 text-sm font-medium text-dark-100 transition-colors hover:border-dark-600 disabled:opacity-50" className="flex flex-1 flex-col items-center justify-center gap-1.5 rounded-xl border border-dark-700 bg-dark-800/80 py-2.5 transition-all hover:border-dark-600 hover:bg-dark-700 disabled:opacity-50"
title={provider.display_name}
> >
{oauthLoading === provider.name ? ( {oauthLoading === provider.name ? (
<span className="h-5 w-5 animate-spin rounded-full border-2 border-dark-400 border-t-white" /> <span className="h-5 w-5 animate-spin rounded-full border-2 border-dark-400 border-t-white" />
) : ( ) : (
<OAuthProviderIcon provider={provider.name} className="h-5 w-5" /> <OAuthProviderIcon provider={provider.name} className="h-5 w-5" />
)} )}
{t( <span className="text-[10px] leading-none text-dark-500">
`auth.continueWith${provider.name.charAt(0).toUpperCase() + provider.name.slice(1)}`, {provider.display_name}
`Continue with ${provider.display_name}`, </span>
)}
</button> </button>
))} ))}
</div> </div>
</> </>
)} )}
{/* Email auth section - only when enabled */} {/* Email auth section - collapsible */}
{isEmailAuthEnabled && ( {isEmailAuthEnabled && (
<> <>
{/* Divider */} <div className="my-4 flex items-center gap-3">
<div className="my-6 flex items-center gap-3">
<div className="h-px flex-1 bg-dark-700" /> <div className="h-px flex-1 bg-dark-700" />
<span className="text-xs text-dark-500">{t('auth.or', 'or')}</span> <button
type="button"
onClick={() => setShowEmailForm(!showEmailForm)}
className="flex items-center gap-1.5 rounded-full border border-dark-700 bg-dark-800/60 px-3.5 py-1.5 text-xs font-medium text-dark-300 transition-all hover:border-dark-600 hover:bg-dark-700 hover:text-dark-200"
>
<svg
className="h-3.5 w-3.5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
<span>{t('auth.loginWithEmail')}</span>
<svg
className={`h-3 w-3 text-dark-400 transition-transform duration-300 ${showEmailForm ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="h-px flex-1 bg-dark-700" /> <div className="h-px flex-1 bg-dark-700" />
</div> </div>
<div className="space-y-5"> {/* Collapsible email form */}
<div
className={`grid transition-[grid-template-rows] duration-300 ease-in-out ${
showEmailForm ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
}`}
>
<div
className={`overflow-hidden transition-opacity duration-300 ${
showEmailForm ? 'opacity-100' : 'opacity-0'
}`}
>
<div className="space-y-4 pb-1 pt-1">
{/* Login / Register toggle */} {/* Login / Register toggle */}
<div className="flex rounded-lg bg-dark-800 p-1"> <div className="flex rounded-lg bg-dark-800 p-1">
<button <button
@@ -530,7 +587,7 @@ export default function Login() {
</button> </button>
</div> </div>
<form className="space-y-4" onSubmit={handleEmailSubmit}> <form className="space-y-3" onSubmit={handleEmailSubmit}>
{/* First name field - only for registration */} {/* First name field - only for registration */}
{authMode === 'register' && ( {authMode === 'register' && (
<div> <div>
@@ -575,7 +632,9 @@ export default function Login() {
id="password" id="password"
name="password" name="password"
type="password" type="password"
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'} autoComplete={
authMode === 'login' ? 'current-password' : 'new-password'
}
required required
className="input" className="input"
placeholder="••••••••" placeholder="••••••••"
@@ -604,7 +663,11 @@ export default function Login() {
</div> </div>
)} )}
<button type="submit" disabled={isLoading} className="btn-primary w-full py-3"> <button
type="submit"
disabled={isLoading}
className="btn-primary w-full py-2.5"
>
{isLoading ? ( {isLoading ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
@@ -630,7 +693,7 @@ export default function Login() {
{/* Forgot password link - only for login */} {/* Forgot password link - only for login */}
{authMode === 'login' && ( {authMode === 'login' && (
<div className="space-y-2 text-center"> <div className="text-center">
<button <button
type="button" type="button"
onClick={() => setShowForgotPassword(true)} onClick={() => setShowForgotPassword(true)}
@@ -641,6 +704,8 @@ export default function Login() {
</div> </div>
)} )}
</div> </div>
</div>
</div>
</> </>
)} )}
</div> </div>
@@ -651,10 +716,10 @@ export default function Login() {
{showForgotPassword && ( {showForgotPassword && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={closeForgotPasswordModal} /> <div className="absolute inset-0 bg-black/60" onClick={closeForgotPasswordModal} />
<div className="relative w-full max-w-md rounded-2xl border border-dark-700 bg-dark-900 p-6"> <div className="relative w-full max-w-sm rounded-2xl border border-dark-700 bg-dark-900 p-5">
<button <button
onClick={closeForgotPasswordModal} onClick={closeForgotPasswordModal}
className="absolute right-4 top-4 text-dark-400 transition-colors hover:text-dark-200" className="absolute right-3 top-3 text-dark-400 transition-colors hover:text-dark-200"
> >
<svg <svg
className="h-5 w-5" className="h-5 w-5"
@@ -669,9 +734,9 @@ export default function Login() {
{forgotPasswordSent ? ( {forgotPasswordSent ? (
<div className="text-center"> <div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-success-500/20"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-success-500/20">
<svg <svg
className="h-8 w-8 text-success-400" className="h-7 w-7 text-success-400"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -684,10 +749,10 @@ export default function Login() {
/> />
</svg> </svg>
</div> </div>
<h3 className="mb-2 text-xl font-bold text-dark-50"> <h3 className="mb-2 text-lg font-bold text-dark-50">
{t('auth.checkEmail', 'Check your email')} {t('auth.checkEmail', 'Check your email')}
</h3> </h3>
<p className="mb-4 text-dark-400"> <p className="mb-4 text-sm text-dark-400">
{t( {t(
'auth.passwordResetSent', 'auth.passwordResetSent',
'If an account exists with this email, we sent password reset instructions.', 'If an account exists with this email, we sent password reset instructions.',
@@ -699,17 +764,17 @@ export default function Login() {
</div> </div>
) : ( ) : (
<> <>
<h3 className="mb-2 text-xl font-bold text-dark-50"> <h3 className="mb-2 text-lg font-bold text-dark-50">
{t('auth.forgotPassword', 'Forgot password?')} {t('auth.forgotPassword', 'Forgot password?')}
</h3> </h3>
<p className="mb-6 text-dark-400"> <p className="mb-4 text-sm text-dark-400">
{t( {t(
'auth.forgotPasswordHint', 'auth.forgotPasswordHint',
'Enter your email and we will send you instructions to reset your password.', 'Enter your email and we will send you instructions to reset your password.',
)} )}
</p> </p>
<form onSubmit={handleForgotPassword} className="space-y-4"> <form onSubmit={handleForgotPassword} className="space-y-3">
<div> <div>
<label htmlFor="forgotEmail" className="label"> <label htmlFor="forgotEmail" className="label">
Email Email
@@ -726,7 +791,7 @@ export default function Login() {
</div> </div>
{forgotPasswordError && ( {forgotPasswordError && (
<div className="rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-3 text-sm text-error-400"> <div className="rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-2.5 text-sm text-error-400">
{forgotPasswordError} {forgotPasswordError}
</div> </div>
)} )}