dh_demo

DreamHanks demo project
git clone git://git.lair.cx/dh_demo
Log | Files | Refs | README

commit bba0b0864bfe4d2132bf4df5e6cfd568f00aa0fc
parent 963a915760a4fd2ec71d9f06566ca2313e6e1b9b
Author: Yongbin Kim <iam@yongbin.kim>
Date:   Fri, 20 Jan 2023 10:33:19 +0900

fix: 폼 관련 버그 수정 및 개선

- 폼 입력 후 엔터 키로 진행하지 못하던 문제 수정
- 로그인 폼에서 오류 발생한 이후 다시 입력할 수 없던 문제 수정
- 오류 코드가 읽을 수 있는 텍스트로 출력되게끔 수정
- 전체 로직 개선

Signed-off-by: Yongbin Kim <iam@yongbin.kim>

Diffstat:
M__tests__/__snapshots__/login_test.tsx.snap | 163++++++++++++++++++++++---------------------------------------------------------
M__tests__/__snapshots__/register_test.tsx.snap | 66++++++++++++++++++++++++++++++++++--------------------------------
Mcomponents/contexts/TokenContext.tsx | 12++++++------
Mcomponents/elements/Button.module.css | 2+-
Mcomponents/elements/Button.module.css.map | 4++--
Mcomponents/elements/Button.module.scss | 5++---
Mcomponents/elements/Button.tsx | 22++++++++++++++++++----
Acomponents/form/Form.tsx | 20++++++++++++++++++++
Mpages/users/login.tsx | 129++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mpages/users/signup/[id].tsx | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mpages/users/signup/index.tsx | 86+++++++++++++++++++++++++++++++++++++++----------------------------------------
11 files changed, 305 insertions(+), 304 deletions(-)

diff --git a/__tests__/__snapshots__/login_test.tsx.snap b/__tests__/__snapshots__/login_test.tsx.snap @@ -25,130 +25,55 @@ exports[`/users/login renders correctly 1`] = ` <div class="container" > - <div - class="fields-wrapper" - > - <label - class="field has-no-color is-not-readonly" - > - <input - class="input" - type="text" - value="" - /> - <span - class="label" - > - Email - </span> - <div - class="label-background" - > - Email - </div> - </label> - <label - class="field has-no-color is-not-readonly" - > - <input - class="input" - type="password" - value="" - /> - <span - class="label" - > - Password - </span> - <div - class="label-background" - > - Password - </div> - </label> - <button - class="button is-primary" - > - Login - </button> - </div> - </div> - </div> -</div> -`; - -exports[`Login renders correctly 1`] = ` -<div> - <div - class="hero" - > - <div - class="container" - > - <div - class="hero-body" - > - <h1 - class="is-large is-headline" + <form> + <div + class="fields-wrapper" > - Login - </h1> - </div> - </div> - </div> - <div - class="section" - > - <div - class="container" - > - <div - class="fields-wrapper" - > - <label - class="field has-no-color is-not-readonly" - > - <input - class="input" - type="text" - value="" - /> - <span - class="label" + <label + class="field has-no-color is-not-readonly" > - Email - </span> - <div - class="label-background" + <input + class="input" + type="text" + value="" + /> + <span + class="label" + > + Email + </span> + <div + class="label-background" + > + Email + </div> + </label> + <label + class="field has-no-color is-not-readonly" > - Email - </div> - </label> - <label - class="field has-no-color is-not-readonly" - > + <input + class="input" + type="password" + value="" + /> + <span + class="label" + > + Password + </span> + <div + class="label-background" + > + Password + </div> + </label> <input - class="input" - type="password" - value="" + class="button is-primary" + type="submit" + value="Login" /> - <span - class="label" - > - Password - </span> - <div - class="label-background" - > - Password - </div> - </label> - <button - class="button is-primary" - > - Login - </button> - </div> + </div> + </form> </div> </div> </div> diff --git a/__tests__/__snapshots__/register_test.tsx.snap b/__tests__/__snapshots__/register_test.tsx.snap @@ -25,40 +25,42 @@ exports[`/users/signup renders correctly 1`] = ` <div class="container" > - <div - class="fields-wrapper" - > - <label - class="field has-no-color is-not-readonly" + <form> + <div + class="fields-wrapper" > - <input - class="input" - type="text" - value="" - /> - <span - class="label" - > - Email - </span> - <div - class="label-background" + <label + class="field has-no-color is-not-readonly" > - Email - </div> - </label> - </div> - <p> - 입력해주신 주소로 인증 메일을 보내드립니다. 메일에 포함된 링크를 통해, 가입을 진행할 수 있습니다. - </p> - <p> - 메일 주소는 공개되지 않습니다. - </p> - <button - class="button is-primary" - > - Sign up - </button> + <input + class="input" + type="text" + value="" + /> + <span + class="label" + > + Email + </span> + <div + class="label-background" + > + Email + </div> + </label> + </div> + <p> + 입력해주신 주소로 인증 메일을 보내드립니다. 메일에 포함된 링크를 통해, 가입을 진행할 수 있습니다. + </p> + <p> + 메일 주소는 공개되지 않습니다. + </p> + <input + class="button is-primary" + type="submit" + value="Sign up" + /> + </form> </div> </div> </div> diff --git a/components/contexts/TokenContext.tsx b/components/contexts/TokenContext.tsx @@ -12,7 +12,7 @@ export interface AccessTokenPayload extends JwtPayload { } const TokenContext = createContext<AccessTokenPayload | null>(null) -const TokenRefreshContext = createContext<() => void>(() => { +const TokenFlushContext = createContext<() => void>(() => { }) export function TokenProvider ({ children }: { children: ReactNode }) { @@ -65,16 +65,16 @@ export function TokenProvider ({ children }: { children: ReactNode }) { } }, [accessToken, accessToken?.exp]) - const trigger = useCallback( + const flushTrigger = useCallback( () => setCheckCounter(v => v + 1), [], ) return ( <TokenContext.Provider value={accessToken}> - <TokenRefreshContext.Provider value={trigger}> + <TokenFlushContext.Provider value={flushTrigger}> {children} - </TokenRefreshContext.Provider> + </TokenFlushContext.Provider> </TokenContext.Provider> ) } @@ -87,8 +87,8 @@ export function useToken () { return token } -export function useTokenRefreshTrigger () { - return useContext(TokenRefreshContext) +export function useTokenFlushTrigger () { + return useContext(TokenFlushContext) } function getCookie (name: string) { diff --git a/components/elements/Button.module.css b/components/elements/Button.module.css @@ -1 +1 @@ -.button{display:inline-flex;min-width:5rem;padding:0 1rem;height:2rem;justify-content:center;align-items:center;border:1px solid rgba(0,0,0,0);border-radius:9999px;vertical-align:top;transition:background-color 100ms,box-shadow 100ms;user-select:none;cursor:pointer;background-color:#e8ebe8;color:#191c1b}.button:hover{background-color:#d8dbd9}.button:active{background-color:#e8ebe8}.button.is-disabled{background-color:#fafdfa;color:rgba(25,28,27,.5)}.button.is-primary{background-color:#e6f1ed;color:#006b5a}.button.is-primary:hover{background-color:#d5e7e2}.button.is-primary:active{background-color:#e6f1ed}.button.is-primary.is-disabled{background-color:#fafdfa;color:rgba(0,107,90,.5)}.button.is-secondary{background-color:#f0eff2;color:#7b4998}.button.is-secondary:hover{background-color:#e7e2eb}.button.is-secondary:active{background-color:#f0eff2}.button.is-secondary.is-disabled{background-color:#fafdfa;color:rgba(123,73,152,.5)}.button.is-tertiary{background-color:#ebf1f0;color:#426278}.button.is-tertiary:hover{background-color:#dee6e7}.button.is-tertiary:active{background-color:#ebf1f0}.button.is-tertiary.is-disabled{background-color:#fafdfa;color:rgba(66,98,120,.5)}.button.is-error{background-color:#fae9e6;color:red}.button.is-error:hover{background-color:#fbd7d5}.button.is-error:active{background-color:#fae9e6}.button.is-error.is-disabled{background-color:#fafdfa;color:rgba(255,0,0,.5)}@media(prefers-color-scheme: dark){.button{background-color:#292c2b;color:#e1e3e0}.button:hover{background-color:#373a39}.button:active{background-color:#292c2b}.button.is-disabled{background-color:#191c1b;color:rgba(225,227,224,.5)}.button.is-primary{background-color:#1b2c28;color:#2cdebf}.button.is-primary:hover{background-color:#1c3934}.button.is-primary:active{background-color:#1b2c28}.button.is-primary.is-disabled{background-color:#191c1b;color:rgba(44,222,191,.5)}.button.is-secondary{background-color:#29282d;color:#e6b4ff}.button.is-secondary:hover{background-color:#38333d}.button.is-secondary:active{background-color:#29282d}.button.is-secondary.is-disabled{background-color:#191c1b;color:rgba(230,180,255,.5)}.button.is-tertiary{background-color:#252a2b;color:#aacbe4}.button.is-tertiary:hover{background-color:#2f3639}.button.is-tertiary:active{background-color:#252a2b}.button.is-tertiary.is-disabled{background-color:#191c1b;color:rgba(170,203,228,.5)}.button.is-error{background-color:#2b2827;color:#ffb4ab}.button.is-error:hover{background-color:#3c3331}.button.is-error:active{background-color:#2b2827}.button.is-error.is-disabled{background-color:#191c1b;color:rgba(255,180,171,.5)}}/*# sourceMappingURL=Button.module.css.map */ +.button{display:inline-block;min-width:5rem;padding:0 1rem;height:2rem;text-align:center;border:1px solid rgba(0,0,0,0);border-radius:9999px;vertical-align:top;transition:background-color 100ms,box-shadow 100ms;user-select:none;cursor:pointer;background-color:#e8ebe8;color:#191c1b}.button:hover{background-color:#d8dbd9}.button:active{background-color:#e8ebe8}.button.is-disabled{background-color:#fafdfa;color:rgba(25,28,27,.5)}.button.is-primary{background-color:#e6f1ed;color:#006b5a}.button.is-primary:hover{background-color:#d5e7e2}.button.is-primary:active{background-color:#e6f1ed}.button.is-primary.is-disabled{background-color:#fafdfa;color:rgba(0,107,90,.5)}.button.is-secondary{background-color:#f0eff2;color:#7b4998}.button.is-secondary:hover{background-color:#e7e2eb}.button.is-secondary:active{background-color:#f0eff2}.button.is-secondary.is-disabled{background-color:#fafdfa;color:rgba(123,73,152,.5)}.button.is-tertiary{background-color:#ebf1f0;color:#426278}.button.is-tertiary:hover{background-color:#dee6e7}.button.is-tertiary:active{background-color:#ebf1f0}.button.is-tertiary.is-disabled{background-color:#fafdfa;color:rgba(66,98,120,.5)}.button.is-error{background-color:#fae9e6;color:red}.button.is-error:hover{background-color:#fbd7d5}.button.is-error:active{background-color:#fae9e6}.button.is-error.is-disabled{background-color:#fafdfa;color:rgba(255,0,0,.5)}@media(prefers-color-scheme: dark){.button{background-color:#292c2b;color:#e1e3e0}.button:hover{background-color:#373a39}.button:active{background-color:#292c2b}.button.is-disabled{background-color:#191c1b;color:rgba(225,227,224,.5)}.button.is-primary{background-color:#1b2c28;color:#2cdebf}.button.is-primary:hover{background-color:#1c3934}.button.is-primary:active{background-color:#1b2c28}.button.is-primary.is-disabled{background-color:#191c1b;color:rgba(44,222,191,.5)}.button.is-secondary{background-color:#29282d;color:#e6b4ff}.button.is-secondary:hover{background-color:#38333d}.button.is-secondary:active{background-color:#29282d}.button.is-secondary.is-disabled{background-color:#191c1b;color:rgba(230,180,255,.5)}.button.is-tertiary{background-color:#252a2b;color:#aacbe4}.button.is-tertiary:hover{background-color:#2f3639}.button.is-tertiary:active{background-color:#252a2b}.button.is-tertiary.is-disabled{background-color:#191c1b;color:rgba(170,203,228,.5)}.button.is-error{background-color:#2b2827;color:#ffb4ab}.button.is-error:hover{background-color:#3c3331}.button.is-error:active{background-color:#2b2827}.button.is-error.is-disabled{background-color:#191c1b;color:rgba(255,180,171,.5)}}/*# sourceMappingURL=Button.module.css.map */ diff --git a/components/elements/Button.module.css.map b/components/elements/Button.module.css.map @@ -1 +1 @@ -{"version":3,"sourceRoot":"","sources":["Button.module.scss","../../styles/core/_elevate.scss","../../styles/core/_colors.scss"],"names":[],"mappings":"AA4BA,QACE,oBACA,UA1BU,KA2BV,QA1BQ,OA2BR,OA1BO,KA2BP,uBACA,mBACA,+BACA,cA5Bc,OA6Bd,mBACA,mDACA,iBACA,eCYA,yBDxCA,cAEA,cCsCA,yBDlCA,eCkCA,yBD9BA,oBC8BA,yBD5BE,wBAsBE,mBCMJ,yBDxCA,cAEA,yBCsCA,yBDlCA,0BCkCA,yBD9BA,+BC8BA,yBD5BE,wBAsBE,qBCMJ,yBDxCA,cAEA,2BCsCA,yBDlCA,4BCkCA,yBD9BA,iCC8BA,yBD5BE,0BAsBE,oBCMJ,yBDxCA,cAEA,0BCsCA,yBDlCA,2BCkCA,yBD9BA,gCC8BA,yBD5BE,yBAsBE,iBCMJ,yBDxCA,UAEA,uBCsCA,yBDlCA,wBCkCA,yBD9BA,6BC8BA,yBD5BE,uBEwGF,mCFpGF,QCwBE,yBDxCA,cAEA,cCsCA,yBDlCA,eCkCA,yBD9BA,oBC8BA,yBD5BE,2BAsBE,mBCMJ,yBDxCA,cAEA,yBCsCA,yBDlCA,0BCkCA,yBD9BA,+BC8BA,yBD5BE,0BAsBE,qBCMJ,yBDxCA,cAEA,2BCsCA,yBDlCA,4BCkCA,yBD9BA,iCC8BA,yBD5BE,2BAsBE,oBCMJ,yBDxCA,cAEA,0BCsCA,yBDlCA,2BCkCA,yBD9BA,gCC8BA,yBD5BE,2BAsBE,iBCMJ,yBDxCA,cAEA,uBCsCA,yBDlCA,wBCkCA,yBD9BA,6BC8BA,yBD5BE","file":"Button.module.css"} -\ No newline at end of file +{"version":3,"sourceRoot":"","sources":["Button.module.scss","../../styles/core/_elevate.scss","../../styles/core/_colors.scss"],"names":[],"mappings":"AA4BA,QACE,qBACA,UA1BU,KA2BV,QA1BQ,OA2BR,OA1BO,KA2BP,kBACA,+BACA,cA3Bc,OA4Bd,mBACA,mDACA,iBACA,eCaA,yBDxCA,cAEA,cCsCA,yBDlCA,eCkCA,yBD9BA,oBC8BA,yBD5BE,wBAqBE,mBCOJ,yBDxCA,cAEA,yBCsCA,yBDlCA,0BCkCA,yBD9BA,+BC8BA,yBD5BE,wBAqBE,qBCOJ,yBDxCA,cAEA,2BCsCA,yBDlCA,4BCkCA,yBD9BA,iCC8BA,yBD5BE,0BAqBE,oBCOJ,yBDxCA,cAEA,0BCsCA,yBDlCA,2BCkCA,yBD9BA,gCC8BA,yBD5BE,yBAqBE,iBCOJ,yBDxCA,UAEA,uBCsCA,yBDlCA,wBCkCA,yBD9BA,6BC8BA,yBD5BE,uBEwGF,mCFpGF,QCwBE,yBDxCA,cAEA,cCsCA,yBDlCA,eCkCA,yBD9BA,oBC8BA,yBD5BE,2BAqBE,mBCOJ,yBDxCA,cAEA,yBCsCA,yBDlCA,0BCkCA,yBD9BA,+BC8BA,yBD5BE,0BAqBE,qBCOJ,yBDxCA,cAEA,2BCsCA,yBDlCA,4BCkCA,yBD9BA,iCC8BA,yBD5BE,2BAqBE,oBCOJ,yBDxCA,cAEA,0BCsCA,yBDlCA,2BCkCA,yBD9BA,gCC8BA,yBD5BE,2BAqBE,iBCOJ,yBDxCA,cAEA,uBCsCA,yBDlCA,wBCkCA,yBD9BA,6BC8BA,yBD5BE","file":"Button.module.css"} +\ No newline at end of file diff --git a/components/elements/Button.module.scss b/components/elements/Button.module.scss @@ -27,12 +27,11 @@ $border-radius: 9999px; } .button { - display: inline-flex; + display: inline-block; min-width: $min-width; padding: $padding; height: $height; - justify-content: center; - align-items: center; + text-align: center; border: 1px solid transparent; border-radius: $border-radius; vertical-align: top; diff --git a/components/elements/Button.tsx b/components/elements/Button.tsx @@ -1,17 +1,30 @@ import classNames from '@/lib/classnames' -import { HTMLAttributes } from 'react' +import { HTMLAttributes, InputHTMLAttributes } from 'react' import styles from './Button.module.css' -export interface ButtonProps extends HTMLAttributes<HTMLButtonElement> { +export interface CommonButtonProps { color?: 'primary' | 'secondary' | 'tertiary' | 'error' disabled?: boolean } -export default function Button (props: ButtonProps) { +export type ButtonProps = HTMLAttributes<HTMLButtonElement> & CommonButtonProps +export type SubmitButtonProps = InputHTMLAttributes<HTMLInputElement> & CommonButtonProps + +export function Button (props: ButtonProps) { + return ButtonImpl('button', props) +} + +export function SubmitButton (props: SubmitButtonProps) { + return ButtonImpl('input', props, { + type: 'submit', + }) +} + +function ButtonImpl<T extends CommonButtonProps> (Tag: any, props: T, additionalProps: Partial<T> = {}) { const { color, disabled, ...restProps } = props return ( - <button + <Tag {...classNames( styles.button, color ? `is-${props.color}` : null, @@ -19,6 +32,7 @@ export default function Button (props: ButtonProps) { )} disabled={disabled} {...restProps} + {...additionalProps} /> ) } diff --git a/components/form/Form.tsx b/components/form/Form.tsx @@ -0,0 +1,20 @@ +import { FormEvent, ReactNode } from 'react' + +export interface FormProps { + onSubmit?: () => void + + children?: ReactNode +} + +export default function Form (props: FormProps) { + const handleSubmit = (e: FormEvent<HTMLFormElement>) => { + e.preventDefault() + props.onSubmit?.() + } + + return ( + <form onSubmit={handleSubmit}> + {props.children} + </form> + ) +} diff --git a/pages/users/login.tsx b/pages/users/login.tsx @@ -1,15 +1,14 @@ -import Button from '@/components/elements/Button' +import { useTokenFlushTrigger } from '@/components/contexts/TokenContext' +import { SubmitButton } from '@/components/elements/Button' import Title from '@/components/elements/Title' import Field from '@/components/form/Field' import Fields from '@/components/form/Fields' +import Form from '@/components/form/Form' import Container from '@/components/layout/Container' import Hero from '@/components/layout/Hero' import Section from '@/components/layout/Section' -import { ApiError, isApiError } from '@/lib/apierror' -import useApi from '@/lib/hooks/use_api' -import { TokenResponse } from '@/pages/api/auth/token' import { useRouter } from 'next/router' -import { MouseEvent, useCallback, useEffect, useState } from 'react' +import { useCallback, useState } from 'react' export default function LoginPage () { return ( @@ -31,71 +30,79 @@ function LoginForm () { const router = useRouter() const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const flushToken = useTokenFlushTrigger() + const [errorMessage, setErrorMessage] = useState<string | null>(null) + const [isLoading, setLoading] = useState(false) - const [err, setError] = useState<ApiError | null>(null) - const [result, isLoading, statusCode, submit] = useApi<TokenResponse | ApiError, { email: string, password: string }>( - '/api/auth/token', - { - method: 'POST', - credentials: 'same-origin', - body: () => ({ email, password }), - }, - ) + const handleSubmit = useCallback(() => { + const handleError = (status: number) => { + switch (status) { + case 401: + setErrorMessage('이메일 혹은 비밀번호가 잘못되었습니다.') + break - const handleSubmit = useCallback((e: MouseEvent<HTMLButtonElement>) => { - if (isLoading) { - return + default: + setErrorMessage('알 수 없는 오류가 발생했습니다.') + break + } } - submit() - }, [email, password]) + setLoading(true) + setErrorMessage(null) - // 로그인 요청 응답 처리 - useEffect(() => { - if (result == null) { - return - } + !(async () => { + const res = await fetch('/api/auth/token', { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify({ email, password }), + }) - if (isApiError(result)) { - setError(result) - return - } else { - setError(null) - } + if (!res.ok) { + handleError(res.status) + setLoading(false) + return + } + + flushToken() - // 메인 페이지(혹은 redirect_to)로 이동 - typeof router.query.redirect_to === 'string' - ? router.push(router.query.redirect_to as string) - : router.push('/') - }, [result]) + if (typeof router.query.redirect_to === 'string') { + await router.push(router.query.redirect_to as string) + } else { + await router.push('/') + } + })().catch ((err) => { + console.error(err) + setErrorMessage('알 수 없는 오류가 발생했습니다.') + }) + }, [email, password]) return ( - <Fields> - <Field - type="text" - color={err?.code != null ? 'error' : undefined} - disabled={isLoading || result != null} - placeholder="Email" - value={email} - onValueChange={setEmail} - /> - <Field - type="password" - color={err?.code != null ? 'error' : undefined} - disabled={isLoading || result != null} - placeholder="Password" - value={password} - message={err?.code} - onValueChange={setPassword} - /> + <Form onSubmit={handleSubmit}> + <Fields> + <Field + type="text" + color={errorMessage != null ? 'error' : undefined} + disabled={isLoading} + placeholder="Email" + value={email} + message={errorMessage ?? undefined} + onValueChange={setEmail} + /> + <Field + type="password" + color={errorMessage != null ? 'error' : undefined} + disabled={isLoading} + placeholder="Password" + value={password} + onValueChange={setPassword} + /> - <Button - color="primary" - disabled={isLoading} - onClick={handleSubmit} - > - Login - </Button> - </Fields> + <SubmitButton + color="primary" + disabled={isLoading} + value={'Login'} + /> + </Fields> + </Form> ) } diff --git a/pages/users/signup/[id].tsx b/pages/users/signup/[id].tsx @@ -1,7 +1,8 @@ -import Button from '@/components/elements/Button' +import { Button, SubmitButton } from '@/components/elements/Button' import Title from '@/components/elements/Title' import Field from '@/components/form/Field' import Fields from '@/components/form/Fields' +import Form from '@/components/form/Form' import Container from '@/components/layout/Container' import Hero from '@/components/layout/Hero' import Section from '@/components/layout/Section' @@ -9,7 +10,7 @@ import { isApiError } from '@/lib/apierror' import { getUnconfirmedSignupRequest } from '@/lib/models/signup_request' import { GetServerSideProps } from 'next' import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' interface PageProps { id: number @@ -51,6 +52,7 @@ export default function SignUpCompletePage (props: PageProps) { const [passwordRepeatError, setPasswordRepeatError] = useState<string | null>(null) const [nickname, setNickname] = useState('') const [nicknameError, setNicknameError] = useState<string | null>(null) + const [isLoading, setLoading] = useState(false) // 비밀번호 확인 오류 표시 useEffect(() => { @@ -64,39 +66,74 @@ export default function SignUpCompletePage (props: PageProps) { ) }, [password, passwordRepeat]) - const doSignUp = async () => { - const res = await fetch(`/api/users/signup/${requestId}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - password, - nickname, - }), - }) + const handleSubmit = useCallback(() => { + const handleError = (status: number, err: any) => { + if (!isApiError(err)) { + setPasswordError('알 수 없는 오류가 발생했습니다.') + return + } - if (res.ok) { - await router.push('/users/login') - return - } + switch (status) { + case 400: + switch (err.code) { + case 'invalid_password': + setPasswordError('비밀번호가 비어있거나 유효하지 않습니다.') + return - const data = await res.json() - if (isApiError(data)) { - switch (data.field) { - case 'nickname': - setNicknameError(data.code) + case 'invalid_nickname': + setNicknameError('닉네임이 비어있거나 유효하지 않습니다.') + return + } break - default: - setPasswordError(data.code) + case 409: + switch (err.code) { + case 'nickname_duplicated': + setNicknameError('이미 사용중인 닉네임입니다.') + return + } break } + + setPasswordError('알 수 없는 오류가 발생했습니다.') } - } + + setLoading(false) + setPasswordError(null) + setNicknameError(null) + + if (password !== passwordRepeat) { + setPasswordRepeatError('비밀번호가 일치하지 않습니다.') + return + } + + !(async () => { + const res = await fetch(`/api/users/signup/${requestId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + password, + nickname, + }), + }) + + if (res.ok) { + await router.push('/users/login') + return + } + + const err = await res.json() + handleError(res.status, err) + })().catch((err) => { + console.error(err) + setPasswordError('알 수 없는 오류가 발생했습니다.') + }) + }, []) return ( - <> + <Form onSubmit={handleSubmit}> <Hero> <Title kind="headline">Sign up</Title> </Hero> @@ -136,14 +173,13 @@ export default function SignUpCompletePage (props: PageProps) { /> </Fields> - <Button + <SubmitButton color="primary" - onClick={doSignUp} - > - Sign up - </Button> + disabled={isLoading} + value={'Sign up'} + /> </Container> </Section> - </> + </Form> ) } diff --git a/pages/users/signup/index.tsx b/pages/users/signup/index.tsx @@ -1,15 +1,13 @@ -import Button from '@/components/elements/Button' +import { SubmitButton } from '@/components/elements/Button' import Title from '@/components/elements/Title' import Field from '@/components/form/Field' import Fields from '@/components/form/Fields' +import Form from '@/components/form/Form' import Container from '@/components/layout/Container' import Hero from '@/components/layout/Hero' import Section from '@/components/layout/Section' -import { ApiError } from '@/lib/apierror' -import useApi from '@/lib/hooks/use_api' -import { SignUpResponse } from '@/pages/api/users/signup' import { useRouter } from 'next/router' -import { MouseEvent, useEffect, useMemo, useState } from 'react' +import { useCallback, useState } from 'react' export default function SignUpPage () { return ( @@ -30,52 +28,54 @@ export default function SignUpPage () { function SignUpForm () { const router = useRouter() const [email, setEmail] = useState('') - const [result, isLoading, statusCode, submit] = useApi<SignUpResponse, { email: string }>( - '/api/users/signup', - { - method: 'POST', - body: () => ({ email }), - }, - ) + const [isLoading, setLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState<string | null>(null) - const err = useMemo(() => { - if (result == null || 'status' in result) { - return null - } - return result - }, [result]) + const handleSubmit = useCallback(() => { + const handleError = (status: number) => { + switch (status) { + case 401: + setErrorMessage('이메일 혹은 비밀번호가 잘못되었습니다.') + break - const handleSubmit = (e: MouseEvent<HTMLButtonElement>) => { - if (isLoading) { - return + default: + setErrorMessage('알 수 없는 오류가 발생했습니다.') + break + } } - submit() - } + setLoading(true) + setErrorMessage(null) - useEffect(() => { - if (result == null) { - return - } + !(async () => { + const res = await fetch('/api/users/signup', { + method: 'POST', + body: JSON.stringify({ email }), + }) - if (!('status' in result)) { - return - } + if (!res.ok) { + handleError(res.status) + setLoading(false) + return + } - router.push('/users/signup/sent') - .catch(console.error) - }, [result]) + await router.push('/users/signup/sent') + })().catch((err) => { + console.error(err) + setErrorMessage('알 수 없는 오류가 발생했습니다.') + }) + }, [email]) return ( - <> + <Form onSubmit={handleSubmit}> <Fields> <Field type="text" - disabled={isLoading || result != null} + disabled={isLoading} placeholder="Email" - color={err == null ? undefined : 'error'} + color={errorMessage == null ? undefined : 'error'} value={email} - message={err == null ? undefined : err.code} + message={errorMessage ?? undefined} onValueChange={setEmail} /> </Fields> @@ -84,13 +84,11 @@ function SignUpForm () { 메일에 포함된 링크를 통해, 가입을 진행할 수 있습니다.</p> <p>메일 주소는 공개되지 않습니다.</p> - <Button + <SubmitButton color="primary" - disabled={isLoading || result != null} - onClick={handleSubmit} - > - Sign up - </Button> - </> + disabled={isLoading} + value="Sign up" + /> + </Form> ) }