diff --git a/next.config.ts b/next.config.ts
index 81f3423..d504471 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,6 +1,26 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
+ images: {
+ remotePatterns: [
+ {
+ protocol: 'https',
+ hostname: '**.kakaocdn.net',
+ },
+ {
+ protocol: 'http',
+ hostname: '**.kakaocdn.net',
+ },
+ {
+ protocol: 'https',
+ hostname: '**.kakao.com',
+ },
+ {
+ protocol: 'http',
+ hostname: '**.kakao.com',
+ },
+ ],
+ },
async headers() {
return [
{
diff --git a/package.json b/package.json
index bfd04fc..d66c8f2 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.21",
"clsx": "^2.1.1",
"framer-motion": "^12.34.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9ed113d..4e67f7e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.3)
+ '@hookform/resolvers':
+ specifier: ^5.2.2
+ version: 5.2.2(react-hook-form@7.71.1(react@19.2.3))
'@tanstack/react-query':
specifier: ^5.90.21
version: 5.90.21(react@19.2.3)
@@ -504,6 +507,11 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@hookform/resolvers@5.2.2':
+ resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
+ peerDependencies:
+ react-hook-form: ^7.55.0
+
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -959,6 +967,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+ '@standard-schema/utils@0.3.0':
+ resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
+
'@storybook/addon-a11y@10.2.3':
resolution: {integrity: sha512-EZLTmu/f5uENvbTKzCXlRN9TpaiKVzMfw4JF3qkw4Efae12xTJMIWieehHhl3Clc1x2d6ZtE7vhtMI9mKYQdKw==}
peerDependencies:
@@ -3858,6 +3869,11 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
+ '@hookform/resolvers@5.2.2(react-hook-form@7.71.1(react@19.2.3))':
+ dependencies:
+ '@standard-schema/utils': 0.3.0
+ react-hook-form: 7.71.1(react@19.2.3)
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -4160,6 +4176,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
+ '@standard-schema/utils@0.3.0': {}
+
'@storybook/addon-a11y@10.2.3(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))':
dependencies:
'@storybook/global': 5.0.0
diff --git a/src/app/(auth)/_components/AuthCard.module.css b/src/app/(auth)/_components/AuthCard.module.css
new file mode 100644
index 0000000..1b62797
--- /dev/null
+++ b/src/app/(auth)/_components/AuthCard.module.css
@@ -0,0 +1,46 @@
+.wrapper {
+ width: 100%;
+ max-width: 550px;
+}
+
+.card {
+ width: 100%;
+ background-color: var(--color-background-primary);
+ border-radius: 20px;
+ /* figma 시안: top 72px, right 45px, bottom 70px, left 45px */
+ padding: 72px 45px 70px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 24px;
+}
+
+.logoLink {
+ display: flex;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+/* PC/태블릿: logoLarge 표시 */
+.logoLarge {
+ display: block;
+}
+
+/* 모바일: logoSmall 표시 */
+.logoSmall {
+ display: none;
+}
+
+@media (max-width: 767px) {
+ .logoLarge {
+ display: none;
+ }
+
+ .logoSmall {
+ display: block;
+ }
+
+ .card {
+ padding: 48px 22px 40px;
+ }
+}
diff --git a/src/app/(auth)/_components/AuthCard.tsx b/src/app/(auth)/_components/AuthCard.tsx
new file mode 100644
index 0000000..7a7ce53
--- /dev/null
+++ b/src/app/(auth)/_components/AuthCard.tsx
@@ -0,0 +1,23 @@
+import Link from 'next/link';
+import Image from 'next/image';
+import logoLarge from '@/assets/logos/logoLarge.svg';
+import logoSmall from '@/assets/logos/logoSmall.svg';
+import styles from './AuthCard.module.css';
+
+interface AuthCardProps {
+ children: React.ReactNode;
+}
+
+export default function AuthCard({ children }: AuthCardProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/app/(auth)/layout.module.css b/src/app/(auth)/layout.module.css
new file mode 100644
index 0000000..10656c1
--- /dev/null
+++ b/src/app/(auth)/layout.module.css
@@ -0,0 +1,8 @@
+.layout {
+ min-height: 100vh;
+ background-color: var(--color-background-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 16px;
+}
diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx
new file mode 100644
index 0000000..f752ba4
--- /dev/null
+++ b/src/app/(auth)/layout.tsx
@@ -0,0 +1,5 @@
+import styles from './layout.module.css';
+
+export default function AuthLayout({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
diff --git a/src/app/(auth)/login/LoginForm.module.css b/src/app/(auth)/login/LoginForm.module.css
new file mode 100644
index 0000000..adcf1e7
--- /dev/null
+++ b/src/app/(auth)/login/LoginForm.module.css
@@ -0,0 +1,136 @@
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ width: 100%;
+}
+
+.fields {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.fieldGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.label {
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--color-text-primary);
+}
+
+/* 버튼으로 변경 - 페이지 이동이 아닌 모달 오픈 동작이므로 */
+.forgotButton {
+ align-self: flex-end;
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--color-brand-primary);
+ font-family: inherit;
+}
+
+.forgotButton:hover {
+ text-decoration: underline;
+}
+
+.actions {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 24px;
+}
+
+.signupPrompt {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 0;
+}
+
+.promptText {
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--color-text-primary);
+}
+
+.signupLink {
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--color-brand-primary);
+ text-decoration: none;
+}
+
+.signupLink:hover {
+ text-decoration: underline;
+}
+
+/* ─── OR 구분선 ─── */
+.divider {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.dividerLine {
+ flex: 1;
+ height: 1px;
+ background-color: var(--color-background-tertiary);
+}
+
+.dividerText {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--color-text-default);
+ white-space: nowrap;
+}
+
+/* ─── 간편 로그인 ─── */
+.social {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.socialLabel {
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--color-text-default);
+}
+
+.kakaoButton {
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.kakaoButton:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+/* 모바일 */
+@media (max-width: 767px) {
+ .label {
+ font-size: 14px;
+ }
+
+ .promptText,
+ .signupLink {
+ font-size: 14px;
+ }
+
+ .socialLabel {
+ font-size: 14px;
+ }
+}
diff --git a/src/app/(auth)/login/LoginForm.tsx b/src/app/(auth)/login/LoginForm.tsx
new file mode 100644
index 0000000..59e670e
--- /dev/null
+++ b/src/app/(auth)/login/LoginForm.tsx
@@ -0,0 +1,170 @@
+'use client';
+
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import Image from 'next/image';
+import { Input } from '@/components/input';
+import { PasswordInput } from '@/components/input';
+import BaseButton from '@/components/Button/base/BaseButton';
+import ResetPassword from '@/components/Modal/domain/components/ResetPassword/ResetPassword';
+import LinkPassToast from '@/components/toast/LinkPassToast';
+import kakaotalkButton from '@/assets/buttons/kakao/kakaotalkButton.svg';
+import { loginSchema, type LoginFormValues } from './schema';
+import styles from './LoginForm.module.css';
+
+export default function LoginForm() {
+ const router = useRouter();
+
+ const [isResetModalOpen, setIsResetModalOpen] = useState(false);
+ const [resetEmail, setResetEmail] = useState('');
+ const [isToastOpen, setIsToastOpen] = useState(false);
+ // 임시: autoDismissMs를 매우 크게 줘서 사라지지 않게 함
+
+ const {
+ register,
+ handleSubmit,
+ setError,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(loginSchema),
+ mode: 'onBlur',
+ });
+
+ const onSubmit = async (data: LoginFormValues) => {
+ try {
+ const response = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) {
+ const errorMessage = '이메일 혹은 비밀번호를 확인해주세요.';
+ setError('email', { message: errorMessage });
+ setError('password', { message: errorMessage });
+ return;
+ }
+
+ const { user } = await response.json();
+ // 소속 팀이 있으면 해당 팀 페이지로, 없으면 팀 추가 페이지로
+ if (user?.teamId) {
+ router.push(`/${user.teamId}`);
+ } else {
+ router.push('/addteam');
+ }
+ router.refresh();
+ } catch {
+ setError('email', { message: '네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' });
+ }
+ };
+
+ const handleResetPasswordSubmit = async () => {
+ try {
+ await fetch('/api/auth/send-reset-password-email', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: resetEmail }),
+ });
+ } finally {
+ // API 성공/실패 여부와 관계없이 모달 닫고 토스트 표시
+ // 보안상 이메일 존재 여부를 노출하지 않는 설계 (email enumeration attack 방어)
+ setIsResetModalOpen(false);
+ setResetEmail('');
+ setIsToastOpen(true);
+ }
+ };
+
+ return (
+ <>
+
+
+ {
+ setIsResetModalOpen(false);
+ setResetEmail('');
+ }}
+ onSubmit={handleResetPasswordSubmit}
+ input={{
+ email: {
+ value: resetEmail,
+ onChange: (e) => setResetEmail(e.target.value),
+ },
+ }}
+ />
+
+ {/* 비밀번호 재설정 이메일 전송 완료 토스트 */}
+ setIsToastOpen(false)} />
+ >
+ );
+}
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx
new file mode 100644
index 0000000..92a0f2f
--- /dev/null
+++ b/src/app/(auth)/login/page.tsx
@@ -0,0 +1,10 @@
+import AuthCard from '@/app/(auth)/_components/AuthCard';
+import LoginForm from './LoginForm';
+
+export default function LoginPage() {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/(auth)/login/schema.ts b/src/app/(auth)/login/schema.ts
new file mode 100644
index 0000000..f1b8d32
--- /dev/null
+++ b/src/app/(auth)/login/schema.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod';
+
+export const loginSchema = z.object({
+ email: z
+ .string()
+ .min(1, '이메일은 필수 입력입니다.')
+ .pipe(z.email('이메일 형식으로 작성해 주세요.')),
+
+ password: z.string().min(1, '비밀번호는 필수 입력입니다.'),
+});
+
+export type LoginFormValues = z.infer;
diff --git a/src/app/(auth)/reset-password/ResetPasswordForm.module.css b/src/app/(auth)/reset-password/ResetPasswordForm.module.css
new file mode 100644
index 0000000..51fc9e4
--- /dev/null
+++ b/src/app/(auth)/reset-password/ResetPasswordForm.module.css
@@ -0,0 +1,38 @@
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+ width: 100%;
+}
+
+.fields {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.fieldGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.label {
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--color-text-primary);
+}
+
+.rootError {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--color-status-danger);
+ margin: 0;
+}
+
+/* 모바일 */
+@media (max-width: 767px) {
+ .label {
+ font-size: 14px;
+ }
+}
diff --git a/src/app/(auth)/reset-password/ResetPasswordForm.tsx b/src/app/(auth)/reset-password/ResetPasswordForm.tsx
new file mode 100644
index 0000000..802efc9
--- /dev/null
+++ b/src/app/(auth)/reset-password/ResetPasswordForm.tsx
@@ -0,0 +1,98 @@
+'use client';
+
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { PasswordInput } from '@/components/input';
+import BaseButton from '@/components/Button/base/BaseButton';
+import { resetPasswordSchema, type ResetPasswordFormValues } from './schema';
+import styles from './ResetPasswordForm.module.css';
+
+export default function ResetPasswordForm() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const token = searchParams.get('token') ?? '';
+
+ const {
+ register,
+ handleSubmit,
+ setError,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(resetPasswordSchema),
+ mode: 'onBlur',
+ });
+
+ const onSubmit = async (data: ResetPasswordFormValues) => {
+ if (!token) {
+ setError('root', {
+ message: '유효하지 않은 링크입니다. 비밀번호 재설정 이메일을 다시 요청해주세요.',
+ });
+ return;
+ }
+
+ try {
+ const response = await fetch('/api/auth/reset-password', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ token,
+ password: data.password,
+ passwordConfirmation: data.passwordConfirmation,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ const message = errorData?.message ?? '비밀번호 재설정에 실패했습니다. 다시 시도해주세요.';
+ setError('root', { message });
+ return;
+ }
+
+ // 명세: 재설정 완료 후 로그인 페이지로 이동
+ router.push('/login');
+ } catch {
+ setError('root', { message: '네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' });
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx
new file mode 100644
index 0000000..aae803e
--- /dev/null
+++ b/src/app/(auth)/reset-password/page.tsx
@@ -0,0 +1,14 @@
+import { Suspense } from 'react';
+import AuthCard from '@/app/(auth)/_components/AuthCard';
+import ResetPasswordForm from './ResetPasswordForm';
+
+export default function ResetPasswordPage() {
+ return (
+
+ {/* useSearchParams는 Suspense boundary 필요 */}
+
+
+
+
+ );
+}
diff --git a/src/app/(auth)/reset-password/schema.ts b/src/app/(auth)/reset-password/schema.ts
new file mode 100644
index 0000000..daa5353
--- /dev/null
+++ b/src/app/(auth)/reset-password/schema.ts
@@ -0,0 +1,21 @@
+import { z } from 'zod';
+
+export const resetPasswordSchema = z
+ .object({
+ password: z
+ .string()
+ .min(1, '비밀번호는 필수 입력입니다.')
+ .min(8, '비밀번호는 8자 이상이어야 합니다.')
+ .regex(
+ /^([a-z]|[A-Z]|[0-9]|[!@#$%^&*])+$/,
+ '비밀번호는 영문, 숫자, 특수문자(!@#$%^&*)만 사용할 수 있습니다.',
+ ),
+
+ passwordConfirmation: z.string().min(1, '비밀번호 확인을 입력해주세요.'),
+ })
+ .refine((data) => data.password === data.passwordConfirmation, {
+ message: '비밀번호가 일치하지 않습니다.',
+ path: ['passwordConfirmation'],
+ });
+
+export type ResetPasswordFormValues = z.infer;
diff --git a/src/app/(auth)/signup/SignupForm.module.css b/src/app/(auth)/signup/SignupForm.module.css
new file mode 100644
index 0000000..63b95f8
--- /dev/null
+++ b/src/app/(auth)/signup/SignupForm.module.css
@@ -0,0 +1,128 @@
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ width: 100%;
+}
+
+.fields {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.fieldGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.label {
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--color-text-primary);
+}
+
+.rootError {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--color-status-danger);
+ margin: 0;
+}
+
+.actions {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 24px;
+}
+
+.loginPrompt {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 0;
+}
+
+.promptText {
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--color-text-primary);
+}
+
+.loginLink {
+ font-size: 16px;
+ font-weight: 500;
+ /* Color/blue/400 = --color-brand-primary (#5189FA) */
+ color: var(--color-brand-primary);
+ text-decoration: none;
+}
+
+.loginLink:hover {
+ text-decoration: underline;
+}
+
+/* ─── OR 구분선 ─── */
+.divider {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.dividerLine {
+ flex: 1;
+ height: 1px;
+ background-color: var(--color-background-tertiary);
+}
+
+.dividerText {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--color-text-default);
+ white-space: nowrap;
+}
+
+/* ─── 간편 회원가입 ─── */
+.social {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.socialLabel {
+ font-size: 16px;
+ font-weight: 500;
+ /* Text/Default (#64748B) */
+ color: var(--color-text-default);
+}
+
+.kakaoButton {
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.kakaoButton:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+/* 모바일 */
+@media (max-width: 767px) {
+ .label {
+ font-size: 14px;
+ }
+
+ .promptText,
+ .loginLink {
+ font-size: 14px;
+ }
+
+ .socialLabel {
+ font-size: 14px;
+ }
+}
diff --git a/src/app/(auth)/signup/SignupForm.tsx b/src/app/(auth)/signup/SignupForm.tsx
new file mode 100644
index 0000000..1bfe186
--- /dev/null
+++ b/src/app/(auth)/signup/SignupForm.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import Image from 'next/image';
+import { Input } from '@/components/input';
+import { PasswordInput } from '@/components/input';
+import BaseButton from '@/components/Button/base/BaseButton';
+import kakaotalkButton from '@/assets/buttons/kakao/kakaotalkButton.svg';
+import { signupSchema, type SignupFormValues } from './schema';
+import styles from './SignupForm.module.css';
+
+export default function SignupForm() {
+ const router = useRouter();
+
+ const {
+ register,
+ handleSubmit,
+ setError,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(signupSchema),
+ mode: 'onBlur',
+ });
+
+ const onSubmit = async (data: SignupFormValues) => {
+ try {
+ const response = await fetch('/api/auth/signup', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ const message = errorData?.message ?? '회원가입에 실패했습니다. 다시 시도해주세요.';
+ setError('root', { message });
+ return;
+ }
+
+ // 신규 가입자는 소속 팀이 없으므로 무조건 팀 생성/참여 페이지로 이동
+ router.push('/addteam');
+ } catch {
+ setError('root', { message: '네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' });
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx
new file mode 100644
index 0000000..bd15de9
--- /dev/null
+++ b/src/app/(auth)/signup/page.tsx
@@ -0,0 +1,10 @@
+import AuthCard from '@/app/(auth)/_components/AuthCard';
+import SignupForm from './SignupForm';
+
+export default function SignupPage() {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/(auth)/signup/schema.ts b/src/app/(auth)/signup/schema.ts
new file mode 100644
index 0000000..63245da
--- /dev/null
+++ b/src/app/(auth)/signup/schema.ts
@@ -0,0 +1,28 @@
+import { z } from 'zod';
+
+export const signupSchema = z
+ .object({
+ email: z
+ .string()
+ .min(1, '이메일은 필수 입력입니다.')
+ .pipe(z.email('이메일 형식으로 작성해 주세요.')),
+
+ nickname: z.string().min(1, '이름을 입력해 주세요.').max(30, '이름은 30자 이하여야 합니다.'),
+
+ password: z
+ .string()
+ .min(1, '비밀번호는 필수 입력입니다.')
+ .min(8, '비밀번호는 8자 이상이어야 합니다.')
+ .regex(
+ /^([a-z]|[A-Z]|[0-9]|[!@#$%^&*])+$/,
+ '비밀번호는 영문, 숫자, 특수문자(!@#$%^&*)만 사용할 수 있습니다.',
+ ),
+
+ passwordConfirmation: z.string().min(1, '비밀번호 확인을 입력해주세요.'),
+ })
+ .refine((data) => data.password === data.passwordConfirmation, {
+ message: '비밀번호가 일치하지 않습니다.',
+ path: ['passwordConfirmation'],
+ });
+
+export type SignupFormValues = z.infer;
diff --git a/src/app/(root)/landing/LandingLayout.module.css b/src/app/(root)/landing/LandingLayout.module.css
new file mode 100644
index 0000000..d4f2c43
--- /dev/null
+++ b/src/app/(root)/landing/LandingLayout.module.css
@@ -0,0 +1,10 @@
+/* ============================================================
+ LandingLayout.module.css
+
+ MobileHeader는 fixed가 아닌 일반 flow → padding-top 불필요
+ PC에서 Sidebar는 fixed, 콘텐츠 영역 오프셋은 각 섹션에서 처리
+ ============================================================ */
+
+.content {
+ /* 의도적으로 비워둠: 각 섹션이 자체 레이아웃 책임 */
+}
diff --git a/src/app/(root)/landing/LandingPage.module.css b/src/app/(root)/landing/LandingPage.module.css
new file mode 100644
index 0000000..8a99578
--- /dev/null
+++ b/src/app/(root)/landing/LandingPage.module.css
@@ -0,0 +1,376 @@
+/* app/(root)/landing/LandingPage.module.css */
+
+.pageWrapper {
+ display: flex;
+ height: 100vh;
+ overflow: hidden;
+}
+
+.sidebarWrapper {
+ display: block;
+}
+
+.mobileHeaderWrapper {
+ display: none;
+}
+
+.scrollContainer {
+ flex: 1;
+ height: 100vh;
+ overflow-y: scroll;
+ scroll-snap-type: y mandatory;
+ scroll-behavior: smooth;
+ scrollbar-width: none;
+}
+
+.scrollContainer::-webkit-scrollbar {
+ display: none;
+}
+
+.section {
+ height: 100vh;
+ scroll-snap-align: start;
+ display: flex;
+ align-items: center;
+ overflow: hidden;
+}
+
+.sectionHero {
+ background-color: var(--color-background-secondary);
+ justify-content: space-between;
+ padding-left: 80px;
+}
+
+.sectionKanban {
+ background-color: var(--color-slate-50);
+ justify-content: space-between;
+ padding: 0 80px;
+ gap: 40px;
+}
+
+.sectionCheck {
+ background-color: var(--color-brand-primary);
+ justify-content: space-between;
+ padding: 0 40px;
+ gap: 16px;
+}
+
+.sectionComment {
+ background-color: var(--color-slate-50);
+ justify-content: space-between;
+ padding: 0 80px;
+ gap: 40px;
+}
+
+.sectionCta {
+ background-color: var(--color-background-primary);
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+ gap: 24px;
+ padding: 0 80px;
+}
+
+.heroContent {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-around;
+ align-self: stretch;
+ flex-shrink: 1;
+ gap: 12px;
+ position: relative;
+ z-index: 1;
+ min-width: 300px;
+}
+
+.heroTopGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.heroTextGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding-left: 26px;
+}
+
+.heroButtonWrapper {
+ padding-left: 26px;
+}
+
+.heroSubtitle {
+ font-size: 18px;
+ font-weight: 400;
+ color: var(--color-text-default);
+ margin: 0;
+}
+
+.heroTitle {
+ font-size: 64px;
+ font-weight: 700;
+ color: var(--color-brand-primary);
+ margin: 0;
+ line-height: 1.1;
+}
+
+.heroImageWrapper {
+ flex: 1 1 0;
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ align-self: stretch;
+ overflow: hidden;
+ min-width: 0;
+}
+
+.heroImage {
+ height: 100%;
+ width: auto;
+ max-width: none;
+ object-fit: cover;
+ object-position: left center;
+}
+
+.heroImagePc {
+ display: block;
+}
+.heroImageMobile {
+ display: none;
+}
+.sectionImagePc {
+ display: block;
+}
+.sectionImageMobile {
+ display: none;
+}
+
+.splitWord {
+ display: inline;
+}
+
+.sectionTextBlock {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-width: 440px;
+ flex-shrink: 1;
+ min-width: 300px;
+}
+
+.sectionTextBlockTop {
+ align-self: flex-start;
+ padding-top: 192px;
+}
+
+.sectionTextBlockCenter {
+ align-self: center;
+ justify-content: center;
+}
+
+.sectionTextBlockWide {
+ max-width: 520px;
+}
+
+.sectionTitle {
+ font-size: 40px;
+ font-weight: 700;
+ color: var(--color-brand-primary);
+ margin: 0;
+ line-height: 1.3;
+}
+
+.sectionTitleInverse {
+ color: var(--color-text-inverse);
+}
+
+.sectionDescription {
+ font-size: 16px;
+ font-weight: 400;
+ color: var(--color-text-default);
+ margin: 0;
+ line-height: 1.6;
+}
+
+.sectionDescriptionInverse {
+ color: var(--color-text-inverse);
+ opacity: 0.85;
+}
+
+.sectionImageWrapper {
+ flex: 1 1 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-width: 0;
+}
+
+.sectionImage {
+ width: 100%;
+ height: auto;
+}
+
+.sectionImageWrapperBottom {
+ align-items: flex-end;
+ overflow: hidden;
+ height: 100vh;
+}
+
+.sectionCheckImage {
+ width: 85%;
+ height: auto;
+ margin-bottom: -80px;
+}
+
+.sectionCommentImage {
+ width: 100%;
+ height: auto;
+ max-height: 100vh;
+ object-fit: contain;
+ object-position: bottom;
+}
+
+.ctaSubtitle {
+ font-size: 16px;
+ color: var(--color-text-default);
+ margin: 0;
+}
+
+.ctaTitle {
+ font-size: 48px;
+ font-weight: 700;
+ color: var(--color-text-primary);
+ margin: 0;
+ line-height: 1.2;
+}
+
+.sectionCtaAuto {
+ height: auto;
+ min-height: 360px;
+}
+
+.linkReset {
+ text-decoration: none;
+ display: inline-block;
+}
+
+.ctaButton {
+ width: 160px;
+}
+
+.loginImage {
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+@media (max-width: 375px) {
+ .sidebarWrapper {
+ display: none;
+ }
+
+ .mobileHeaderWrapper {
+ display: block;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 100;
+ }
+
+ .heroImagePc {
+ display: none;
+ }
+ .heroImageMobile {
+ display: block;
+ }
+ .sectionImagePc {
+ display: none;
+ }
+ .sectionImageMobile {
+ display: block;
+ }
+
+ .sectionHero {
+ flex-direction: column;
+ padding: 100px 24px 0;
+ justify-content: flex-start;
+ gap: 24px;
+ }
+
+ .heroContent {
+ align-self: auto;
+ justify-content: flex-start;
+ gap: 24px;
+ }
+
+ .heroTitle {
+ font-size: 36px;
+ }
+ .heroSubtitle {
+ font-size: 14px;
+ }
+ .heroTextGroup {
+ padding-left: 0;
+ }
+ .heroButtonWrapper {
+ padding-left: 0;
+ }
+
+ .heroImageWrapper {
+ flex: none;
+ align-self: auto;
+ width: 100%;
+ justify-content: center;
+ align-items: flex-end;
+ }
+
+ .sectionKanban,
+ .sectionComment {
+ flex-direction: column;
+ padding: 40px 24px 0;
+ justify-content: flex-start;
+ gap: 20px;
+ }
+
+ .sectionCheck {
+ flex-direction: column-reverse;
+ padding: 40px 24px 0;
+ justify-content: flex-end;
+ gap: 20px;
+ }
+
+ .sectionTextBlockTop {
+ align-self: auto;
+ padding-top: 0;
+ }
+ .sectionTextBlockCenter {
+ align-self: auto;
+ padding-top: 40px;
+ }
+ .sectionTextBlockWide {
+ max-width: 100%;
+ }
+
+ .sectionCheckImage {
+ width: 95%;
+ margin-bottom: -40px;
+ }
+ .sectionTitle {
+ font-size: 26px;
+ }
+ .sectionDescription {
+ font-size: 14px;
+ }
+
+ .sectionCta {
+ padding: 0 24px;
+ }
+ .ctaTitle {
+ font-size: 28px;
+ }
+ .ctaSubtitle {
+ font-size: 14px;
+ }
+}
diff --git a/src/app/(root)/landing/LandingPage.tsx b/src/app/(root)/landing/LandingPage.tsx
new file mode 100644
index 0000000..6c92877
--- /dev/null
+++ b/src/app/(root)/landing/LandingPage.tsx
@@ -0,0 +1,19 @@
+import LandingShell from './components/LandingShell';
+
+import HeroSection from './components/HeroSection/HeroSection';
+import FeatureSection from './components/FeatureSection/FeatureSection';
+import CtaSection from './components/CtaSection/CtaSection';
+
+export default function LandingPage() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(root)/landing/components/CtaSection/CtaSection.module.css b/src/app/(root)/landing/components/CtaSection/CtaSection.module.css
new file mode 100644
index 0000000..f1f8766
--- /dev/null
+++ b/src/app/(root)/landing/components/CtaSection/CtaSection.module.css
@@ -0,0 +1,74 @@
+/* ============================================================
+ CtaSection.module.css
+
+ figma PC 수치:
+ - 위치: x:774, y:3527 (전체 랜딩 기준) → 중앙 정렬
+ - 배경: #FFFFFF
+ - 제목: "지금 바로 시작해보세요" Pretendard 24/700, color:#5189FA
+ - 설명: Pretendard 16/400, color:#64748B
+ - 버튼: 160×48
+ - 내부 gap: 28px (제목+설명 묶음과 버튼 사이)
+ - 제목+설명 사이 gap: 12px
+
+ 모바일:
+ - 제목: 18/700
+ - 설명: 12/400
+ ============================================================ */
+
+.section {
+ background: var(--color-background-primary); /* #FFFFFF */
+}
+
+.inner {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 80px 24px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 28px;
+}
+
+@media (min-width: 1200px) {
+ .inner {
+ padding: 100px calc(72px + 24px);
+ }
+}
+
+/* title + desc 묶음을 하나의 flex 아이템으로 처리 */
+.title {
+ margin: 0 0 12px;
+ font-size: 18px;
+ font-weight: 700;
+ line-height: 1.3;
+ color: var(--color-brand-primary); /* #5189FA */
+ word-break: keep-all;
+}
+
+@media (min-width: 768px) {
+ .title {
+ font-size: 24px;
+ }
+}
+
+.desc {
+ margin: 0;
+ font-size: 12px;
+ font-weight: 400;
+ line-height: 1.6;
+ color: var(--color-text-default); /* #64748B */
+ word-break: keep-all;
+}
+
+@media (min-width: 768px) {
+ .desc {
+ font-size: 16px;
+ }
+}
+
+.ctaLink {
+ display: block;
+ text-decoration: none;
+ width: 160px;
+}
diff --git a/src/app/(root)/landing/components/CtaSection/CtaSection.tsx b/src/app/(root)/landing/components/CtaSection/CtaSection.tsx
new file mode 100644
index 0000000..66279f2
--- /dev/null
+++ b/src/app/(root)/landing/components/CtaSection/CtaSection.tsx
@@ -0,0 +1,18 @@
+import Link from 'next/link';
+
+import BaseButton from '@/components/Button/base/BaseButton';
+import styles from './CtaSection.module.css';
+
+export default function CtaSection() {
+ return (
+
+
+
지금 바로 시작해보세요
+
팀원 모두와 같은 방향, 같은 속도로 나아가는 가장 쉬운 방법
+
+
지금 시작하기
+
+
+
+ );
+}
diff --git a/src/app/(root)/landing/components/FeatureSection/FeatureSection.module.css b/src/app/(root)/landing/components/FeatureSection/FeatureSection.module.css
new file mode 100644
index 0000000..da3742c
--- /dev/null
+++ b/src/app/(root)/landing/components/FeatureSection/FeatureSection.module.css
@@ -0,0 +1,270 @@
+/* ============================================================
+ FeatureSection.module.css
+
+ section이 100vh, display:flex, flex-direction:row
+ copy / media 가 section 직속 자식
+
+ figma PC (1920px 기준, Sidebar 72px):
+ 2번: copy x:180 y:192 / image x:563 (상하 중앙)
+ 3번: image x:165 (하단) / copy x:1264 y:266 (order로 좌우 반전)
+ 4번: copy x:180 y:192 / image x:705 (하단)
+ ============================================================ */
+
+/* ── 배경 ── */
+.bgSlate {
+ background: var(--color-slate-50);
+}
+.bgBrand {
+ background: var(--color-brand-primary);
+}
+
+/* ── 섹션 ── */
+.section {
+ width: 100%;
+ overflow: hidden;
+}
+
+@media (min-width: 1200px) {
+ .section {
+ height: 100vh;
+ min-height: 700px;
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ }
+}
+
+/* ============================================================
+ copy 공통
+ ============================================================ */
+.copy {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 48px 24px 32px;
+}
+
+@media (min-width: 768px) and (max-width: 1199px) {
+ .copy {
+ padding: 48px 40px 32px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .copy {
+ flex-shrink: 0;
+ gap: 18px;
+ justify-content: flex-start;
+ }
+}
+
+/* ── 2번 copy ── */
+
+@media (min-width: 1200px) {
+ .copyLeft {
+ width: 560px;
+ padding-left: calc(72px + 108px);
+ padding-right: 60px;
+ padding-top: 192px;
+ padding-bottom: 0;
+ }
+}
+
+/* ── 4번 copy ── */
+
+@media (min-width: 1200px) {
+ .copyLeftWide {
+ width: 705px;
+ padding-left: calc(72px + 108px);
+ padding-right: 60px;
+ padding-top: 192px;
+ padding-bottom: 0;
+ }
+}
+
+/* ── 3번 copy: order:2로 우측에 배치 ── */
+
+@media (min-width: 1200px) {
+ .copyRight {
+ order: 2;
+ width: 560px;
+ padding-left: 60px;
+ padding-right: calc(72px + 108px);
+ padding-top: 192px;
+ padding-bottom: 0;
+ }
+}
+
+/* ============================================================
+ 텍스트 내부
+ ============================================================ */
+.icon {
+ width: 28px;
+ height: 28px;
+ display: block;
+ flex-shrink: 0;
+}
+
+@media (min-width: 1200px) {
+ .icon {
+ width: 48px;
+ height: 48px;
+ }
+}
+
+.textGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+@media (min-width: 1200px) {
+ .textGroup {
+ gap: 18px;
+ }
+}
+
+.title {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 700;
+ line-height: 1.3125;
+ white-space: pre-line;
+ word-break: keep-all;
+ color: var(--color-brand-primary);
+}
+
+@media (min-width: 1200px) {
+ .title {
+ font-size: 32px;
+ }
+}
+
+.bgBrand .title {
+ color: var(--color-text-inverse);
+}
+
+.desc {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 1.5;
+ white-space: pre-line;
+ word-break: keep-all;
+ color: var(--color-slate-400);
+}
+
+@media (min-width: 1200px) {
+ .desc {
+ font-size: 16px;
+ }
+}
+
+.bgBrand .desc {
+ color: var(--color-blue-100);
+}
+
+/* ============================================================
+ media 공통
+ ============================================================ */
+.media {
+ display: flex;
+ /*
+ * 태블릿/모바일 기본값
+ * justify-content: flex-end → 이미지가 우측 끝에 붙음
+ * align-items: flex-end → 이미지가 하단에 붙음
+ */
+ justify-content: flex-end;
+ align-items: flex-end;
+ overflow: hidden;
+}
+
+@media (min-width: 1200px) {
+ .media {
+ flex: 1;
+ overflow: hidden;
+ }
+}
+
+/* ── 2번 media ── */
+
+@media (min-width: 1200px) {
+ .mediaTwo {
+ align-items: center;
+ justify-content: flex-end;
+ }
+}
+
+/* ── 3번 media: order:1로 좌측 배치, padding-top으로 이미지 높이 제한 ── */
+/*
+ * 3번 SVG는 투명 여백이 없어서 height:100% 시 섹션 전체를 차지함
+ * padding-top: 230px으로 이미지 시작점을 내려 실질 높이 제한
+ * order:1 → copyRight(order:2)보다 앞에 → 시각적으로 좌측
+ */
+
+@media (min-width: 1200px) {
+ .mediaThree {
+ order: 1;
+ align-items: flex-end;
+ justify-content: flex-start;
+ padding-top: 230px;
+ }
+}
+
+/* ── 4번 media ── */
+
+@media (min-width: 1200px) {
+ .mediaFour {
+ align-items: flex-end;
+ justify-content: flex-end;
+ }
+}
+
+/* ============================================================
+ 반응형 이미지 분기
+ ============================================================ */
+.imgPc {
+ display: none;
+}
+.imgTablet {
+ display: none;
+}
+
+.imgMobile {
+ display: block;
+ width: 100%;
+ height: auto;
+ /* SVG 자체 여백 보정 */
+ margin-bottom: -10px;
+ margin-right: -20px;
+}
+
+@media (min-width: 768px) and (max-width: 1199px) {
+ .imgMobile {
+ display: none;
+ }
+ .imgTablet {
+ display: block;
+ width: 100%;
+ height: auto;
+ /* 태블릿: 이미지가 우측 하단에 자연스럽게 붙도록 max-width 제거 */
+ margin-bottom: -10px;
+ margin-right: -20px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .imgMobile {
+ display: none;
+ }
+ .imgTablet {
+ display: none;
+ }
+ .imgPc {
+ display: block;
+ height: 100%;
+ width: auto;
+ /* SVG 자체 하단 여백 보정 */
+ margin-bottom: -10px;
+ }
+}
diff --git a/src/app/(root)/landing/components/FeatureSection/FeatureSection.tsx b/src/app/(root)/landing/components/FeatureSection/FeatureSection.tsx
new file mode 100644
index 0000000..b25d5ac
--- /dev/null
+++ b/src/app/(root)/landing/components/FeatureSection/FeatureSection.tsx
@@ -0,0 +1,103 @@
+import Image, { StaticImageData } from 'next/image';
+
+import gradationFolder from '@/assets/icons/landing/gradation_folder.svg';
+import gradationCheck from '@/assets/icons/landing/gradation_check.svg';
+import gradationMessage from '@/assets/icons/landing/gradation_message.svg';
+
+import landingPC02 from '@/assets/img/landing/pc/landingPC_02.svg';
+import landingPC03 from '@/assets/img/landing/pc/landingPC_03.svg';
+import landingPC04 from '@/assets/img/landing/pc/landingPC_04.svg';
+import landingTablet02 from '@/assets/img/landing/tablet/landingTablet_02.svg';
+import landingTablet03 from '@/assets/img/landing/tablet/landingTablet_03.svg';
+import landingTablet04 from '@/assets/img/landing/tablet/landingTablet_04.svg';
+import landingMobile02 from '@/assets/img/landing/mobile/mobileSmall_02.svg';
+import landingMobile03 from '@/assets/img/landing/mobile/mobileSmall_03.svg';
+import landingMobile04 from '@/assets/img/landing/mobile/mobileSmall_04.svg';
+
+import styles from './FeatureSection.module.css';
+
+type Variant = 'two' | 'three' | 'four';
+
+type FeatureData = {
+ bg: 'slate' | 'brand';
+ icon: StaticImageData;
+ title: string;
+ desc: string;
+ imgPc: StaticImageData;
+ imgTablet: StaticImageData;
+ imgMobile: StaticImageData;
+ copyClass: string;
+ mediaClass: string;
+ /** PC 이미지에 추가로 붙는 클래스 (없으면 undefined) */
+ imgPcClass?: string;
+};
+
+const FEATURE_DATA: Record = {
+ two: {
+ bg: 'slate',
+ icon: gradationFolder,
+ title: '칸반보드로 함께\n할 일 목록을 관리해요',
+ desc: '팀원과 함께 실시간으로 할 일을 추가하고\n지금 무엇을 해야 하는지 한눈에 볼 수 있어요',
+ imgPc: landingPC02,
+ imgTablet: landingTablet02,
+ imgMobile: landingMobile02,
+ copyClass: styles.copyLeft,
+ mediaClass: styles.mediaTwo,
+ },
+ three: {
+ bg: 'brand',
+ icon: gradationCheck,
+ title: '세부적으로 할 일들을\n간편하게 체크해요',
+ desc: '일정에 맞춰 해야 할 세부 항목을 정리하고,\n하나씩 빠르게 완료해보세요',
+ imgPc: landingPC03,
+ imgTablet: landingTablet03,
+ imgMobile: landingMobile03,
+ copyClass: styles.copyRight,
+ mediaClass: styles.mediaThree,
+ /*
+ * 3번 이미지(966×649)는 가로 비율이 커서
+ * imgPc 공통값 height:100%를 그대로 쓰면 가로가 섹션 너비를 초과함
+ * → imgPcWide 클래스로 width:100%, height:auto 적용
+ */
+ },
+ four: {
+ bg: 'slate',
+ icon: gradationMessage,
+ title: '할 일 공유를 넘어\n의견을 나누고 함께 결정해요',
+ desc: '댓글로 진행상황을 기록하고 피드백을 주고받으며\n함께 결정을 내릴 수 있어요.',
+ imgPc: landingPC04,
+ imgTablet: landingTablet04,
+ imgMobile: landingMobile04,
+ copyClass: styles.copyLeftWide,
+ mediaClass: styles.mediaFour,
+ },
+};
+
+export default function FeatureSection({ variant }: { variant: Variant }) {
+ const { bg, icon, title, desc, imgPc, imgTablet, imgMobile, copyClass, mediaClass, imgPcClass } =
+ FEATURE_DATA[variant];
+
+ return (
+
+ );
+}
diff --git a/src/app/(root)/landing/components/HeroSection/HeroSection.module.css b/src/app/(root)/landing/components/HeroSection/HeroSection.module.css
new file mode 100644
index 0000000..0890d37
--- /dev/null
+++ b/src/app/(root)/landing/components/HeroSection/HeroSection.module.css
@@ -0,0 +1,218 @@
+/* ============================================================
+ HeroSection.module.css
+
+ PC (≥1200px):
+ - section: flex row
+ - copy: 좌측, padding-left로 Sidebar 오프셋, padding-top/bottom으로 상하 위치
+ - visual: 우측, flex:1, 이미지가 섹션 높이 꽉 차게
+
+ 태블릿 (768~1199px) / 모바일 (<768px):
+ - section: block, normal flow
+ - copy: 텍스트 + 버튼 상단
+ - visual: 이미지 하단
+ ============================================================ */
+
+/* ── 섹션 ── */
+.section {
+ position: relative;
+ overflow: hidden;
+ background: var(--color-background-secondary);
+}
+
+@media (min-width: 1200px) {
+ .section {
+ height: 100vh;
+ min-height: 760px;
+ display: flex;
+ flex-direction: row;
+ }
+}
+
+/* ── copy: 텍스트 + 버튼 ── */
+.copy {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ padding: 40px 24px 0;
+}
+
+@media (min-width: 768px) and (max-width: 1199px) {
+ .copy {
+ padding: 48px 40px 0;
+ }
+}
+
+@media (min-width: 1200px) {
+ .copy {
+ /* Sidebar 72px + 좌측 여백으로 figma x:260 맞추기 */
+ padding-left: 260px;
+ padding-right: 40px;
+ padding-top: 208px;
+ padding-bottom: 228px;
+ /* 버튼을 하단으로 밀기 */
+ justify-content: space-between;
+ gap: 0;
+ /* copy가 내용 너비만큼만 차지하도록 */
+ flex-shrink: 0;
+ width: 580px; /* 260px 오프셋 + 텍스트 영역 약 320px */
+ }
+}
+
+/* ── topGroup: 아이콘 + textGroup ── */
+.topGroup {
+ display: flex;
+ flex-direction: column;
+}
+
+/* ── 아이콘 ── */
+.icon {
+ display: block;
+ width: 32px;
+ height: 32px;
+ margin-bottom: 8px;
+}
+
+@media (min-width: 768px) {
+ .icon {
+ width: 40px;
+ height: 40px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .icon {
+ width: 48px;
+ height: 48px;
+ margin-bottom: 4px;
+ }
+}
+
+/* ── textGroup ── */
+.textGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+@media (min-width: 1200px) {
+ /* figma: 아이콘(x:76) 대비 텍스트(x:105) → 29px 들여쓰기 */
+ .textGroup {
+ padding-left: 29px;
+ }
+}
+
+/* ── kicker ── */
+.kicker {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.4;
+ color: var(--color-slate-400);
+}
+
+@media (min-width: 768px) {
+ .kicker {
+ font-size: 16px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .kicker {
+ font-size: 20px;
+ }
+}
+
+/* ── title ── */
+.title {
+ margin: 0;
+ font-size: 32px;
+ font-weight: 700;
+ line-height: 1.19;
+ color: var(--color-brand-primary);
+ word-break: keep-all;
+}
+
+@media (min-width: 768px) {
+ .title {
+ font-size: 40px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .title {
+ font-size: 48px;
+ }
+}
+
+/* ── ctaLink: 버튼 ── */
+.ctaLink {
+ display: block;
+ text-decoration: none;
+ width: 160px;
+}
+
+@media (min-width: 1200px) {
+ .ctaLink {
+ /* textGroup과 같은 들여쓰기 */
+ margin-left: 29px;
+ width: 160px;
+ }
+}
+
+/* ── visual: 이미지 영역 ── */
+.visual {
+ display: block;
+}
+
+@media (min-width: 1200px) {
+ .visual {
+ /* copy 우측 남은 공간 전부 차지 */
+ flex: 1;
+ position: relative;
+ overflow: hidden;
+ }
+}
+
+/* 반응형 이미지 분기 */
+.imgPc {
+ display: none;
+}
+.imgTablet {
+ display: none;
+}
+.imgMobile {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+@media (min-width: 768px) and (max-width: 1199px) {
+ .imgMobile {
+ display: none;
+ }
+ .imgTablet {
+ display: block;
+ width: 100%;
+ height: auto;
+ }
+}
+
+@media (min-width: 1200px) {
+ .imgMobile {
+ display: none;
+ }
+ .imgTablet {
+ display: none;
+ }
+ .imgPc {
+ display: block;
+ /* 섹션 높이에 맞게 꽉 차도록 */
+ position: absolute;
+ right: 0;
+ top: 0;
+ height: 100%;
+ width: auto;
+ }
+}
diff --git a/src/app/(root)/landing/components/HeroSection/HeroSection.tsx b/src/app/(root)/landing/components/HeroSection/HeroSection.tsx
new file mode 100644
index 0000000..7aa8dfb
--- /dev/null
+++ b/src/app/(root)/landing/components/HeroSection/HeroSection.tsx
@@ -0,0 +1,49 @@
+import Image from 'next/image';
+import Link from 'next/link';
+
+import BaseButton from '@/components/Button/base/BaseButton';
+import gradationLogo from '@/assets/icons/landing/gradation_logo.svg';
+import landingPC01 from '@/assets/img/landing/pc/landingPC_01.svg';
+import landingTablet01 from '@/assets/img/landing/tablet/landingTablet_01.svg';
+import landingMobile01 from '@/assets/img/landing/mobile/mobileSmall_01.svg';
+
+import styles from './HeroSection.module.css';
+
+export default function HeroSection() {
+ return (
+
+ {/* 텍스트 + 버튼 영역 */}
+
+
+
+
+
함께 만들어가는 To do list
+
Coworkers
+
+
+
+
+
지금 시작하기
+
+
+
+ {/*
+ * visual: 이미지만 담당
+ * 모바일/태블릿: normal flow, 섹션 하단에 자연스럽게 배치
+ * PC: absolute, 섹션 우측에 고정
+ */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(root)/landing/components/LandingShell.tsx b/src/app/(root)/landing/components/LandingShell.tsx
new file mode 100644
index 0000000..ead8780
--- /dev/null
+++ b/src/app/(root)/landing/components/LandingShell.tsx
@@ -0,0 +1,13 @@
+type LandingShellProps = {
+ children: React.ReactNode;
+};
+
+/**
+ * LandingShell — 랜딩페이지 콘텐츠 래퍼
+ *
+ * Sidebar와 MobileHeader는 (root)/layout.tsx에서 이미 렌더링되므로
+ * LandingShell에서 중복 렌더링하지 않는다.
+ */
+export default function LandingShell({ children }: LandingShellProps) {
+ return <>{children}>;
+}
diff --git a/src/app/(root)/layout.tsx b/src/app/(root)/layout.tsx
index 35836f3..5322a4d 100644
--- a/src/app/(root)/layout.tsx
+++ b/src/app/(root)/layout.tsx
@@ -23,9 +23,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
const pathname = usePathname();
const router = useRouter();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
- const { data: user } = useCurrentUser();
+ const { data: user, isPending } = useCurrentUser();
- const isLoggedIn = user !== null && user !== undefined;
+ // isPending: 최초 로딩 중 (undefined)
+ // user === null: 로딩 완료 후 비로그인
+ // user !== null: 로그인
+ const isLoggedIn = !isPending && user !== null && user !== undefined;
const isLanding = pathname === '/';
const firstGroup = user?.memberships?.[0]?.group;
@@ -100,26 +103,30 @@ export default function RootLayout({ children }: { children: React.ReactNode })
)
) : null
}
- addButton={(isCollapsed: boolean) => (
- <>
- {!isCollapsed && {}} />}
-
-
- }
- label="자유게시판"
- isActive
- iconOnly={isCollapsed}
- href="/boards"
- />
- >
- )}
+ addButton={
+ isLoggedIn
+ ? (isCollapsed: boolean) => (
+ <>
+ {!isCollapsed && {}} />}
+
+
+ }
+ label="자유게시판"
+ isActive
+ iconOnly={isCollapsed}
+ href="/boards"
+ />
+ >
+ )
+ : undefined
+ }
/>
- 프로젝트 준비
-
- );
-}
\ No newline at end of file
+/**
+ * 랜딩 페이지 — Server Component
+ *
+ * [왜 Server Component인가?]
+ * Next.js에서 metadata(SEO 태그)는 Server Component에서만 export할 수 있다.
+ * page.tsx를 Server Component로 유지하면:
+ * 1. metadata export → 서버에서 , 등 생성 → 크롤러가 읽음
+ * 2. 클라이언트 hydration 후 Framer Motion 애니메이션 활성화
+ *
+ * React SPA(CRA)와의 차이:
+ * - React: 서버에서 빈 HTML 생성 → 크롤러가 JS 실행 전 빈 페이지를 봄 → SEO 불리
+ * - Next.js App Router: 'use client'여도 서버에서 초기 HTML 생성 → SEO 유리
+ *
+ * [metadata가 SEO에서 하는 역할]
+ * - title: 검색 결과 제목으로 표시
+ * - description: 검색 결과 스니펫으로 표시
+ * - openGraph: SNS 공유 시 미리보기 카드
+ * - robots: 크롤러 색인 여부 제어
+ */
+
+import type { Metadata } from 'next';
+import LandingPage from './landing/LandingPage';
+
+export const metadata: Metadata = {
+ title: 'Coworkers — 함께 만들어가는 업무 관리 서비스',
+ description:
+ '팀원과 함께 실시간으로 할 일을 추가하고, 칸반보드로 업무 현황을 한눈에 확인하세요. Coworkers로 팀 협업을 더 쉽게.',
+ openGraph: {
+ title: 'Coworkers — 함께 만들어가는 업무 관리 서비스',
+ description:
+ '팀원과 함께 실시간으로 할 일을 추가하고, 칸반보드로 업무 현황을 한눈에 확인하세요.',
+ type: 'website',
+ locale: 'ko_KR',
+ },
+ robots: {
+ index: true,
+ follow: true,
+ },
+};
+
+export default function Page() {
+ return ;
+}
diff --git a/src/app/api/auth/kakao/route.ts b/src/app/api/auth/kakao/route.ts
new file mode 100644
index 0000000..2737077
--- /dev/null
+++ b/src/app/api/auth/kakao/route.ts
@@ -0,0 +1,32 @@
+import { NextResponse } from 'next/server';
+
+/**
+ * GET /api/auth/kakao
+ *
+ * 카카오 인가 URL을 생성하여 리다이렉트합니다.
+ *
+ * 왜 서버 라우트 핸들러에서 처리하는가?
+ * - REST API 키를 클라이언트 번들에 노출하지 않기 위해서입니다.
+ * - NEXT_PUBLIC_ 접두사 없이 서버 전용 환경변수로 관리합니다.
+ */
+export async function GET() {
+ const KAKAO_REST_API_KEY = process.env.KAKAO_REST_API_KEY;
+ const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000';
+
+ if (!KAKAO_REST_API_KEY) {
+ return NextResponse.json(
+ { message: 'KAKAO_REST_API_KEY 환경변수가 설정되지 않았습니다.' },
+ { status: 500 },
+ );
+ }
+
+ const redirectUri = `${APP_URL}/oauth/kakao`;
+ console.log('[kakao] 인가 URL 생성 - redirectUri:', redirectUri);
+
+ const kakaoAuthUrl = new URL('https://kauth.kakao.com/oauth/authorize');
+ kakaoAuthUrl.searchParams.set('client_id', KAKAO_REST_API_KEY);
+ kakaoAuthUrl.searchParams.set('redirect_uri', redirectUri);
+ kakaoAuthUrl.searchParams.set('response_type', 'code');
+
+ return NextResponse.redirect(kakaoAuthUrl.toString());
+}
diff --git a/src/app/oauth/kakao/route.ts b/src/app/oauth/kakao/route.ts
new file mode 100644
index 0000000..1d10f41
--- /dev/null
+++ b/src/app/oauth/kakao/route.ts
@@ -0,0 +1,61 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { fetchApiServer } from '@/shared/apis/fetchApi.server';
+import { setAuthCookies } from '@/app/api/auth/_lib/cookies';
+
+interface KakaoSignInResponse {
+ accessToken: string;
+ refreshToken: string;
+ user: {
+ id: number;
+ email: string;
+ nickname: string;
+ image: string | null;
+ teamId: string;
+ createdAt: string;
+ updatedAt: string;
+ };
+}
+
+/**
+ * GET /oauth/kakao
+ *
+ * 카카오가 인가코드를 전달하는 Redirect URI 페이지 핸들러입니다.
+ * Swagger 예시 기준 redirectUri: http://localhost:3000/oauth/kakao
+ */
+export async function GET(req: NextRequest) {
+ const { searchParams } = new URL(req.url);
+ const code = searchParams.get('code');
+ const error = searchParams.get('error');
+
+ if (error || !code) {
+ return NextResponse.redirect(new URL('/login', req.url));
+ }
+
+ const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000';
+ const redirectUri = `${APP_URL}/oauth/kakao`;
+
+ try {
+ console.log('[oauth/kakao] code:', code);
+ console.log('[oauth/kakao] redirectUri:', redirectUri);
+
+ const response = await fetchApiServer('/auth/signIn/KAKAO', {
+ method: 'POST',
+ body: JSON.stringify({ token: code, redirectUri }),
+ });
+
+ if (!response.ok) {
+ const errorBody = await response.json().catch(() => ({}));
+ console.error('[oauth/kakao] 백엔드 응답 실패:', response.status, errorBody);
+ return NextResponse.redirect(new URL('/login?error=kakao_failed', req.url));
+ }
+
+ const data: KakaoSignInResponse = await response.json();
+ await setAuthCookies(data.accessToken, data.refreshToken);
+
+ const redirectPath = data.user?.teamId ? `/${data.user.teamId}` : '/addteam';
+ return NextResponse.redirect(new URL(redirectPath, req.url));
+ } catch (e) {
+ console.error('[oauth/kakao] 예외 발생:', e);
+ return NextResponse.redirect(new URL('/login?error=kakao_failed', req.url));
+ }
+}
diff --git a/src/assets/buttons/human/profile.svg b/src/assets/buttons/human/profile.svg
new file mode 100644
index 0000000..b3f42c6
--- /dev/null
+++ b/src/assets/buttons/human/profile.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/assets/icons/landing/gradation_check.svg b/src/assets/icons/landing/gradation_check.svg
new file mode 100644
index 0000000..0c2cb6f
--- /dev/null
+++ b/src/assets/icons/landing/gradation_check.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/icons/landing/gradation_folder.svg b/src/assets/icons/landing/gradation_folder.svg
new file mode 100644
index 0000000..e4fc7ab
--- /dev/null
+++ b/src/assets/icons/landing/gradation_folder.svg
@@ -0,0 +1,14 @@
+
diff --git a/src/assets/icons/landing/gradation_logo.svg b/src/assets/icons/landing/gradation_logo.svg
new file mode 100644
index 0000000..8f93509
--- /dev/null
+++ b/src/assets/icons/landing/gradation_logo.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/icons/landing/gradation_message.svg b/src/assets/icons/landing/gradation_message.svg
new file mode 100644
index 0000000..8320f09
--- /dev/null
+++ b/src/assets/icons/landing/gradation_message.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/img/landing/mobile/mobileSmall_03.svg b/src/assets/img/landing/mobile/mobileSmall_03.svg
index 81d5cfd..04a938e 100644
--- a/src/assets/img/landing/mobile/mobileSmall_03.svg
+++ b/src/assets/img/landing/mobile/mobileSmall_03.svg
@@ -1,227 +1,215 @@
-