feat: 인증 페이지 구현 (로그인/회원가입/이메일 인증) (#4) #6

Merged
seonkyu.kim merged 1 commits from feature/#4-login-page into develop 2026-02-26 05:51:15 +00:00
8 changed files with 1308 additions and 13 deletions
Showing only changes of commit ccfda47b96 - Show all commits

View File

@ -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">
&copy; 2026 Stein Co., Ltd. &copy; 2026 Stein Co., Ltd.
</p> </p>

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

View File

@ -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>
)}
</>
); );
} }

View File

@ -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>
)}
</>
); );
} }

View File

@ -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">
&copy; 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>
)}
</>
); );
} }

View File

@ -21,4 +21,5 @@ export interface SignupRequest {
password: string; password: string;
passwordConfirm: string; passwordConfirm: string;
name: string; name: string;
phone: string;
} }

View File

@ -110,6 +110,16 @@
} }
} }
/* fadeIn 애니메이션 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* shake 애니메이션 (1회) */ /* shake 애니메이션 (1회) */
@keyframes shake { @keyframes shake {
0%, 0%,

View File

@ -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 />,