- 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>
142 lines
4.5 KiB
TypeScript
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>
|
|
);
|
|
}
|