SPMS_WEB/react/src/features/auth/pages/SignupPage.tsx
SEAN af6ecab428 feat: 가이드라인 기반 공통 컴포넌트 및 레이아웃 개선
- 공통 컴포넌트 11개 생성 (PageHeader, StatusBadge, CategoryBadge, FilterDropdown, DateRangeInput, SearchInput, FilterResetButton, Pagination, EmptyState, CopyButton, PlatformBadge)
- AppHeader: 다단계 breadcrumb, 알림 드롭다운 구현
- AppLayout: 푸터 개인정보처리방침/이용약관 모달 추가
- AppSidebar: 이메일 폰트 자동 축소 (clamp)
- SignupPage: 모달 닫기 버튼 x 아이콘으로 통일
- Suspense fallback SVG 스피너로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:27:21 +09:00

516 lines
19 KiB
TypeScript

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<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 [isLoading, setIsLoading] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
setError,
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 = 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<ApiError>;
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 (
<>
<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
>
{/* 서버 에러 */}
{serverError && (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
error
</span>
<span>{serverError}</span>
</div>
)}
{/* 이메일 */}
<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"
disabled={isLoading}
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] disabled:cursor-not-allowed disabled:opacity-60"
>
{isLoading ? (
<>
<span className="material-symbols-outlined animate-spin text-lg">
progress_activity
</span>
</>
) : (
"가입하기"
)}
</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="flex-1 text-lg font-bold text-foreground">
</h3>
<button
type="button"
onClick={() => setTermsModal(false)}
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
>
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button>
</div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</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="flex-1 text-lg font-bold text-foreground">
</h3>
<button
type="button"
onClick={() => setPrivacyModal(false)}
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
>
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button>
</div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</div>
</div>
)}
</>
);
}