Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/(auth)/signup/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
30 changes: 23 additions & 7 deletions components/auth/FormField.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>;

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<HTMLInputElement, FieldProps>(
({ id, label, type = 'text', ...rest }, ref) => {
const FormField = forwardRef<HTMLInputElement, Props>(
({ id, label, error, showError, className, ...rest }, ref) => {
const msg = showError ? error : '';
return (
<div className="space-y-1">
<label htmlFor={id} className="pl-1 text-sm font-medium">
{label}
</label>
<input id={id} className={inputClass} type={type} ref={ref} {...rest} />
<input
id={id}
ref={ref}
className={`${base} ${msg ? errCls : ''} ${className ?? ''}`}
aria-invalid={!!msg}
aria-describedby={msg ? `${id}-error` : undefined}
{...rest}
/>
{msg && (
<p id={`${id}-error`} className="pl-1 text-xs text-red-500">
{msg}
</p>
)}
</div>
);
}
);

FormField.displayName = 'FormField';

export default FormField;
1 change: 0 additions & 1 deletion components/auth/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ const LoginForm = ({ handleOpenModal }: LoginFormProps) => {
router.push('/main');
} catch (err: unknown) {
if (err instanceof Error) {
// console.log('로그인 에러 : ', err.message);
}
}
};
Expand Down
31 changes: 0 additions & 31 deletions components/auth/SignupForm.tsx

This file was deleted.

100 changes: 100 additions & 0 deletions components/auth/SignupForm/SignupForm.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<form onSubmit={onSubmit}>
<div className="space-y-6">
<FormField
id="nickname"
label="닉네임"
error={errors.nickname}
showError={showError('nickname')}
{...register('nickname')}
/>
<FormField
id="email"
label="이메일"
type="email"
error={errors.email}
showError={showError('email')}
{...register('email')}
/>
<FormField
id="password"
label="비밀번호"
type="password"
error={errors.password}
showError={showError('password')}
{...register('password')}
/>
<FormField
id="passwordConfirm"
label="비밀번호 확인"
type="password"
error={errors.passwordConfirm}
showError={showError('passwordConfirm')}
{...register('passwordConfirm')}
/>
</div>

<button
type="submit"
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]"
>
가입하기
</button>

<p className="text-text-sub mt-4 text-center text-sm">
계정이 있으신가요?
<Link
className="text-primary ml-1 cursor-pointer font-medium"
href="/login"
>
로그인
</Link>
</p>
</form>
);
};

export default SignupForm;
20 changes: 20 additions & 0 deletions components/auth/SignupForm/signup.api.ts
Original file line number Diff line number Diff line change
@@ -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;
}
63 changes: 63 additions & 0 deletions components/auth/SignupForm/useSignupForm.tsx
Original file line number Diff line number Diff line change
@@ -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<Field, boolean> => ({
nickname: true,
email: true,
password: true,
passwordConfirm: true,
});

export function useSignupForm() {
/* 입력값 상태*/
const [v, setV] = useState<SignupValues>({
nickname: '',
email: '',
password: '',
passwordConfirm: '',
});
/* 건드렸는지 */
const [t, setT] = useState<Record<Field, boolean>>({
nickname: false,
email: false,
password: false,
passwordConfirm: false,
});
/* focus 상태 */
const [f, setF] = useState<Field | null>(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<HTMLInputElement>) =>
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 };
}
13 changes: 13 additions & 0 deletions components/auth/SignupForm/validators.ts
Original file line number Diff line number Diff line change
@@ -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 ? '비밀번호가 일치하지 않습니다' : '';