From e574caa7ce704e9eec914952f80e59e85d6e6010 Mon Sep 17 00:00:00 2001 From: koreacomp5 Date: Tue, 18 Nov 2025 03:27:30 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EA=B4=80=EB=A0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/find-id/FindIdDevOption.tsx | 82 ++++ src/app/find-id/FindIdOption.tsx | 131 ++++++ src/app/find-id/IdFindDone.tsx | 93 ++++ src/app/find-id/IdFindFailed.tsx | 71 +++ src/app/find-id/page.tsx | 50 +++ src/app/layout.tsx | 2 +- src/app/login/loginoption.tsx | 10 +- src/app/login/page.tsx | 12 +- src/app/register/RegisterCodeErrorModal.tsx | 58 +++ src/app/register/RegisterDone.tsx | 80 ++++ src/app/register/RegisterForm.tsx | 424 ++++++++++++++++++ src/app/register/RegisterOption.tsx | 80 ++++ src/app/register/page.tsx | 48 ++ src/app/reset-password/ResetPasswordDone.tsx | 80 ++++ src/app/reset-password/ResetPaswordOption.tsx | 61 +++ src/app/reset-password/page.tsx | 184 ++++++++ 16 files changed, 1458 insertions(+), 8 deletions(-) create mode 100644 src/app/find-id/FindIdDevOption.tsx create mode 100644 src/app/find-id/FindIdOption.tsx create mode 100644 src/app/find-id/IdFindDone.tsx create mode 100644 src/app/find-id/IdFindFailed.tsx create mode 100644 src/app/find-id/page.tsx create mode 100644 src/app/register/RegisterCodeErrorModal.tsx create mode 100644 src/app/register/RegisterDone.tsx create mode 100644 src/app/register/RegisterForm.tsx create mode 100644 src/app/register/RegisterOption.tsx create mode 100644 src/app/reset-password/ResetPasswordDone.tsx create mode 100644 src/app/reset-password/ResetPaswordOption.tsx create mode 100644 src/app/reset-password/page.tsx diff --git a/src/app/find-id/FindIdDevOption.tsx b/src/app/find-id/FindIdDevOption.tsx new file mode 100644 index 0000000..ca29738 --- /dev/null +++ b/src/app/find-id/FindIdDevOption.tsx @@ -0,0 +1,82 @@ +"use client"; + +import React, { useState } from "react"; + +type FindIdDevOptionProps = { + doneEnabled?: boolean; + setDoneEnabled?: (enabled: boolean) => void; + failedEnabled?: boolean; + setFailedEnabled?: (enabled: boolean) => void; +}; + +export default function FindIdDevOption({ + doneEnabled, + setDoneEnabled, + failedEnabled, + setFailedEnabled, +}: FindIdDevOptionProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + {isOpen && ( +
+ +
    +
  • +

    find-id done overlay

    + +
  • +
  • +

    find-id failed overlay

    + +
  • +
+
+
+ )} + + ); +} + + diff --git a/src/app/find-id/FindIdOption.tsx b/src/app/find-id/FindIdOption.tsx new file mode 100644 index 0000000..9aa7998 --- /dev/null +++ b/src/app/find-id/FindIdOption.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import LoginInputSvg from "@/app/svgs/inputformx"; +import Link from "next/link"; + +type FindIdOptionProps = { + onOpenDone: (userId?: string) => void; + onOpenFailed: () => void; +}; + +export default function FindIdOption({ onOpenDone, onOpenFailed }: FindIdOptionProps) { + const [name, setName] = useState(""); + const [phone, setPhone] = useState(""); + const [focused, setFocused] = useState>({}); + const [errors, setErrors] = useState>({}); + + const isNameValid = useMemo(() => name.trim().length > 0, [name]); + const isPhoneValid = useMemo(() => /^\d{9,11}$/.test(phone), [phone]); + const canSubmit = useMemo(() => isNameValid && isPhoneValid, [isNameValid, isPhoneValid]); + + function validateAll() { + const next: Record = {}; + if (!isNameValid) next.name = "이름을 입력해 주세요."; + if (!isPhoneValid) next.phone = "휴대폰 번호를 숫자만 9~11자리로 입력해 주세요."; + setErrors(next); + return Object.keys(next).length === 0; + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!validateAll()) return; + const mockUserId = `${name.trim()}@example.com`; + onOpenDone(mockUserId); + } + + return ( +
+ + + +
+
+ 아이디 찾기 +
+

+ 가입 시 등록한 회원정보를 입력해 주세요. +

+
+ +
+
+ +
+ setName(e.target.value)} + onFocus={() => setFocused((p) => ({ ...p, name: true }))} + onBlur={() => setFocused((p) => ({ ...p, name: false }))} + placeholder="이름을 입력해 주세요." + className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]" + /> + {name.trim().length > 0 && focused.name && ( + + )} +
+ {errors.name &&

{errors.name}

} +
+ +
+ +
+ setPhone(e.target.value.replace(/[^0-9]/g, ""))} + onFocus={() => setFocused((p) => ({ ...p, phone: true }))} + onBlur={() => setFocused((p) => ({ ...p, phone: false }))} + placeholder="-없이 입력해 주세요." + className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]" + /> + {phone.trim().length > 0 && focused.phone && ( + + )} +
+ {errors.phone &&

{errors.phone}

} +
+ + + + +
+
+ ); +} + diff --git a/src/app/find-id/IdFindDone.tsx b/src/app/find-id/IdFindDone.tsx new file mode 100644 index 0000000..7d24ad5 --- /dev/null +++ b/src/app/find-id/IdFindDone.tsx @@ -0,0 +1,93 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; + +type IdFindDoneProps = { + on: boolean; + onClose?: () => void; + children?: React.ReactNode; + title?: string; + description?: string; + primaryHref?: string; + primaryText?: string; + userId?: string; + secondaryHref?: string; + secondaryText?: string; + joinedAtText?: string; +}; + +export default function IdFindDone({ + on, + onClose, + children, + title = "아이디 확인", + description = "회원님의 정보와 일치하는 아이디 목록입니다.", + primaryHref = "/login", + primaryText = "로그인", + userId = "test@example.com", + secondaryHref = "/reset-password", + secondaryText = "비밀번호 재설정", + joinedAtText = "가입일: 2025년 10월 17일", +}: IdFindDoneProps) { + if (!on) return null; + + return ( +
+
+
+ {children ? ( + children + ) : ( +
+
+
+ {title} +
+

+ {description} +

+
+
+
+ {userId && ( +

+ {userId} +

+ )} + {joinedAtText && ( +

+ ({joinedAtText}) +

+ )} +
+
+
+ + {secondaryText} + + + {primaryText} + +
+
+ )} +
+
+ {onClose && ( +
+ ); +} diff --git a/src/app/find-id/IdFindFailed.tsx b/src/app/find-id/IdFindFailed.tsx new file mode 100644 index 0000000..3ea00f8 --- /dev/null +++ b/src/app/find-id/IdFindFailed.tsx @@ -0,0 +1,71 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; + +type IdFindFailedProps = { + on: boolean; + onClose?: () => void; + children?: React.ReactNode; + title?: string; + description?: string; + primaryHref?: string; + primaryText?: string; +}; + +/** + * 아이디 찾기 실패 단일 페이지(전체 덮기) 컴포넌트. + * - 기본 텍스트/버튼 제공. 필요 시 children으로 완전 대체 가능. + * - 디자인 토큰은 globals.css (@theme inline)을 사용. + */ +export default function IdFindFailed({ + on, + onClose, + children, + title = "아이디 확인", + description = "회원님의 정보로 가입된 아이디가 없습니다.\n회원가입을 진행해 주세요.", + primaryHref = "/register", + primaryText = "회원가입", +}: IdFindFailedProps) { + if (!on) return null; + + return ( +
+
+
+ {children ? ( + children + ) : ( +
+
+ {title} +
+

+ {description} +

+
+ + {primaryText} + + +
+
+ )} +
+
+ {onClose && ( +
+ ); +} + + diff --git a/src/app/find-id/page.tsx b/src/app/find-id/page.tsx new file mode 100644 index 0000000..32dd77b --- /dev/null +++ b/src/app/find-id/page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState } from "react"; +import IdFindDone from "./IdFindDone"; +import IdFindFailed from "./IdFindFailed"; +import FindIdOption from "./FindIdOption"; +import FindIdDevOption from "./FindIdDevOption"; + +export default function FindIdPage() { + const [isDoneOpen, setIsDoneOpen] = useState(false); + const [isFailedOpen, setIsFailedOpen] = useState(false); + const [foundUserId, setFoundUserId] = useState(undefined); + + return ( +
+ setIsDoneOpen(false)} + /> + setIsFailedOpen(false)} + /> + + { + setFoundUserId(id); + setIsDoneOpen(true); + }} + onOpenFailed={() => { + setIsFailedOpen(true); + }} + /> + + + +

+ Copyright ⓒ 2025 XL LMS. All rights reserved +

+
+ ); +} + + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8037e96..6297ab8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -11,7 +11,7 @@ export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( - + {children} diff --git a/src/app/login/loginoption.tsx b/src/app/login/loginoption.tsx index 2128823..213f273 100644 --- a/src/app/login/loginoption.tsx +++ b/src/app/login/loginoption.tsx @@ -28,7 +28,15 @@ export default function LoginOption({ { isOpen && (
-
+
+
  • login error modal

    diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index d45749a..1c4c529 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -7,7 +7,7 @@ import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg"; import LoginCheckboxInactiveSvg from "@/app/svgs/logincheckboxinactivesvg"; import LoginInputSvg from "@/app/svgs/inputformx"; import LoginErrorModal from "./LoginErrorModal"; -import LoginOption from "@/app/login/loginoption"; +import LoginOption from "@/app/login/LoginOption"; export default function LoginPage() { const [userId, setUserId] = useState(""); @@ -70,7 +70,7 @@ export default function LoginPage() { onBlur={() => setIsUserIdFocused(false)} placeholder="아이디 (이메일)" className=" - h-[56px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 + h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:ring-0 focus:ring-offset-0 focus:shadow-none focus:appearance-none focus:border-neutral-700 text-[18px] text-neutral-700 font-normal leading-[150%] placeholder:text-input-placeholder-text @@ -106,7 +106,7 @@ export default function LoginPage() { onBlur={() => setIsPasswordFocused(false)} placeholder="비밀번호" className=" - h-[56px] px-[12px] py-[7px] rounded-[8px] w-full border border-neutral-40 + h-[40px] px-[12px] py-[7px] rounded-[8px] w-full border border-neutral-40 focus:outline-none focus:ring-0 focus:ring-offset-0 focus:shadow-none focus:appearance-none focus:border-neutral-700 text-[18px] text-neutral-700 font-normal leading-[150%] placeholder:text-input-placeholder-text @@ -164,7 +164,7 @@ export default function LoginPage() { {/* 로그인 버튼 */} @@ -180,11 +180,11 @@ export default function LoginPage() {
    - + 아이디 찾기 - + 비밀번호 재설정
    diff --git a/src/app/register/RegisterCodeErrorModal.tsx b/src/app/register/RegisterCodeErrorModal.tsx new file mode 100644 index 0000000..d37f8a5 --- /dev/null +++ b/src/app/register/RegisterCodeErrorModal.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React from "react"; + +type RegisterCodeErrorModalProps = { + open: boolean; + onClose: () => void; + message?: string; + confirmText?: string; +}; + +/** + * 회원가입 이메일 인증번호 오류 안내 모달. + * - open: 모달 표시 여부 + * - onClose: 닫기 핸들러 + * - message: 본문 메시지 (기본값: 인증번호 불일치 안내) + * - confirmText: 확인 버튼 텍스트 (기본값: "확인") + */ +export default function RegisterCodeErrorModal({ + open, + onClose, + message = "인증번호가 일치하지 않습니다.\n다시 한번 확인 후 입력해 주세요.", + confirmText = "확인", +}: RegisterCodeErrorModalProps) { + if (!open) return null; + + return ( +
    + +
    +
+
+ ); +} + + diff --git a/src/app/register/RegisterDone.tsx b/src/app/register/RegisterDone.tsx new file mode 100644 index 0000000..5c43ce7 --- /dev/null +++ b/src/app/register/RegisterDone.tsx @@ -0,0 +1,80 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; + +type RegisterDoneProps = { + open: boolean; + onClose?: () => void; + children?: React.ReactNode; + title?: string; + description?: string; + primaryHref?: string; + primaryText?: string; +}; + +/** + * 전체 화면을 덮는 회원가입 완료 싱글페이지 컴포넌트. + * - open: true일 때 화면 전체를 덮어 표시 + * - children: 피그마에서 생성/복사한 컴포넌트를 그대로 주입 가능 + * - 기본 UI(title/description/primary 버튼) 제공, 필요 시 children으로 대체 + */ +export default function RegisterDone({ + open, + onClose, + children, + title = "회원가입이 완료되었습니다.", + description = "이제 로그인하여 서비스를 이용하실 수 있어요.", + primaryHref = "/login", + primaryText = "로그인 하러 가기", +}: RegisterDoneProps) { + if (!open) return null; + + return ( +
+
+
+ {children ? ( + children + ) : ( +
+
+ {title} +
+

+ {description} +

+
+ + {primaryText} + + {onClose && ( + + )} +
+
+ )} +
+
+ {onClose && ( +
+ ); +} + + diff --git a/src/app/register/RegisterForm.tsx b/src/app/register/RegisterForm.tsx new file mode 100644 index 0000000..2610ca7 --- /dev/null +++ b/src/app/register/RegisterForm.tsx @@ -0,0 +1,424 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg"; +import LoginCheckboxInactiveSvg from "@/app/svgs/logincheckboxinactivesvg"; +import LoginInputSvg from "@/app/svgs/inputformx"; + +type Gender = "male" | "female" | ""; + +type RegisterFormProps = { + onOpenDone: () => void; + onOpenCodeError: (message?: string) => void; +}; + +export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFormProps) { + const [name, setName] = useState(""); + const [phone, setPhone] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [gender, setGender] = useState(""); + const [birthdate, setBirthdate] = useState(""); + + // 이메일 인증 관련 + const [emailCodeSent, setEmailCodeSent] = useState(false); + const [emailCode, setEmailCode] = useState(""); + const [emailCodeVerified, setEmailCodeVerified] = useState(false); + + const [agreeAge, setAgreeAge] = useState(false); + const [agreeTos, setAgreeTos] = useState(false); + const [agreePrivacy, setAgreePrivacy] = useState(false); + + const allAgree = useMemo(() => agreeAge && agreeTos && agreePrivacy, [agreeAge, agreeTos, agreePrivacy]); + function toggleAllAgree(checked: boolean) { + setAgreeAge(checked); + setAgreeTos(checked); + setAgreePrivacy(checked); + } + + const [focused, setFocused] = useState>({}); + const [errors, setErrors] = useState>({}); + + const isEmailValid = useMemo(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email), [email]); + const isPhoneValid = useMemo(() => /^\d{9,11}$/.test(phone), [phone]); + const isPasswordValid = useMemo(() => password.length >= 8 && password.length <= 16, [password]); + const isPasswordConfirmValid = useMemo(() => passwordConfirm === password && passwordConfirm.length > 0, [password, passwordConfirm]); + + const canSubmit = useMemo(() => { + return ( + name.trim().length > 0 && + isPhoneValid && + isEmailValid && + isPasswordValid && + isPasswordConfirmValid && + gender !== "" && + birthdate.trim().length > 0 && + allAgree + ); + }, [name, isPhoneValid, isEmailValid, isPasswordValid, isPasswordConfirmValid, gender, birthdate, allAgree]); + + function validateAll() { + const nextErrors: Record = {}; + if (name.trim().length === 0) nextErrors.name = "이름을 입력해 주세요."; + if (!isPhoneValid) nextErrors.phone = "휴대폰 번호를 숫자만 9~11자리로 입력해 주세요."; + if (!isEmailValid) nextErrors.email = "올바른 이메일 형식을 입력해 주세요."; + if (!isPasswordValid) nextErrors.password = "비밀번호는 8~16자여야 합니다."; + if (!isPasswordConfirmValid) nextErrors.passwordConfirm = "비밀번호가 일치하지 않습니다."; + if (gender === "") nextErrors.gender = "성별을 선택해 주세요."; + if (birthdate.trim().length === 0) nextErrors.birthdate = "생년월일을 선택해 주세요."; + if (!allAgree) nextErrors.agreements = "필수 약관에 모두 동의해 주세요."; + setErrors(nextErrors); + return Object.keys(nextErrors).length === 0; + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!validateAll()) return; + onOpenDone(); + } + + return ( +
+ {/* 로고 / 제목 영역 */} +
+
+ 회원가입 +
+
+ +
+ {/* 이름 */} +
+ +
+ setName(e.target.value)} + onFocus={() => setFocused((p) => ({ ...p, name: true }))} + onBlur={() => setFocused((p) => ({ ...p, name: false }))} + placeholder="이름을 입력해 주세요." + className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]" + /> + {name.trim().length > 0 && focused.name && ( + + )} +
+ {errors.name &&

{errors.name}

} +
+ + {/* 휴대폰 번호 */} +
+ +
+ setPhone(e.target.value.replace(/[^0-9]/g, ""))} + onFocus={() => setFocused((p) => ({ ...p, phone: true }))} + onBlur={() => setFocused((p) => ({ ...p, phone: false }))} + placeholder="-없이 입력해 주세요." + className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]" + /> + {phone.trim().length > 0 && focused.phone && ( + + )} +
+ {errors.phone &&

{errors.phone}

} +
+ + {/* 아이디(이메일) + 인증번호 전송 */} +
+ +
+
+ setEmail(e.target.value)} + onFocus={() => setFocused((p) => ({ ...p, email: true }))} + onBlur={() => setFocused((p) => ({ ...p, email: false }))} + placeholder="이메일을 입력해 주세요." + className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]" + /> + {email.trim().length > 0 && focused.email && ( + + )} +
+ +
+ {errors.email &&

{errors.email}

} + {emailCodeSent && ( +
+ +
+
+ { + const onlyDigits = e.target.value.replace(/[^0-9]/g, ""); + setEmailCode(onlyDigits.slice(0, 6)); + }} + onFocus={() => setFocused((p) => ({ ...p, emailCode: true }))} + onBlur={() => setFocused((p) => ({ ...p, emailCode: false }))} + placeholder="인증번호 6자리" + className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]" + /> + {emailCode.trim().length > 0 && focused.emailCode && !emailCodeVerified && ( + + )} +
+ +
+
+ )} +
+ + {/* 비밀번호 */} +
+ +
+ setPassword(e.target.value)} + onFocus={() => setFocused((p) => ({ ...p, password: true }))} + onBlur={() => setFocused((p) => ({ ...p, password: false }))} + placeholder="8~16자의 영문/숫자/특수문자를 조합해서 입력해 주세요." + className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]" + /> + {password.trim().length > 0 && focused.password && ( + + )} +
+ {errors.password &&

{errors.password}

} +
+ + {/* 비밀번호 확인 */} +
+ +
+ setPasswordConfirm(e.target.value)} + onFocus={() => setFocused((p) => ({ ...p, passwordConfirm: true }))} + onBlur={() => setFocused((p) => ({ ...p, passwordConfirm: false }))} + placeholder="비밀번호를 다시 입력해 주세요." + className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]" + /> + {passwordConfirm.trim().length > 0 && focused.passwordConfirm && ( + + )} +
+ {errors.passwordConfirm &&

{errors.passwordConfirm}

} +
+ + {/* 성별 */} +
+
성별
+
+ + +
+ {errors.gender &&

{errors.gender}

} +
+ + {/* 생년월일 */} +
+ + setBirthdate(e.target.value)} + className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[16px] text-neutral-700" + /> + {errors.birthdate &&

{errors.birthdate}

} +
+ + {/* 약관 동의 */} +
+ +
+
+ + + +
+ {errors.agreements &&

{errors.agreements}

} +
+ + {/* 액션 버튼 */} +
+ + 돌아가기 + + +
+
+
+ ); +} + diff --git a/src/app/register/RegisterOption.tsx b/src/app/register/RegisterOption.tsx new file mode 100644 index 0000000..398db1f --- /dev/null +++ b/src/app/register/RegisterOption.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; + +type RegisterOptionProps = { + doneOpen?: boolean; + setDoneOpen?: (enabled: boolean) => void; + codeErrorModalEnabled?: boolean; + setCodeErrorModalEnabled?: (enabled: boolean) => void; +}; + +export default function RegisterOption({ + doneOpen, + setDoneOpen, + codeErrorModalEnabled, + setCodeErrorModalEnabled, +}: RegisterOptionProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ +
    +
  • +

    register done

    + +
  • +
  • +

    register code error modal

    + +
  • +
+
+
+ )} + + ); +} + diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index e69de29..e28f52f 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useState } from "react"; +import RegisterForm from "@/app/register/RegisterForm"; +import RegisterOption from "@/app/register/RegisterOption"; +import RegisterDone from "@/app/register/RegisterDone"; +import RegisterCodeErrorModal from "@/app/register/RegisterCodeErrorModal"; + +export default function RegisterPage() { + const [doneOpen, setDoneOpen] = useState(false); + const [codeErrorOpen, setCodeErrorOpen] = useState(false); + const [codeErrorMessage, setCodeErrorMessage] = useState(undefined); + + return ( + <> +
+ setDoneOpen(true)} + onOpenCodeError={(msg) => { + setCodeErrorMessage(msg); + setCodeErrorOpen(true); + }} + /> + +

+ Copyright ⓒ 2025 XL LMS. All rights reserved +

+
+ + setDoneOpen(false)} + /> + setCodeErrorOpen(false)} + message={codeErrorMessage} + /> + + ); +} + + diff --git a/src/app/reset-password/ResetPasswordDone.tsx b/src/app/reset-password/ResetPasswordDone.tsx new file mode 100644 index 0000000..fd9622d --- /dev/null +++ b/src/app/reset-password/ResetPasswordDone.tsx @@ -0,0 +1,80 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; + +type ResetPasswordDoneProps = { + open: boolean; + onClose?: () => void; + children?: React.ReactNode; + title?: string; + description?: string; + primaryHref?: string; + primaryText?: string; +}; + +/** + * 비밀번호 재설정 완료 전체화면 컴포넌트. + * - open이 true일 때 화면 전체를 덮어 표시 + * - children 전달 시 피그마 컴포넌트를 그대로 주입하여 커스텀 UI 표시 + * - 기본 제공 UI는 디자인 토큰(색상/타이포)과 기존 완료 컴포넌트 패턴과 동일하게 맞춤 + */ +export default function ResetPasswordDone({ + open, + onClose, + children, + title = "비밀번호 재설정이 완료됐습니다.", + description = "새로운 비밀번호로 다시 로그인 해주세요.", + primaryHref = "/login", + primaryText = "로그인", +}: ResetPasswordDoneProps) { + if (!open) return null; + + return ( +
+
+
+ {children ? ( + children + ) : ( +
+
+ {title} +
+

+ {description} +

+
+ + {primaryText} + + {onClose && ( + + )} +
+
+ )} +
+
+ {onClose && ( +
+ ); +} + + diff --git a/src/app/reset-password/ResetPaswordOption.tsx b/src/app/reset-password/ResetPaswordOption.tsx new file mode 100644 index 0000000..a1a493f --- /dev/null +++ b/src/app/reset-password/ResetPaswordOption.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React, { useState } from "react"; + +type ResetPaswordOptionProps = { + doneModalEnabled?: boolean; + setDoneModalEnabled?: (enabled: boolean) => void; +}; + +export default function ResetPaswordOption({ doneModalEnabled, setDoneModalEnabled }: ResetPaswordOptionProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + {isOpen && ( +
+ +
    +
  • +

    reset password done modal

    + +
  • +
+
+
+ )} + + ); +} + + diff --git a/src/app/reset-password/page.tsx b/src/app/reset-password/page.tsx new file mode 100644 index 0000000..e560b44 --- /dev/null +++ b/src/app/reset-password/page.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import LoginInputSvg from "@/app/svgs/inputformx"; +import ResetPasswordDone from "./ResetPasswordDone"; +import ResetPaswordOption from "./ResetPaswordOption"; + +export default function ResetPasswordPage() { + const [isDoneOpen, setIsDoneOpen] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [focused, setFocused] = useState>({}); + const [errors, setErrors] = useState>({}); + + const isEmailValid = useMemo(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email), [email]); + const isPasswordValid = useMemo(() => password.length >= 8 && password.length <= 16, [password]); + const isPasswordConfirmValid = useMemo(() => passwordConfirm === password && passwordConfirm.length > 0, [password, passwordConfirm]); + + const canSubmit = useMemo(() => { + return isEmailValid && isPasswordValid && isPasswordConfirmValid; + }, [isEmailValid, isPasswordValid, isPasswordConfirmValid]); + + function validateAll() { + const nextErrors: Record = {}; + if (!isEmailValid) nextErrors.email = "올바른 이메일 형식을 입력해 주세요."; + if (!isPasswordValid) nextErrors.password = "비밀번호는 8~16자여야 합니다."; + if (!isPasswordConfirmValid) nextErrors.passwordConfirm = "비밀번호가 일치하지 않습니다."; + setErrors(nextErrors); + return Object.keys(nextErrors).length === 0; + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!validateAll()) return; + // 성공 시 완료 오버레이 표시 + setIsDoneOpen(true); + } + + return ( +
+ setIsDoneOpen(false)} + /> + + + +
+ {/* 제목 영역 */} +
+
+ 비밀번호 재설정 +
+

+ 비밀번호 재설정을 위해 아래 정보를 입력해 주세요. +

+
+ +
+ {/* 아이디(이메일) + 인증번호 전송 */} +
+ +
+
+ setEmail(e.target.value)} + onFocus={() => setFocused((p) => ({ ...p, email: true }))} + onBlur={() => setFocused((p) => ({ ...p, email: false }))} + placeholder="이메일을 입력해 주세요." + className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]" + /> + {email.trim().length > 0 && focused.email && ( + + )} +
+ +
+ {errors.email &&

{errors.email}

} +
+ + {/* 새 비밀번호 */} +
+ +
+ setPassword(e.target.value)} + onFocus={() => setFocused((p) => ({ ...p, password: true }))} + onBlur={() => setFocused((p) => ({ ...p, password: false }))} + placeholder="8~16자의 영문/숫자/특수문자를 조합해서 입력해 주세요." + className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]" + /> + {password.trim().length > 0 && focused.password && ( + + )} +
+ {errors.password &&

{errors.password}

} +
+ + {/* 새 비밀번호 확인 */} +
+ +
+ setPasswordConfirm(e.target.value)} + onFocus={() => setFocused((p) => ({ ...p, passwordConfirm: true }))} + onBlur={() => setFocused((p) => ({ ...p, passwordConfirm: false }))} + placeholder="비밀번호를 다시 입력해 주세요." + className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]" + /> + {passwordConfirm.trim().length > 0 && focused.passwordConfirm && ( + + )} +
+ {errors.passwordConfirm &&

{errors.passwordConfirm}

} +
+ + {/* 액션 버튼 */} +
+ + 이전 + + +
+
+
+

+ Copyright ⓒ 2025 XL LMS. All rights reserved +

+
+ ); +}