feat: 인증 페이지 구현 (로그인/회원가입/이메일 인증) (#4)
- LoginPage: react-hook-form + zod 유효성검사, 비밀번호 토글, shake 애니메이션, 로그인 성공/실패 처리, 성공 오버레이 - SignupPage: 이메일/비밀번호/이름/전화번호 실시간 검증, 전화번호 자동 하이픈, 약관 동의 체크박스, 인증 메일 전송 모달, 이용약관/개인정보 모달 - VerifyEmailPage: 6자리 코드 입력(자동 포커스/붙여넣기), 인증 성공/실패, 재전송 60초 쿨다운, 인증 완료 모달 + 홈 이동 오버레이 - ResetPasswordModal: 비밀번호 재설정 이메일 발송, sonner 토스트 - AuthLayout: flex 기반 풋터 위치 수정 (콘텐츠 중앙 + 풋터 하단) - 라우터: verify-email 가드 추가 (인증 완료 시 홈 리다이렉트) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
db3a22bb57
commit
ccfda47b96
|
|
@ -2,11 +2,14 @@ import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
export default function AuthLayout() {
|
export default function AuthLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center bg-sidebar p-4 antialiased">
|
<div className="flex min-h-screen flex-col items-center bg-sidebar p-4 antialiased">
|
||||||
<Outlet />
|
{/* 콘텐츠 중앙 정렬 */}
|
||||||
|
<div className="flex flex-1 w-full items-center justify-center">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 풋터 */}
|
{/* 풋터 */}
|
||||||
<footer className="absolute bottom-6 w-full text-center">
|
<footer className="pb-6 w-full text-center">
|
||||||
<p className="text-xs font-medium tracking-wide text-gray-500">
|
<p className="text-xs font-medium tracking-wide text-gray-500">
|
||||||
© 2026 Stein Co., Ltd.
|
© 2026 Stein Co., Ltd.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
141
react/src/features/auth/components/ResetPasswordModal.tsx
Normal file
141
react/src/features/auth/components/ResetPasswordModal.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const resetSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, "이메일을 입력해주세요.")
|
||||||
|
.email("올바른 이메일 형식을 입력해주세요."),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ResetForm = z.infer<typeof resetSchema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetPasswordModal({ open, onClose }: Props) {
|
||||||
|
const [shakeFields, setShakeFields] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ResetForm>({
|
||||||
|
resolver: zodResolver(resetSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const triggerShake = useCallback((fieldNames: string[]) => {
|
||||||
|
setShakeFields(new Set(fieldNames));
|
||||||
|
setTimeout(() => setShakeFields(new Set()), 400);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* 유효성 통과 → 발송 처리 */
|
||||||
|
const onSubmit = (_data: ResetForm) => {
|
||||||
|
// TODO: API 연동
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
toast.success("임시 비밀번호가 발송되었습니다.");
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 유효성 실패 → shake */
|
||||||
|
const onError = (fieldErrors: typeof errors) => {
|
||||||
|
triggerShake(Object.keys(fieldErrors));
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 닫기 */
|
||||||
|
const handleClose = () => {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md overflow-hidden rounded-xl bg-white shadow-2xl">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-8 pt-8 pb-4">
|
||||||
|
<h2 className="mb-2 text-xl font-bold text-foreground">
|
||||||
|
비밀번호 재설정
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
|
가입하신 이메일 주소를 입력하시면 임시 비밀번호를 보내드립니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
<form
|
||||||
|
className="px-8 pb-8"
|
||||||
|
onSubmit={handleSubmit(onSubmit, onError)}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-bold text-gray-700">
|
||||||
|
이메일
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="user@spms.com"
|
||||||
|
className={`w-full rounded border bg-white px-4 py-3 font-sans text-sm text-gray-900 placeholder-gray-400 transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary ${
|
||||||
|
errors.email
|
||||||
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
|
: "border-gray-300"
|
||||||
|
} ${shakeFields.has("email") ? "animate-shake" : ""}`}
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{errors.email.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded-lg border border-gray-300 bg-white py-3 px-4 text-sm font-bold text-foreground transition-all hover:bg-gray-50"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 rounded-lg bg-primary py-3 px-4 text-sm font-bold text-white shadow-lg shadow-blue-500/20 transition-all hover:bg-[#1d4ed8] active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
임시 비밀번호 발송
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 풋터 */}
|
||||||
|
<div className="border-t border-gray-100 bg-gray-50 px-8 py-4 text-center">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
도움이 필요하신가요?{" "}
|
||||||
|
<button className="font-medium text-primary hover:underline">
|
||||||
|
관리자 문의하기
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,301 @@
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import ResetPasswordModal from "../components/ResetPasswordModal";
|
||||||
|
|
||||||
|
/* ───── zod 스키마 ───── */
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, "이메일을 입력해주세요.")
|
||||||
|
.email("올바른 이메일 형식을 입력해주세요."),
|
||||||
|
password: z.string().min(1, "비밀번호를 입력해주세요."),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginForm = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setUser = useAuthStore((s) => s.setUser);
|
||||||
|
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [resetOpen, setResetOpen] = useState(false);
|
||||||
|
const [shakeFields, setShakeFields] = useState<Set<string>>(new Set());
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
const [loginSuccess, setLoginSuccess] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginForm>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* shake 트리거 */
|
||||||
|
const triggerShake = useCallback((fieldNames: string[]) => {
|
||||||
|
setShakeFields(new Set(fieldNames));
|
||||||
|
setTimeout(() => setShakeFields(new Set()), 400);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* 유효성 통과 → 로그인 처리 */
|
||||||
|
const onSubmit = async (data: LoginForm) => {
|
||||||
|
setLoginError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// TODO: 실제 API 연동 시 교체
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 테스트 계정
|
||||||
|
* - admin@spms.com / 1234 → 이메일 인증 완료 → 홈 이동
|
||||||
|
* - test@spms.com / 1234 → 이메일 미인증 → 인증 페이지 이동
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
(data.email === "admin@spms.com" || data.email === "test@spms.com") &&
|
||||||
|
data.password === "1234"
|
||||||
|
) {
|
||||||
|
const emailVerified = data.email === "admin@spms.com";
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
setLoginSuccess(true);
|
||||||
|
|
||||||
|
if (emailVerified) {
|
||||||
|
/* 이메일 인증 완료 → 인증 상태 저장 후 홈 이동 */
|
||||||
|
setUser({
|
||||||
|
id: 1,
|
||||||
|
email: data.email,
|
||||||
|
name: "관리자",
|
||||||
|
role: "ADMIN",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
setTimeout(() => navigate("/", { replace: true }), 1000);
|
||||||
|
} else {
|
||||||
|
/* 이메일 미인증 → 인증 페이지로 바로 이동 (인증 상태 저장 안 함) */
|
||||||
|
setTimeout(() => navigate("/verify-email", { replace: true }), 1000);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
setLoginError("이메일 또는 비밀번호가 올바르지 않습니다.");
|
||||||
|
triggerShake(["email", "password"]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 유효성 실패 → shake */
|
||||||
|
const onError = (fieldErrors: typeof errors) => {
|
||||||
|
setLoginError(null);
|
||||||
|
triggerShake(Object.keys(fieldErrors));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center">
|
<>
|
||||||
<h1 className="text-2xl font-bold text-white">로그인</h1>
|
<div className="z-10 w-full max-w-[420px] rounded-xl bg-white p-10 shadow-2xl">
|
||||||
</div>
|
{/* 로고 */}
|
||||||
|
<div className="mb-10 text-center">
|
||||||
|
<h1 className="mb-2 text-4xl font-black tracking-tight text-primary">
|
||||||
|
SPMS
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm font-medium text-gray-500">
|
||||||
|
Stein Push Message Service
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
<form
|
||||||
|
className="space-y-6"
|
||||||
|
onSubmit={handleSubmit(onSubmit, onError)}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
{/* 이메일 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-bold text-gray-700"
|
||||||
|
>
|
||||||
|
이메일
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="user@spms.com"
|
||||||
|
className={`w-full rounded-lg border bg-white px-4 py-3 text-sm text-gray-900 placeholder-gray-400 transition-all focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/50 ${
|
||||||
|
errors.email
|
||||||
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
|
: "border-gray-300"
|
||||||
|
} ${shakeFields.has("email") ? "animate-shake" : ""}`}
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{errors.email.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-bold text-gray-700"
|
||||||
|
>
|
||||||
|
비밀번호
|
||||||
|
</label>
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className={`w-full rounded-lg border bg-white px-4 py-3 pr-12 text-sm text-gray-900 placeholder-gray-400 transition-all focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/50 ${
|
||||||
|
errors.password
|
||||||
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
|
: "border-gray-300"
|
||||||
|
} ${shakeFields.has("password") ? "animate-shake" : ""}`}
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 flex items-center justify-center rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined block text-[20px] leading-none">
|
||||||
|
{showPassword ? "visibility_off" : "visibility"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{errors.password.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그인 실패 메시지 */}
|
||||||
|
{loginError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0 text-lg text-red-500">
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-red-600">
|
||||||
|
{loginError}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로그인 버튼 */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full rounded-lg bg-primary py-3.5 px-4 font-bold text-white shadow-lg shadow-blue-500/20 transition-all hover:bg-[#1d4ed8] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="size-5 animate-spin"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
로그인 중...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"로그인"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 찾기 */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm font-medium text-gray-400 transition-colors hover:text-primary hover:underline"
|
||||||
|
onClick={() => setResetOpen(true)}
|
||||||
|
>
|
||||||
|
비밀번호를 잊으셨나요?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 회원가입 */}
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-sm text-gray-400">계정이 없으신가요?</span>
|
||||||
|
<Link
|
||||||
|
to="/signup"
|
||||||
|
className="ml-1 text-sm font-semibold text-primary transition-colors hover:text-[#1d4ed8] hover:underline"
|
||||||
|
>
|
||||||
|
회원가입
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 재설정 모달 */}
|
||||||
|
<ResetPasswordModal
|
||||||
|
open={resetOpen}
|
||||||
|
onClose={() => setResetOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 로그인 성공 오버레이 */}
|
||||||
|
{loginSuccess && (
|
||||||
|
<div className="fixed inset-0 z-[100] animate-[fadeIn_0.3s_ease]">
|
||||||
|
<div className="absolute inset-0 bg-white/60" />
|
||||||
|
<div className="absolute left-1/2 top-6 z-10 -translate-x-1/2">
|
||||||
|
<div className="flex items-center gap-3 rounded-xl bg-green-600 px-6 py-3.5 text-white shadow-lg">
|
||||||
|
<svg
|
||||||
|
className="size-5 flex-shrink-0 animate-spin"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">로그인 성공</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,468 @@
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
/* ───── 정규식 ───── */
|
||||||
|
const pwRegex =
|
||||||
|
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,}$/;
|
||||||
|
const nameRegex = /^[가-힣]{2,}$/;
|
||||||
|
const phoneRegex = /^\d{2,3}-\d{3,4}-\d{4}$/;
|
||||||
|
|
||||||
|
/* ───── zod 스키마 ───── */
|
||||||
|
const signupSchema = z
|
||||||
|
.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, "이메일을 입력해주세요.")
|
||||||
|
.email("올바른 이메일 형식이 아닙니다."),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(1, "비밀번호를 입력해주세요.")
|
||||||
|
.regex(pwRegex, "8자 이상, 영문/숫자/특수문자 포함"),
|
||||||
|
passwordConfirm: z.string().min(1, "비밀번호 확인을 입력해주세요."),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, "이름을 입력해주세요.")
|
||||||
|
.regex(nameRegex, "한글 2자 이상 입력해주세요."),
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.min(1, "전화번호를 입력해주세요.")
|
||||||
|
.regex(phoneRegex, "올바른 전화번호 형식이 아닙니다."),
|
||||||
|
agree: z.literal(true, {
|
||||||
|
errorMap: () => ({ message: "약관에 동의해주세요." }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.passwordConfirm, {
|
||||||
|
message: "비밀번호가 일치하지 않습니다.",
|
||||||
|
path: ["passwordConfirm"],
|
||||||
|
});
|
||||||
|
|
||||||
|
type SignupForm = z.infer<typeof signupSchema>;
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [shakeFields, setShakeFields] = useState<Set<string>>(new Set());
|
||||||
|
const [emailSentModal, setEmailSentModal] = useState(false);
|
||||||
|
const [termsModal, setTermsModal] = useState(false);
|
||||||
|
const [privacyModal, setPrivacyModal] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<SignupForm>({
|
||||||
|
resolver: zodResolver(signupSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: { agree: false as unknown as true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const password = watch("password");
|
||||||
|
const passwordConfirm = watch("passwordConfirm");
|
||||||
|
|
||||||
|
/* 비밀번호 일치 상태 */
|
||||||
|
const pwMatchStatus =
|
||||||
|
!passwordConfirm
|
||||||
|
? null
|
||||||
|
: passwordConfirm === password
|
||||||
|
? "match"
|
||||||
|
: "mismatch";
|
||||||
|
|
||||||
|
/* shake 트리거 */
|
||||||
|
const triggerShake = useCallback((fieldNames: string[]) => {
|
||||||
|
setShakeFields(new Set(fieldNames));
|
||||||
|
setTimeout(() => setShakeFields(new Set()), 400);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* 전화번호 자동 하이픈 */
|
||||||
|
const handlePhoneInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
let v = e.target.value.replace(/[^0-9]/g, "");
|
||||||
|
if (v.length > 3 && v.length <= 7) {
|
||||||
|
v = v.slice(0, 3) + "-" + v.slice(3);
|
||||||
|
} else if (v.length > 7) {
|
||||||
|
v = v.slice(0, 3) + "-" + v.slice(3, 7) + "-" + v.slice(7, 11);
|
||||||
|
}
|
||||||
|
setValue("phone", v, { shouldValidate: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 가입 성공 */
|
||||||
|
const onSubmit = (_data: SignupForm) => {
|
||||||
|
// TODO: API 연동
|
||||||
|
setEmailSentModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 유효성 실패 → shake */
|
||||||
|
const onError = (fieldErrors: typeof errors) => {
|
||||||
|
triggerShake(Object.keys(fieldErrors));
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 힌트 스타일 헬퍼 */
|
||||||
|
const hintClass = (field: keyof SignupForm, defaultMsg: string) => {
|
||||||
|
const err = errors[field];
|
||||||
|
const isError = !!err;
|
||||||
|
return {
|
||||||
|
className: `mt-1.5 flex items-center gap-1.5 text-xs ${isError ? "text-red-600" : "text-gray-500"}`,
|
||||||
|
icon: isError ? "error" : "info",
|
||||||
|
message: isError ? (err.message as string) : defaultMsg,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 입력 필드 공통 className */
|
||||||
|
const inputClass = (field: keyof SignupForm) =>
|
||||||
|
`w-full rounded border bg-white px-4 py-3 font-sans text-sm text-gray-900 placeholder-gray-400 transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary ${
|
||||||
|
errors[field]
|
||||||
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
|
: "border-gray-300"
|
||||||
|
} ${shakeFields.has(field) ? "animate-shake" : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<>
|
||||||
<h1 className="text-2xl font-bold">회원가입</h1>
|
<div className="my-auto flex w-full max-w-[500px] flex-col items-center">
|
||||||
</div>
|
{/* 로고 */}
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="mb-2 text-4xl font-black tracking-tight text-primary">
|
||||||
|
SPMS
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm font-medium text-gray-400">
|
||||||
|
Stein Push Message Service
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 */}
|
||||||
|
<div className="w-full rounded-xl bg-white p-8 shadow-2xl md:p-10">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">회원가입</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
사용자 정보를 입력해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-2xl">close</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
<form
|
||||||
|
className="space-y-5"
|
||||||
|
onSubmit={handleSubmit(onSubmit, onError)}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
{/* 이메일 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-gray-700">
|
||||||
|
이메일
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="user@spms.com"
|
||||||
|
className={inputClass("email")}
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
{(() => {
|
||||||
|
const h = hintClass(
|
||||||
|
"email",
|
||||||
|
"인증 메일이 발송되므로 사용 가능한 이메일을 입력해주세요.",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className={h.className}>
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
{h.icon}
|
||||||
|
</span>
|
||||||
|
<span>{h.message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-gray-700">
|
||||||
|
비밀번호
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="비밀번호를 입력하세요"
|
||||||
|
className={inputClass("password")}
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
{(() => {
|
||||||
|
const h = hintClass(
|
||||||
|
"password",
|
||||||
|
"8자 이상, 영문/숫자/특수문자 포함",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className={h.className}>
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
{h.icon}
|
||||||
|
</span>
|
||||||
|
<span>{h.message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 확인 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-gray-700">
|
||||||
|
비밀번호 확인
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="비밀번호를 다시 입력하세요"
|
||||||
|
className={inputClass("passwordConfirm")}
|
||||||
|
{...register("passwordConfirm")}
|
||||||
|
/>
|
||||||
|
{pwMatchStatus === "mismatch" && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>비밀번호가 일치하지 않습니다.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pwMatchStatus === "match" && !errors.passwordConfirm && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-green-600">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
check_circle
|
||||||
|
</span>
|
||||||
|
<span>비밀번호가 일치합니다.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="my-1 border-t border-gray-200" />
|
||||||
|
|
||||||
|
{/* 이름 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-gray-700">
|
||||||
|
이름
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="홍길동"
|
||||||
|
className={inputClass("name")}
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{errors.name.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 전화번호 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-gray-700">
|
||||||
|
전화번호
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={13}
|
||||||
|
placeholder="010-0000-0000"
|
||||||
|
className={inputClass("phone")}
|
||||||
|
{...register("phone", { onChange: handlePhoneInput })}
|
||||||
|
/>
|
||||||
|
{errors.phone && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{errors.phone.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 약관 동의 */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<label className="group flex cursor-pointer items-center gap-2.5">
|
||||||
|
<div className="relative flex flex-shrink-0 items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className={`peer size-5 cursor-pointer appearance-none rounded border-2 transition-all checked:border-primary checked:bg-primary ${
|
||||||
|
errors.agree ? "border-red-500" : "border-gray-300"
|
||||||
|
} ${shakeFields.has("agree") ? "animate-shake" : ""}`}
|
||||||
|
{...register("agree")}
|
||||||
|
/>
|
||||||
|
<span className="material-symbols-outlined pointer-events-none absolute text-sm text-white opacity-0 peer-checked:opacity-100">
|
||||||
|
check
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-sm transition-colors ${
|
||||||
|
errors.agree ? "text-red-500" : "text-gray-600"
|
||||||
|
} ${shakeFields.has("agree") ? "animate-shake" : ""}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTermsModal(true)}
|
||||||
|
className="font-semibold text-primary underline hover:text-[#1d4ed8]"
|
||||||
|
>
|
||||||
|
서비스 이용약관
|
||||||
|
</button>
|
||||||
|
{" 및 "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPrivacyModal(true)}
|
||||||
|
className="font-semibold text-primary underline hover:text-[#1d4ed8]"
|
||||||
|
>
|
||||||
|
개인정보 처리방침
|
||||||
|
</button>
|
||||||
|
에 동의합니다.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가입 버튼 */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary py-3.5 px-4 font-bold text-white shadow-lg shadow-blue-500/20 transition-all hover:bg-[#1d4ed8] active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
가입하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 하단 로그인 링크 */}
|
||||||
|
<div className="mt-8 border-t border-gray-100 pt-6 text-center">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
이미 계정이 있으신가요?
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="ml-1 font-semibold text-primary hover:text-[#1d4ed8] hover:underline"
|
||||||
|
>
|
||||||
|
로그인하기
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ───── 인증 메일 전송 모달 ───── */}
|
||||||
|
{emailSentModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-green-600">
|
||||||
|
mark_email_read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-foreground">
|
||||||
|
인증 메일을 전송했습니다
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-sm text-foreground">
|
||||||
|
입력하신 이메일로 인증 링크를 보내드렸습니다.
|
||||||
|
</p>
|
||||||
|
<div className="mb-5 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>메일을 확인하여 가입을 완료해주세요.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/login", { replace: true })}
|
||||||
|
className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8]"
|
||||||
|
>
|
||||||
|
로그인하러 가기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ───── 서비스 이용약관 모달 ───── */}
|
||||||
|
{termsModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setTermsModal(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-blue-600">
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-foreground">
|
||||||
|
서비스 이용약관
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="mb-5 h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTermsModal(false)}
|
||||||
|
className="rounded border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-foreground transition hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ───── 개인정보 처리방침 모달 ───── */}
|
||||||
|
{privacyModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setPrivacyModal(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-blue-600">
|
||||||
|
shield
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-foreground">
|
||||||
|
개인정보 처리방침
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="mb-5 h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPrivacyModal(false)}
|
||||||
|
className="rounded border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-foreground transition hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,375 @@
|
||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
export default function VerifyEmailPage() {
|
export default function VerifyEmailPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setUser = useAuthStore((s) => s.setUser);
|
||||||
|
|
||||||
|
const [code, setCode] = useState<string[]>(Array(6).fill(""));
|
||||||
|
const [codeError, setCodeError] = useState<string | null>(null);
|
||||||
|
const [shakeCode, setShakeCode] = useState(false);
|
||||||
|
const [successModal, setSuccessModal] = useState(false);
|
||||||
|
const [resendModal, setResendModal] = useState(false);
|
||||||
|
const [homeOverlay, setHomeOverlay] = useState(false);
|
||||||
|
const [resendCooldown, setResendCooldown] = useState(0);
|
||||||
|
|
||||||
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
|
||||||
|
/* 첫 번째 칸에 포커스 */
|
||||||
|
useEffect(() => {
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* 재전송 쿨다운 타이머 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (resendCooldown <= 0) return;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setResendCooldown((prev) => prev - 1);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [resendCooldown]);
|
||||||
|
|
||||||
|
const isFilled = code.every((c) => c.length === 1);
|
||||||
|
|
||||||
|
/* shake 트리거 */
|
||||||
|
const triggerShake = useCallback(() => {
|
||||||
|
setShakeCode(true);
|
||||||
|
setTimeout(() => setShakeCode(false), 400);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* 입력 처리 */
|
||||||
|
const handleInput = (index: number, value: string) => {
|
||||||
|
const digit = value.replace(/[^0-9]/g, "");
|
||||||
|
const newCode = [...code];
|
||||||
|
newCode[index] = digit;
|
||||||
|
setCode(newCode);
|
||||||
|
setCodeError(null);
|
||||||
|
|
||||||
|
if (digit && index < 5) {
|
||||||
|
inputRefs.current[index + 1]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 포커스 시 해당 칸부터 끝까지 초기화 */
|
||||||
|
const handleFocus = (index: number) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setCode((prev) => {
|
||||||
|
const newCode = [...prev];
|
||||||
|
for (let i = index; i < 6; i++) {
|
||||||
|
newCode[i] = "";
|
||||||
|
}
|
||||||
|
return newCode;
|
||||||
|
});
|
||||||
|
setCodeError(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 백스페이스 → 이전 칸 */
|
||||||
|
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Backspace" && !code[index] && index > 0) {
|
||||||
|
inputRefs.current[index - 1]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 붙여넣기 */
|
||||||
|
const handlePaste = (e: React.ClipboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const paste = e.clipboardData.getData("text").replace(/[^0-9]/g, "");
|
||||||
|
if (paste.length >= 6) {
|
||||||
|
const newCode = paste.slice(0, 6).split("");
|
||||||
|
setCode(newCode);
|
||||||
|
setCodeError(null);
|
||||||
|
inputRefs.current[5]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 인증하기
|
||||||
|
* 테스트: "123456" → 성공, 그 외 → 실패
|
||||||
|
*/
|
||||||
|
const handleVerify = () => {
|
||||||
|
const fullCode = code.join("");
|
||||||
|
if (fullCode.length !== 6) return;
|
||||||
|
|
||||||
|
if (fullCode === "123456") {
|
||||||
|
setSuccessModal(true);
|
||||||
|
} else {
|
||||||
|
setCodeError("인증 코드가 올바르지 않습니다.");
|
||||||
|
triggerShake();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 재전송 */
|
||||||
|
const handleResend = () => {
|
||||||
|
if (resendCooldown > 0) return;
|
||||||
|
|
||||||
|
// TODO: API 연동
|
||||||
|
setResendModal(true);
|
||||||
|
setResendCooldown(60);
|
||||||
|
setCode(Array(6).fill(""));
|
||||||
|
setCodeError(null);
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 재전송 모달 닫기 */
|
||||||
|
const closeResendModal = () => {
|
||||||
|
setResendModal(false);
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 홈 이동 */
|
||||||
|
const goHome = () => {
|
||||||
|
setSuccessModal(false);
|
||||||
|
setHomeOverlay(true);
|
||||||
|
|
||||||
|
// 인증 완료 → 인증 상태 저장
|
||||||
|
setUser({
|
||||||
|
id: 1,
|
||||||
|
email: "test@spms.com",
|
||||||
|
name: "테스트 사용자",
|
||||||
|
role: "ADMIN",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => navigate("/", { replace: true }), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<>
|
||||||
<h1 className="text-2xl font-bold">이메일 인증</h1>
|
<div className="flex w-full max-w-[460px] flex-col items-center">
|
||||||
</div>
|
{/* 로고 */}
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="mb-2 text-4xl font-black tracking-tight text-primary">
|
||||||
|
SPMS
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm font-medium text-gray-400">
|
||||||
|
Stein Push Message Service
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인증 카드 */}
|
||||||
|
<div className="w-full rounded-xl bg-white p-8 shadow-2xl md:p-10">
|
||||||
|
{/* 아이콘 + 제목 */}
|
||||||
|
<div className="mb-2 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-blue-600">
|
||||||
|
mark_email_read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-bold text-foreground">이메일 인증</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-sm text-foreground">
|
||||||
|
가입하신 이메일로 전송된 인증 코드 6자리를 입력해주세요.
|
||||||
|
</p>
|
||||||
|
<div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>인증 코드는 발송 후 5분간 유효합니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 코드 입력 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-gray-700">
|
||||||
|
인증 코드
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{code.map((digit, i) => (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
inputRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete={i === 0 ? "one-time-code" : "off"}
|
||||||
|
maxLength={1}
|
||||||
|
value={digit}
|
||||||
|
onChange={(e) => handleInput(i, e.target.value)}
|
||||||
|
onFocus={() => handleFocus(i)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||||
|
onPaste={i === 0 ? handlePaste : undefined}
|
||||||
|
className={`h-14 w-full rounded-lg border bg-white text-center font-sans text-2xl font-bold tracking-widest text-gray-900 transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary ${
|
||||||
|
codeError
|
||||||
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
|
: "border-gray-300"
|
||||||
|
} ${shakeCode ? "animate-shake" : ""}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{codeError && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{codeError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인증하기 버튼 */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isFilled}
|
||||||
|
onClick={handleVerify}
|
||||||
|
className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
인증하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 재전송 */}
|
||||||
|
<div className="mt-5 border-t border-gray-100 pt-5 text-center">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
메일을 받지 못하셨나요?
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResend}
|
||||||
|
className={`ml-1 font-semibold text-primary transition hover:text-[#1d4ed8] hover:underline ${
|
||||||
|
resendCooldown > 0
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
인증 코드 재전송
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
{resendCooldown > 0 && (
|
||||||
|
<p className="mt-1 text-xs text-gray-400">
|
||||||
|
{resendCooldown}초 후 재전송 가능
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 풋터 */}
|
||||||
|
<footer className="mt-12 text-xs font-medium tracking-wide text-gray-500">
|
||||||
|
© 2026 Stein Co., Ltd. All rights reserved.
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인증 완료 모달 */}
|
||||||
|
{successModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-green-600">
|
||||||
|
check_circle
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-foreground">
|
||||||
|
인증이 완료되었습니다
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-sm text-foreground">
|
||||||
|
이메일 인증이 성공적으로 완료되었습니다.
|
||||||
|
</p>
|
||||||
|
<div className="mb-5 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>이제 모든 서비스를 이용하실 수 있습니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goHome}
|
||||||
|
className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8]"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 재전송 완료 모달 */}
|
||||||
|
{resendModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-green-600">
|
||||||
|
forward_to_inbox
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-foreground">
|
||||||
|
인증 코드가 재전송되었습니다
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-sm text-foreground">
|
||||||
|
입력하신 이메일로 새로운 인증 코드를 발송했습니다.
|
||||||
|
</p>
|
||||||
|
<div className="mb-5 space-y-1.5 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>이전 인증 코드는 더 이상 유효하지 않습니다.</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
schedule
|
||||||
|
</span>
|
||||||
|
<span>{resendCooldown}초 후 재전송이 가능합니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeResendModal}
|
||||||
|
className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8]"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 홈 이동 오버레이 */}
|
||||||
|
{homeOverlay && (
|
||||||
|
<div className="fixed inset-0 z-[100] animate-[fadeIn_0.3s_ease]">
|
||||||
|
<div className="absolute inset-0 bg-white/60" />
|
||||||
|
<div className="absolute left-1/2 top-6 z-10 -translate-x-1/2">
|
||||||
|
<div className="flex items-center gap-3 rounded-xl bg-green-600 px-6 py-3.5 text-white shadow-lg">
|
||||||
|
<svg
|
||||||
|
className="size-5 flex-shrink-0 animate-spin"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">인증 완료</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,5 @@ export interface SignupRequest {
|
||||||
password: string;
|
password: string;
|
||||||
passwordConfirm: string;
|
passwordConfirm: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
phone: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* fadeIn 애니메이션 */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* shake 애니메이션 (1회) */
|
/* shake 애니메이션 (1회) */
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
0%,
|
0%,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { createBrowserRouter } from "react-router-dom";
|
import { createBrowserRouter } from "react-router-dom";
|
||||||
import { lazy, Suspense, type ComponentType } from "react";
|
import { lazy, Suspense, type ComponentType } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
import AppLayout from "@/components/layout/AppLayout";
|
import AppLayout from "@/components/layout/AppLayout";
|
||||||
import AuthLayout from "@/components/layout/AuthLayout";
|
import AuthLayout from "@/components/layout/AuthLayout";
|
||||||
import ProtectedRoute from "./ProtectedRoute";
|
import ProtectedRoute from "./ProtectedRoute";
|
||||||
import PublicRoute from "./PublicRoute";
|
import PublicRoute from "./PublicRoute";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
/** lazy import 래퍼 */
|
/** lazy import 래퍼 */
|
||||||
function lazyPage(importFn: () => Promise<{ default: ComponentType }>) {
|
function lazyPage(importFn: () => Promise<{ default: ComponentType }>) {
|
||||||
|
|
@ -49,6 +51,15 @@ const MyPage = () => lazyPage(() => import("@/features/settings/pages/MyPage"));
|
||||||
const ProfileEditPage = () => lazyPage(() => import("@/features/settings/pages/ProfileEditPage"));
|
const ProfileEditPage = () => lazyPage(() => import("@/features/settings/pages/ProfileEditPage"));
|
||||||
const NotificationsPage = () => lazyPage(() => import("@/features/settings/pages/NotificationsPage"));
|
const NotificationsPage = () => lazyPage(() => import("@/features/settings/pages/NotificationsPage"));
|
||||||
|
|
||||||
|
/** 이메일 인증 페이지 가드: 인증 완료 상태면 홈으로 */
|
||||||
|
function VerifyEmailGuard() {
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
return <VerifyEmailPage />;
|
||||||
|
}
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
/* 공개 라우트 (인증 불필요) */
|
/* 공개 라우트 (인증 불필요) */
|
||||||
|
|
@ -59,11 +70,17 @@ export const router = createBrowserRouter([
|
||||||
children: [
|
children: [
|
||||||
{ path: "/login", element: <LoginPage /> },
|
{ path: "/login", element: <LoginPage /> },
|
||||||
{ path: "/signup", element: <SignupPage /> },
|
{ path: "/signup", element: <SignupPage /> },
|
||||||
{ path: "/verify-email", element: <VerifyEmailPage /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
/* 이메일 인증 (로그인 후, 인증 전 — 인증 완료 시 홈으로) */
|
||||||
|
element: <AuthLayout />,
|
||||||
|
children: [
|
||||||
|
{ path: "/verify-email", Component: VerifyEmailGuard },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
/* 보호된 라우트 (인증 필요) */
|
/* 보호된 라우트 (인증 필요) */
|
||||||
element: <ProtectedRoute />,
|
element: <ProtectedRoute />,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user