diff --git a/package.json b/package.json index 3df975e..0ebdbda 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "axios": "^1.12.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.23.24", "lucide-react": "^0.544.0", "next": "15.5.3", "next-seo": "^6.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 148530e..d7fced6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + framer-motion: + specifier: ^12.23.24 + version: 12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0) lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.1.0) @@ -2247,6 +2250,23 @@ packages: } engines: { node: ">= 6" } + framer-motion@12.23.24: + resolution: + { + integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==, + } + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + function-bind@1.1.2: resolution: { @@ -3114,6 +3134,18 @@ packages: engines: { node: ">=10" } hasBin: true + motion-dom@12.23.23: + resolution: + { + integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==, + } + + motion-utils@12.23.6: + resolution: + { + integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==, + } + ms@2.1.3: resolution: { @@ -5657,6 +5689,15 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + framer-motion@12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + motion-dom: 12.23.23 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -6114,6 +6155,12 @@ snapshots: mkdirp@3.0.1: {} + motion-dom@12.23.23: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + ms@2.1.3: {} nano-spawn@1.0.3: {} diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index 4e12985..3cd0c2d 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -1,14 +1,24 @@ +import { AuthMain } from "@/components/auth/AuthMain"; +import { ForgotPasswordForm } from "@/components/auth/forgotPassword/ForgotPasswordForm"; +import { ForgotPasswordHeader } from "@/components/auth/forgotPassword/ForgotPasswordHeader"; import { makePageMetadata } from "@/seo/metadata"; export const metadata = { ...makePageMetadata({ title: "비밀번호 찾기", - description: "PlanMate 비밀번호 찾기 1단계: 이메일 입력 페이지", + description: "PlanMate 비밀번호 찾기 페이지", canonical: "/forgot-password", }), robots: { index: false, follow: false }, }; export default function ForgotPasswordPage() { - return
비밀번호 찾기 - 이메일 입력 페이지
; + return ( + <> + + + + + + ); } diff --git a/src/app/(auth)/forgot-password/reset/page.tsx b/src/app/(auth)/forgot-password/reset/page.tsx deleted file mode 100644 index 36e36dd..0000000 --- a/src/app/(auth)/forgot-password/reset/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { makePageMetadata } from "@/seo/metadata"; - -export const metadata = { - ...makePageMetadata({ - title: "비밀번호 찾기 — 비밀번호 재설정", - description: "PlanMate 비밀번호 찾기 3단계: 새 비밀번호 설정 페이지", - canonical: "/forgot-password/reset", - }), - robots: { index: false, follow: false }, -}; - -export default function ForgotPasswordResetPage() { - return
비밀번호 찾기 - 비밀번호 재설정 페이지
; -} diff --git a/src/app/(auth)/forgot-password/verify/page.tsx b/src/app/(auth)/forgot-password/verify/page.tsx deleted file mode 100644 index 2784c3a..0000000 --- a/src/app/(auth)/forgot-password/verify/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { makePageMetadata } from "@/seo/metadata"; - -export const metadata = { - ...makePageMetadata({ - title: "비밀번호 찾기 — 인증번호 입력", - description: "PlanMate 비밀번호 찾기 2단계: 이메일 인증번호 입력 페이지", - canonical: "/forgot-password/verify", - }), - robots: { index: false, follow: false }, -}; - -export default function ForgotPasswordVerifyPage() { - return
비밀번호 찾기 - 인증번호 입력 페이지
; -} diff --git a/src/app/(auth)/signup/basic/page.tsx b/src/app/(auth)/signup/basic/page.tsx new file mode 100644 index 0000000..216e494 --- /dev/null +++ b/src/app/(auth)/signup/basic/page.tsx @@ -0,0 +1,24 @@ +import { AuthMain } from "@/components/auth/AuthMain"; +import { SignupForm } from "@/components/auth/signup/SignupForm"; +import { SignupHeader } from "@/components/auth/signup/SignupHeader"; +import { makePageMetadata } from "@/seo/metadata"; + +export const metadata = { + ...makePageMetadata({ + title: "회원가입", + description: "MyPlanMate 회원가입페이지", + canonical: "/signup/basic", + }), + robots: { index: false, follow: false }, +}; + +export default function SignupEmailPage() { + return ( + <> + + + + + + ); +} diff --git a/src/app/(auth)/signup/email/page.tsx b/src/app/(auth)/signup/email/page.tsx deleted file mode 100644 index 3c13f44..0000000 --- a/src/app/(auth)/signup/email/page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { AuthHeader } from "@/components/auth/AuthHeader"; -import { AuthMain } from "@/components/auth/AuthMain"; -import { SubTitle, Title } from "@/components/auth/AuthTitle"; -import { SignupForm } from "@/components/auth/signup/SignupForm"; -import { makePageMetadata } from "@/seo/metadata"; - -export const metadata = { - ...makePageMetadata({ - title: "회원가입 — 이메일 입력", - description: "MyPlanMate 회원가입 2단계: 이메일 입력 페이지", - canonical: "/signup/email", - }), - robots: { index: false, follow: false }, -}; - -export default function SignupEmailPage() { - return ( - <> - - 이메일 - 계정으로 사용할 이메일을 알려주세요. - - - - - - - ); -} diff --git a/src/app/(auth)/signup/name/page.tsx b/src/app/(auth)/signup/name/page.tsx deleted file mode 100644 index b74a13f..0000000 --- a/src/app/(auth)/signup/name/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { AuthHeader } from "@/components/auth/AuthHeader"; -import { AuthMain } from "@/components/auth/AuthMain"; -import { SubTitle, Title } from "@/components/auth/AuthTitle"; -import { SignupForm } from "@/components/auth/signup/SignupForm"; -import { makePageMetadata } from "@/seo/metadata"; - -export const metadata = { - ...makePageMetadata({ - title: "회원가입 — 이름 입력", - description: "MyPlanMate 회원가입 1단계: 이름 입력 페이지", - canonical: "/signup/name", - }), - robots: { index: false, follow: false }, -}; - -export default function SignupNamePage() { - return ( - <> - - 이름 - 이름을 알려주세요. - - - - - - - ); -} diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 229f6bf..4ed937b 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -7,7 +7,7 @@ import { makePageMetadata } from "@/seo/metadata"; export const metadata = { ...makePageMetadata({ title: "회원가입", - description: "PlanMate 회원가입 페이지", + description: "MyPlanMate 회원가입 페이지", canonical: "/signup", }), robots: { index: false, follow: false }, // 인증 관련 페이지는 검색 제외 diff --git a/src/app/(auth)/signup/password/page.tsx b/src/app/(auth)/signup/password/page.tsx deleted file mode 100644 index 683491e..0000000 --- a/src/app/(auth)/signup/password/page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { AuthHeader } from "@/components/auth/AuthHeader"; -import { AuthMain } from "@/components/auth/AuthMain"; -import { SubTitle, Title } from "@/components/auth/AuthTitle"; -import { SignupForm } from "@/components/auth/signup/SignupForm"; -import { makePageMetadata } from "@/seo/metadata"; - -export const metadata = { - ...makePageMetadata({ - title: "회원가입 — 비밀번호 설정", - description: "PlanMate 회원가입 3단계: 비밀번호 설정 페이지", - canonical: "/signup/password", - }), - robots: { index: false, follow: false }, -}; - -export default function SignupPasswordPage() { - return ( - <> - - 비밀번호 - 8자 이상 / 특수문자 포함 - - - - - - - ); -} diff --git a/src/app/(auth)/signup/terms/page.tsx b/src/app/(auth)/signup/terms/page.tsx deleted file mode 100644 index 0034491..0000000 --- a/src/app/(auth)/signup/terms/page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { AuthHeader } from "@/components/auth/AuthHeader"; -import { AuthMain } from "@/components/auth/AuthMain"; -import { SubTitle, Title } from "@/components/auth/AuthTitle"; -import { SignupForm } from "@/components/auth/signup/SignupForm"; -import { makePageMetadata } from "@/seo/metadata"; - -export const metadata = { - ...makePageMetadata({ - title: "회원가입 — 약관 동의", - description: "PlanMate 회원가입 4단계: 약관 동의 페이지", - canonical: "/signup/terms", - }), - robots: { index: false, follow: false }, -}; - -export default function SignupTermsPage() { - return ( - <> - - 약관동의 - 서비스 이용을 위한 필수 약관입니다. - - - - - - - ); -} diff --git a/src/components/auth/AuthMain.tsx b/src/components/auth/AuthMain.tsx index 24892fe..b50c5ea 100644 --- a/src/components/auth/AuthMain.tsx +++ b/src/components/auth/AuthMain.tsx @@ -1,6 +1,23 @@ -import type { AuthCommonProps } from "@/types/auth"; +"use client"; + +import { useForgotPasswordStepStore } from "@/stores/forgotPasswordStepStore"; +import { useSignupStepStore } from "@/stores/signupStepStore"; +import type { AuthMainProps } from "@/types/auth"; +import { useEffect } from "react"; + +export function AuthMain({ children, flow }: AuthMainProps) { + const resetSignupStep = useSignupStepStore((s) => s.reset); + const resetForgotStep = useForgotPasswordStepStore((s) => s.reset); + + useEffect(() => { + if (flow === "signup") { + resetSignupStep(); + } + if (flow === "forgot") { + resetForgotStep(); + } + }, [flow, resetSignupStep, resetForgotStep]); -export function AuthMain({ children }: AuthCommonProps) { return (
{children} diff --git a/src/components/auth/AuthStepTransition.tsx b/src/components/auth/AuthStepTransition.tsx new file mode 100644 index 0000000..73a83ef --- /dev/null +++ b/src/components/auth/AuthStepTransition.tsx @@ -0,0 +1,22 @@ +import { authStepSlide, authTransition } from "@/lib/variants/motion.auth"; +import type { AuthStepTransitionProps } from "@/types/auth"; +import { AnimatePresence, motion } from "framer-motion"; + +export function AuthStepTransition({ stepKey, direction, children }: AuthStepTransitionProps) { + return ( + + + {children} + + + ); +} diff --git a/src/components/auth/signup/SignupStepIndicator.tsx b/src/components/auth/StepIndicator.tsx similarity index 88% rename from src/components/auth/signup/SignupStepIndicator.tsx rename to src/components/auth/StepIndicator.tsx index b232a11..245160d 100644 --- a/src/components/auth/signup/SignupStepIndicator.tsx +++ b/src/components/auth/StepIndicator.tsx @@ -1,13 +1,9 @@ "use client"; import { cn } from "@/lib/utils"; -import type { SignupStepIndicatorProps } from "@/types/auth"; +import type { StepIndicatorProps } from "@/types/auth"; -export function SignupStepIndicator({ - currentStep, - totalSteps = 4, - className, -}: SignupStepIndicatorProps) { +export function StepIndicator({ currentStep, totalSteps = 4, className }: StepIndicatorProps) { const steps = Array.from({ length: totalSteps }, (_, index) => index + 1); return ( diff --git a/src/components/auth/forgotPassword/ForgotPasswordEmailStep.tsx b/src/components/auth/forgotPassword/ForgotPasswordEmailStep.tsx new file mode 100644 index 0000000..0db64b8 --- /dev/null +++ b/src/components/auth/forgotPassword/ForgotPasswordEmailStep.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState } from "react"; + +import { useAuthFormSubmit } from "@/hooks/useAuthFormSubmit"; +import { cn } from "@/lib/utils"; +import { isValidEmail } from "@/lib/validators"; +import { useForgotPasswordFormStore } from "@/stores/forgotPasswordFormStore"; +import { useForgotPasswordStepStore } from "@/stores/forgotPasswordStepStore"; + +import { Button } from "@/shared/button"; +import { Input } from "@/shared/input"; +import type { StepFieldMeta } from "@/types/auth"; + +export function ForgotPasswordEmailStep({ fieldId, fieldName }: StepFieldMeta) { + // ✅ 1) 전역 스토어에서 이메일 값 + setter 가져오기 + const { email, setEmail } = useForgotPasswordFormStore(); + + // ✅ 2) 다음 스텝으로 이동하는 액션 + const { goNext } = useForgotPasswordStepStore(); + + // ✅ 3) 이메일 에러 메시지 (UI 전용 로컬 상태) + const [emailError, setEmailError] = useState(""); + + // ✅ 4) 공통 submit 훅으로 form 기본 동작 막기 + 콜백 실행 + const handleSubmit = useAuthFormSubmit(() => { + let hasError = false; + + // 이메일 형식 검증 + if (!isValidEmail(email)) { + setEmailError("올바른 이메일을 입력해주세요."); + hasError = true; + } + + // 하나라도 실패하면 이 스텝에 머무르기 + if (hasError) return; + + // 디버깅용 로그 (나중에 실제 API 요청으로 대체) + console.log("📨 Forgot Password Email Step:", { email }); + + // 검증 통과 시 다음 스텝으로 이동 + goNext(); + }); + + return ( +
+ {/* 라벨 + 에러 메시지 한 줄에 배치 */} +
+ + + {/* 에러가 있을 때만 문구 표시 */} + {emailError && {emailError}} +
+ + {/* 이메일 인풋 필드 */} + setEmail(e.target.value)} + // 포커스 되면 에러 메시지 초기화 + onFocus={() => setEmailError("")} + className={cn(emailError && "border-[1.5px] border-[var(--color-danger-600)]")} + /> + +

+ 입력하신 이메일 주소로 비밀번호 재설정 링크 또는 인증 코드를 보내드릴게요. +

+ + {/* 다음 스텝으로 진행하는 버튼 */} + +
+ ); +} diff --git a/src/components/auth/forgotPassword/ForgotPasswordForm.tsx b/src/components/auth/forgotPassword/ForgotPasswordForm.tsx new file mode 100644 index 0000000..e8d45bd --- /dev/null +++ b/src/components/auth/forgotPassword/ForgotPasswordForm.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { FORGOT_PASSWORD_STEP_FIELD_META, FORGOT_PASSWORD_STEP_ORDER } from "@/lib/constants"; +import { cn } from "@/lib/utils"; +import { useForgotPasswordStepStore } from "@/stores/forgotPasswordStepStore"; + +import Link from "next/link"; + +import { AuthStepTransition } from "../AuthStepTransition"; +import { StepIndicator } from "../StepIndicator"; +import { ForgotPasswordEmailStep } from "./ForgotPasswordEmailStep"; +import { ForgotPasswordResetStep } from "./ForgotPasswordResetStep"; +import { ForgotPasswordVerifyStep } from "./ForgotPasswordVerifyStep"; + +export function ForgotPasswordForm() { + // ✅ 1) 현재 스텝 이름 + 전환 방향 가져오기 + const { step, direction } = useForgotPasswordStepStore(); + + const isEmailStep = step === "email"; + const isVerifyStep = step === "verify"; + const isResetStep = step === "reset"; + + // ✅ 2) 현재 스텝이 몇 번째인지 계산 (1부터 시작) + const currentStepIndex = FORGOT_PASSWORD_STEP_ORDER.indexOf(step); + const currentStepNumber = currentStepIndex + 1; + + // ✅ 3) 스텝별 필드 메타 (id / name) + const { fieldId, fieldName } = FORGOT_PASSWORD_STEP_FIELD_META[step]; + + return ( + // 스텝이 바뀌면 슬라이드 전환 + +
+ {/* 상단 단계 인디케이터 (3단계) */} + + + {/* 스텝별 폼 */} +
+ {isEmailStep && } + {isVerifyStep && } + {isResetStep && } +
+ + {/* 하단 로그인 링크 */} +

+ 비밀번호를 이미 재설정하셨나요?{" "} + + 로그인하기 + +

+
+
+ ); +} diff --git a/src/components/auth/forgotPassword/ForgotPasswordHeader.tsx b/src/components/auth/forgotPassword/ForgotPasswordHeader.tsx new file mode 100644 index 0000000..8d56fd9 --- /dev/null +++ b/src/components/auth/forgotPassword/ForgotPasswordHeader.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { AuthHeader } from "@/components/auth/AuthHeader"; +import { SubTitle, Title } from "@/components/auth/AuthTitle"; +import { FORGOT_PASSWORD_STEP_COPY } from "@/lib/constants"; +import { useForgotPasswordStepStore } from "@/stores/forgotPasswordStepStore"; + +export function ForgotPasswordHeader() { + const step = useForgotPasswordStepStore((s) => s.step); + const { title, subtitle } = FORGOT_PASSWORD_STEP_COPY[step]; + + return ( + + {title} + {subtitle} + + ); +} diff --git a/src/components/auth/forgotPassword/ForgotPasswordResetStep.tsx b/src/components/auth/forgotPassword/ForgotPasswordResetStep.tsx new file mode 100644 index 0000000..c1c39ef --- /dev/null +++ b/src/components/auth/forgotPassword/ForgotPasswordResetStep.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; + +import { useAuthFormSubmit } from "@/hooks/useAuthFormSubmit"; +import { usePasswordVisibility } from "@/hooks/usePasswordVisibility"; +import { cn } from "@/lib/utils"; +import { isValidPassword } from "@/lib/validators"; +import { useForgotPasswordFormStore } from "@/stores/forgotPasswordFormStore"; +import { useForgotPasswordStepStore } from "@/stores/forgotPasswordStepStore"; + +import { Button } from "@/shared/button"; +import { Icon } from "@/shared/Icon"; +import { Input } from "@/shared/input"; +import type { StepFieldMeta } from "@/types/auth"; + +export function ForgotPasswordResetStep({ fieldId, fieldName }: StepFieldMeta) { + // ✅ 1) 전역 스토어에서 새 비밀번호 값 + setter 가져오기 + const { email, code, newPassword, setNewPassword, reset } = useForgotPasswordFormStore(); + + // ✅ 2) 스텝 이동 액션 + const { goPrev } = useForgotPasswordStepStore(); + + // ✅ 3) 로컬 상태: 에러 + 비밀번호 확인 값 + const [passwordError, setPasswordError] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [passwordConfirmError, setPasswordConfirmError] = useState(""); + + // ✅ 4) 비밀번호 보기/숨기기 토글 훅 + const { inputType, iconName, ariaLabel, toggleVisibility } = usePasswordVisibility(false); + + // ✅ 5) submit 핸들러 (공통 훅 사용) + const handleSubmit = useAuthFormSubmit(() => { + let hasError = false; + + // 1) 비밀번호 규칙 검증 (길이 + 특수문자) + if (!isValidPassword(newPassword)) { + setPasswordError("8자리 이상, 특수문자를 포함해야 합니다."); + hasError = true; + } + + // 2) 비밀번호 확인 일치 여부 검증 + if (newPassword !== passwordConfirm) { + setPasswordConfirmError("비밀번호가 일치하지 않습니다."); + hasError = true; + } + + if (hasError) return; + + // 최종 payload 생성 (이메일 + 코드 + 새 비밀번호) + const forgotPayload = { email, code, newPassword }; + + // 디버깅용 출력 (나중에 Supabase 비밀번호 재설정 API로 교체) + console.log("🔑 Forgot Password Reset Step:", forgotPayload); + + // 전체 입력값 초기화 + reset(); + }); + + return ( +
+ {/* 1. 새 비밀번호 필드 */} +
+
+ + + {passwordError && ( + {passwordError} + )} +
+ +
+ setNewPassword(e.target.value)} + onFocus={() => setPasswordError("")} + className={cn( + "w-full pr-10", + passwordError && "border-[1.5px] border-[var(--color-danger-600)]", + )} + /> + + {/* 보기/숨기기 토글 버튼 */} + +
+
+ + {/* 2. 새 비밀번호 확인 필드 */} +
+
+ + + {passwordConfirmError && ( + {passwordConfirmError} + )} +
+ +
+ { + setPasswordConfirmError(""); + setPasswordConfirm(e.target.value); + }} + className={cn( + "w-full pr-10", + passwordConfirmError && "border-[1.5px] border-[var(--color-danger-600)]", + )} + /> + + {/* 동일 토글 버튼 재사용 */} + +
+
+ + {/* 버튼 영역 */} +
+ + +
+
+ ); +} diff --git a/src/components/auth/forgotPassword/ForgotPasswordVerifyStep.tsx b/src/components/auth/forgotPassword/ForgotPasswordVerifyStep.tsx new file mode 100644 index 0000000..fadd6a3 --- /dev/null +++ b/src/components/auth/forgotPassword/ForgotPasswordVerifyStep.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; + +import { useAuthFormSubmit } from "@/hooks/useAuthFormSubmit"; +import { useOtpCode } from "@/hooks/useOtpCode"; +import { CODE_LENGTH } from "@/lib/constants"; +import { cn } from "@/lib/utils"; +import { isValidCode } from "@/lib/validators"; +import { useForgotPasswordFormStore } from "@/stores/forgotPasswordFormStore"; +import { useForgotPasswordStepStore } from "@/stores/forgotPasswordStepStore"; + +import { Button } from "@/shared/button"; +import { Input } from "@/shared/input"; +import type { StepFieldMeta } from "@/types/auth"; + +export function ForgotPasswordVerifyStep({ fieldId, fieldName }: StepFieldMeta) { + // ✅ 1) 전역 스토어에서 code setter 가져오기 + const { setCode } = useForgotPasswordFormStore(); + + // ✅ 2) 스텝 이동 액션 + const { goNext, goPrev } = useForgotPasswordStepStore(); + + // ✅ 3) OTP 훅: 4자리 코드 입력 UX 관리 + const { values, inputRefs, handleChange, handleKeyDown, codeValue } = useOtpCode(CODE_LENGTH); + + // ✅ 4) 코드 에러 메시지 (로컬 상태) + const [codeError, setCodeError] = useState(""); + + // ✅ 5) submit 핸들러 (공통 훅 사용) + const handleSubmit = useAuthFormSubmit(() => { + let hasError = false; + + // 숫자 4자리 검증 + if (!isValidCode(codeValue)) { + setCodeError("4자리 숫자 인증번호를 정확히 입력해주세요."); + hasError = true; + } + + if (hasError) return; + + // 전역 스토어에 최종 코드 저장 + setCode(codeValue); + + // 디버깅용 로그 + console.log("✅ Forgot Password Verify Step:", { code: codeValue }); + + // 다음 스텝(비밀번호 재설정)으로 이동 + goNext(); + }); + + return ( +
+
+ {/* 라벨 + 에러 메시지 */} +
+ + + {codeError && {codeError}} +
+ + {/* 4개의 한 자리 인풋 */} +
+ {values.map((value, index) => ( + { + inputRefs.current[index] = el; + }} + id={index === 0 ? fieldId : `${fieldId}-${index}`} + name={`${fieldName}-${index}`} + value={value} + onChange={(e) => { + setCodeError(""); + handleChange(index, e.target.value); + }} + onKeyDown={(e) => handleKeyDown(index, e)} + inputMode="numeric" + maxLength={1} + status="default" + className={cn( + "h-20 w-20 text-center t-20-b", + codeError && "border-[1.5px] border-[var(--color-danger-600)]", + )} + /> + ))} +
+
+ +

+ 인증번호를 받지 못하셨나요?{" "} + +

+ + {/* RHF 등과 연결할 전체 코드 값 hidden 필드 */} + + + {/* 버튼 영역 */} +
+ + +
+
+ ); +} diff --git a/src/components/auth/login/LoginForm.tsx b/src/components/auth/login/LoginForm.tsx index f287c07..8fbd95d 100644 --- a/src/components/auth/login/LoginForm.tsx +++ b/src/components/auth/login/LoginForm.tsx @@ -1,138 +1,154 @@ "use client"; -import { useFadeSlideInOnMount } from "@/hooks/useMotionPresets"; +import { useAuthFormSubmit } from "@/hooks/useAuthFormSubmit"; +import { usePasswordVisibility } from "@/hooks/usePasswordVisibility"; import { cn } from "@/lib/utils"; +import { isValidEmail, isValidPassword } from "@/lib/validators"; +import { authFadeSlideUp, authTransition } from "@/lib/variants/motion.auth"; +import { useLoginFormStore } from "@/stores/loginFormStore"; + import { Button } from "@/shared/button"; import { Icon } from "@/shared/Icon"; import { Input } from "@/shared/input"; -import { useAuthFormStore } from "@/stores/authForm.store"; + +import { motion } from "framer-motion"; import Link from "next/link"; -import type { ChangeEvent, FormEvent } from "react"; +import { useState } from "react"; export function LoginForm() { - const { - email, - password, - emailError, - passwordError, - isPasswordVisible, - setEmail, - setPassword, - clearEmailError, - clearPasswordError, - togglePasswordVisible, - validateLogin, - } = useAuthFormStore(); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); // 기본 form submit 동작 막기 - - const isValid = validateLogin(); - if (!isValid) { - // 에러 상태/값 초기화는 스토어에서 이미 처리 - return; + // ✅ 1) 전역 스토어: 이메일/비밀번호 값 + 액션 + const { email, password, setEmail, setPassword, reset } = useLoginFormStore(); + + // ✅ 2) 로컬 에러 상태: UI 전용 + const [emailError, setEmailError] = useState(""); + const [passwordError, setPasswordError] = useState(""); + + // ✅ 3) 비밀번호 토글 훅 + const { inputType, iconName, ariaLabel, toggleVisibility } = usePasswordVisibility(false); + + // ✅ 4) 공통 submit 훅으로 preventDefault 처리 + const handleSubmit = useAuthFormSubmit(() => { + let hasError = false; + + // 이메일 검증 + if (!isValidEmail(email)) { + setEmailError("올바른 이메일을 입력해주세요."); + hasError = true; } - // TODO: 실제 로그인 요청 로직 - // ex) await login({ email, password }); - }; + // 비밀번호 검증 (8자리 이상 + 특수문자) + if (!isValidPassword(password)) { + setPasswordError("8자리 이상, 특수문자를 포함해야 합니다."); + hasError = true; + } + + if (hasError) { + return; // ❌ 에러가 하나라도 있으면 제출 중단 + } - const handleEmailChange = (event: ChangeEvent) => { - setEmail(event.target.value); - }; + // ✅ 5) 검증 통과 시: 로그인 데이터 콘솔 출력 + // (나중에 이 자리에서 Supabase Auth 요청으로 교체) - const handlePasswordChange = (event: ChangeEvent) => { - setPassword(event.target.value); - }; + console.log("🟢 Login submit:", { email, password }); - const fadeClass = useFadeSlideInOnMount("up"); + // ✅ 6) 성공 후 인풋 값 초기화 + reset(); + }); return ( -
{/* 이메일 필드 */} -
+
- {emailError && ( - 이메일 정보를 확인해 주세요. + {emailError} )}
+ setEmail(event.target.value)} // ✅ setEmail으로 업데이트 + onFocus={() => setEmailError("")} // ✅ 포커스 시 에러 해제 + className={cn( + emailError && "border-[1.5px] border-[var(--color-danger-600)]", // ✅ 에러 시 테두리 강조 + )} />
{/* 비밀번호 필드 + 표시 토글 */} -
+
+ {/* ✅ 비밀번호 에러 문구 */} {passwordError && ( - 비밀번호를 확인해 주세요. + {passwordError} )}
+
setPassword(event.target.value)} + onFocus={() => setPasswordError("")} // ✅ 포커스 시 에러 해제 + className={cn( + "w-full pr-10", + passwordError && "border-[1.5px] border-[var(--color-danger-600)]", // ✅ 에러 테두리 + )} /> - {/* 비밀번호 표시/숨김 토글 아이콘 */} + + {/* ✅ 비밀번호 보기/숨기기 토글 버튼 */}
{/* 로그인 버튼 */} -
+
{/* 헬퍼 링크: 비밀번호 찾기 / 회원가입 */} -
+

비밀번호를 잊으셨나요?{" "}

+

회원이 아니신가요?{" "} {/* 간편 로그인 + 소셜 버튼 */} -

+
+ {/* 구분선 + 텍스트 */}
간편 로그인 @@ -171,6 +189,6 @@ export function LoginForm() {
- + ); } diff --git a/src/components/auth/signup/SignupEmailStep.tsx b/src/components/auth/signup/SignupEmailStep.tsx new file mode 100644 index 0000000..1e58ff7 --- /dev/null +++ b/src/components/auth/signup/SignupEmailStep.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useAuthFormSubmit } from "@/hooks/useAuthFormSubmit"; +import { cn } from "@/lib/utils"; +import { isValidEmail } from "@/lib/validators"; +import { useSignupFormStore } from "@/stores/signupFormStore"; +import { useSignupStepStore } from "@/stores/signupStepStore"; + +import { Button } from "@/shared/button"; +import { Input } from "@/shared/input"; + +import { StepFieldMeta } from "@/types/auth"; +import { useState } from "react"; + +export function SignupEmailStep({ fieldId, fieldName }: StepFieldMeta) { + const { email, setEmail } = useSignupFormStore(); + + const { goNext } = useSignupStepStore(); + + const [emailError, setEmailError] = useState(""); + + const handleSubmit = useAuthFormSubmit(() => { + let hasError = false; + + if (!isValidEmail(email)) { + setEmailError("올바른 이메일을 입력해주세요."); + hasError = true; + } + + if (hasError) return; + + console.log("📨 Signup Email Step:", { email }); + + goNext(); + }); + + return ( +
+
+ + + {emailError && {emailError}} +
+ + setEmail(e.target.value)} + onFocus={() => setEmailError("")} + className={cn(emailError && "border-[1.5px] border-[var(--color-danger-600)]")} + /> + + +
+ ); +} diff --git a/src/components/auth/signup/SignupForm.tsx b/src/components/auth/signup/SignupForm.tsx index 6585e50..47abe24 100644 --- a/src/components/auth/signup/SignupForm.tsx +++ b/src/components/auth/signup/SignupForm.tsx @@ -1,76 +1,54 @@ "use client"; +import { SIGNUP_STEP_FIELD_META, SIGNUP_STEP_ORDER } from "@/lib/constants"; import { cn } from "@/lib/utils"; -import { Button } from "@/shared/button"; -import { Input } from "@/shared/input"; -import { SignupFormProps } from "@/types/auth"; +import { useSignupStepStore } from "@/stores/signupStepStore"; + import Link from "next/link"; -import { SignupStepIndicator } from "./SignupStepIndicator"; -export function SignupForm({ - className, - fieldId, - fieldName, - label, - type = "text", - placeholder, - autoComplete, - nextHref, - prevHref, -}: SignupFormProps) { - return ( -
- - {/* 단일 필드 */} -
- - -
+import { AuthStepTransition } from "../AuthStepTransition"; +import { StepIndicator } from "../StepIndicator"; +import { SignupEmailStep } from "./SignupEmailStep"; +import { SignupNameStep } from "./SignupNameStep"; +import { SignupPasswordStep } from "./SignupPasswordStep"; +import { SignupTermsStep } from "./SignupTermsStep"; - {/* 다음 / 이전 버튼 (임시: Link로 라우팅) */} -
- - - +export function SignupForm() { + const { step, direction } = useSignupStepStore(); - {prevHref && ( - - - + const currentStepIndex = SIGNUP_STEP_ORDER.indexOf(step); + const currentStepNumber = currentStepIndex + 1; + + const { fieldId, fieldName } = SIGNUP_STEP_FIELD_META[step]; + + return ( + +
+ > + - {/* 하단 로그인 링크 */} -

- 이미 회원이신가요?{" "} - - 로그인하기 - -

- +
+ {step === "email" && } + {step === "name" && } + {step === "password" && } + {step === "terms" && } +
+ +

+ 이미 회원이신가요?{" "} + + 로그인하기 + +

+
+
); } diff --git a/src/components/auth/signup/SignupGroupButton.tsx b/src/components/auth/signup/SignupGroupButton.tsx index 02fffd5..c24d400 100644 --- a/src/components/auth/signup/SignupGroupButton.tsx +++ b/src/components/auth/signup/SignupGroupButton.tsx @@ -1,84 +1,80 @@ "use client"; -import { useFadeSlideInOnMount } from "@/hooks/useMotionPresets"; import { cn } from "@/lib/utils"; +import { authFadeSlideUp, authTransition } from "@/lib/variants/motion.auth"; import { Button } from "@/shared/button"; import { Icon } from "@/shared/Icon"; +import { motion } from "framer-motion"; import Image from "next/image"; import Link from "next/link"; import * as React from "react"; -interface SignupGroupButtonProps { - className?: string; -} - -export const SignupGroupButton = React.forwardRef( - ({ className }, ref) => { - const fadeClass = useFadeSlideInOnMount("up"); - - return ( -
- {/* 1) 일반 회원가입 → 이메일 회원가입 폼 페이지로 이동 */} - - - - - {/* 2) Google로 가입 */} +export const SignupGroupButton = React.forwardRef(() => { + return ( + + {/* 1) 일반 회원가입 → 이메일 회원가입 폼 페이지로 이동 */} + + - {/* 3) 카카오로 가입 */} - -
- ); - }, -); + {/* 2) Google로 가입 */} + + + {/* 3) 카카오로 가입 */} + + + ); +}); SignupGroupButton.displayName = "SignupGroupButton"; diff --git a/src/components/auth/signup/SignupHeader.tsx b/src/components/auth/signup/SignupHeader.tsx new file mode 100644 index 0000000..9289be6 --- /dev/null +++ b/src/components/auth/signup/SignupHeader.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { AuthHeader } from "@/components/auth/AuthHeader"; +import { SubTitle, Title } from "@/components/auth/AuthTitle"; +import { SIGNUP_STEP_COPY } from "@/lib/constants"; +import { useSignupStepStore } from "@/stores/signupStepStore"; + +export function SignupHeader() { + const step = useSignupStepStore((s) => s.step); + const { title, subtitle } = SIGNUP_STEP_COPY[step]; + + return ( + + {title} + {subtitle} + + ); +} diff --git a/src/components/auth/signup/SignupNameStep.tsx b/src/components/auth/signup/SignupNameStep.tsx new file mode 100644 index 0000000..163382d --- /dev/null +++ b/src/components/auth/signup/SignupNameStep.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useAuthFormSubmit } from "@/hooks/useAuthFormSubmit"; +import { cn } from "@/lib/utils"; +import { isValidName } from "@/lib/validators"; +import { useSignupFormStore } from "@/stores/signupFormStore"; +import { useSignupStepStore } from "@/stores/signupStepStore"; + +import { Button } from "@/shared/button"; +import { Input } from "@/shared/input"; + +import { StepFieldMeta } from "@/types/auth"; +import { useState } from "react"; + +export function SignupNameStep({ fieldId, fieldName }: StepFieldMeta) { + const { name, setName } = useSignupFormStore(); + + const { goNext, goPrev } = useSignupStepStore(); + + const [nameError, setNameError] = useState(""); + + const handleSubmit = useAuthFormSubmit(() => { + let hasError = false; + + if (!isValidName(name)) { + setNameError("이름은 2자 이상이어야 합니다."); + hasError = true; + } + + if (hasError) return; + + console.log("👤 Signup Name Step:", { name }); + goNext(); + }); + + return ( +
+
+ + + {nameError && {nameError}} +
+ + setName(e.target.value)} + onFocus={() => setNameError("")} + className={cn(nameError && "border-[1.5px] border-[var(--color-danger-600)]")} + /> + +
+ + +
+
+ ); +} diff --git a/src/components/auth/signup/SignupPasswordStep.tsx b/src/components/auth/signup/SignupPasswordStep.tsx new file mode 100644 index 0000000..e477827 --- /dev/null +++ b/src/components/auth/signup/SignupPasswordStep.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useAuthFormSubmit } from "@/hooks/useAuthFormSubmit"; +import { usePasswordVisibility } from "@/hooks/usePasswordVisibility"; +import { cn } from "@/lib/utils"; +import { isValidPassword } from "@/lib/validators"; +import { useSignupFormStore } from "@/stores/signupFormStore"; +import { useSignupStepStore } from "@/stores/signupStepStore"; + +import { Button } from "@/shared/button"; +import { Icon } from "@/shared/Icon"; +import { Input } from "@/shared/input"; + +import { StepFieldMeta } from "@/types/auth"; +import { useState } from "react"; + +export function SignupPasswordStep({ fieldId, fieldName }: StepFieldMeta) { + const { password, setPassword } = useSignupFormStore(); + const { goNext, goPrev } = useSignupStepStore(); + + const [passwordError, setPasswordError] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [passwordConfirmError, setPasswordConfirmError] = useState(""); + + const { inputType, iconName, ariaLabel, toggleVisibility } = usePasswordVisibility(false); + + const handleSubmit = useAuthFormSubmit(() => { + let hasError = false; + + if (!isValidPassword(password)) { + setPasswordError("8자리 이상, 특수문자를 포함해야 합니다."); + hasError = true; + } + + if (password !== passwordConfirm) { + setPasswordConfirmError("비밀번호가 일치하지 않습니다."); + hasError = true; + } + + if (hasError) return; + + console.log("🔐 Signup Password Step:", { password }); + + goNext(); + }); + + return ( +
+ {/* 1. 비밀번호 필드 */} +
+
+ + + {passwordError && ( + {passwordError} + )} +
+ +
+ setPassword(event.target.value)} + onFocus={() => setPasswordError("")} + className={cn( + "w-full pr-10", + passwordError && "border-[1.5px] border-[var(--color-danger-600)]", + )} + /> + + +
+
+ + {/* 2. 비밀번호 확인 필드 */} +
+
+ + + {passwordConfirmError && ( + {passwordConfirmError} + )} +
+ +
+ setPasswordConfirm(event.target.value)} + onFocus={() => setPasswordConfirmError("")} + className={cn( + "w-full pr-10", + passwordConfirmError && "border-[1.5px] border-[var(--color-danger-600)]", + )} + /> + + +
+
+ + {/* 3. 버튼 (세로 정렬) */} +
+ + +
+
+ ); +} diff --git a/src/components/auth/signup/SignupTermsStep.tsx b/src/components/auth/signup/SignupTermsStep.tsx new file mode 100644 index 0000000..d537e0e --- /dev/null +++ b/src/components/auth/signup/SignupTermsStep.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useAuthFormSubmit } from "@/hooks/useAuthFormSubmit"; +import { Button } from "@/shared/button"; +import { useSignupFormStore } from "@/stores/signupFormStore"; +import { useSignupStepStore } from "@/stores/signupStepStore"; +import { StepFieldMeta } from "@/types/auth"; +import { useState } from "react"; + +export function SignupTermsStep({ fieldId, fieldName }: StepFieldMeta) { + const { email, name, password, agreeToTerms, setAgreeToTerms, reset } = useSignupFormStore(); + const { goPrev } = useSignupStepStore(); + + const [serviceChecked, setServiceChecked] = useState(false); + const [privacyChecked, setPrivacyChecked] = useState(false); + + const [termsError, setTermsError] = useState(""); + + const syncTerms = (nextService: boolean, nextPrivacy: boolean) => { + setServiceChecked(nextService); + setPrivacyChecked(nextPrivacy); + + const nextAgree = nextService && nextPrivacy; + setAgreeToTerms(nextAgree); + + if (nextAgree) { + setTermsError(""); + } + }; + + const handleSubmit = useAuthFormSubmit(() => { + if (!agreeToTerms) { + setTermsError("필수 약관에 모두 동의해야 합니다."); + return; + } + + const signupPayload = { + email, + name, + password, + agreeToTerms, + }; + + console.log("🎉 Signup completed:", signupPayload); + + reset(); + }); + + return ( +
+
+ 약관 동의 + + {/* 상단 에러 메시지 */} + {termsError && {termsError}} + + {/* [필수] 서비스 이용약관 */} + + + {/* [필수] 개인정보 처리방침 */} + +
+ + {/* 버튼: 회원가입 완료 / 이전 (세로 정렬) */} +
+ + +
+
+ ); +} diff --git a/src/components/landing/LandingFeatureSection2.tsx b/src/components/landing/LandingFeatureSection2.tsx index 47f56ee..9cca848 100644 --- a/src/components/landing/LandingFeatureSection2.tsx +++ b/src/components/landing/LandingFeatureSection2.tsx @@ -2,30 +2,30 @@ import { LandingFeatureText } from "@/components/landing/LandingFeatureText"; import { LandingLayoutPreview } from "@/components/landing/LandingLayoutPreview"; -import { useInView } from "@/hooks/useInView"; import { cn } from "@/lib/utils"; +import { fadeInFromLeft, sectionTransition, viewportOnce35 } from "@/lib/variants/motion.landing"; import type { LandingFeaturesSection2Props } from "@/types/landing"; +import { motion } from "framer-motion"; export function LandingFeaturesSection2({ className }: LandingFeaturesSection2Props) { - const { ref, isInView } = useInView({ - threshold: 0.5, - once: false, - }); return ( -
-
+ ); } diff --git a/src/components/landing/LandingFeaturesSection1.tsx b/src/components/landing/LandingFeaturesSection1.tsx index 354a8e0..85dbd0f 100644 --- a/src/components/landing/LandingFeaturesSection1.tsx +++ b/src/components/landing/LandingFeaturesSection1.tsx @@ -1,31 +1,31 @@ "use client"; import { LandingFeatureGrid } from "@/components/landing/LandingFeatureGrid"; -import { useInView } from "@/hooks/useInView"; import { cn } from "@/lib/utils"; +import { fadeInFromRight, sectionTransition, viewportOnce30 } from "@/lib/variants/motion.landing"; import { useFeaturePreviewStore } from "@/stores/featurePreviewStore"; import type { LandingFeaturesSection1Props } from "@/types/landing"; +import { motion } from "framer-motion"; import Image from "next/image"; export function LandingFeaturesSection1({ className }: LandingFeaturesSection1Props) { const activeFeature = useFeaturePreviewStore((state) => state.activeFeature); - const { ref, isInView } = useInView({ - threshold: 0.5, - once: false, - }); return ( -
{/* Left: 제목 + 기능 리스트 */}
@@ -60,6 +60,6 @@ export function LandingFeaturesSection1({ className }: LandingFeaturesSection1Pr
- + ); } diff --git a/src/components/landing/LandingHeroCtas.tsx b/src/components/landing/LandingHeroCtas.tsx index 28c23ea..f481382 100644 --- a/src/components/landing/LandingHeroCtas.tsx +++ b/src/components/landing/LandingHeroCtas.tsx @@ -1,23 +1,26 @@ +"use client"; + import { cn } from "@/lib/utils"; +import { heroCtaBounceAnimate, heroCtaBounceTransition } from "@/lib/variants/motion.landing"; import { Button } from "@/shared/button"; import { Icon } from "@/shared/Icon"; +import { motion } from "framer-motion"; import Link from "next/link"; export function LandingHeroCtas() { return (
- - - + + + + + +