feat(auth): unified auth card per design-04 — 7D.3

This commit is contained in:
reloop
2026-04-12 04:24:15 +09:00
parent 0605ba846b
commit 750378c03a
3 changed files with 565 additions and 259 deletions

View File

@@ -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;
}
`;

View File

@@ -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;
}
`;

View 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;
}
`;