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 ( +
+
+

© {year} Syncly. 소규모 팀을 위한 협업 플랫폼.

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

+
+
+
+

+ 템플릿 UI 스크린샷 +

+
+
+
+
+
+ ); +}