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() {
|
||||
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">
|
||||
{/* 콘텐츠 중앙 정렬 */}
|
||||
<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">
|
||||
© 2026 Stein Co., Ltd.
|
||||
</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() {
|
||||
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 (
|
||||
<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 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() {
|
||||
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 (
|
||||
<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 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() {
|
||||
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 (
|
||||
<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 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;
|
||||
passwordConfirm: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* fadeIn 애니메이션 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* shake 애니메이션 (1회) */
|
||||
@keyframes shake {
|
||||
0%,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { createBrowserRouter } from "react-router-dom";
|
||||
import { lazy, Suspense, type ComponentType } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import AppLayout from "@/components/layout/AppLayout";
|
||||
import AuthLayout from "@/components/layout/AuthLayout";
|
||||
import ProtectedRoute from "./ProtectedRoute";
|
||||
import PublicRoute from "./PublicRoute";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
/** lazy import 래퍼 */
|
||||
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 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([
|
||||
{
|
||||
/* 공개 라우트 (인증 불필요) */
|
||||
|
|
@ -59,11 +70,17 @@ export const router = createBrowserRouter([
|
|||
children: [
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/signup", element: <SignupPage /> },
|
||||
{ path: "/verify-email", element: <VerifyEmailPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
/* 이메일 인증 (로그인 후, 인증 전 — 인증 완료 시 홈으로) */
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{ path: "/verify-email", Component: VerifyEmailGuard },
|
||||
],
|
||||
},
|
||||
{
|
||||
/* 보호된 라우트 (인증 필요) */
|
||||
element: <ProtectedRoute />,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user