feat(auth): unified auth card per design-04 — 7D.3
This commit is contained in:
@@ -1,140 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api } from '@/lib/api';
|
||||
import { setToken } from '@/lib/auth';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
Stack,
|
||||
} from '@/components/ui/primitives';
|
||||
import AuthCard from '@/components/auth/AuthCard';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.post('/auth/login', { email, password });
|
||||
setToken(r.data.accessToken);
|
||||
router.replace(r.data.user.onboarded ? '/dashboard' : '/onboarding');
|
||||
} catch (e) {
|
||||
setErr('이메일 또는 비밀번호가 맞지 않아');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Hero>
|
||||
<HeroTitle>ReLoop</HeroTitle>
|
||||
<HeroSub>적응형 복습 스케줄링 · 페르소나 망각 곡선 기반</HeroSub>
|
||||
</Hero>
|
||||
<FormCard>
|
||||
<FormTitle>로그인</FormTitle>
|
||||
<form onSubmit={submit}>
|
||||
<Stack $gap={theme.space.md}>
|
||||
<div>
|
||||
<Label>이메일</Label>
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>비밀번호</Label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{err && <ErrorText>{err}</ErrorText>}
|
||||
<Button type="submit" $block $size="lg" disabled={loading}>
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
<SubRow>
|
||||
<span>계정이 없어?</span>
|
||||
<Link href="/register">회원가입</Link>
|
||||
</SubRow>
|
||||
</FormCard>
|
||||
</Page>
|
||||
);
|
||||
return <AuthCard initialTab="login" />;
|
||||
}
|
||||
|
||||
const Page = styled.div`
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: ${theme.space.lg};
|
||||
gap: ${theme.space.xl};
|
||||
`;
|
||||
|
||||
const Hero = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const HeroTitle = styled.h1`
|
||||
font-size: 44px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, #818cf8, #c084fc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const HeroSub = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
const FormCard = styled(Card)`
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
`;
|
||||
|
||||
const FormTitle = styled.h2`
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const SubRow = styled.div`
|
||||
margin-top: ${theme.space.lg};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
|
||||
a {
|
||||
color: ${theme.color.accent};
|
||||
font-weight: 600;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,129 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api } from '@/lib/api';
|
||||
import { setToken } from '@/lib/auth';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
Stack,
|
||||
} from '@/components/ui/primitives';
|
||||
import AuthCard from '@/components/auth/AuthCard';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.post('/auth/register', { email, password, nickname });
|
||||
setToken(r.data.accessToken);
|
||||
router.replace('/onboarding');
|
||||
} catch (e: any) {
|
||||
if (e.response?.status === 409) {
|
||||
setErr('이미 등록된 이메일이야');
|
||||
} else {
|
||||
setErr('회원가입 실패 — 다시 시도해 줘');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<FormCard>
|
||||
<FormTitle>회원가입</FormTitle>
|
||||
<form onSubmit={submit}>
|
||||
<Stack $gap={theme.space.md}>
|
||||
<div>
|
||||
<Label>이메일</Label>
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>비밀번호 (6자 이상)</Label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>닉네임</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
required
|
||||
maxLength={32}
|
||||
/>
|
||||
</div>
|
||||
{err && <ErrorText>{err}</ErrorText>}
|
||||
<Button type="submit" $block $size="lg" disabled={loading}>
|
||||
{loading ? '가입 중...' : '가입하기'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
<SubRow>
|
||||
<span>이미 계정이 있어?</span>
|
||||
<Link href="/login">로그인</Link>
|
||||
</SubRow>
|
||||
</FormCard>
|
||||
</Page>
|
||||
);
|
||||
return <AuthCard initialTab="register" />;
|
||||
}
|
||||
|
||||
const Page = styled.div`
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const FormCard = styled(Card)`
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
`;
|
||||
|
||||
const FormTitle = styled.h2`
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const SubRow = styled.div`
|
||||
margin-top: ${theme.space.lg};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
|
||||
a {
|
||||
color: ${theme.color.accent};
|
||||
font-weight: 600;
|
||||
}
|
||||
`;
|
||||
|
||||
561
frontend/src/components/auth/AuthCard.tsx
Normal file
561
frontend/src/components/auth/AuthCard.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { api } from '@/lib/api';
|
||||
import { setToken } from '@/lib/auth';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { Button, Card, Input } from '@/components/ui/primitives';
|
||||
import { animations, theme } from '@/styles/theme';
|
||||
|
||||
type AuthTab = 'login' | 'register';
|
||||
|
||||
interface AuthCardProps {
|
||||
initialTab: AuthTab;
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /\S+@\S+\.\S+/;
|
||||
|
||||
export default function AuthCard({ initialTab }: AuthCardProps) {
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<AuthTab>(initialTab);
|
||||
const [contentKey, setContentKey] = useState(0);
|
||||
const [email, setEmail] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shaking, setShaking] = useState(false);
|
||||
const shakeTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTab(initialTab);
|
||||
setContentKey((value) => value + 1);
|
||||
setErr(null);
|
||||
}, [initialTab]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (shakeTimeoutRef.current) {
|
||||
window.clearTimeout(shakeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const triggerError = (message: string) => {
|
||||
setErr(message);
|
||||
setShaking(false);
|
||||
if (shakeTimeoutRef.current) {
|
||||
window.clearTimeout(shakeTimeoutRef.current);
|
||||
}
|
||||
window.requestAnimationFrame(() => {
|
||||
setShaking(true);
|
||||
shakeTimeoutRef.current = window.setTimeout(() => {
|
||||
setShaking(false);
|
||||
}, 400);
|
||||
});
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
if (!EMAIL_REGEX.test(email)) {
|
||||
return '올바른 이메일 형식이 아니야';
|
||||
}
|
||||
|
||||
if (password.length < 4) {
|
||||
return '비밀번호는 4자 이상 입력해 줘';
|
||||
}
|
||||
|
||||
if (tab === 'register' && !nickname.trim()) {
|
||||
return '닉네임을 입력해 줘';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleLogin = async (nextEmail: string, nextPassword: string) => {
|
||||
const response = await api.post('/auth/login', {
|
||||
email: nextEmail,
|
||||
password: nextPassword,
|
||||
});
|
||||
setToken(response.data.accessToken);
|
||||
router.replace(response.data.user.onboarded ? '/dashboard' : '/onboarding');
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErr(null);
|
||||
|
||||
const validationMessage = validate();
|
||||
if (validationMessage) {
|
||||
triggerError(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (tab === 'login') {
|
||||
await handleLogin(email, password);
|
||||
} else {
|
||||
const response = await api.post('/auth/register', {
|
||||
email,
|
||||
password,
|
||||
nickname: nickname.trim(),
|
||||
});
|
||||
setToken(response.data.accessToken);
|
||||
router.replace('/onboarding');
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (tab === 'register') {
|
||||
if (error.response?.status === 409) {
|
||||
triggerError('이미 등록된 이메일이야');
|
||||
} else {
|
||||
triggerError('회원가입 실패 — 다시 시도해 줘');
|
||||
}
|
||||
} else {
|
||||
triggerError('이메일 또는 비밀번호가 맞지 않아');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDemoLogin = async () => {
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await handleLogin('demo@reloop.local', 'demo1234');
|
||||
} catch {
|
||||
triggerError('데모 로그인에 실패했어');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const switchTab = (nextTab: AuthTab) => {
|
||||
if (nextTab === tab || loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTab(nextTab);
|
||||
setContentKey((value) => value + 1);
|
||||
setErr(null);
|
||||
};
|
||||
|
||||
const isLogin = tab === 'login';
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<GlowTop />
|
||||
<GlowBottom />
|
||||
<Shell>
|
||||
<BrandBlock>
|
||||
<LogoMark>
|
||||
<LogoGlow />
|
||||
<LogoGlyph>
|
||||
<Icon name="infinity" weight="bold" size={30} />
|
||||
</LogoGlyph>
|
||||
</LogoMark>
|
||||
<BrandTitle>ReLoop</BrandTitle>
|
||||
<BrandSub>다시 보기 직전의 딱 한 순간</BrandSub>
|
||||
</BrandBlock>
|
||||
|
||||
<AuthSurface as="section">
|
||||
<TabRail aria-label="인증 탭">
|
||||
<TabIndicator $tab={tab} />
|
||||
<TabButton
|
||||
type="button"
|
||||
$active={isLogin}
|
||||
onClick={() => switchTab('login')}
|
||||
>
|
||||
로그인
|
||||
</TabButton>
|
||||
<TabButton
|
||||
type="button"
|
||||
$active={!isLogin}
|
||||
onClick={() => switchTab('register')}
|
||||
>
|
||||
회원가입
|
||||
</TabButton>
|
||||
</TabRail>
|
||||
|
||||
<ContentPanel key={contentKey}>
|
||||
<Form $shaking={shaking} onSubmit={handleSubmit} noValidate>
|
||||
{tab === 'register' && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="auth-nickname">닉네임</FieldLabel>
|
||||
<InputFrame $invalid={!!err && !nickname.trim()}>
|
||||
<FieldInput
|
||||
id="auth-nickname"
|
||||
type="text"
|
||||
autoComplete="nickname"
|
||||
placeholder="목표를 이룰 이름"
|
||||
value={nickname}
|
||||
onChange={(event) => setNickname(event.target.value)}
|
||||
maxLength={32}
|
||||
/>
|
||||
</InputFrame>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="auth-email">이메일</FieldLabel>
|
||||
<InputFrame $invalid={!!err && !EMAIL_REGEX.test(email)}>
|
||||
<FieldInput
|
||||
id="auth-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="hello@example.com"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
</InputFrame>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="auth-password">비밀번호</FieldLabel>
|
||||
<InputFrame $invalid={!!err && password.length > 0 && password.length < 4}>
|
||||
<FieldInput
|
||||
id="auth-password"
|
||||
type="password"
|
||||
autoComplete={isLogin ? 'current-password' : 'new-password'}
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</InputFrame>
|
||||
</Field>
|
||||
|
||||
{err && (
|
||||
<ErrorBox role="alert" aria-live="polite">
|
||||
<Icon name="info" size={18} weight="bold" />
|
||||
<span>{err}</span>
|
||||
</ErrorBox>
|
||||
)}
|
||||
|
||||
<PrimaryButton type="submit" disabled={loading}>
|
||||
{loading
|
||||
? isLogin
|
||||
? '로그인 중...'
|
||||
: '가입 중...'
|
||||
: isLogin
|
||||
? '로그인'
|
||||
: '회원가입'}
|
||||
</PrimaryButton>
|
||||
|
||||
<DemoButton type="button" onClick={handleDemoLogin} disabled={loading}>
|
||||
로그인 없이 체험하기
|
||||
</DemoButton>
|
||||
</Form>
|
||||
</ContentPanel>
|
||||
</AuthSurface>
|
||||
</Shell>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const Page = styled.main`
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(79, 70, 229, 0.12), transparent 34%),
|
||||
radial-gradient(circle at bottom right, rgba(124, 58, 237, 0.12), transparent 38%),
|
||||
${theme.color.bgDeep};
|
||||
color: ${theme.color.textBright};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 20px;
|
||||
`;
|
||||
|
||||
const GlowOrb = styled.div`
|
||||
position: absolute;
|
||||
width: min(48vw, 520px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
filter: blur(120px);
|
||||
pointer-events: none;
|
||||
opacity: 0.55;
|
||||
`;
|
||||
|
||||
const GlowTop = styled(GlowOrb)`
|
||||
top: -18%;
|
||||
left: -14%;
|
||||
background: rgba(79, 70, 229, 0.18);
|
||||
`;
|
||||
|
||||
const GlowBottom = styled(GlowOrb)`
|
||||
right: -14%;
|
||||
bottom: -18%;
|
||||
background: rgba(124, 58, 237, 0.18);
|
||||
`;
|
||||
|
||||
const Shell = styled.div`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
`;
|
||||
|
||||
const BrandBlock = styled.header`
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const LogoMark = styled.div`
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
`;
|
||||
|
||||
const LogoGlow = styled.div`
|
||||
position: absolute;
|
||||
inset: 8px;
|
||||
border-radius: 999px;
|
||||
background: ${theme.color.brandGradient};
|
||||
filter: blur(20px);
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
const LogoGlyph = styled.div`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.03)),
|
||||
${theme.color.brandGradient};
|
||||
color: white;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
${theme.shadow.glowIndigoStrong};
|
||||
`;
|
||||
|
||||
const BrandTitle = styled.h1`
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
`;
|
||||
|
||||
const BrandSub = styled.p`
|
||||
margin: 0;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
`;
|
||||
|
||||
const AuthSurface = styled(Card)`
|
||||
background: rgba(21, 21, 28, 0.88);
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
box-shadow: ${theme.shadow.cardElevated};
|
||||
backdrop-filter: blur(24px);
|
||||
padding: 24px;
|
||||
|
||||
@media (min-width: ${theme.breakpoint.mobile}) {
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
const TabRail = styled.div`
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
margin-bottom: 22px;
|
||||
border-radius: 18px;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
`;
|
||||
|
||||
const TabIndicator = styled.div<{ $tab: AuthTab }>`
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
left: 6px;
|
||||
width: calc(50% - 9px);
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid ${theme.color.borderBrightAlpha};
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transform: translateX(${({ $tab }) => ($tab === 'register' ? '100%' : '0%')});
|
||||
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
`;
|
||||
|
||||
const TabButton = styled.button<{ $active: boolean }>`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
color: ${({ $active }) =>
|
||||
$active ? theme.color.textBright : 'rgba(248, 250, 252, 0.56)'};
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
`;
|
||||
|
||||
const ContentPanel = styled.div`
|
||||
animation: ${animations.fadeInUp} 0.28s ease-out;
|
||||
`;
|
||||
|
||||
const Form = styled.form<{ $shaking: boolean }>`
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
|
||||
${({ $shaking }) =>
|
||||
$shaking &&
|
||||
css`
|
||||
animation: ${animations.shake} 0.4s cubic-bezier(.36, .07, .19, .97) both;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Field = styled.div`
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const FieldLabel = styled.label`
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
padding-left: 2px;
|
||||
`;
|
||||
|
||||
const InputFrame = styled.div<{ $invalid?: boolean }>`
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: ${({ $invalid }) =>
|
||||
$invalid ? theme.color.danger : theme.color.brandGradient};
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.12);
|
||||
}
|
||||
|
||||
&:focus-within::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${({ $invalid }) =>
|
||||
$invalid &&
|
||||
css`
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.14);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const FieldInput = styled(Input)`
|
||||
background: transparent;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
border-radius: 14px;
|
||||
min-height: 54px;
|
||||
padding: 14px 16px;
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 15px;
|
||||
|
||||
&::placeholder {
|
||||
color: ${theme.color.textMute};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorBox = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.22);
|
||||
color: #fca5a5;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
|
||||
svg {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
}
|
||||
`;
|
||||
|
||||
const PrimaryButton = styled(Button).attrs({
|
||||
$block: true,
|
||||
$size: 'lg',
|
||||
})`
|
||||
margin-top: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.02)),
|
||||
${theme.color.brandGradient};
|
||||
color: white;
|
||||
box-shadow: ${theme.shadow.glowIndigo};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(1.06);
|
||||
box-shadow: ${theme.shadow.glowIndigoStrong};
|
||||
}
|
||||
`;
|
||||
|
||||
const DemoButton = styled.button`
|
||||
width: fit-content;
|
||||
margin: -2px auto 0;
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: ${theme.color.textMute};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: ${theme.color.textSub};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user