SPMS_WEB/react/src/features/auth/components/ResetPasswordModal.tsx
SEAN ccfda47b96 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>
2026-02-26 14:37:42 +09:00

142 lines
4.5 KiB
TypeScript

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