- 공통 컴포넌트 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>
516 lines
19 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|