diff --git a/frontend/app/(auth)/register/page.tsx b/frontend/app/(auth)/register/page.tsx new file mode 100644 index 00000000..bd098543 --- /dev/null +++ b/frontend/app/(auth)/register/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useRegister } from "@/hooks/useRegister"; +import { LoginHeader } from "@/components/auth/LoginHeader"; +import { RegisterForm } from "@/components/auth/RegisterForm"; +import { LoginFooter } from "@/components/auth/LoginFooter"; + +/** + * 회원가입 페이지 컴포넌트 + * 비즈니스 로직은 useRegister 훅에서 처리하고, UI 컴포넌트들을 조합하여 구성 + */ +export default function RegisterPage() { + const { + formData, + isLoading, + error, + validationErrors, + showPassword, + showPasswordConfirm, + isFormValid, + handleInputChange, + handleRegister, + togglePasswordVisibility, + togglePasswordConfirmVisibility, + } = useRegister(); + + return ( +
+
+ + + + + +
+
+ ); +} + diff --git a/frontend/components/auth/LoginFooter.tsx b/frontend/components/auth/LoginFooter.tsx index 99b5da16..42edf9e2 100644 --- a/frontend/components/auth/LoginFooter.tsx +++ b/frontend/components/auth/LoginFooter.tsx @@ -1,11 +1,29 @@ import { UI_CONFIG } from "@/constants/auth"; +import Link from "next/link"; + +interface LoginFooterProps { + showRegisterLink?: boolean; +} /** * 로그인 페이지 푸터 컴포넌트 */ -export function LoginFooter() { +export function LoginFooter({ showRegisterLink = true }: LoginFooterProps) { return (
+ {showRegisterLink && ( +
+

+ 계정이 없으신가요?{" "} + + 회원가입 + +

+
+ )}

{UI_CONFIG.COPYRIGHT}

{UI_CONFIG.POWERED_BY}

diff --git a/frontend/components/auth/RegisterForm.tsx b/frontend/components/auth/RegisterForm.tsx new file mode 100644 index 00000000..2fd26432 --- /dev/null +++ b/frontend/components/auth/RegisterForm.tsx @@ -0,0 +1,259 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Eye, EyeOff, Loader2, ArrowLeft } from "lucide-react"; +import { RegisterFormData } from "@/types/auth"; +import { ErrorMessage } from "./ErrorMessage"; +import Link from "next/link"; + +interface RegisterFormProps { + formData: RegisterFormData; + isLoading: boolean; + error: string; + validationErrors: Record; + showPassword: boolean; + showPasswordConfirm: boolean; + isFormValid: boolean; + onInputChange: (e: React.ChangeEvent) => void; + onSubmit: (e: React.FormEvent) => void; + onTogglePassword: () => void; + onTogglePasswordConfirm: () => void; +} + +/** + * 회원가입 폼 컴포넌트 + */ +export function RegisterForm({ + formData, + isLoading, + error, + validationErrors, + showPassword, + showPasswordConfirm, + isFormValid, + onInputChange, + onSubmit, + onTogglePassword, + onTogglePasswordConfirm, +}: RegisterFormProps) { + return ( + + + 회원가입 + 새로운 계정을 생성합니다 + + + + +
+ {/* 사용자 ID */} +
+ + + {validationErrors.userId && ( +

{validationErrors.userId}

+ )} +
+ + {/* 비밀번호 */} +
+ +
+ + +
+ {validationErrors.password && ( +

{validationErrors.password}

+ )} +
+ + {/* 비밀번호 확인 */} +
+ +
+ + +
+ {validationErrors.passwordConfirm && ( +

{validationErrors.passwordConfirm}

+ )} +
+ + {/* 이름 */} +
+ + + {validationErrors.userName && ( +

{validationErrors.userName}

+ )} +
+ + {/* 면허번호 */} +
+ + + {validationErrors.licenseNumber && ( +

{validationErrors.licenseNumber}

+ )} +

+ 운전면허번호를 하이픈(-)을 포함하여 입력해주세요 +

+
+ + {/* 차량 번호 */} +
+ + + {validationErrors.vehicleNumber && ( +

{validationErrors.vehicleNumber}

+ )} +

+ 한국 차량 번호 형식으로 입력해주세요 +

+
+ + {/* 휴대폰 번호 */} +
+ + + {validationErrors.phoneNumber && ( +

{validationErrors.phoneNumber}

+ )} +

+ 하이픈(-)을 포함하여 입력해주세요 +

+
+ + {/* 버튼 그룹 */} +
+ + + + +
+
+
+
+ ); +} + diff --git a/frontend/hooks/useRegister.ts b/frontend/hooks/useRegister.ts new file mode 100644 index 00000000..09ec46ad --- /dev/null +++ b/frontend/hooks/useRegister.ts @@ -0,0 +1,262 @@ +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { RegisterFormData } from "@/types/auth"; +import { authApi } from "@/lib/api/auth"; +import { useToast } from "@/hooks/use-toast"; + +/** + * 회원가입 비즈니스 로직 훅 + */ +export function useRegister() { + const router = useRouter(); + const { toast } = useToast(); + const [formData, setFormData] = useState({ + userId: "", + password: "", + passwordConfirm: "", + userName: "", + licenseNumber: "", + vehicleNumber: "", + phoneNumber: "", + }); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const [validationErrors, setValidationErrors] = useState>({}); + const [showPassword, setShowPassword] = useState(false); + const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); + const [isFormValid, setIsFormValid] = useState(false); + + /** + * 실시간 폼 유효성 검사 및 에러 메시지 업데이트 + */ + useEffect(() => { + const checkFormValidity = () => { + const errors: Record = {}; + + // 사용자 ID 검사 (입력이 있을 때만) + if (formData.userId.length > 0 && formData.userId.length < 2) { + errors.userId = "사용자 ID는 최소 2자 이상이어야 합니다"; + } + + // 비밀번호 검사 (입력이 있을 때만) + if (formData.password.length > 0 && formData.password.length < 6) { + errors.password = "비밀번호는 최소 6자 이상이어야 합니다"; + } + + // 비밀번호 확인 검사 (입력이 있을 때만) + if (formData.passwordConfirm.length > 0 && formData.password !== formData.passwordConfirm) { + errors.passwordConfirm = "비밀번호가 일치하지 않습니다"; + } + + // 이름 검사 (입력이 있을 때만) + if (formData.userName.length > 0 && formData.userName.trim().length < 2) { + errors.userName = "이름은 최소 2자 이상이어야 합니다"; + } + + // 면허번호 검사 (입력이 있을 때만) + if (formData.licenseNumber.length > 0 && !validateLicenseNumber(formData.licenseNumber)) { + errors.licenseNumber = "올바른 면허번호 형식이 아닙니다 (예: 12-34-567890-12)"; + } + + // 차량 번호 검사 (입력이 있을 때만) + if (formData.vehicleNumber.length > 0 && !validateVehicleNumber(formData.vehicleNumber)) { + errors.vehicleNumber = "올바른 차량 번호 형식이 아닙니다 (예: 12가3456, 123가4567)"; + } + + // 휴대폰 번호 검사 (입력이 있을 때만) + if (formData.phoneNumber.length > 0 && !validatePhoneNumber(formData.phoneNumber)) { + errors.phoneNumber = "올바른 휴대폰 번호 형식이 아닙니다 (예: 010-1234-5678)"; + } + + setValidationErrors(errors); + + // 모든 필드가 채워져 있고 에러가 없는지 확인 + const allFieldsFilled = + formData.userId.trim().length >= 2 && + formData.password.length >= 6 && + formData.passwordConfirm.length > 0 && + formData.userName.trim().length >= 2 && + formData.licenseNumber.length > 0 && + formData.vehicleNumber.length > 0 && + formData.phoneNumber.length > 0; + + const isValid = allFieldsFilled && Object.keys(errors).length === 0; + setIsFormValid(isValid); + }; + + checkFormValidity(); + }, [formData]); + + /** + * 입력값 변경 핸들러 + */ + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + + // 전역 에러 초기화 + if (error) setError(""); + }; + + /** + * 비밀번호 표시/숨김 토글 + */ + const togglePasswordVisibility = () => { + setShowPassword((prev) => !prev); + }; + + /** + * 비밀번호 확인 표시/숨김 토글 + */ + const togglePasswordConfirmVisibility = () => { + setShowPasswordConfirm((prev) => !prev); + }; + + /** + * 면허번호 유효성 검사 + * 한국 운전면허 형식: 12-34-567890-12 (지역번호-발급년도-일련번호-체크) + */ + const validateLicenseNumber = (licenseNumber: string): boolean => { + // 하이픈 포함 형식: 12-34-567890-12 + const pattern = /^\d{2}-\d{2}-\d{6}-\d{2}$/; + return pattern.test(licenseNumber); + }; + + /** + * 차량 번호 유효성 검사 + * 한국 차량 번호 형식: 12가3456, 123가4567, 서울12가3456 등 + */ + const validateVehicleNumber = (vehicleNumber: string): boolean => { + // 공백 제거 + const cleanNumber = vehicleNumber.replace(/\s/g, ""); + + // 한국 차량 번호 패턴 + // 1. 구형: 12가3456 (2자리 숫자 + 한글 1자 + 4자리 숫자) + // 2. 신형: 123가4567 (3자리 숫자 + 한글 1자 + 4자리 숫자) + // 3. 지역명 포함: 서울12가3456, 서울123가4567 + const patterns = [ + /^\d{2}[가-힣]{1}\d{4}$/, // 12가3456 + /^\d{3}[가-힣]{1}\d{4}$/, // 123가4567 + /^[가-힣]{2}\d{2}[가-힣]{1}\d{4}$/, // 서울12가3456 + /^[가-힣]{2}\d{3}[가-힣]{1}\d{4}$/, // 서울123가4567 + ]; + + return patterns.some(pattern => pattern.test(cleanNumber)); + }; + + /** + * 휴대폰 번호 유효성 검사 + * 형식: 010-1234-5678, 011-123-4567 등 + */ + const validatePhoneNumber = (phoneNumber: string): boolean => { + // 하이픈 포함 형식: 010-1234-5678, 011-123-4567 + const pattern = /^01[016789]-\d{3,4}-\d{4}$/; + return pattern.test(phoneNumber); + }; + + /** + * 폼 유효성 검사 + */ + const validateForm = (): boolean => { + const errors: Record = {}; + + // 사용자 ID 검사 + if (formData.userId.length < 2) { + errors.userId = "사용자 ID는 최소 2자 이상이어야 합니다"; + } + + // 비밀번호 검사 + if (formData.password.length < 6) { + errors.password = "비밀번호는 최소 6자 이상이어야 합니다"; + } + + // 비밀번호 확인 검사 + if (formData.password !== formData.passwordConfirm) { + errors.passwordConfirm = "비밀번호가 일치하지 않습니다"; + } + + // 이름 검사 + if (formData.userName.trim().length < 2) { + errors.userName = "이름은 최소 2자 이상이어야 합니다"; + } + + // 면허번호 검사 + if (!validateLicenseNumber(formData.licenseNumber)) { + errors.licenseNumber = "올바른 면허번호 형식이 아닙니다 (예: 12-34-567890-12)"; + } + + // 차량 번호 검사 + if (!validateVehicleNumber(formData.vehicleNumber)) { + errors.vehicleNumber = "올바른 차량 번호 형식이 아닙니다 (예: 12가3456, 123가4567)"; + } + + // 휴대폰 번호 검사 + if (!validatePhoneNumber(formData.phoneNumber)) { + errors.phoneNumber = "올바른 휴대폰 번호 형식이 아닙니다 (예: 010-1234-5678)"; + } + + setValidationErrors(errors); + return Object.keys(errors).length === 0; + }; + + /** + * 회원가입 핸들러 + */ + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + + // 유효성 검사 + if (!validateForm()) { + return; + } + + setIsLoading(true); + setError(""); + + try { + const response = await authApi.register({ + userId: formData.userId, + password: formData.password, + userName: formData.userName, + licenseNumber: formData.licenseNumber, + vehicleNumber: formData.vehicleNumber.replace(/\s/g, ""), // 공백 제거 + phoneNumber: formData.phoneNumber, + }); + + if (response.success) { + // 회원가입 성공 - toast 알림 표시 + toast({ + title: "회원가입 완료", + description: "회원가입이 성공적으로 완료되었습니다. 로그인 페이지로 이동합니다.", + }); + + // 로그인 페이지로 이동 + setTimeout(() => { + router.push("/login"); + }, 1500); + } else { + setError(response.message || "회원가입에 실패했습니다"); + } + } catch (err: any) { + console.error("회원가입 오류:", err); + setError(err.message || "회원가입 중 오류가 발생했습니다"); + } finally { + setIsLoading(false); + } + }; + + return { + formData, + isLoading, + error, + validationErrors, + showPassword, + showPasswordConfirm, + isFormValid, + handleInputChange, + handleRegister, + togglePasswordVisibility, + togglePasswordConfirmVisibility, + }; +} + diff --git a/frontend/lib/api/auth.ts b/frontend/lib/api/auth.ts new file mode 100644 index 00000000..119514bc --- /dev/null +++ b/frontend/lib/api/auth.ts @@ -0,0 +1,28 @@ +import { RegisterFormData, RegisterResponse } from "@/types/auth"; + +/** + * 인증 관련 API (임시 mock) + */ +export const authApi = { + /** + * 회원가입 (임시 구현) + */ + async register(data: Omit): Promise { + // TODO: 백엔드 API 연동 필요 + console.log("회원가입 요청:", data); + + // 임시로 성공 응답 반환 + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + success: true, + message: "회원가입이 완료되었습니다", + data: { + userId: data.userId, + }, + }); + }, 1000); + }); + }, +}; + diff --git a/frontend/types/auth.ts b/frontend/types/auth.ts index cd8e65b6..bc7f4af1 100644 --- a/frontend/types/auth.ts +++ b/frontend/types/auth.ts @@ -22,3 +22,22 @@ export interface AuthStatus { isLoggedIn: boolean; isAdmin?: boolean; } + +export interface RegisterFormData { + userId: string; + password: string; + passwordConfirm: string; + userName: string; + licenseNumber: string; + vehicleNumber: string; + phoneNumber: string; +} + +export interface RegisterResponse { + success: boolean; + message?: string; + data?: { + userId: string; + }; + errorCode?: string; +}