Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ Feat ] signup page #109

Merged
merged 19 commits into from
Oct 21, 2024
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
23 changes: 8 additions & 15 deletions src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,26 @@
"use client";
import Modal from "@/common/component/Modal";
import ToastProvider from "@/common/component/Toast";
import AuthHeader from "@/view/index/AuthHeader";
import Card from "@/view/index/Card";
import Footer from "@/view/index/Footer";
import AuthHeader from "@/shared/component/AuthHeader";
import Card from "@/shared/component/Card";
import FormFooter from "@/shared/component/FormFooter";
import LoginForm from "@/view/login/LoginForm";
import { containerStyle, headingStyle } from "@/view/login/index.css";
import { useRouter } from "next/navigation";
import { containerStyle, headingStyle, wrapper } from "@/view/login/index.css";

const LoginPage = () => {
const router = useRouter();
const onClose = () => {
router.push("/");
};
return (
<Modal isOpen={true} onClose={() => {}}>
<AuthHeader handleClick={onClose} />
<div className={wrapper}>
<AuthHeader isLoginPage />
<div className={containerStyle}>
<h1 className={headingStyle}>알고허브로 로그인</h1>
<Card>
<LoginForm />
<Footer
<FormFooter
guideLabel="아직 계정이 없으신가요?"
link={{ href: "/signup", label: "회원가입하기" }}
/>
</Card>
</div>
<ToastProvider />
</Modal>
</div>
);
};

Expand Down
19 changes: 18 additions & 1 deletion src/app/signup/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import AuthHeader from "@/shared/component/AuthHeader";
import FormFooter from "@/shared/component/FormFooter";
import { wrapper } from "@/view/login/index.css";
import SignupForm from "@/view/signup/SignupForm";
import { containerStyle } from "@/view/signup/index.css";

const SignupPage = () => {
return <div>SignupPage</div>;
return (
<div className={wrapper}>
<AuthHeader />
<div className={containerStyle}>
<SignupForm />
<FormFooter
guideLabel="이미 계정이 있으신가요?"
link={{ href: "/login", label: "로그인하기" }}
/>
</div>
</div>
);
};

export default SignupPage;
2 changes: 2 additions & 0 deletions src/common/component/Toast/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import Portal from "@/common/component/Portal";
import Toast from "@/common/component/Toast/Toast";
import { containerStyle } from "@/common/component/Toast/index.css";
Expand Down
27 changes: 27 additions & 0 deletions src/route-stories/join/signup.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import SignupPage from "@/app/signup/page";
import type { Meta, StoryObj } from "@storybook/react";

const meta: Meta<typeof SignupPage> = {
title: "page/SignupPage",
component: SignupPage,
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/PBHmaVSKndAId6lY6G2qEb/AlgoHub?node-id=491-26932&t=5sYtgK4iiARkvjFd-4",
},
},
};

type Story = StoryObj<typeof SignupPage>;

export const Signup: Story = {
parameters: {
nextjs: {
navigation: {
pathname: "/signup",
},
},
},
};

export default meta;
8 changes: 0 additions & 8 deletions src/route-stories/onboarding/onboarding.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import RootLayout from "@/app/layout";
import OnboardingPage from "@/app/page";
import type { Meta, StoryObj } from "@storybook/react";

Expand All @@ -11,13 +10,6 @@ const meta: Meta<typeof OnboardingPage> = {
url: "https://www.figma.com/design/PBHmaVSKndAId6lY6G2qEb/AlgoHub?node-id=491-26604&t=BZcUqksImvGD8cnl-4",
},
},
decorators: [
(Story) => (
<RootLayout>
<Story />
</RootLayout>
),
],
};

type Story = StoryObj<typeof OnboardingPage>;
Expand Down
33 changes: 33 additions & 0 deletions src/shared/component/AuthHeader/index.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { theme } from "@/styles/themes.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";

export const headerStyle = recipe({
base: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",

width: "100vw",
height: "7.2rem",
padding: "0 3.2rem 0 6.4rem",

backgroundColor: theme.color.bg,
borderBottom: `1px solid ${theme.color.mg5}`,
},
variants: {
showLogo: {
false: {
flexDirection: "row-reverse",
},
},
},
});

export const iconStyle = style({
cursor: "pointer",
":hover": {
backgroundColor: theme.color.mg4,
borderRadius: ".4rem",
},
});
46 changes: 46 additions & 0 deletions src/shared/component/AuthHeader/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use client";
import { IcnClose, IcnLogo } from "@/asset/svg";
import { handleA11yClick } from "@/common/util/dom";
import { logoContainer, logoStyle } from "@/shared/component/Header/index.css";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { headerStyle, iconStyle } from "./index.css";

type AuthHeaderProps = {
isLoginPage?: boolean;
};

const AuthHeader = ({ isLoginPage = false }: AuthHeaderProps) => {
const router = useRouter();
const handleClose = () => router.back();
return (
<header className={headerStyle({ showLogo: isLoginPage })}>
{isLoginPage ? (
<>
<Link
href={"/"}
className={logoContainer}
aria-label="온보딩 페이지로 이동"
>
<IcnLogo className={logoStyle} />
</Link>
<Link href={"/"}>
<IcnClose className={iconStyle} width={"2rem"} height={"2rem"} />
</Link>
</>
) : (
<IcnClose
className={iconStyle}
width={"2rem"}
height={"2rem"}
role="button"
onClick={handleClose}
onKeyDown={handleA11yClick(handleClose)}
tabIndex={0}
/>
)}
</header>
);
};

export default AuthHeader;
File renamed without changes.
36 changes: 10 additions & 26 deletions src/shared/component/Form/Form.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Button from "@/common/component/Button";
import { useCheckOnServer } from "@/shared/hook/useCheckOnServer";
import {
getMultipleRevalidationHandlers,
handleOnChangeMode,
Expand Down Expand Up @@ -211,35 +210,20 @@ export const ValidateOnServer: Story = {
mode: "onTouched",
});

const nickname = form.watch("nickname");
const backjoonId = form.watch("baekjoonId");

const { isNicknameLoading, isBaekjoonIdLoading } = useCheckOnServer(
// useQuery 모방용 임시 훅
form,
nickname,
backjoonId,
serverValidationSchema,
);
const { errors, dirtyFields } = form.formState;

// 기본적으로 메세지 표시x, 서버 검증 시 로딩중 표시
// 검증 완료 시 메시지 표시, 에러 발생 시 에러 & 에러 메세지 표시
const nicknameValidationSuccess =
!(errors.nickname || isNicknameLoading) && dirtyFields.nickname;
const nicknameMsg = isNicknameLoading
? "로딩중"
: nicknameValidationSuccess
? "사용가능한 닉네임이에요."
: errors.nickname?.message;
// 기본적으로 메세지 표시x,
// 에러 발생 시 에러 & 에러 메세지 표시
const nicknameValidationSuccess = !errors.nickname && dirtyFields.nickname;
const nicknameMsg = nicknameValidationSuccess
? "사용가능한 닉네임이에요."
: errors.nickname?.message;

const baekjoonIdValidationSuccess =
!(errors.baekjoonId || isBaekjoonIdLoading) && dirtyFields.baekjoonId;
const bjMsg = isBaekjoonIdLoading
? "로딩중"
: baekjoonIdValidationSuccess
? "정상적으로 연동되었어요."
: errors.baekjoonId?.message;
!errors.baekjoonId && dirtyFields.baekjoonId;
const bjMsg = baekjoonIdValidationSuccess
? "정상적으로 연동되었어요."
: errors.baekjoonId?.message;

const onSubmit = (_values: z.infer<typeof serverValidationSchema>) => {};
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ interface FooterProps {
};
}

const Footer = ({ guideLabel, link: { label, href } }: FooterProps) => {
const FormFooter = ({ guideLabel, link: { label, href } }: FooterProps) => {
return (
<div className={labelContainer}>
<footer className={labelContainer}>
<p className={labelStyle.guide}>{guideLabel}</p>
<Link href={href} className={labelStyle.link}>
<Link href={href} className={labelStyle.link} scroll={false}>
{label}
</Link>
</div>
</footer>
);
};

export default Footer;
export default FormFooter;
2 changes: 1 addition & 1 deletion src/shared/component/Header/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { IcnLogo } from "@/asset/svg";
import LoginMenu from "@/shared/component/Header/LoginMenu";
import UserMenu from "@/shared/component/Header/UserMenu";
import {
headerStyle,
logoContainer,
logoStyle,
} from "@/shared/component/Header/index.css";
import LoginMenu from "@/view/login/LoginMenu/LoginMenu";
import Link from "next/link";

const Header = () => {
Expand Down
23 changes: 6 additions & 17 deletions src/shared/hook/useCheckOnServer.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,14 @@
import { validateNickname } from "@/api/validate";
import { baseSignupSchema } from "@/view/signup/SignupForm/schema";
import { useEffect, useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import type { z } from "zod";

// TODO: API연결 후 useQuery로 교체하며 동일하게 기능 적용하기
export const useCheckOnServer = <
T extends UseFormReturn<{
nickname: string;
baekjoonId: string;
introduction: string;
}>,
>(
form: T,
export const useCheckOnServer = (
form: UseFormReturn<z.infer<typeof baseSignupSchema>>,
nickname: string,
baekjoonId: string,
serverValidationSchema: z.ZodObject<{
nickname: z.ZodString;
baekjoonId: z.ZodString;
introduction: z.ZodString;
}>,
) => {
const [isNicknameLoading, setNicknameLoading] = useState(false);
const [isBaekjoonIdLoading, setBaekjoonIdLoading] = useState(false);
Expand All @@ -33,10 +23,9 @@ export const useCheckOnServer = <
form.clearErrors(fieldName);
return;
}

const partialSchema = serverValidationSchema.partial();
const parseResult = partialSchema.safeParse({ [fieldName]: value });

const parseResult = baseSignupSchema.partial().safeParse({
[fieldName]: value,
});
if (!parseResult.success) {
form.setError(fieldName, parseResult.error);
return;
Expand Down
25 changes: 2 additions & 23 deletions src/shared/util/form.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
import type { ChangeEvent } from "react";
import type {
ControllerRenderProps,
DeepMap,
FieldPath,
FieldValues,
UseFormReturn,
} from "react-hook-form";

const shouldValidateOnChange = <
TFieldValues extends FieldValues,
TFieldName extends keyof DeepMap<TFieldValues, boolean>,
>(
fieldName: TFieldName,
touchedFields: DeepMap<TFieldValues, boolean>,
dirtyFields: DeepMap<TFieldValues, boolean>,
) => {
return touchedFields[fieldName] && dirtyFields[fieldName];
};

/**
* 비밀번호 확인처럼 여러 필드의 유효성 검사를 한번에 하는 handlers를 반환하는 함수
* @param otherFieldNames 자신을 제외하고 같이 검사할 필드들의 name
Expand All @@ -30,10 +18,7 @@ export const getMultipleRevalidationHandlers =
...otherFieldNames: TFieldName[]
) =>
(form: UseFormReturn, field: ControllerRenderProps) => {
const {
trigger,
formState: { touchedFields, dirtyFields },
} = form;
const { trigger } = form;
const { name } = field;
const fieldNames = [name, ...otherFieldNames];
return {
Expand All @@ -43,13 +28,7 @@ export const getMultipleRevalidationHandlers =
},
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
field.onChange(e);
if (
fieldNames.every((fieldName) =>
shouldValidateOnChange(fieldName, touchedFields, dirtyFields),
)
) {
trigger(fieldNames);
}
trigger(fieldNames);
},
};
};
Expand Down
Loading