diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index cc0ac3d..d48a743 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -1,26 +1,26 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
-language: "ko-KR"
+language: 'ko-KR'
reviews:
- profile: "assertive"
+ profile: 'assertive'
request_changes_workflow: false
high_level_summary: true
poem: false
# 리뷰 제외 대상 (노이즈 방지)
path_filters:
- - "!node_modules/**"
- - "!package-lock.json"
- - "!.next/**"
- - "!public/**"
+ - '!node_modules/**'
+ - '!package-lock.json'
+ - '!.next/**'
+ - '!public/**'
# 프로젝트 내 커스텀 컨벤션 파일 학습
knowledge_base:
code_guidelines:
enabled: true
filePatterns:
- - "README.md"
+ - 'README.md'
chat:
auto_reply: true
diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md
index d15fad8..6656a4c 100644
--- a/.github/ISSUE_TEMPLATE/issue_template.md
+++ b/.github/ISSUE_TEMPLATE/issue_template.md
@@ -28,13 +28,13 @@
-- [ ]
+- [ ]
## 완료 기준
-- [ ]
+- [ ]
## 참고 자료
diff --git a/.gitignore b/.gitignore
index 5ef6a52..33f14f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,8 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+# agent files (개인 작업용)
+AGENTS.md
+.agents/
+.claude/
diff --git a/.prettierignore b/.prettierignore
index ae867a6..d00c30f 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,7 +1,8 @@
-node_modules
-.next
-out
-build
-coverage
-public
-package-lock.json
\ No newline at end of file
+node_modules
+.next
+out
+build
+coverage
+public
+package-lock.json
+next-env.d.ts
diff --git a/.prettierrc b/.prettierrc
index c93c1a7..8792645 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -1,3 +1,10 @@
-{
- "plugins": ["prettier-plugin-tailwindcss"]
-}
+{
+ "semi": true,
+ "singleQuote": true,
+ "trailingComma": "all",
+ "printWidth": 100,
+ "tabWidth": 2,
+ "arrowParens": "always",
+ "endOfLine": "lf",
+ "plugins": ["prettier-plugin-tailwindcss"]
+}
diff --git a/README.md b/README.md
index 18314d6..ee6cd60 100644
--- a/README.md
+++ b/README.md
@@ -8,11 +8,11 @@ Slack · Notion · Jira 같은 기존 협업툴은 강력하지만, 협업툴에
처음부터 워크스페이스 구조를 직접 설계하지 않아도, 목적별 템플릿을 선택하면 공지·업무·일정·회의록·자료·채팅을 **한 공간에서** 바로 관리할 수 있습니다.
-| 기존 협업툴 | Syncly |
-| --- | --- |
-| 사용자가 직접 구조를 설계 | 목적별 템플릿으로 자동 구성 |
-| 기능이 많아 처음 쓰기 어려움 | 필요한 기능만 정리된 화면 |
-| 여러 툴을 조합해야 함 | 한 공간에서 모든 협업 |
+| 기존 협업툴 | Syncly |
+| ---------------------------- | --------------------------- |
+| 사용자가 직접 구조를 설계 | 목적별 템플릿으로 자동 구성 |
+| 기능이 많아 처음 쓰기 어려움 | 필요한 기능만 정리된 화면 |
+| 여러 툴을 조합해야 함 | 한 공간에서 모든 협업 |
## 타겟 사용자
@@ -23,34 +23,34 @@ Slack · Notion · Jira 같은 기존 협업툴은 강력하지만, 협업툴에
## 주요 기능 (MVP)
-| 기능 | 설명 |
-| --- | --- |
-| 로그인 | GitHub 소셜 로그인, 프로필 설정 |
-| 템플릿 선택 | 팀플 / 프로젝트 / 매장 운영 템플릿 |
-| 워크스페이스 생성 | 템플릿 기반 자동 구성, 생성자 = 관리자 |
-| 프로젝트 관리 | 업무 생성, 담당자 지정, 상태(대기/진행중/완료), 마감일 |
-| 캘린더 | 일정·마감일·회의 등록, 월간/주간 확인 |
-| 공지 | 작성·수정·삭제, 중요 공지 고정 |
-| 업무 회의록 | 회의 내용·참석자·결정사항·후속 업무 기록 |
-| 자료실 | 파일 업로드, 링크 저장, 자료 목록 관리 |
-| 채팅 | 워크스페이스 멤버 간 메시지 |
-| 차트표 | 전체/완료 업무 개수, 진행률, 담당자별 통계 |
-| 초대 | 초대 링크 생성, 멤버 목록 및 역할 관리 |
+| 기능 | 설명 |
+| ----------------- | ------------------------------------------------------ |
+| 로그인 | GitHub 소셜 로그인, 프로필 설정 |
+| 템플릿 선택 | 팀플 / 프로젝트 / 매장 운영 템플릿 |
+| 워크스페이스 생성 | 템플릿 기반 자동 구성, 생성자 = 관리자 |
+| 프로젝트 관리 | 업무 생성, 담당자 지정, 상태(대기/진행중/완료), 마감일 |
+| 캘린더 | 일정·마감일·회의 등록, 월간/주간 확인 |
+| 공지 | 작성·수정·삭제, 중요 공지 고정 |
+| 업무 회의록 | 회의 내용·참석자·결정사항·후속 업무 기록 |
+| 자료실 | 파일 업로드, 링크 저장, 자료 목록 관리 |
+| 채팅 | 워크스페이스 멤버 간 메시지 |
+| 차트표 | 전체/완료 업무 개수, 진행률, 담당자별 통계 |
+| 초대 | 초대 링크 생성, 멤버 목록 및 역할 관리 |
자세한 내용은 PRD 문서를 참고하세요.
## 기술 스택
-| 영역 | 사용 기술 |
-| --- | --- |
-| 프레임워크 | [Next.js 16](https://nextjs.org) (App Router), React 19, TypeScript |
-| 백엔드 / 인증 | [Supabase](https://supabase.com) (`@supabase/ssr`, `supabase-js`) |
-| 서버 상태 | [TanStack Query](https://tanstack.com/query) |
-| 클라이언트 상태 | [Zustand](https://zustand-demo.pmnd.rs) |
-| UI | [Tailwind CSS v4](https://tailwindcss.com), [shadcn/ui](https://ui.shadcn.com), Radix UI, lucide-react |
-| 폼 / 검증 | [react-hook-form](https://react-hook-form.com), [zod](https://zod.dev) |
-| 레이아웃 | [react-grid-layout](https://github.com/react-grid-layout/react-grid-layout) (워크스페이스 컴포넌트 배치) |
-| 날짜 | [date-fns](https://date-fns.org) |
+| 영역 | 사용 기술 |
+| --------------- | -------------------------------------------------------------------------------------------------------- |
+| 프레임워크 | [Next.js 16](https://nextjs.org) (App Router), React 19, TypeScript |
+| 백엔드 / 인증 | [Supabase](https://supabase.com) (`@supabase/ssr`, `supabase-js`) |
+| 서버 상태 | [TanStack Query](https://tanstack.com/query) |
+| 클라이언트 상태 | [Zustand](https://zustand-demo.pmnd.rs) |
+| UI | [Tailwind CSS v4](https://tailwindcss.com), [shadcn/ui](https://ui.shadcn.com), Radix UI, lucide-react |
+| 폼 / 검증 | [react-hook-form](https://react-hook-form.com), [zod](https://zod.dev) |
+| 레이아웃 | [react-grid-layout](https://github.com/react-grid-layout/react-grid-layout) (워크스페이스 컴포넌트 배치) |
+| 날짜 | [date-fns](https://date-fns.org) |
## 시작하기
@@ -79,27 +79,35 @@ npm run dev
## 스크립트
-| 명령어 | 설명 |
-| --- | --- |
-| `npm run dev` | 개발 서버 실행 |
-| `npm run build` | 프로덕션 빌드 |
-| `npm run start` | 프로덕션 서버 실행 |
-| `npm run lint` | ESLint 검사 |
+| 명령어 | 설명 |
+| ---------------------- | ----------------------------------------- |
+| `npm run dev` | 개발 서버 실행 |
+| `npm run build` | 프로덕션 빌드 |
+| `npm run start` | 프로덕션 서버 실행 |
+| `npm run lint` | ESLint 검사 |
+| `npm run typecheck` | TypeScript 타입 검사 |
+| `npm run format` | Prettier 포맷 적용 |
+| `npm run format:check` | Prettier 포맷 검사 |
+| `npm run check` | lint + typecheck + format:check 일괄 검사 |
## 프로젝트 구조
+FSD(Feature-Sliced Design) 구조를 따릅니다. Next.js Pages Router와의 폴더명 충돌을 피하기 위해 FSD의 `pages` 레이어는 `views`로 명명합니다.
+
```
src/
-├── app/ # Next.js App Router (페이지, 레이아웃)
-├── components/
-│ └── ui/ # shadcn/ui 컴포넌트
-└── lib/
- ├── client.ts # Supabase 브라우저 클라이언트
- ├── server.ts # Supabase 서버 클라이언트
- ├── middleware.ts # Supabase 세션 미들웨어
- └── utils.ts # 공용 유틸 (cn 등)
+├── app/ # Next.js App Router — 라우팅·전역 스타일 (화면은 views에서 import)
+├── views/ # 페이지 단위 조합 (FSD pages 레이어)
+│ └── landing/
+├── widgets/ # 페이지를 구성하는 독립 UI 블록
+│ └── landing/ # landing-header / hero / teams / features / cta
+└── shared/ # 공통 코드
+ ├── ui/ # shadcn/ui 컴포넌트
+ └── lib/ # Supabase 클라이언트, cn, motion 상수
```
+레이어 import 방향은 `app → views → widgets → features → entities → shared` 이며, ESLint로 경계를 강제합니다.
+
## 향후 확장 (MVP 이후)
템플릿 공유 커뮤니티 · AI 회의록 정리 · 대화 → 업무 변환 · 외부 링크 연동(Figma/GitHub) · 알림 기능 · 모바일 앱
diff --git a/components.json b/components.json
index 4f4eb5e..eb2988e 100644
--- a/components.json
+++ b/components.json
@@ -15,11 +15,11 @@
"menuColor": "default",
"menuAccent": "subtle",
"aliases": {
- "components": "@/components",
- "utils": "@/lib/utils",
- "ui": "@/components/ui",
- "lib": "@/lib",
- "hooks": "@/hooks"
+ "components": "@/shared/ui",
+ "utils": "@/shared/lib/utils",
+ "ui": "@/shared/ui",
+ "lib": "@/shared/lib",
+ "hooks": "@/shared/lib/hooks"
},
"registries": {
"@supabase": "https://supabase.com/ui/r/{name}.json"
diff --git a/docs/conventions/code-convention.md b/docs/conventions/code-convention.md
index f4d22de..e5528e8 100644
--- a/docs/conventions/code-convention.md
+++ b/docs/conventions/code-convention.md
@@ -4,22 +4,22 @@ Syncly의 코드 구조, 네이밍, 품질 기준을 통일하기 위한 문서
## 1. Naming Convention
-| 대상 | 규칙 | 예시 |
-| --- | --- | --- |
-| 변수 | camelCase | `userList`, `activeIndex` |
-| 함수 | camelCase, 동사로 시작 | `fetchUser`, `createWorkspace` |
-| boolean 변수 | `is`, `has`, `can`, `should` 접두사 | `isOpen`, `hasPermission` |
-| 상수 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
-| 객체/배열 상수 | camelCase | `moduleTypeMap` |
-| 타입/인터페이스 | PascalCase | `WorkspaceModule`, `UserProfile` |
-| React 컴포넌트 | PascalCase | `WorkspaceCard` |
-| 커스텀 훅 | `use` 접두사 + camelCase | `useWorkspaceModules` |
-| 이벤트 핸들러 | `handle` 접두사 | `handleSubmit` |
-| 이벤트 핸들러 props | `on` 접두사 | `onSubmit`, `onClose` |
-| 컴포넌트 파일 | PascalCase | `WorkspaceCard.tsx` |
-| 유틸/훅/타입 파일 | kebab-case | `date-format.ts`, `use-auth.ts` |
-| 폴더명 | kebab-case | `workspace-module` |
-| URL 경로 | kebab-case | `/workspace-settings` |
+| 대상 | 규칙 | 예시 |
+| ------------------- | ----------------------------------- | -------------------------------- |
+| 변수 | camelCase | `userList`, `activeIndex` |
+| 함수 | camelCase, 동사로 시작 | `fetchUser`, `createWorkspace` |
+| boolean 변수 | `is`, `has`, `can`, `should` 접두사 | `isOpen`, `hasPermission` |
+| 상수 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
+| 객체/배열 상수 | camelCase | `moduleTypeMap` |
+| 타입/인터페이스 | PascalCase | `WorkspaceModule`, `UserProfile` |
+| React 컴포넌트 | PascalCase | `WorkspaceCard` |
+| 커스텀 훅 | `use` 접두사 + camelCase | `useWorkspaceModules` |
+| 이벤트 핸들러 | `handle` 접두사 | `handleSubmit` |
+| 이벤트 핸들러 props | `on` 접두사 | `onSubmit`, `onClose` |
+| 컴포넌트 파일 | PascalCase | `WorkspaceCard.tsx` |
+| 유틸/훅/타입 파일 | kebab-case | `date-format.ts`, `use-auth.ts` |
+| 폴더명 | kebab-case | `workspace-module` |
+| URL 경로 | kebab-case | `/workspace-settings` |
## 2. TypeScript Convention
@@ -166,14 +166,14 @@ PR 생성 전 아래 항목을 확인합니다.
아래 규칙은 팀 합의 후 ESLint를 통해 자동 검사할 예정입니다.
-| 규칙 | 목적 |
-| --- | --- |
-| `console.log` 금지 | 디버깅 코드가 배포 코드에 포함되는 것을 방지 |
-| unused import / variable 금지 | 불필요한 코드 제거 |
-| `any` 사용 제한 | 타입 안정성 유지 |
-| React Hooks 규칙 강제 | Hooks 오사용 방지 |
-| FSD layer import 규칙 강제 | 프로젝트 구조 일관성 유지 |
-| 공통 UI 중복 구현 제한 | UI 컴포넌트 재사용성 유지 |
+| 규칙 | 목적 |
+| ----------------------------- | -------------------------------------------- |
+| `console.log` 금지 | 디버깅 코드가 배포 코드에 포함되는 것을 방지 |
+| unused import / variable 금지 | 불필요한 코드 제거 |
+| `any` 사용 제한 | 타입 안정성 유지 |
+| React Hooks 규칙 강제 | Hooks 오사용 방지 |
+| FSD layer import 규칙 강제 | 프로젝트 구조 일관성 유지 |
+| 공통 UI 중복 구현 제한 | UI 컴포넌트 재사용성 유지 |
## 9. Design Token Rule
diff --git a/docs/conventions/prettier-convention.md b/docs/conventions/prettier-convention.md
index a33867b..3ae3f55 100644
--- a/docs/conventions/prettier-convention.md
+++ b/docs/conventions/prettier-convention.md
@@ -27,16 +27,16 @@ PR 전에는 반드시 포맷 검사를 통과해야 합니다.
## 3. 규칙 설명
-| 옵션 | 값 | 설명 |
-| --- | --- | --- |
-| `semi` | `true` | 문장 끝에 세미콜론을 사용합니다. |
-| `singleQuote` | `true` | 문자열은 작은따옴표를 사용합니다. |
-| `trailingComma` | `all` | 가능한 곳에는 trailing comma를 사용합니다. |
-| `printWidth` | `100` | 한 줄 최대 길이는 100자로 제한합니다. |
-| `tabWidth` | `2` | 들여쓰기는 2칸을 사용합니다. |
-| `arrowParens` | `always` | 화살표 함수 인자는 항상 괄호를 사용합니다. |
-| `endOfLine` | `lf` | 운영체제별 줄바꿈 차이를 방지하기 위해 LF로 통일합니다. |
-| `plugins` | `prettier-plugin-tailwindcss` | Tailwind class를 자동 정렬합니다. |
+| 옵션 | 값 | 설명 |
+| --------------- | ----------------------------- | ------------------------------------------------------- |
+| `semi` | `true` | 문장 끝에 세미콜론을 사용합니다. |
+| `singleQuote` | `true` | 문자열은 작은따옴표를 사용합니다. |
+| `trailingComma` | `all` | 가능한 곳에는 trailing comma를 사용합니다. |
+| `printWidth` | `100` | 한 줄 최대 길이는 100자로 제한합니다. |
+| `tabWidth` | `2` | 들여쓰기는 2칸을 사용합니다. |
+| `arrowParens` | `always` | 화살표 함수 인자는 항상 괄호를 사용합니다. |
+| `endOfLine` | `lf` | 운영체제별 줄바꿈 차이를 방지하기 위해 LF로 통일합니다. |
+| `plugins` | `prettier-plugin-tailwindcss` | Tailwind class를 자동 정렬합니다. |
## 4. Line Ending
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 05e726d..d028ff8 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,17 +1,66 @@
-import { defineConfig, globalIgnores } from "eslint/config";
-import nextVitals from "eslint-config-next/core-web-vitals";
-import nextTs from "eslint-config-next/typescript";
+import { defineConfig, globalIgnores } from 'eslint/config';
+import nextVitals from 'eslint-config-next/core-web-vitals';
+import nextTs from 'eslint-config-next/typescript';
+
+// FSD 레이어 경계 — 하위 레이어가 상위 레이어를 import하지 못하도록 제한한다.
+// 순서: app → views → widgets → features → entities → shared
+const fsdLayerRules = [
+ {
+ layer: 'views',
+ forbidden: ['@/app/*'],
+ },
+ {
+ layer: 'widgets',
+ forbidden: ['@/app/*', '@/views/*'],
+ },
+ {
+ layer: 'features',
+ forbidden: ['@/app/*', '@/views/*', '@/widgets/*'],
+ },
+ {
+ layer: 'entities',
+ forbidden: ['@/app/*', '@/views/*', '@/widgets/*', '@/features/*'],
+ },
+ {
+ layer: 'shared',
+ forbidden: ['@/app/*', '@/views/*', '@/widgets/*', '@/features/*', '@/entities/*'],
+ },
+].map(({ layer, forbidden }) => ({
+ files: [`src/${layer}/**/*.{ts,tsx}`],
+ rules: {
+ 'no-restricted-imports': [
+ 'error',
+ {
+ patterns: [
+ {
+ group: forbidden,
+ message: `FSD 규칙 위반: ${layer} 레이어는 상위 레이어를 import할 수 없습니다.`,
+ },
+ ],
+ },
+ ],
+ },
+}));
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
+ {
+ rules: {
+ // 디버깅 코드가 배포 코드에 포함되는 것을 방지 (개발자 로그는 warn/error만 허용)
+ 'no-console': ['error', { allow: ['warn', 'error'] }],
+ // 타입 안정성 유지
+ '@typescript-eslint/no-explicit-any': 'error',
+ },
+ },
+ ...fsdLayerRules,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
- ".next/**",
- "out/**",
- "build/**",
- "next-env.d.ts",
+ '.next/**',
+ 'out/**',
+ 'build/**',
+ 'next-env.d.ts',
]),
]);
diff --git a/next.config.ts b/next.config.ts
index 5e6a392..f6f93a2 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,4 +1,4 @@
-import type { NextConfig } from "next";
+import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
@@ -6,12 +6,12 @@ const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
- protocol: "https",
- hostname: "atkvaovvzdiznndrkjgt.supabase.co",
+ protocol: 'https',
+ hostname: 'atkvaovvzdiznndrkjgt.supabase.co',
},
{
- protocol: "http",
- hostname: "localhost",
+ protocol: 'http',
+ hostname: 'localhost',
},
],
},
diff --git a/package-lock.json b/package-lock.json
index fe751bc..314852e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.22.0",
+ "motion": "^12.42.2",
"next": "16.2.9",
"radix-ui": "^1.6.0",
"react": "19.2.4",
@@ -6743,6 +6744,33 @@
"node": ">= 0.6"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz",
+ "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.42.2",
+ "motion-utils": "^12.39.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -8539,6 +8567,47 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/motion": {
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz",
+ "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "framer-motion": "^12.42.2",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz",
+ "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.39.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.39.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
+ "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
diff --git a/package.json b/package.json
index 29f6da5..822019d 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,11 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint"
+ "lint": "eslint",
+ "typecheck": "tsc --noEmit",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "check": "npm run lint && npm run typecheck && npm run format:check"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
@@ -17,6 +21,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.22.0",
+ "motion": "^12.42.2",
"next": "16.2.9",
"radix-ui": "^1.6.0",
"react": "19.2.4",
diff --git a/postcss.config.mjs b/postcss.config.mjs
index 61e3684..297374d 100644
--- a/postcss.config.mjs
+++ b/postcss.config.mjs
@@ -1,6 +1,6 @@
const config = {
plugins: {
- "@tailwindcss/postcss": {},
+ '@tailwindcss/postcss': {},
},
};
diff --git a/public/landing/avatar-1.png b/public/landing/avatar-1.png
new file mode 100644
index 0000000..22806f8
Binary files /dev/null and b/public/landing/avatar-1.png differ
diff --git a/public/landing/avatar-2.png b/public/landing/avatar-2.png
new file mode 100644
index 0000000..2ae8fc0
Binary files /dev/null and b/public/landing/avatar-2.png differ
diff --git a/public/landing/avatar-3.png b/public/landing/avatar-3.png
new file mode 100644
index 0000000..90bac87
Binary files /dev/null and b/public/landing/avatar-3.png differ
diff --git a/public/landing/hero-bg.png b/public/landing/hero-bg.png
new file mode 100644
index 0000000..8c0bee3
Binary files /dev/null and b/public/landing/hero-bg.png differ
diff --git a/src/app/globals.css b/src/app/globals.css
index 94ce365..a33a764 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,11 +1,35 @@
-@import "tailwindcss";
-@import "tw-animate-css";
-@import "shadcn/tailwind.css";
-@import "react-grid-layout/css/styles.css";
-@import "react-resizable/css/styles.css";
+@import 'tailwindcss';
+@import 'tw-animate-css';
+@import 'shadcn/tailwind.css';
+@import 'react-grid-layout/css/styles.css';
+@import 'react-resizable/css/styles.css';
@custom-variant dark (&:is(.dark *));
+@theme {
+ --color-brand: #5b4ee8;
+ --color-brand-ink: #1a1b2e;
+ --color-brand-muted: #7b7fa8;
+ --color-brand-surface: #f7f8fc;
+ --color-brand-soft: #eef0fb;
+ --color-brand-secondary: #f1f3f9;
+ --color-brand-start: #615fff;
+ --color-brand-end: #8e51ff;
+ --color-brand-deep: #7f22fe;
+
+ --animate-gradient-x: gradient-x 6s ease infinite;
+
+ @keyframes gradient-x {
+ 0%,
+ 100% {
+ background-position: 0% 50%;
+ }
+ 50% {
+ background-position: 100% 50%;
+ }
+ }
+}
+
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -126,10 +150,11 @@
body {
@apply bg-background text-foreground;
}
- button:not(:disabled), [role="button"]:not(:disabled) {
+ button:not(:disabled),
+ [role='button']:not(:disabled) {
cursor: pointer;
}
html {
@apply font-sans;
}
-}
\ No newline at end of file
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f4689aa..bc58872 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,23 +1,23 @@
-import { cn } from "@/lib/utils";
-import type { Metadata } from "next";
-import { Geist, Geist_Mono, Inter } from "next/font/google";
-import "./globals.css";
+import { cn } from '@/shared/lib/utils';
+import type { Metadata } from 'next';
+import { Geist, Geist_Mono, Inter } from 'next/font/google';
+import './globals.css';
-const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
+const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
+ variable: '--font-geist-sans',
+ subsets: ['latin'],
});
const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
+ variable: '--font-geist-mono',
+ subsets: ['latin'],
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: 'Create Next App',
+ description: 'Generated by create next app',
};
export default function RootLayout({
@@ -29,11 +29,11 @@ export default function RootLayout({
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 6d736d0..036b276 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,7 +1,6 @@
+// 루트(/) 라우트 — views/landing의 페이지 컴포넌트를 렌더만 한다
+import { LandingPage } from '@/views/landing';
+
export default function Home() {
- return (
-
- 메인 페이지
-
- );
+ return ;
}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
deleted file mode 100644
index a5ef193..0000000
--- a/src/lib/utils.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { clsx, type ClassValue } from "clsx";
-import { twMerge } from "tailwind-merge";
-
-export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs));
-}
diff --git a/src/lib/client.ts b/src/shared/lib/client.ts
similarity index 76%
rename from src/lib/client.ts
rename to src/shared/lib/client.ts
index e50bdf6..d8def7a 100644
--- a/src/lib/client.ts
+++ b/src/shared/lib/client.ts
@@ -1,4 +1,4 @@
-import { createBrowserClient } from "@supabase/ssr";
+import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
diff --git a/src/lib/middleware.ts b/src/shared/lib/middleware.ts
similarity index 85%
rename from src/lib/middleware.ts
rename to src/shared/lib/middleware.ts
index cd1713e..2a14462 100644
--- a/src/lib/middleware.ts
+++ b/src/shared/lib/middleware.ts
@@ -1,5 +1,5 @@
-import { createServerClient } from "@supabase/ssr";
-import { NextResponse, type NextRequest } from "next/server";
+import { createServerClient } from '@supabase/ssr';
+import { NextResponse, type NextRequest } from 'next/server';
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
@@ -17,9 +17,7 @@ export async function updateSession(request: NextRequest) {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
- cookiesToSet.forEach(({ name, value }) =>
- request.cookies.set(name, value),
- );
+ cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
supabaseResponse = NextResponse.next({
request,
});
@@ -42,12 +40,12 @@ export async function updateSession(request: NextRequest) {
if (
!user &&
- !request.nextUrl.pathname.startsWith("/login") &&
- !request.nextUrl.pathname.startsWith("/auth")
+ !request.nextUrl.pathname.startsWith('/login') &&
+ !request.nextUrl.pathname.startsWith('/auth')
) {
// no user, potentially respond by redirecting the user to the login page
const url = request.nextUrl.clone();
- url.pathname = "/auth/login";
+ url.pathname = '/auth/login';
return NextResponse.redirect(url);
}
diff --git a/src/shared/lib/motion/index.ts b/src/shared/lib/motion/index.ts
new file mode 100644
index 0000000..12a6c49
--- /dev/null
+++ b/src/shared/lib/motion/index.ts
@@ -0,0 +1,10 @@
+// motion 상수 모듈의 Public API
+export {
+ EASE_OUT,
+ DURATION_BASE,
+ STAGGER_CHILDREN,
+ VIEWPORT_ONCE,
+ fadeUp,
+ staggerContainer,
+ hoverLift,
+} from './variants';
diff --git a/src/shared/lib/motion/variants.ts b/src/shared/lib/motion/variants.ts
new file mode 100644
index 0000000..33f2504
--- /dev/null
+++ b/src/shared/lib/motion/variants.ts
@@ -0,0 +1,36 @@
+// Motion 공용 애니메이션 상수 — 랜딩 등에서 재사용하는 variants와 트랜지션 값을 중앙 관리한다
+import type { Variants } from 'motion/react';
+
+// 부드러운 감속 곡선 (easeOutQuint 계열) — 등장 애니메이션 공통 이징
+export const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
+
+// 등장 애니메이션 기본 지속 시간(초)
+export const DURATION_BASE = 0.6;
+
+// 스태거 등장 시 자식 간 시차(초)
+export const STAGGER_CHILDREN = 0.12;
+
+// 스크롤 리빌 공통 뷰포트 옵션 — 한 번만 재생, 요소가 80px 들어왔을 때 시작
+export const VIEWPORT_ONCE = { once: true, margin: '-80px' } as const;
+
+// 아래에서 위로 떠오르며 나타나는 기본 등장 효과
+export const fadeUp: Variants = {
+ hidden: { opacity: 0, y: 24 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: DURATION_BASE, ease: EASE_OUT },
+ },
+};
+
+// 자식들을 순차 등장시키는 컨테이너 — 자식에 fadeUp 등을 걸어 사용
+export const staggerContainer: Variants = {
+ hidden: {},
+ visible: { transition: { staggerChildren: STAGGER_CHILDREN } },
+};
+
+// 카드 호버 시 살짝 떠오르는 효과 — whileHover에 전달
+export const hoverLift = {
+ y: -6,
+ transition: { duration: 0.25, ease: EASE_OUT },
+} as const;
diff --git a/src/lib/server.ts b/src/shared/lib/server.ts
similarity index 90%
rename from src/lib/server.ts
rename to src/shared/lib/server.ts
index 12455a8..aa0d9d5 100644
--- a/src/lib/server.ts
+++ b/src/shared/lib/server.ts
@@ -1,5 +1,5 @@
-import { createServerClient } from "@supabase/ssr";
-import { cookies } from "next/headers";
+import { createServerClient } from '@supabase/ssr';
+import { cookies } from 'next/headers';
/**
* If using Fluid compute: Don't put this client in a global variable. Always create a new client within each
diff --git a/src/shared/lib/utils.ts b/src/shared/lib/utils.ts
new file mode 100644
index 0000000..2819a83
--- /dev/null
+++ b/src/shared/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/components/ui/button.tsx b/src/shared/ui/button.tsx
similarity index 60%
rename from src/components/ui/button.tsx
rename to src/shared/ui/button.tsx
index 880376d..6ee9a0b 100644
--- a/src/components/ui/button.tsx
+++ b/src/shared/ui/button.tsx
@@ -1,57 +1,57 @@
-import * as React from "react";
-import { cva, type VariantProps } from "class-variance-authority";
-import { Slot } from "radix-ui";
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { Slot } from 'radix-ui';
-import { cn } from "@/lib/utils";
+import { cn } from '@/shared/lib/utils';
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground hover:bg-primary/80",
+ default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline:
- "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
- "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
- "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
- "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
- link: "text-primary underline-offset-4 hover:underline",
+ 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
+ link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default:
- "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
- lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- icon: "size-8",
- "icon-xs":
+ lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
+ icon: 'size-8',
+ 'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
- "icon-sm":
- "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
- "icon-lg": "size-9",
+ 'icon-sm':
+ 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
+ 'icon-lg': 'size-9',
},
},
defaultVariants: {
- variant: "default",
- size: "default",
+ variant: 'default',
+ size: 'default',
},
},
);
function Button({
className,
- variant = "default",
- size = "default",
+ variant = 'default',
+ size = 'default',
asChild = false,
...props
-}: React.ComponentProps<"button"> &
+}: React.ComponentProps<'button'> &
VariantProps & {
asChild?: boolean;
}) {
- const Comp = asChild ? Slot.Root : "button";
+ const Comp = asChild ? Slot.Root : 'button';
return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/landing/landing-cta/index.ts b/src/widgets/landing/landing-cta/index.ts
new file mode 100644
index 0000000..0a377f4
--- /dev/null
+++ b/src/widgets/landing/landing-cta/index.ts
@@ -0,0 +1,3 @@
+// landing-cta 위젯의 Public API — CTA 섹션과 푸터를 함께 제공한다
+export { default as CtaSection } from './ui/CtaSection';
+export { default as LandingFooter } from './ui/LandingFooter';
diff --git a/src/widgets/landing/landing-cta/ui/CtaSection.tsx b/src/widgets/landing/landing-cta/ui/CtaSection.tsx
new file mode 100644
index 0000000..6fa55c4
--- /dev/null
+++ b/src/widgets/landing/landing-cta/ui/CtaSection.tsx
@@ -0,0 +1,44 @@
+'use client';
+
+// 하단 CTA 섹션 — 보라 그라데이션 배경이 천천히 흐르고 버튼이 호버에 반응한다
+import { motion } from 'motion/react';
+import Link from 'next/link';
+import { fadeUp, staggerContainer, VIEWPORT_ONCE } from '@/shared/lib/motion';
+
+export default function CtaSection() {
+ return (
+
+
+
+ 팀마다 다른 협업
+ , 시작은 더 간단하게
+
+
+ 설치 없이, 복잡한 설정 없이. 5분이면 우리 팀 워크 스페이스 완성!
+
+ 상황에 맞는 템플릿으로 가장 쉬운 협업을 시작하세요.
+
+
+
+
+ 무료로 시작하기
+
+
+
+ );
+}
diff --git a/src/widgets/landing/landing-cta/ui/LandingFooter.tsx b/src/widgets/landing/landing-cta/ui/LandingFooter.tsx
new file mode 100644
index 0000000..64c6cbb
--- /dev/null
+++ b/src/widgets/landing/landing-cta/ui/LandingFooter.tsx
@@ -0,0 +1,23 @@
+// 랜딩 푸터 — 카피라이트와 GitHub 링크를 표시하는 정적 영역
+export default function LandingFooter() {
+ const year = new Date().getFullYear();
+
+ return (
+
+ );
+}
diff --git a/src/widgets/landing/landing-features/config/features.ts b/src/widgets/landing/landing-features/config/features.ts
new file mode 100644
index 0000000..52d5991
--- /dev/null
+++ b/src/widgets/landing/landing-features/config/features.ts
@@ -0,0 +1,55 @@
+// 기능 소개 카드 데이터 — PRD의 워크스페이스 구성 요소 7종
+import {
+ BarChart3,
+ Calendar,
+ ClipboardList,
+ FileText,
+ FolderOpen,
+ Megaphone,
+ MessageSquare,
+} from 'lucide-react';
+import type { LucideIcon } from 'lucide-react';
+
+export interface FeatureCard {
+ icon: LucideIcon;
+ title: string;
+ descriptionLines: [string, string];
+}
+
+export const featureCards: FeatureCard[] = [
+ {
+ icon: ClipboardList,
+ title: '업무 관리',
+ descriptionLines: ['칸반 보드로', '직관적인 업무 분배'],
+ },
+ {
+ icon: Calendar,
+ title: '캘린더',
+ descriptionLines: ['일정과 마감일을', '한눈에 확인'],
+ },
+ {
+ icon: Megaphone,
+ title: '공지',
+ descriptionLines: ['주요 내용을 빠르게', '전달하고 고정'],
+ },
+ {
+ icon: FileText,
+ title: '회의록',
+ descriptionLines: ['회의 내용을 기록하고', '후속 업무로 연결'],
+ },
+ {
+ icon: FolderOpen,
+ title: '자료실',
+ descriptionLines: ['파일, 링크를 체계적으로', '정리하고 공유'],
+ },
+ {
+ icon: MessageSquare,
+ title: '채팅',
+ descriptionLines: ['팀원과 실시간으로', '소통하고 협업'],
+ },
+ {
+ icon: BarChart3,
+ title: '진행률 차트',
+ descriptionLines: ['데이터로 진행 상황을', '한눈에 확인'],
+ },
+];
diff --git a/src/widgets/landing/landing-features/index.ts b/src/widgets/landing/landing-features/index.ts
new file mode 100644
index 0000000..6f28167
--- /dev/null
+++ b/src/widgets/landing/landing-features/index.ts
@@ -0,0 +1,2 @@
+// landing-features 위젯의 Public API
+export { default as FeaturesSection } from './ui/FeaturesSection';
diff --git a/src/widgets/landing/landing-features/ui/FeaturesSection.tsx b/src/widgets/landing/landing-features/ui/FeaturesSection.tsx
new file mode 100644
index 0000000..b663446
--- /dev/null
+++ b/src/widgets/landing/landing-features/ui/FeaturesSection.tsx
@@ -0,0 +1,201 @@
+'use client';
+
+// 기능 소개 섹션 — 좌측 헤딩 옆에서 기능 카드 트랙이 자동 캐러셀로 순환한다
+import { useEffect, useState } from 'react';
+import { motion } from 'motion/react';
+import { Pause, Play } from 'lucide-react';
+import { cn } from '@/shared/lib/utils';
+import { fadeUp, staggerContainer, VIEWPORT_ONCE } from '@/shared/lib/motion';
+import { featureCards } from '../config/features';
+
+// 캐러셀은 카드를 인덱스 순서대로 light/accent 두 스타일로 번갈아 표시한다
+type CardVariant = 'light' | 'accent';
+
+const CARD_STYLES: Record = {
+ light: 'items-start bg-white px-10 py-7.5 drop-shadow-[0px_0px_7.5px_rgba(91,78,232,0.1)]',
+ accent: 'bg-brand items-start px-10 py-7.5 drop-shadow-[0px_0px_7.5px_rgba(91,78,232,0.1)]',
+};
+
+const AUTO_ROTATE_MS = 2400;
+const CARD_WIDTH = 300;
+const CARD_GAP = 30;
+const VISIBLE_CARD_COUNT = 3;
+const RESET_INDEX = featureCards.length % 2 === 0 ? featureCards.length : featureCards.length * 2;
+const carouselCards = [
+ ...featureCards,
+ ...(featureCards.length % 2 === 0 ? [] : featureCards),
+ ...featureCards.slice(0, VISIBLE_CARD_COUNT),
+];
+
+function getDisplayVariant(index: number): CardVariant {
+ return index % 2 === 0 ? 'light' : 'accent';
+}
+
+export default function FeaturesSection() {
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [isInstantReset, setIsInstantReset] = useState(false);
+ const [isPaused, setIsPaused] = useState(false);
+ const [isManuallyPaused, setIsManuallyPaused] = useState(false);
+ const [isPageVisible, setIsPageVisible] = useState(true);
+
+ // 백그라운드 탭에서는 setInterval이 스로틀되며 activeIndex가 복제 카드 버퍼를 넘어가
+ // 트랙이 빈 영역으로 밀려 카드가 사라진다. 탭이 숨겨지면 자동 순환을 멈춘다.
+ useEffect(() => {
+ const handleVisibilityChange = () => {
+ setIsPageVisible(!document.hidden);
+ };
+
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+
+ return () => {
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+ };
+ }, []);
+
+ useEffect(() => {
+ // hover/focus 임시 정지, 사용자 수동 정지(터치 포함), 백그라운드 탭 중 하나라도 걸리면 순환 정지
+ if (isPaused || isManuallyPaused || !isPageVisible) {
+ return;
+ }
+
+ const timer = window.setInterval(() => {
+ setActiveIndex((currentIndex) => currentIndex + 1);
+ }, AUTO_ROTATE_MS);
+
+ return () => {
+ window.clearInterval(timer);
+ };
+ }, [isPaused, isManuallyPaused, isPageVisible]);
+
+ useEffect(() => {
+ if (!isInstantReset) {
+ return;
+ }
+
+ const frame = window.requestAnimationFrame(() => {
+ setIsInstantReset(false);
+ });
+
+ return () => {
+ window.cancelAnimationFrame(frame);
+ };
+ }, [isInstantReset]);
+
+ const handleAnimationComplete = () => {
+ // 정확히 일치가 아니라 이상(>=)으로 판정해 혹시 인덱스가 버퍼를 넘어가도 복구한다
+ if (activeIndex < RESET_INDEX) {
+ return;
+ }
+
+ setIsInstantReset(true);
+ setActiveIndex(0);
+ };
+
+ return (
+
+
+
+
+ 필요한 기능이
+
한 곳에 모여 있어요
+
+
+ 팀플, 사이드 프로젝트, 소규모 매장 운영까지.
+
+ 템플릿 하나로 필요한 협업 기능이 자동으로 구성됩니다.
+
+
+
+
setIsPaused(true)}
+ onMouseLeave={() => setIsPaused(false)}
+ onFocusCapture={() => setIsPaused(true)}
+ onBlurCapture={() => setIsPaused(false)}
+ >
+
+ {carouselCards.map((feature, index) => {
+ const displayVariant = getDisplayVariant(index);
+ const isClonedCard = index >= RESET_INDEX;
+
+ return (
+
+
+
+
+
+ {feature.title}
+
+
+ {feature.descriptionLines[0]}
+
+ {feature.descriptionLines[1]}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/src/widgets/landing/landing-header/index.ts b/src/widgets/landing/landing-header/index.ts
new file mode 100644
index 0000000..0f18205
--- /dev/null
+++ b/src/widgets/landing/landing-header/index.ts
@@ -0,0 +1,2 @@
+// landing-header 위젯의 Public API
+export { default as LandingHeader } from './ui/LandingHeader';
diff --git a/src/widgets/landing/landing-header/ui/LandingHeader.tsx b/src/widgets/landing/landing-header/ui/LandingHeader.tsx
new file mode 100644
index 0000000..e6b2943
--- /dev/null
+++ b/src/widgets/landing/landing-header/ui/LandingHeader.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+// 랜딩 상단 네비게이션 — 스크롤 시 배경 블러와 그림자가 나타나는 sticky 헤더
+import { useEffect, useState } from 'react';
+import Link from 'next/link';
+import { ArrowRight, Boxes } from 'lucide-react';
+import { cn } from '@/shared/lib/utils';
+
+const SCROLL_THRESHOLD = 8;
+
+export default function LandingHeader() {
+ const [isScrolled, setIsScrolled] = useState(false);
+
+ useEffect(() => {
+ const handleScroll = () => {
+ setIsScrolled(window.scrollY > SCROLL_THRESHOLD);
+ };
+ handleScroll();
+ window.addEventListener('scroll', handleScroll, { passive: true });
+ return () => {
+ window.removeEventListener('scroll', handleScroll);
+ };
+ }, []);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/widgets/landing/landing-hero/index.ts b/src/widgets/landing/landing-hero/index.ts
new file mode 100644
index 0000000..3681040
--- /dev/null
+++ b/src/widgets/landing/landing-hero/index.ts
@@ -0,0 +1,2 @@
+// landing-hero 위젯의 Public API
+export { default as HeroSection } from './ui/HeroSection';
diff --git a/src/widgets/landing/landing-hero/ui/HeroSection.tsx b/src/widgets/landing/landing-hero/ui/HeroSection.tsx
new file mode 100644
index 0000000..4587bd9
--- /dev/null
+++ b/src/widgets/landing/landing-hero/ui/HeroSection.tsx
@@ -0,0 +1,76 @@
+'use client';
+
+// 랜딩 히어로 섹션 — 좌측 텍스트가 순차 등장하고 우측 UI 목업 일러스트가 함께 보인다
+import { motion } from 'motion/react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { ArrowRight, Sparkles } from 'lucide-react';
+import { fadeUp, staggerContainer } from '@/shared/lib/motion';
+
+// Figma 원본 버튼 그림자 값 — Tailwind 기본 shadow와 달라 임의값으로 유지한다
+const PRIMARY_BUTTON_SHADOW = '0px 10px 7.5px #c6d2ff, 0px 4px 3px #c6d2ff';
+
+export default function HeroSection() {
+ return (
+
+
+
+
+
+ 소규모 팀을 위한 맞춤형 협업 플랫폼
+
+
+ 협업은 더 가볍게
+
+ 성과는 더 빠르게
+
+
+
+ 팀플, 사이드 프로젝트, 소규모 매장 운영까지.
+
+ 템플릿 하나면 필요한 협업 화면이 자동으로 구성됩니다.
+
+
+
+ 지금 시작하기
+
+
+
+ 데모 보기
+
+
+
+
+ );
+}
diff --git a/src/widgets/landing/landing-steps/index.ts b/src/widgets/landing/landing-steps/index.ts
new file mode 100644
index 0000000..cebaa5c
--- /dev/null
+++ b/src/widgets/landing/landing-steps/index.ts
@@ -0,0 +1,2 @@
+// landing-steps 위젯의 Public API
+export { default as StepsSection } from './ui/StepsSection';
diff --git a/src/widgets/landing/landing-steps/ui/StepsSection.tsx b/src/widgets/landing/landing-steps/ui/StepsSection.tsx
new file mode 100644
index 0000000..3aa50ce
--- /dev/null
+++ b/src/widgets/landing/landing-steps/ui/StepsSection.tsx
@@ -0,0 +1,168 @@
+'use client';
+
+// 온보딩 3단계 소개 섹션 — 템플릿 선택, 자동 구성, 초대까지의 흐름을 스텝 카드로 보여준다
+import { motion } from 'motion/react';
+import Image from 'next/image';
+import { Link2 } from 'lucide-react';
+import { fadeUp, staggerContainer, VIEWPORT_ONCE } from '@/shared/lib/motion';
+import { templateItems } from '@/widgets/landing/landing-templates';
+
+interface StepHeading {
+ step: string;
+ titleLines: [string, string];
+ description: string;
+}
+
+const STEP_HEADINGS: StepHeading[] = [
+ {
+ step: 'STEP 01',
+ titleLines: ['목적에 맞는 템플릿을', '선택하세요'],
+ description: '우리 팀의 목적에 맞는 템플릿을 선택해요',
+ },
+ {
+ step: 'STEP 02',
+ titleLines: ['필요한 기능이', '자동으로 구성됩니다'],
+ description: '목적에 맞게 준비된 위젯을 자유롭게 배치해요',
+ },
+ {
+ step: 'STEP 03',
+ titleLines: ['이제 바로', '협업을 시작하세요'],
+ description: '팀원만 초대하면 모든 준비가 완료됩니다!',
+ },
+];
+
+function StepHeader({ heading }: { heading: StepHeading }) {
+ return (
+
+
+
+ {heading.step}
+
+
+ {heading.titleLines[0]}
+
+ {heading.titleLines[1]}
+
+
+
{heading.description}
+
+ );
+}
+
+export default function StepsSection() {
+ return (
+
+
+
+
+ 클릭 몇 번으로, 우리 팀에 딱 맞는
+
+ 협업 공간이 완성돼요
+
+
+ 복잡한 세팅 없이 우리 팀만의 협업 공간을 만들어 보세요.
+
+
+
+
+
+
+ {templateItems.map((item, index) => (
+
+
+
+
+
+
+
+ {item.title}
+
+
+ {item.stepDescription}
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/landing/landing-templates/config/templates.ts b/src/widgets/landing/landing-templates/config/templates.ts
new file mode 100644
index 0000000..a9e91a1
--- /dev/null
+++ b/src/widgets/landing/landing-templates/config/templates.ts
@@ -0,0 +1,62 @@
+// 템플릿 선택 카드 데이터 — 도메인 한정 포인트 컬러는 전역 토큰 대신 여기서 매핑 관리한다
+import { GraduationCap, Rocket, Store } from 'lucide-react';
+import type { LucideIcon } from 'lucide-react';
+
+export interface TemplateItem {
+ id: 'team_project' | 'side_project' | 'store';
+ icon: LucideIcon;
+ title: string;
+ shortDescription: string;
+ stepDescription: string;
+ fullDescription: string;
+ gradient: string;
+ borderColor: string;
+ tagBg: string;
+ tagText: string;
+ tags: string[];
+}
+
+export const templateItems: TemplateItem[] = [
+ {
+ id: 'team_project',
+ icon: GraduationCap,
+ title: '팀 프로젝트',
+ shortDescription: '대학교 팀플이나 스터디 그룹에 딱 맞는 구성',
+ stepDescription: '대학교 팀플, 스터디',
+ fullDescription:
+ '대학교 팀플이나 스터디 그룹에 딱 맞는 구성. 역할 분담부터 일정 관리까지 한 곳에서!',
+ gradient: 'linear-gradient(135deg, #8e51ff 0%, #615fff 100%)',
+ borderColor: '#ede9fe',
+ tagBg: '#ede9fe',
+ tagText: '#7008e7',
+ tags: ['업무 분담 보드', '회의록', '자료실', '캘린더', '그룹 채팅'],
+ },
+ {
+ id: 'side_project',
+ icon: Rocket,
+ title: '사이드 프로젝트',
+ shortDescription: '빠르게 실행하는 소규모 개발팀을 위한 구성',
+ stepDescription: '개발팀, 스타트업 소규모 팀',
+ fullDescription:
+ '빠르게 실행하는 소규모 개발팀을 위한 구성. 칸반과 스프린트로 속도 있게 진행하세요!',
+ gradient: 'linear-gradient(135deg, #2b7fff 0%, #00b8db 100%)',
+ borderColor: '#dbeafe',
+ tagBg: '#dbeafe',
+ tagText: '#1447e6',
+ tags: ['칸반 보드', '스프린트 관리', '회의록', '채팅', '진행률 차트'],
+ },
+ {
+ id: 'store',
+ icon: Store,
+ title: '매장 운영',
+ shortDescription: '직원들과 스케줄, 업무를 쉽게 공유하세요',
+ stepDescription: '카페, 음식점, 소규모 가게',
+ fullDescription:
+ '매장 직원들과 공지, 스케줄, 업무를 쉽게 공유하세요. 복잡한 기능 없이 꼭 필요한 것만!',
+ gradient: 'linear-gradient(135deg, #fe9a00 0%, #ff6900 100%)',
+ borderColor: '#ffe888',
+ tagBg: '#fef3c6',
+ tagText: '#bb4d00',
+ tags: ['공지 게시판', '업무 스케줄', '자료실', '채팅', '캘린더'],
+ },
+];
diff --git a/src/widgets/landing/landing-templates/index.ts b/src/widgets/landing/landing-templates/index.ts
new file mode 100644
index 0000000..3c25613
--- /dev/null
+++ b/src/widgets/landing/landing-templates/index.ts
@@ -0,0 +1,4 @@
+// landing-templates 위젯의 Public API
+export { default as TemplatesSection } from './ui/TemplatesSection';
+export { templateItems } from './config/templates';
+export type { TemplateItem } from './config/templates';
diff --git a/src/widgets/landing/landing-templates/ui/TemplatesSection.tsx b/src/widgets/landing/landing-templates/ui/TemplatesSection.tsx
new file mode 100644
index 0000000..3afbd91
--- /dev/null
+++ b/src/widgets/landing/landing-templates/ui/TemplatesSection.tsx
@@ -0,0 +1,113 @@
+'use client';
+
+// 템플릿 소개 섹션 — 좌측 리스트에서 템플릿을 선택하면 우측 미리보기 패널이 전환된다
+import { useState } from 'react';
+import { motion } from 'motion/react';
+import { ChevronRight } from 'lucide-react';
+import { cn } from '@/shared/lib/utils';
+import { fadeUp, staggerContainer, VIEWPORT_ONCE } from '@/shared/lib/motion';
+import { templateItems } from '../config/templates';
+
+export default function TemplatesSection() {
+ const [selectedId, setSelectedId] = useState(templateItems[0].id);
+ const selected = templateItems.find((item) => item.id === selectedId) ?? templateItems[0];
+
+ return (
+
+
+
+
+
+ 어떤 목적이든,
+
+ 맞춤 템플릿으로 시작하세요
+
+
+ 복잡한 세팅 없이 우리 팀만의 협업 공간을 만들어 보세요.
+
+
+
+ {templateItems.map((item) => {
+ const isSelected = item.id === selectedId;
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ {selected.title}
+
+
+ {selected.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+ {selected.fullDescription}
+
+
+
+
+
+
+
+
+ );
+}