diff --git a/.claude/agents/figma-to-component.md b/.claude/agents/figma-to-component.md new file mode 100644 index 0000000..3b96c79 --- /dev/null +++ b/.claude/agents/figma-to-component.md @@ -0,0 +1,70 @@ +--- +name: figma-to-component +description: Figma 디자인을 Weeth 디자인 시스템에 맞는 React 컴포넌트로 변환. 피그마 URL이나 스크린샷이 주어졌을 때 사용. +tools: Read, Write, Edit, Glob, Grep, Bash +--- + +Weeth 프로젝트의 디자인 시스템을 완벽히 이해하는 프론트엔드 전문가입니다. +Figma 디자인을 받아 프로젝트 컨벤션에 맞는 컴포넌트를 생성합니다. + +## Step 1. 디자인 분석 + +Figma 속성을 토큰으로 매핑한 표를 먼저 출력합니다: + +``` +Figma Property | Value | Mapped Token/Class +--------------- | ---------- | ------------------------- +Background | #1E2125 | bg-container-neutral +Border Radius | 8px | rounded-lg +Font | Sub1 Bold | typo-sub1 text-text-strong +Padding | 20px | p-500 +Gap | 12px | gap-300 +``` + +**토큰 매칭 우선순위:** +1. Tailwind 토큰 클래스 (`bg-container-neutral`, `text-text-strong`) +2. CSS 변수 (`var(--color-primary)`) +3. 신규 토큰 필요 시 → 사용자에게 제안 후 확인 + +## Step 2. 기존 패턴 확인 + +`src/components/ui/` 기존 컴포넌트를 먼저 읽어 패턴을 파악합니다. + +## Step 3. 컴포넌트 생성 + +```tsx +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/cn'; + +const variants = cva('base-styles', { + variants: { + variant: { primary: '...', secondary: '...' }, + size: { lg: '...', md: '...', sm: '...' }, + }, + defaultVariants: { variant: 'primary', size: 'md' }, +}); + +interface Props extends React.HTMLAttributes, VariantProps {} + +function Component({ className, variant, size, ...props }: Props) { + return ( +
+ ); +} + +export { Component, variants, type Props }; +``` + +**원칙:** +- 하드코딩 값 사용 금지 +- `className` 항상 노출 +- Radix UI 사용 시 `asChild` 지원 +- 생성 후 `src/components/ui/index.ts`에 export 추가 + +## Step 4. 결과 요약 + +``` +✅ 파일 생성: src/components/ui/ComponentName.tsx +✅ 디자인 토큰: N개 사용 +⚠️ 신규 토큰 필요: --token-name (제안값) +``` diff --git a/.claude/agents/pr-writer.md b/.claude/agents/pr-writer.md new file mode 100644 index 0000000..34f290e --- /dev/null +++ b/.claude/agents/pr-writer.md @@ -0,0 +1,84 @@ +--- +name: pr-writer +description: 코드 변경사항을 분석해 PR 템플릿에 맞는 문서를 작성. PR 올리기 전에 사용. +tools: Bash, Read, Glob +--- + +Weeth 프로젝트의 PR 문서를 작성하는 전문가입니다. +변경된 코드를 분석해 `.github/pull_request_template.md` 형식에 맞는 PR 본문을 생성합니다. + +## 분석 순서 + +### 1. 변경사항 파악 + +```bash +git diff main...HEAD --stat # 변경된 파일 목록 +git log main...HEAD --oneline # 커밋 메시지 목록 +git diff main...HEAD # 실제 변경 내용 +``` + +### 2. 브랜치명에서 이슈번호 추출 + +```bash +git branch --show-current +# 예: feat/WTH-42-button-component → 이슈 #42 +``` + +### 3. PR 유형 판단 기준 + +| 변경 내용 | PR 유형 | +|----------|---------| +| 새 파일 생성, 새 기능 | 새로운 기능 추가 | +| 버그 수정, 오동작 해결 | 버그 수정 | +| 리팩토링, 구조 변경 | 코드 리팩토링 | +| 오타, 변수명, 탭 사이즈 | 코드에 영향 없는 변경사항 | +| 주석 추가/수정 | 주석 추가 및 수정 | +| README, md 파일 | 문서 수정 | +| package.json, CI 등 | 빌드/패키지 매니저 수정 | +| 파일/폴더 이름 변경 | 파일 혹은 폴더명 수정 | +| 파일/폴더 삭제 | 파일 혹은 폴더 삭제 | + +## 출력 형식 + +분석 후 아래 템플릿을 채워서 출력합니다. +스크린샷 섹션은 사용자가 직접 추가해야 하므로 안내 문구만 남깁니다. + +```markdown +## ✅ PR 유형 + +어떤 변경 사항이 있었나요? + +- [x] 새로운 기능 추가 ← 해당하는 항목에 x 표시 +- [ ] 버그 수정 +- [ ] 코드에 영향을 주지 않는 변경사항(오타 수정, 탭 사이즈 변경, 변수명 변경) +- [ ] 코드 리팩토링 +- [ ] 주석 추가 및 수정 +- [ ] 문서 수정 +- [ ] 빌드 부분 혹은 패키지 매니저 수정 +- [ ] 파일 혹은 폴더명 수정 +- [ ] 파일 혹은 폴더 삭제 + +--- + +### 📌 관련 이슈번호 + +- Closed #이슈번호 ← 브랜치명 또는 커밋에서 추출, 없으면 생략 + +--- + +### ✅ Key Changes + +- 변경사항 1 +- 변경사항 2 + +--- + +### 📸 스크린샷 or 실행영상 + + + +--- + +## 🎸 기타 사항 or 추가 코멘트 + +``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ad4bc01 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,107 @@ +name: CI + +on: + pull_request: + branches: [main, develop] + +jobs: + ci: + name: Lint & Build + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: TypeScript + id: typescript + continue-on-error: true + run: pnpm typecheck + + - name: ESLint + id: eslint + continue-on-error: true + run: pnpm lint + + - name: Prettier + id: prettier + continue-on-error: true + run: pnpm format:check + + - name: Build + id: build + continue-on-error: true + run: pnpm build + + - name: PR 검증 결과 코멘트 + if: always() + uses: actions/github-script@v7 + with: + script: | + const results = { + typescript: '${{ steps.typescript.outcome }}', + eslint: '${{ steps.eslint.outcome }}', + prettier: '${{ steps.prettier.outcome }}', + build: '${{ steps.build.outcome }}', + }; + + const icon = (outcome) => + outcome === 'success' ? '✅' : outcome === 'failure' ? '❌' : '⏭️'; + + const label = (outcome) => + outcome === 'success' ? '통과' : outcome === 'failure' ? '실패' : '건너뜀'; + + const allPassed = Object.values(results).every((r) => r === 'success'); + + const body = [ + '## PR 검증 결과', + '', + `${icon(results.typescript)} **TypeScript:** ${label(results.typescript)}`, + `${icon(results.eslint)} **ESLint:** ${label(results.eslint)}`, + `${icon(results.prettier)} **Prettier:** ${label(results.prettier)}`, + `${icon(results.build)} **Build:** ${label(results.build)}`, + '', + allPassed ? '🎉 모든 검증을 통과했습니다!' : '⚠️ 일부 검증에 실패했습니다. 확인 후 수정해주세요.', + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find( + (c) => c.user.login === 'github-actions[bot]' && c.body.includes('PR 검증 결과'), + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/CLAUDE.md b/CLAUDE.md index b7c8494..b6d4af6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,141 +1,55 @@ -# Weeth Frontend - Claude Code Instructions - -## Project Overview - -Weeth 프로젝트의 프론트엔드. Figma 디자인을 기반으로 디자인 시스템을 구현합니다. +# Weeth Frontend ## Tech Stack -- **React 19** + TypeScript -- **Next.js 16** (App Router) -- **Tailwind CSS v4** -- **class-variance-authority (cva)** — variant 스타일 관리 -- **`cn()` from `@/lib/cn`** — className 병합 (clsx + tailwind-merge) -- **Radix UI** — 접근성 있는 UI 프리미티브 -- **Lucide React** — 아이콘 -- **pnpm** — 패키지 매니저 (npm, yarn 사용 금지) +- React 19 + TypeScript, Next.js 16 (App Router) +- Tailwind CSS v4, class-variance-authority (cva) +- `cn()` from `@/lib/cn` — className 병합 +- Radix UI, Lucide React +- **pnpm** 전용 (npm/yarn 사용 금지) ## Project Structure ``` src/ - app/ - globals.css # 디자인 토큰 정의 (CSS variables, @utility) - components/ - ui/ # 재사용 기본 컴포넌트 (비즈니스 로직 없음) - Button.tsx - TextField.tsx - dialog.tsx - alert-dialog.tsx - breadcrumb.tsx - index.ts # 모든 ui 컴포넌트 re-export - lib/ - cn.ts # className 병합 유틸 + app/globals.css # 디자인 토큰 (CSS variables, @utility) + components/ui/ # 재사용 UI 컴포넌트, index.ts에서 re-export + lib/cn.ts # className 병합 유틸 ``` -## Design Tokens (globals.css) - -Figma 디자인을 코드로 옮길 때 반드시 아래 토큰을 우선 사용합니다. +## Design Tokens -**매칭 우선순위:** -1. CSS 변수 기반 Tailwind 클래스 (`bg-container-neutral`, `text-text-strong`) -2. 직접 CSS 변수 (`var(--color-primary)`) -3. 신규 토큰이 필요한 경우 → 사용자에게 먼저 확인 후 추가 +하드코딩 금지. 반드시 아래 토큰 우선 사용. 신규 토큰 필요 시 사용자 확인 후 추가. -**주요 토큰 카테고리:** - -| 카테고리 | 예시 클래스 | +| 카테고리 | 클래스 예시 | |----------|------------| -| 텍스트 색상 | `text-text-strong`, `text-text-normal`, `text-text-alternative`, `text-text-disabled`, `text-text-inverse` | -| 배경 | `bg-container-neutral`, `bg-container-neutral-interaction` | -| 버튼 | `bg-button-primary`, `bg-button-neutral` | -| Typography | `typo-h1` ~ `typo-h3`, `typo-sub1` ~ `typo-sub2`, `typo-body1` ~ `typo-body2`, `typo-caption1` ~ `typo-caption2`, `typo-button1` ~ `typo-button2` | -| Spacing | `p-100` ~ `p-500`, `gap-100` ~ `gap-400`, `px-200`, `py-300` 등 | - -## Component Guidelines +| 텍스트 | `text-text-strong` `text-text-normal` `text-text-alternative` `text-text-disabled` `text-text-inverse` | +| 배경 | `bg-container-neutral` `bg-container-neutral-interaction` | +| 버튼 | `bg-button-primary` `bg-button-neutral` | +| Typography | `typo-h1~h3` `typo-sub1~2` `typo-body1~2` `typo-caption1~2` `typo-button1~2` | +| Spacing | `p-100~500` `gap-100~400` | -### 기본 구조 +## Component Pattern ```tsx -import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from '@/lib/cn'; - -const componentVariants = cva('base-styles', { - variants: { - variant: { - primary: '...', - secondary: '...', - }, - size: { - lg: '...', - md: '...', - sm: '...', - }, - }, - defaultVariants: { - variant: 'primary', - size: 'md', - }, -}); - -interface ComponentProps - extends React.HTMLAttributes, - VariantProps {} - -function Component({ className, variant, size, ...props }: ComponentProps) { - return ( -
- ); -} - -export { Component, componentVariants, type ComponentProps }; -``` - -### 원칙 - -- `className` prop 항상 노출 (외부에서 커스텀 가능하도록) -- 하드코딩 값 사용 금지 — 반드시 디자인 토큰 사용 -- 새 컴포넌트는 `src/components/ui/index.ts`에 export 추가 -- Radix UI 사용 시 `asChild` 패턴으로 합성 지원 - -## Figma → Component Workflow +const variants = cva('base', { variants: { variant: {}, size: {} }, defaultVariants: {} }); -### Step 1. 디자인 분석 +interface Props extends React.HTMLAttributes, VariantProps {} -Figma 속성을 토큰으로 매핑하는 표를 먼저 작성합니다: +function Component({ className, variant, size, ...props }: Props) { + return
; +} +export { Component, variants, type Props }; ``` -Figma Property | Value | Mapped Token/Class ---------------- | ------------- | --------------------------- -Background | #1E2125 | bg-container-neutral -Border Radius | 8px | rounded-lg -Font | Sub1 Bold | typo-sub1 text-text-strong -Padding | 20px | p-500 -Gap | 12px | gap-300 -``` - -### Step 2. 코드 생성 -위 매핑 기반으로 컴포넌트 생성. - -### Step 3. 결과 요약 - -``` -✅ 파일 생성: src/components/ui/Card.tsx -✅ 디자인 토큰: 5개 사용 -⚠️ 신규 토큰 필요: --shadow-card (제안: 0 2px 8px rgba(0,0,0,0.1)) -``` +- `className` 항상 노출, Radix 사용 시 `asChild` 지원 +- 새 컴포넌트는 `src/components/ui/index.ts`에 export 추가 ## Git Conventions ``` -feat: 새 컴포넌트 또는 기능 추가 -fix: 버그 수정 -style: 스타일/토큰 수정 -refactor: 리팩토링 +feat / fix / style / refactor / ci / chore ``` -**주의:** main/master 브랜치 직접 커밋 금지 — 반드시 경고 후 확인 요청. +main 브랜치 직접 커밋 금지. diff --git a/amplify.yml b/amplify.yml new file mode 100644 index 0000000..0292d6f --- /dev/null +++ b/amplify.yml @@ -0,0 +1,19 @@ +version: 1 +frontend: + phases: + preBuild: + commands: + - corepack enable + - corepack prepare pnpm@latest --activate + - pnpm install --frozen-lockfile + build: + commands: + - pnpm build + artifacts: + baseDirectory: .next + files: + - '**/*' + cache: + paths: + - node_modules/**/* + - .next/cache/**/* diff --git a/package.json b/package.json index c3eb838..706a6b1 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "dev": "next dev", "build": "next build", "start": "next start", + "typecheck": "tsc --noEmit", "lint": "eslint .", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "format:check": "prettier --check ." }, "dependencies": { "@tanstack/react-query": "^5.62.11", diff --git a/src/app/(public)/(landing)/page.tsx b/src/app/(public)/(landing)/page.tsx index 03368ea..440447a 100644 --- a/src/app/(public)/(landing)/page.tsx +++ b/src/app/(public)/(landing)/page.tsx @@ -172,40 +172,46 @@ export default function LandingPage() {

ALERT DIALOG

- + - 로그아웃 - 로그아웃 하시겠습니까? + 변경 사항을 적용하시겠어요? + + 선택한 내용이 저장됩니다. +
+ 진행하시려면 '확인'을 눌러주세요. +
- 취소 확인 + 취소
- + - 정말 삭제하시겠습니까? - 이 작업은 되돌릴 수 없습니다. + 이 게시글을 삭제하시겠어요? + + 삭제된 게시글을 복구할 수 없습니다. +
+ 신중히 확인 후 진행해 주세요. +
+ 삭제 취소 - - 삭제 -
diff --git a/src/app/globals.css b/src/app/globals.css index d3930fc..995c1f8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -142,6 +142,7 @@ --radius-md: 12px; --radius-lg: 16px; --radius: 0.625rem; + --shadow-dialog: 0 10px 40px 0 rgba(0, 0, 0, 0.4); --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); diff --git a/src/assets/icons/delete_forever.svg b/src/assets/icons/delete_forever.svg new file mode 100644 index 0000000..d071607 --- /dev/null +++ b/src/assets/icons/delete_forever.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/info.svg b/src/assets/icons/info.svg new file mode 100644 index 0000000..742e153 --- /dev/null +++ b/src/assets/icons/info.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index e950ddf..c247830 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -13,6 +13,8 @@ const buttonVariants = cva( 'bg-button-neutral text-text-normal hover:bg-button-neutral-interaction active:bg-button-neutral-interaction disabled:bg-button-neutral disabled:text-text-disabled', tertiary: 'bg-transparent text-text-normal hover:bg-container-neutral-interaction active:bg-container-neutral-interaction disabled:text-text-disabled', + danger: + 'bg-[var(--state-error)] text-text-strong hover:opacity-90 active:opacity-80 disabled:bg-button-neutral disabled:text-text-disabled', }, size: { lg: 'typo-button1 px-400 py-300 rounded-md', diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index 494559e..caf9128 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -2,12 +2,80 @@ import * as React from 'react'; import { AlertDialog as AlertDialogPrimitive } from 'radix-ui'; +import Image from 'next/image'; import { cn } from '@/lib/cn'; import { Button, type ButtonProps } from '@/components/ui/Button'; -function AlertDialog({ ...props }: React.ComponentProps) { - return ; +import InfoIcon from '@/assets/icons/info.svg'; +import DeleteIcon from '@/assets/icons/delete_forever.svg'; + +type AlertStatus = 'default' | 'danger'; + +const AlertDialogContext = React.createContext<{ status: AlertStatus }>({ + status: 'default', +}); + +const defaultTexts: Record = { + default: { + title: '변경 사항을 적용하시겠어요?', + description: "선택한 내용이 저장됩니다.\n진행하시려면 '확인'을 눌러주세요.", + }, + danger: { + title: '이 게시글을 삭제하시겠어요?', + description: '삭제된 게시글은 복구할 수 없습니다.\n신중히 확인 후 진행해 주세요.', + }, +}; + +interface AlertDialogProps extends React.ComponentProps { + status?: AlertStatus; + /** 간소화 모드: trigger 엘리먼트를 전달하면 내부에서 Content/Header/Footer를 자동 렌더링 */ + trigger?: React.ReactNode; + /** 기본값: status에 따라 자동 설정 */ + title?: string; + /** 기본값: status에 따라 자동 설정 */ + description?: string; +} + +function AlertDialog({ + status = 'default', + trigger, + title, + description, + children, + ...props +}: AlertDialogProps) { + const defaults = defaultTexts[status]; + const resolvedTitle = title ?? defaults.title; + const resolvedDescription = description ?? defaults.description; + + return ( + + + {trigger !== undefined ? ( + <> + {trigger} + + + {resolvedTitle} + + {resolvedDescription.split('\n').map((line, i, arr) => ( + + {line} + {i < arr.length - 1 &&
} +
+ ))} +
+
+ {children} +
+ + ) : ( + children + )} +
+
+ ); } function AlertDialogTrigger({ @@ -46,9 +114,10 @@ function AlertDialogContent({ @@ -56,12 +125,18 @@ function AlertDialogContent({ } function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + const { status } = React.useContext(AlertDialogContext); + const Icon = status === 'danger' ? DeleteIcon : InfoIcon; + return (
+ > + +
{props.children}
+
); } @@ -69,7 +144,7 @@ function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) return (
); @@ -95,7 +170,7 @@ function AlertDialogDescription({ return ( ); @@ -103,14 +178,17 @@ function AlertDialogDescription({ function AlertDialogAction({ className, - variant = 'primary', - size = 'md', + variant, + size = 'lg', ...props }: React.ComponentProps & Pick) { + const { status } = React.useContext(AlertDialogContext); + const defaultVariant = variant || (status === 'danger' ? 'danger' : 'primary'); + return ( -