Skip to content

Commit 72d3de1

Browse files
authored
Merge pull request #19 from DeveloperBlog-Devflow/feature/sing-up-page
feat:회원가입 기능 구현
2 parents f2d90b4 + c1c675e commit 72d3de1

8 files changed

Lines changed: 220 additions & 40 deletions

File tree

app/(auth)/signup/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import AuthCard from '@/components/auth/AuthCard';
2-
import SignupForm from '@/components/auth/SignupForm';
2+
import SignupForm from '@/components/auth/SignupForm/SignupForm';
33

44
const Page = () => {
55
return (

components/auth/FormField.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,43 @@
1+
'use client';
2+
13
import { forwardRef, InputHTMLAttributes } from 'react';
24

3-
type FieldProps = {
5+
type Props = {
46
id: string;
57
label: string;
6-
type?: React.HTMLInputTypeAttribute;
8+
error?: string;
9+
showError?: boolean;
710
} & InputHTMLAttributes<HTMLInputElement>;
811

9-
const inputClass =
12+
const base =
1013
'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';
14+
const errCls = 'border-red-500 focus:ring-red-500';
1115

12-
const FormField = forwardRef<HTMLInputElement, FieldProps>(
13-
({ id, label, type = 'text', ...rest }, ref) => {
16+
const FormField = forwardRef<HTMLInputElement, Props>(
17+
({ id, label, error, showError, className, ...rest }, ref) => {
18+
const msg = showError ? error : '';
1419
return (
1520
<div className="space-y-1">
1621
<label htmlFor={id} className="pl-1 text-sm font-medium">
1722
{label}
1823
</label>
19-
<input id={id} className={inputClass} type={type} ref={ref} {...rest} />
24+
<input
25+
id={id}
26+
ref={ref}
27+
className={`${base} ${msg ? errCls : ''} ${className ?? ''}`}
28+
aria-invalid={!!msg}
29+
aria-describedby={msg ? `${id}-error` : undefined}
30+
{...rest}
31+
/>
32+
{msg && (
33+
<p id={`${id}-error`} className="pl-1 text-xs text-red-500">
34+
{msg}
35+
</p>
36+
)}
2037
</div>
2138
);
2239
}
2340
);
2441

2542
FormField.displayName = 'FormField';
26-
2743
export default FormField;

components/auth/LoginForm.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ const LoginForm = ({ handleOpenModal }: LoginFormProps) => {
3939
router.push('/main');
4040
} catch (err: unknown) {
4141
if (err instanceof Error) {
42-
// console.log('로그인 에러 : ', err.message);
4342
}
4443
}
4544
};

components/auth/SignupForm.tsx

Lines changed: 0 additions & 31 deletions
This file was deleted.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
'use client';
2+
3+
import Link from 'next/link';
4+
import FormField from '@/components/auth/FormField';
5+
import { SignupValues, useSignupForm } from './useSignupForm';
6+
import { useRouter } from 'next/navigation';
7+
import { signupWithEmail } from '@/components/auth/SignupForm/signup.api';
8+
9+
const toPayload = (v: SignupValues) => ({
10+
nickname: v.nickname,
11+
email: v.email,
12+
password: v.password,
13+
});
14+
15+
const SignupForm = () => {
16+
const router = useRouter();
17+
const { values, errors, register, showError, hasError, touchAll } =
18+
useSignupForm();
19+
20+
const onSubmit = async (e: React.FormEvent) => {
21+
e.preventDefault();
22+
touchAll();
23+
if (hasError()) return;
24+
25+
try {
26+
await signupWithEmail(toPayload(values));
27+
alert('회원가입 완료되었습니다.');
28+
router.push('/login');
29+
} catch (e: unknown) {
30+
const code =
31+
typeof e === 'object' &&
32+
e &&
33+
'code' in e &&
34+
typeof (e as { code: unknown }).code === 'string'
35+
? (e as { code: string }).code
36+
: '';
37+
38+
if (code === 'auth/email-already-in-use')
39+
alert('이미 가입된 이메일입니다');
40+
else alert('회원가입에 실패했습니다');
41+
}
42+
};
43+
44+
return (
45+
<form onSubmit={onSubmit}>
46+
<div className="space-y-6">
47+
<FormField
48+
id="nickname"
49+
label="닉네임"
50+
error={errors.nickname}
51+
showError={showError('nickname')}
52+
{...register('nickname')}
53+
/>
54+
<FormField
55+
id="email"
56+
label="이메일"
57+
type="email"
58+
error={errors.email}
59+
showError={showError('email')}
60+
{...register('email')}
61+
/>
62+
<FormField
63+
id="password"
64+
label="비밀번호"
65+
type="password"
66+
error={errors.password}
67+
showError={showError('password')}
68+
{...register('password')}
69+
/>
70+
<FormField
71+
id="passwordConfirm"
72+
label="비밀번호 확인"
73+
type="password"
74+
error={errors.passwordConfirm}
75+
showError={showError('passwordConfirm')}
76+
{...register('passwordConfirm')}
77+
/>
78+
</div>
79+
80+
<button
81+
type="submit"
82+
className="bg-primary hover:bg-primary-hover active:bg-primary-active mt-6 h-12 w-full cursor-pointer rounded-lg text-base font-medium text-white transition-all duration-150 active:scale-[0.98]"
83+
>
84+
가입하기
85+
</button>
86+
87+
<p className="text-text-sub mt-4 text-center text-sm">
88+
계정이 있으신가요?
89+
<Link
90+
className="text-primary ml-1 cursor-pointer font-medium"
91+
href="/login"
92+
>
93+
로그인
94+
</Link>
95+
</p>
96+
</form>
97+
);
98+
};
99+
100+
export default SignupForm;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { auth, db } from '@/lib/firebase';
2+
import { createUserWithEmailAndPassword, updateProfile } from 'firebase/auth';
3+
import { doc, serverTimestamp, setDoc } from 'firebase/firestore';
4+
5+
export type SignupPayload = {
6+
nickname: string;
7+
email: string;
8+
password: string;
9+
};
10+
11+
export async function signupWithEmail(p: SignupPayload) {
12+
const cred = await createUserWithEmailAndPassword(auth, p.email, p.password);
13+
await updateProfile(cred.user, { displayName: p.nickname });
14+
await setDoc(doc(db, 'users', cred.user.uid), {
15+
nickname: p.nickname,
16+
email: p.email,
17+
createdAt: serverTimestamp(),
18+
});
19+
return cred.user;
20+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useMemo, useState } from 'react';
2+
import { vEmail, vNickname, vPassword, vPasswordConfirm } from './validators';
3+
4+
export type SignupValues = {
5+
nickname: string;
6+
email: string;
7+
password: string;
8+
passwordConfirm: string;
9+
};
10+
type Field = keyof SignupValues;
11+
/* 모든 필드를 한 번씩 건드려야 제출 가능 */
12+
const allTouched = (): Record<Field, boolean> => ({
13+
nickname: true,
14+
email: true,
15+
password: true,
16+
passwordConfirm: true,
17+
});
18+
19+
export function useSignupForm() {
20+
/* 입력값 상태*/
21+
const [v, setV] = useState<SignupValues>({
22+
nickname: '',
23+
email: '',
24+
password: '',
25+
passwordConfirm: '',
26+
});
27+
/* 건드렸는지 */
28+
const [t, setT] = useState<Record<Field, boolean>>({
29+
nickname: false,
30+
email: false,
31+
password: false,
32+
passwordConfirm: false,
33+
});
34+
/* focus 상태 */
35+
const [f, setF] = useState<Field | null>(null);
36+
/* error 계산 */
37+
const e = useMemo(
38+
() => ({
39+
nickname: vNickname(v.nickname),
40+
email: vEmail(v.email),
41+
password: vPassword(v.password),
42+
passwordConfirm: vPasswordConfirm(v.password, v.passwordConfirm),
43+
}),
44+
[v]
45+
);
46+
/* props 생성*/
47+
const register = (name: Field) => ({
48+
value: v[name],
49+
onChange: (ev: React.ChangeEvent<HTMLInputElement>) =>
50+
setV((p) => ({ ...p, [name]: ev.target.value })),
51+
onFocus: () => setF(name),
52+
onBlur: () => (
53+
setF((p) => (p === name ? null : p)),
54+
setT((p) => ({ ...p, [name]: true }))
55+
),
56+
});
57+
58+
const showError = (name: Field) => !!e[name] && t[name] && f !== name;
59+
const hasError = () => Object.values(e).some(Boolean);
60+
const touchAll = () => setT(allTouched());
61+
62+
return { values: v, errors: e, register, showError, hasError, touchAll };
63+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const vNickname = (s: string) =>
2+
s.length < 2 || s.length > 10 ? '닉네임은 2~10자여야 합니다' : '';
3+
4+
export const vEmail = (s: string) =>
5+
s && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)
6+
? '이메일 형식이 올바르지 않습니다'
7+
: '';
8+
9+
export const vPassword = (s: string) =>
10+
s && s.length < 8 ? '비밀번호는 8자 이상이어야 합니다' : '';
11+
12+
export const vPasswordConfirm = (pw: string, pc: string) =>
13+
pc && pc !== pw ? '비밀번호가 일치하지 않습니다' : '';

0 commit comments

Comments
 (0)