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 (
+
+ );
+}
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;
+ }}
+ />
+
+ );
+ },
+};