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 { AxiosError } from "axios"; import { signup } from "@/api/auth.api"; import type { ApiError } from "@/types/api"; /* ───── 정규식 ───── */ 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, { message: "약관에 동의해주세요." }), }) .refine((data) => data.password === data.passwordConfirm, { message: "비밀번호가 일치하지 않습니다.", path: ["passwordConfirm"], }); type SignupForm = z.infer; export default function SignupPage() { const navigate = useNavigate(); const [shakeFields, setShakeFields] = useState>(new Set()); const [emailSentModal, setEmailSentModal] = useState(false); const [termsModal, setTermsModal] = useState(false); const [privacyModal, setPrivacyModal] = useState(false); const [isLoading, setIsLoading] = useState(false); const [serverError, setServerError] = useState(null); const { register, handleSubmit, watch, setValue, setError, formState: { errors }, } = useForm({ 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) => { 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 = async (data: SignupForm) => { setIsLoading(true); setServerError(null); try { await signup({ email: data.email, password: data.password, name: data.name, phone: data.phone, agreeTerms: data.agree, agreePrivacy: data.agree, }); setEmailSentModal(true); } catch (err) { const axiosErr = err as AxiosError; const code = axiosErr.response?.data?.code; const msg = axiosErr.response?.data?.msg; if (axiosErr.response?.status === 409 || code === "EMAIL_DUPLICATE") { setError("email", { message: msg ?? "이미 사용 중인 이메일입니다." }); triggerShake(["email"]); } else { setServerError(msg ?? "회원가입에 실패했습니다. 다시 시도해주세요."); } } finally { setIsLoading(false); } }; /* 유효성 실패 → 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 ( <>
{/* 로고 */}

SPMS

Stein Push Message Service

{/* 카드 */}
{/* 헤더 */}

회원가입

사용자 정보를 입력해주세요.

close
{/* 폼 */}
{/* 서버 에러 */} {serverError && (
error {serverError}
)} {/* 이메일 */}
{(() => { const h = hintClass( "email", "인증 메일이 발송되므로 사용 가능한 이메일을 입력해주세요.", ); return (
{h.icon} {h.message}
); })()}
{/* 비밀번호 */}
{(() => { const h = hintClass( "password", "8자 이상, 영문/숫자/특수문자 포함", ); return (
{h.icon} {h.message}
); })()}
{/* 비밀번호 확인 */}
{pwMatchStatus === "mismatch" && (
error 비밀번호가 일치하지 않습니다.
)} {pwMatchStatus === "match" && !errors.passwordConfirm && (
check_circle 비밀번호가 일치합니다.
)}
{/* 구분선 */}
{/* 이름 */}
{errors.name && (
error {errors.name.message}
)}
{/* 전화번호 */}
{errors.phone && (
error {errors.phone.message}
)}
{/* 약관 동의 */}
{/* 가입 버튼 */}
{/* 하단 로그인 링크 */}

이미 계정이 있으신가요? 로그인하기

{/* ───── 인증 메일 전송 모달 ───── */} {emailSentModal && (
mark_email_read

인증 메일을 전송했습니다

입력하신 이메일로 인증 링크를 보내드렸습니다.

info 메일을 확인하여 가입을 완료해주세요.
)} {/* ───── 서비스 이용약관 모달 ───── */} {termsModal && (
{ if (e.target === e.currentTarget) setTermsModal(false); }} >
description

서비스 이용약관

)} {/* ───── 개인정보 처리방침 모달 ───── */} {privacyModal && (
{ if (e.target === e.currentTarget) setPrivacyModal(false); }} >
shield

개인정보 처리방침

)} ); }