DRY and improve sign in/up design

This commit is contained in:
MartinBraquet
2026-06-09 00:02:54 +02:00
parent 6e7be81902
commit 98ef626455
5 changed files with 325 additions and 220 deletions

View File

@@ -1326,6 +1326,7 @@
"signin.seo.title": "Anmelden",
"signin.submit": "Anmelden",
"signin.title": "Anmelden",
"signin.subtitle": "Willkommen zurück — mach da weiter, wo du aufgehört hast.",
"signup.creating_profile": "Profil wird erstellt...",
"signup.email": "E-Mail",
"signup.email_placeholder": "sie@beispiel.de",

View File

@@ -1325,6 +1325,7 @@
"signin.seo.title": "Se connecter",
"signin.submit": "Se connecter",
"signin.title": "Se connecter",
"signin.subtitle": "Bon retour — reprends là où tu t'étais arrêté.",
"signup.creating_profile": "Création de votre profil...",
"signup.email": "E-mail",
"signup.email_placeholder": "vous@exemple.com",

View File

@@ -0,0 +1,146 @@
import clsx from 'clsx'
import {InputHTMLAttributes, ReactNode} from 'react'
// Shared building blocks for the /register and /signin pages so the two stay in
// visual lockstep. Mirrors the home page design language: warm tokens, a soft
// radial glow for depth, and staggered fade-up entrances.
/** Page wrapper: centers the card and paints the hero-style radial glow behind it. */
export function AuthShell({children}: {children: ReactNode}) {
return (
<div className="relative min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
{/* Soft radial glow behind the card for depth — mirrors the home hero.
Dark mode uses a brighter, lighter-hued, faster-falloff core so it reads
as light, not brown haze. */}
{/*<div*/}
{/* aria-hidden*/}
{/* className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[420px] bg-[radial-gradient(ellipse_60%_55%_at_50%_28%,rgba(193,127,62,0.16),transparent_70%)] dark:h-[360px] dark:bg-[radial-gradient(ellipse_48%_42%_at_50%_22%,rgba(224,162,102,0.22),rgba(193,127,62,0.06)_45%,transparent_64%)]"*/}
{/*/>*/}
<div className="max-w-md w-full space-y-8">{children}</div>
</div>
)
}
/** Favicon + title + optional subtitle. */
export function AuthHeader({title, subtitle}: {title: string; subtitle?: string}) {
return (
<div className="animate-fade-up">
{/*<div className="flex justify-center mb-6">*/}
{/* <FavIconBlack className="dark:invert" />*/}
{/*</div>*/}
<h2 className="text-center text-3xl font-extrabold tracking-tight text-ink-1000">{title}</h2>
{subtitle && <p className="mt-3 text-center text-ink-600">{subtitle}</p>}
</div>
)
}
/** The form element, with the standard entrance animation. */
export function AuthForm({
onSubmit,
children,
}: {
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
children: ReactNode
}) {
return (
<form
className="mt-8 space-y-6 animate-fade-up"
style={{animationDelay: '80ms'}}
onSubmit={onSubmit}
>
{children}
</form>
)
}
/** Wraps stacked inputs so adjacent borders collapse into one. */
export function AuthFieldGroup({children}: {children: ReactNode}) {
return <div className="-space-y-px">{children}</div>
}
const authInputBase =
'bg-canvas-50 appearance-none relative block w-full px-4 py-3 border-[1.5px] border-canvas-200 text-ink-1000 placeholder-ink-400 transition-colors focus:outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:z-10 sm:text-sm'
/** Email/password input with a screen-reader label. `position` rounds the outer corner. */
export function AuthInput({
position,
label,
below,
className,
...props
}: {
position: 'top' | 'bottom'
label: string
below?: ReactNode
} & InputHTMLAttributes<HTMLInputElement>) {
return (
<div>
<label htmlFor={props.id} className="sr-only">
{label}
</label>
<input
className={clsx(
authInputBase,
position === 'top' ? 'rounded-t-xl' : 'rounded-b-xl',
className,
)}
{...props}
/>
{below}
</div>
)
}
/** Gradient rule with a centered label. */
export function AuthDivider({label}: {label: string}) {
return (
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden>
<div className="w-full h-px bg-gradient-to-r from-transparent via-canvas-300 to-transparent" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-3 body-bg text-ink-500">{label}</span>
</div>
</div>
)
}
/** Primary CTA, matching the home hero button (amber, shadow, hover lift). */
export function AuthSubmitButton({
isLoading,
children,
}: {
isLoading?: boolean
children: ReactNode
}) {
return (
<button
type="submit"
disabled={isLoading}
className={clsx(
'group relative w-full flex justify-center py-3.5 px-4 text-[15px] font-bold rounded-xl text-white bg-primary-500 shadow-[0_4px_16px_rgba(193,127,62,0.35)] transition-all duration-150 hover:bg-primary-600 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(193,127,62,0.4)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
isLoading && 'opacity-70 cursor-not-allowed hover:translate-y-0',
)}
>
{children}
</button>
)
}
/** Centered error message. Renders nothing when empty. */
export function AuthError({children}: {children: ReactNode}) {
if (!children) return null
return <div className="text-red-500 text-sm text-center">{children}</div>
}
/** Footer line ("Already have an account?" etc.) with link styling + entrance. */
export function AuthFooter({children}: {children: ReactNode}) {
return (
<div
className="text-center text-ink-600 custom-link animate-fade-up"
style={{animationDelay: '160ms'}}
>
<p>{children}</p>
</div>
)
}

View File

@@ -6,10 +6,21 @@ import Link from 'next/link'
import {useSearchParams} from 'next/navigation'
import React, {Suspense, useState} from 'react'
import toast from 'react-hot-toast'
import {
AuthDivider,
AuthError,
AuthFieldGroup,
AuthFooter,
AuthForm,
AuthHeader,
AuthInput,
AuthShell,
AuthSubmitButton,
} from 'web/components/auth/auth-form'
import {GoogleButton} from 'web/components/buttons/sign-up-button'
import FavIconBlack from 'web/components/FavIcon'
import {PageBase} from 'web/components/page-base'
import {SEO} from 'web/components/SEO'
import {NewTabLink} from 'web/components/widgets/new-tab-link'
import {auth} from 'web/lib/firebase/users'
import {useT} from 'web/lib/locale'
import {googleSigninSignup, setOnboardingFlag, signinSignupRedirect} from 'web/lib/util/signup'
@@ -112,146 +123,110 @@ function RegisterComponent() {
description={t('register.seo.description', 'Register for a new account')}
url={`/register`}
/>
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
{registrationSuccess ? (
<div className="text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<svg
className="h-6 w-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="mt-6 text-3xl font-extrabold ">
{t('register.check_email.title', 'Check your email')}
</h2>
<p className="mt-2 text-sm text-gray-600">
{t('register.check_email.sent_prefix', 'We have sent a verification link to ')}
<span className="font-medium">{registeredEmail}</span>
{t('register.check_email.sent_suffix', '.')}
</p>
<p className="mt-4 text-sm text-gray-500">
{t(
'register.check_email.help_prefix',
'Did not receive the email? Check your spam folder or ',
)}
<button
type="button"
className="font-medium text-blue-600 hover:text-blue-500"
onClick={() => setRegistrationSuccess(false)}
>
{t('register.check_email.try_again', 'try again')}
</button>
{t('register.check_email.help_suffix', '.')}
</p>
<div className="mt-6">
<Link
href="/signin"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
{t('register.back_to_login', 'Back to Login')}
</Link>
</div>
<AuthShell>
{registrationSuccess ? (
<div className="text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<svg
className="h-6 w-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
) : (
<div>
<div>
{/*<h2 className="mt-6 text-center text-xl font-extrabold text-red-700">*/}
{/* The project is still in development...*/}
{/*</h2>*/}
<div className="flex justify-center mb-6">
<FavIconBlack className="dark:invert" />
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold ">
{t('register.get_started', 'Get Started')}
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="bg-canvas-50 appearance-none rounded-none relative block w-full px-3 py-2 border rounded-t-md border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Email"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="bg-canvas-50 bg-input appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder={t('register.password_placeholder', 'Password')}
/>
</div>
</div>
<div>
<p className="text-sm mt-2 text-center custom-link">
{t('register.agreement.prefix', 'By signing up, I agree to the ')}
<Link href="/terms">{t('register.terms', 'Terms and Conditions')}</Link>
{t('register.agreement.and', ' and ')}
<Link href="/privacy">{t('register.privacy', 'Privacy Policy')}</Link>
{t('register.agreement.suffix', '.')}
</p>
</div>
{error && <div className="text-red-500 text-sm text-center">{error}</div>}
<div className="space-y-4">
<button
type="submit"
disabled={isLoading}
className={`group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-full text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 ${isLoading ? 'opacity-70 cursor-not-allowed' : ''}`}
>
{isLoading
? t('register.button.creating', 'Creating account...')
: t('register.button.email', 'Sign up with Email')}
</button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 body-bg text-gray-500">
{t('register.or_sign_up_with', 'Or sign up with')}
</span>
</div>
</div>
<GoogleButton onClick={googleSigninSignup} isLoading={isLoading} />
</div>
</form>
<div className="my-8" />
<div className="text-center custom-link">
<p className="">
{t('register.already_account', 'Already have an account?')}{' '}
<Link href="/signin">{t('register.link_signin', 'Sign in')}</Link>
</p>
</div>
<h2 className="mt-6 text-3xl font-extrabold ">
{t('register.check_email.title', 'Check your email')}
</h2>
<p className="mt-2 text-sm text-gray-600">
{t('register.check_email.sent_prefix', 'We have sent a verification link to ')}
<span className="font-medium">{registeredEmail}</span>
{t('register.check_email.sent_suffix', '.')}
</p>
<p className="mt-4 text-sm text-gray-500">
{t(
'register.check_email.help_prefix',
'Did not receive the email? Check your spam folder or ',
)}
<button
type="button"
className="font-medium text-blue-600 hover:text-blue-500"
onClick={() => setRegistrationSuccess(false)}
>
{t('register.check_email.try_again', 'try again')}
</button>
{t('register.check_email.help_suffix', '.')}
</p>
<div className="mt-6">
<Link
href="/signin"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
{t('register.back_to_login', 'Back to Login')}
</Link>
</div>
)}
</div>
</div>
</div>
) : (
<>
<AuthHeader
title={t('register.get_started', 'Get Started')}
subtitle={t('register.subtitle', 'Create your free account — no algorithms, no ads.')}
/>
<AuthForm onSubmit={handleSubmit}>
<AuthFieldGroup>
<AuthInput
id="email"
name="email"
type="email"
required
position="top"
label="Email"
placeholder="Email"
/>
<AuthInput
id="password"
name="password"
type="password"
required
position="bottom"
label="Password"
placeholder={t('register.password_placeholder', 'Password')}
/>
</AuthFieldGroup>
<p className="text-sm mt-2 text-center text-ink-600 custom-link">
{t('register.agreement.prefix', 'By signing up, I agree to the ')}
<NewTabLink href="/terms">{t('register.terms', 'Terms and Conditions')}</NewTabLink>
{t('register.agreement.and', ' and ')}
<NewTabLink href="/privacy">{t('register.privacy', 'Privacy Policy')}</NewTabLink>
{t('register.agreement.suffix', '.')}
</p>
<AuthError>{error}</AuthError>
<div className="space-y-4">
<AuthSubmitButton isLoading={isLoading}>
{isLoading
? t('register.button.creating', 'Creating account...')
: t('register.button.email', 'Sign up with Email')}
</AuthSubmitButton>
<AuthDivider label={t('register.or_sign_up_with', 'Or sign up with')} />
<GoogleButton onClick={googleSigninSignup} isLoading={isLoading} />
</div>
</AuthForm>
<AuthFooter>
{t('register.already_account', 'Already have an account?')}{' '}
<Link href="/signin">{t('register.link_signin', 'Sign in')}</Link>
</AuthFooter>
</>
)}
</AuthShell>
</PageBase>
)
}

View File

@@ -5,8 +5,18 @@ import {signInWithEmailAndPassword} from 'firebase/auth'
import Link from 'next/link'
import {useSearchParams} from 'next/navigation'
import React, {Suspense, useEffect, useState} from 'react'
import {
AuthDivider,
AuthError,
AuthFieldGroup,
AuthFooter,
AuthForm,
AuthHeader,
AuthInput,
AuthShell,
AuthSubmitButton,
} from 'web/components/auth/auth-form'
import {GoogleButton} from 'web/components/buttons/sign-up-button'
import FavIconBlack from 'web/components/FavIcon'
import {InfoIcon} from 'web/components/icons'
import {PageBase} from 'web/components/page-base'
import {SEO} from 'web/components/SEO'
@@ -129,55 +139,43 @@ function RegisterComponent() {
description={t('signin.seo.description', 'Sign in to your account')}
url={`/signin`}
/>
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
{redirectPath && (
<div className="bg-primary-100 border border-primary-200 rounded-lg p-4 flex items-center gap-3">
<InfoIcon className="w-5 h-5 text-primary-700 flex-shrink-0" />
<p className="text-primary-700 text-sm">
{t(
'signin.prompt.sign_in_to_access',
'Please sign in to access the {redirectPath} page',
{redirectPath: redirectPath.replace('/', '')},
)}
</p>
</div>
)}
<div>
<div className="flex justify-center mb-6">
<FavIconBlack className="dark:invert" />
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold ">
{t('signin.title', 'Sign in')}
</h2>
<AuthShell>
{redirectPath && (
<div className="bg-primary-100 border border-primary-200 rounded-lg p-4 flex items-center gap-3">
<InfoIcon className="w-5 h-5 text-primary-700 flex-shrink-0" />
<p className="text-primary-700 text-sm">
{t(
'signin.prompt.sign_in_to_access',
'Please sign in to access the {redirectPath} page',
{redirectPath: redirectPath.replace('/', '')},
)}
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="bg-canvas-50 appearance-none rounded-none relative block w-full px-3 py-2 border rounded-t-md border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Email"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="bg-canvas-50 appearance-none rounded-none relative block w-full px-3 py-2 border rounded-b-md border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder={t('signin.password_placeholder', 'Your password')}
/>
)}
<AuthHeader
title={t('signin.title', 'Sign in')}
subtitle={t('signin.subtitle', 'Welcome back — pick up where you left off.')}
/>
<AuthForm onSubmit={handleSubmit}>
<AuthFieldGroup>
<AuthInput
id="email"
name="email"
type="email"
required
position="top"
label="Email"
placeholder="Email"
/>
<AuthInput
id="password"
name="password"
type="password"
required
position="bottom"
label="Password"
placeholder={t('signin.password_placeholder', 'Your password')}
below={
<div className="text-right mt-1 custom-link">
<button
type="button"
@@ -201,41 +199,25 @@ function RegisterComponent() {
{t('signin.forgot_password', 'Forgot password?')}
</button>
</div>
</div>
</div>
}
/>
</AuthFieldGroup>
{error && <div className="text-red-500 text-sm text-center">{error}</div>}
<AuthError>{error}</AuthError>
<div className="space-y-4">
<button
type="submit"
disabled={isLoading}
className={`group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-full text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 ${isLoading ? 'opacity-70 cursor-not-allowed' : ''}`}
>
{isLoading ? 'Signing in...' : t('signin.submit', 'Sign in with Email')}
</button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 body-bg text-gray-500">
{t('signin.continue', 'Or continue with')}
</span>
</div>
</div>
<GoogleButton onClick={handleGoogleSignIn} isLoading={isLoading} />
</div>
</form>
<div className="text-center custom-link">
<p className="mt-4 text-sm">
{t('signin.no_account', "Don't have an account?")}{' '}
<Link href="/register">{t('signin.link_sign_up', 'Register')}</Link>
</p>
<div className="space-y-4">
<AuthSubmitButton isLoading={isLoading}>
{isLoading ? 'Signing in...' : t('signin.submit', 'Sign in with Email')}
</AuthSubmitButton>
<AuthDivider label={t('signin.continue', 'Or continue with')} />
<GoogleButton onClick={handleGoogleSignIn} isLoading={isLoading} />
</div>
</div>
</div>
</AuthForm>
<AuthFooter>
{t('signin.no_account', "Don't have an account?")}{' '}
<Link href="/register">{t('signin.link_sign_up', 'Register')}</Link>
</AuthFooter>
</AuthShell>
</PageBase>
)
}