From 82718779893c064293759cc961eed310016533ef Mon Sep 17 00:00:00 2001 From: moo1677 Date: Thu, 8 Jan 2026 23:18:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(auth)/signup/page.tsx | 2 +- components/auth/FormField.tsx | 30 ++++-- components/auth/LoginForm.tsx | 6 +- components/auth/SignupForm.tsx | 31 ------ components/auth/SignupForm/SignupForm.tsx | 100 +++++++++++++++++++ components/auth/SignupForm/signup.api.ts | 20 ++++ components/auth/SignupForm/useSignupForm.tsx | 63 ++++++++++++ components/auth/SignupForm/validators.ts | 13 +++ 8 files changed, 223 insertions(+), 42 deletions(-) delete mode 100644 components/auth/SignupForm.tsx create mode 100644 components/auth/SignupForm/SignupForm.tsx create mode 100644 components/auth/SignupForm/signup.api.ts create mode 100644 components/auth/SignupForm/useSignupForm.tsx create mode 100644 components/auth/SignupForm/validators.ts diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx index 258fd2c..79b9e14 100644 --- a/app/(auth)/signup/page.tsx +++ b/app/(auth)/signup/page.tsx @@ -1,5 +1,5 @@ import AuthCard from '@/components/auth/AuthCard'; -import SignupForm from '@/components/auth/SignupForm'; +import SignupForm from '@/components/auth/SignupForm/SignupForm'; const Page = () => { return ( diff --git a/components/auth/FormField.tsx b/components/auth/FormField.tsx index 58c3c32..260194b 100644 --- a/components/auth/FormField.tsx +++ b/components/auth/FormField.tsx @@ -1,27 +1,43 @@ +'use client'; + import { forwardRef, InputHTMLAttributes } from 'react'; -type FieldProps = { +type Props = { id: string; label: string; - type?: React.HTMLInputTypeAttribute; + error?: string; + showError?: boolean; } & InputHTMLAttributes; -const inputClass = +const base = 'border-border placeholder:text-muted hover:border-border-hover focus:border-border-focus focus:ring-border-focus h-10 w-full rounded-md border px-3 text-sm transition-colors focus:ring-1 focus:outline-none'; +const errCls = 'border-red-500 focus:ring-red-500'; -const FormField = forwardRef( - ({ id, label, type = 'text', ...rest }, ref) => { +const FormField = forwardRef( + ({ id, label, error, showError, className, ...rest }, ref) => { + const msg = showError ? error : ''; return (
- + + {msg && ( +

+ {msg} +

+ )}
); } ); FormField.displayName = 'FormField'; - export default FormField; diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx index 1903c86..98d7dbf 100644 --- a/components/auth/LoginForm.tsx +++ b/components/auth/LoginForm.tsx @@ -29,9 +29,9 @@ const LoginForm = ({ handleOpenModal }: LoginFormProps) => { const user = result.user; console.log('로그인 성공 : ', user); router.push('/main'); - } catch (error: unknown) { - if (error instanceof Error) { - console.log('로그인 에러 : ', error.message); + } catch (err: unknown) { + if (err instanceof Error) { + console.log('로그인 에러 : ', err.message); } } }; diff --git a/components/auth/SignupForm.tsx b/components/auth/SignupForm.tsx deleted file mode 100644 index bab973d..0000000 --- a/components/auth/SignupForm.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import FormField from '@/components/auth/FormField'; -import Link from 'next/link'; - -const SignupForm = () => { - return ( - <> -
- - - - -
- - - -

- 계정이 있으신가요? - - 로그인 - -

- - ); -}; - -export default SignupForm; diff --git a/components/auth/SignupForm/SignupForm.tsx b/components/auth/SignupForm/SignupForm.tsx new file mode 100644 index 0000000..5ac6602 --- /dev/null +++ b/components/auth/SignupForm/SignupForm.tsx @@ -0,0 +1,100 @@ +'use client'; + +import Link from 'next/link'; +import FormField from '@/components/auth/FormField'; +import { SignupValues, useSignupForm } from './useSignupForm'; +import { useRouter } from 'next/navigation'; +import { signupWithEmail } from '@/components/auth/SignupForm/signup.api'; + +const toPayload = (v: SignupValues) => ({ + nickname: v.nickname, + email: v.email, + password: v.password, +}); + +const SignupForm = () => { + const router = useRouter(); + const { values, errors, register, showError, hasError, touchAll } = + useSignupForm(); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + touchAll(); + if (hasError()) return; + + try { + await signupWithEmail(toPayload(values)); + alert('회원가입 완료되었습니다.'); + router.push('/login'); + } catch (e: unknown) { + const code = + typeof e === 'object' && + e && + 'code' in e && + typeof (e as { code: unknown }).code === 'string' + ? (e as { code: string }).code + : ''; + + if (code === 'auth/email-already-in-use') + alert('이미 가입된 이메일입니다'); + else alert('회원가입에 실패했습니다'); + } + }; + + return ( +
+
+ + + + +
+ + + +

+ 계정이 있으신가요? + + 로그인 + +

+
+ ); +}; + +export default SignupForm; diff --git a/components/auth/SignupForm/signup.api.ts b/components/auth/SignupForm/signup.api.ts new file mode 100644 index 0000000..4bc10fb --- /dev/null +++ b/components/auth/SignupForm/signup.api.ts @@ -0,0 +1,20 @@ +import { auth, db } from '@/lib/firebase'; +import { createUserWithEmailAndPassword, updateProfile } from 'firebase/auth'; +import { doc, serverTimestamp, setDoc } from 'firebase/firestore'; + +export type SignupPayload = { + nickname: string; + email: string; + password: string; +}; + +export async function signupWithEmail(p: SignupPayload) { + const cred = await createUserWithEmailAndPassword(auth, p.email, p.password); + await updateProfile(cred.user, { displayName: p.nickname }); + await setDoc(doc(db, 'users', cred.user.uid), { + nickname: p.nickname, + email: p.email, + createdAt: serverTimestamp(), + }); + return cred.user; +} diff --git a/components/auth/SignupForm/useSignupForm.tsx b/components/auth/SignupForm/useSignupForm.tsx new file mode 100644 index 0000000..a2e8449 --- /dev/null +++ b/components/auth/SignupForm/useSignupForm.tsx @@ -0,0 +1,63 @@ +import { useMemo, useState } from 'react'; +import { vEmail, vNickname, vPassword, vPasswordConfirm } from './validators'; + +export type SignupValues = { + nickname: string; + email: string; + password: string; + passwordConfirm: string; +}; +type Field = keyof SignupValues; +/* 모든 필드를 한 번씩 건드려야 제출 가능 */ +const allTouched = (): Record => ({ + nickname: true, + email: true, + password: true, + passwordConfirm: true, +}); + +export function useSignupForm() { + /* 입력값 상태*/ + const [v, setV] = useState({ + nickname: '', + email: '', + password: '', + passwordConfirm: '', + }); + /* 건드렸는지 */ + const [t, setT] = useState>({ + nickname: false, + email: false, + password: false, + passwordConfirm: false, + }); + /* focus 상태 */ + const [f, setF] = useState(null); + /* error 계산 */ + const e = useMemo( + () => ({ + nickname: vNickname(v.nickname), + email: vEmail(v.email), + password: vPassword(v.password), + passwordConfirm: vPasswordConfirm(v.password, v.passwordConfirm), + }), + [v] + ); + /* props 생성*/ + const register = (name: Field) => ({ + value: v[name], + onChange: (ev: React.ChangeEvent) => + setV((p) => ({ ...p, [name]: ev.target.value })), + onFocus: () => setF(name), + onBlur: () => ( + setF((p) => (p === name ? null : p)), + setT((p) => ({ ...p, [name]: true })) + ), + }); + + const showError = (name: Field) => !!e[name] && t[name] && f !== name; + const hasError = () => Object.values(e).some(Boolean); + const touchAll = () => setT(allTouched()); + + return { values: v, errors: e, register, showError, hasError, touchAll }; +} diff --git a/components/auth/SignupForm/validators.ts b/components/auth/SignupForm/validators.ts new file mode 100644 index 0000000..fc6897d --- /dev/null +++ b/components/auth/SignupForm/validators.ts @@ -0,0 +1,13 @@ +export const vNickname = (s: string) => + s.length < 2 || s.length > 10 ? '닉네임은 2~10자여야 합니다' : ''; + +export const vEmail = (s: string) => + s && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) + ? '이메일 형식이 올바르지 않습니다' + : ''; + +export const vPassword = (s: string) => + s && s.length < 8 ? '비밀번호는 8자 이상이어야 합니다' : ''; + +export const vPasswordConfirm = (pw: string, pc: string) => + pc && pc !== pw ? '비밀번호가 일치하지 않습니다' : '';