diff --git a/apps/web/public/icons/google.svg b/apps/web/public/icons/google.svg new file mode 100644 index 0000000..1a7aebe --- /dev/null +++ b/apps/web/public/icons/google.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/web/public/icons/kakao.svg b/apps/web/public/icons/kakao.svg new file mode 100644 index 0000000..1319445 --- /dev/null +++ b/apps/web/public/icons/kakao.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/src/views/login/components/Button.tsx b/apps/web/src/views/login/components/Button.tsx new file mode 100644 index 0000000..59bf123 --- /dev/null +++ b/apps/web/src/views/login/components/Button.tsx @@ -0,0 +1,33 @@ +interface ButtonProps { + label: string; + style?: React.CSSProperties; + onClick?: () => void; + disabled?: boolean; + className?: string; +} + +export function Button({ label, style, onClick, disabled, className }: ButtonProps) { + return ( + + ); +} diff --git a/apps/web/src/views/login/components/SNSLoginButton.tsx b/apps/web/src/views/login/components/SNSLoginButton.tsx new file mode 100644 index 0000000..1931550 --- /dev/null +++ b/apps/web/src/views/login/components/SNSLoginButton.tsx @@ -0,0 +1,48 @@ +"use client"; + +import Image from "next/image"; + +export function SNSLoginButton({ provider }: { provider: "google" | "kakao" }) { + const variants = { + google: { + label: "구글 로그인", + bg: "#FFFFFF", + color: "#000000", + border: "1px solid #E3E8EF", + }, + kakao: { + label: "카카오 로그인", + bg: "#FEE500", + color: "#000000", + border: "1px solid #FEE500", + }, + }; + + return ( + + ); +} diff --git a/apps/web/src/views/login/components/TextField.tsx b/apps/web/src/views/login/components/TextField.tsx new file mode 100644 index 0000000..8cae931 --- /dev/null +++ b/apps/web/src/views/login/components/TextField.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useId, useState } from "react"; + +interface TextFieldProps { + type: "text" | "password"; + label: string; + validate?: (value: string) => boolean; + onChange?: (value: string) => void; + value?: string; + className?: string; + style?: React.CSSProperties; + required?: boolean; + disabled?: boolean; + readonly?: boolean; + placeholder?: string; + error?: string; +} + +export function TextField({ + type, + label, + validate, + onChange, + value, + className, + style, + required, + disabled, + readonly, + placeholder, + error, +}: TextFieldProps) { + const id = useId(); + const [isFocused, setIsFocused] = useState(false); + const hasValidationError = validate ? !validate(value || "") : false; + const displayError = hasValidationError ? error : ""; + + const getBorderColor = () => { + if (hasValidationError) return "#E52929"; // 에러 상태 + if (isFocused) return "#3A8DFF"; // focus 상태 + return "#E3E8EF"; + }; + + return ( +
+ + ) => onChange(e.target.value) : undefined} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + value={value} + required={required} + disabled={disabled} + readOnly={readonly} + placeholder={placeholder} + style={{ + width: "100%", + height: "43px", + borderBottom: `1px solid ${getBorderColor()}`, + display: "flex", + flexDirection: "row", + alignItems: "center", + color: "#111", + fontSize: "14px", + fontStyle: "normal", + fontWeight: "500", + lineHeight: "140%", + letterSpacing: "-0.28px", + outline: "none", + transition: "border-color 0.2s ease-in-out", + ...style, + }} + /> + + {displayError} + +
+ ); +} diff --git a/apps/web/src/views/login/index.tsx b/apps/web/src/views/login/index.tsx index 5f5af4f..bb1e1ed 100644 --- a/apps/web/src/views/login/index.tsx +++ b/apps/web/src/views/login/index.tsx @@ -1,11 +1,6 @@ "use client"; -import GoogleButton from "@/widgets/login/GoogleButton"; -import { useSession } from "next-auth/react"; - export function LoginPage() { - const { data: session } = useSession(); - return (
- -
-
-          {JSON.stringify(session, null, 2)}
-        
-
+
Login
); } diff --git a/apps/web/src/views/login/login.stories.tsx b/apps/web/src/views/login/login.stories.tsx new file mode 100644 index 0000000..953c7d9 --- /dev/null +++ b/apps/web/src/views/login/login.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import React from "react"; +import { LoginPage } from "."; +import { Button } from "./components/Button"; +import { SNSLoginButton } from "./components/SNSLoginButton"; +import { TextField } from "./components/TextField"; + +const meta = { + title: "v2/Views/Login", + component: LoginPage, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// 구글 로그인 +export const 구글_로그인: Story = { + args: {}, + render: () => ( +
+ +
+ ), +}; + +// 카카오 로그인 +export const 카카오_로그인: Story = { + args: {}, + render: () => ( +
+ +
+ ), +}; + +// 이메일 주소 입력 +export const 이메일_주소_입력: Story = { + args: {}, + render: () => { + const [email, setEmail] = React.useState(""); + + return ( +
+ { + // 빈 값이거나 유효한 이메일 형식인지 확인 + if (value.length === 0) return true; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(value); + }} + /> +
+ ); + }, +}; + +// 비밀번호 입력 +export const 비밀번호_입력: Story = { + args: {}, + render: () => { + const [password, setPassword] = React.useState(""); + + return ( +
+ { + // 8~16자리 영대•소문자, 숫자, 특수문자 조합 + if (value.length === 0) return true; + return value.length >= 8 && value.length <= 16; + }} + /> +
+ ); + }, +}; + +// 로그인 버튼 +export const 로그인_버튼: Story = { + args: {}, + render: () => ( +
+
+ ), +}; diff --git a/apps/web/src/views/register/index.tsx b/apps/web/src/views/register/index.tsx new file mode 100644 index 0000000..11a5dd7 --- /dev/null +++ b/apps/web/src/views/register/index.tsx @@ -0,0 +1,17 @@ +"use client"; + +export function RegisterPage() { + return ( +
+
Register
+
+ ); +} diff --git a/apps/web/src/views/register/register.stories.tsx b/apps/web/src/views/register/register.stories.tsx new file mode 100644 index 0000000..3710ca6 --- /dev/null +++ b/apps/web/src/views/register/register.stories.tsx @@ -0,0 +1,110 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import React from "react"; +import { RegisterPage } from "."; +import { TextField } from "../login/components/TextField"; + +const meta = { + title: "v2/Views/Register", + component: RegisterPage, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// 이메일 주소 입력 +export const 이메일_주소_입력: Story = { + args: {}, + render: () => { + const [email, setEmail] = React.useState(""); + + return ( +
+ { + // 빈 값이거나 유효한 이메일 형식인지 확인 + if (value.length === 0) return true; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(value); + }} + /> +
+ ); + }, +}; + +// 비밀번호 입력 +export const 비밀번호_입력: Story = { + args: {}, + render: () => { + const [password, setPassword] = React.useState(""); + + return ( +
+ { + // 8~16자리 영대•소문자, 숫자, 특수문자 조합 + if (value.length === 0) return true; + return value.length >= 8 && value.length <= 16; + }} + /> + { + // 빈 값이거나 비밀번호와 일치하는지 확인 + if (value.length === 0) return true; + if (value !== password) return false; + return true; + }} + /> +
+ ); + }, +}; + +// 닉네임 입력 +export const 닉네임: Story = { + args: {}, + render: () => { + const [nickname, setNickname] = React.useState(""); + + return ( +
+ { + // 빈 값이거나 3자 이상인지 확인 + if (value.length === 0) return true; + return value.length >= 6; + }} + /> +
+ ); + }, +};