From 9e85a4d882b33896bc1dd3d9658ed53d03dbd9e7 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 20 May 2026 11:32:49 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=9E=90=EA=B8=B0=EC=86=8C?= =?UTF-8?q?=EA=B0=9C=EC=84=9C=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=ED=83=80?= =?UTF-8?q?=EC=9E=85,=20Mock,=20=EC=9C=A0=ED=8B=B8=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types/matching.ts — JobQuestion, QuestionMatch 신규 타입 정의 - types/job.ts — JobPosting에 questions?: JobQuestion[] 필드 추가 - lib/mock/matching.mock.ts — 문항별 AI 매칭 Mock 데이터 신규 - lib/mock/job.mock.ts — questions 필드 포함 Mock 확장 - lib/mock/cover-letter.mock.ts — 4개 자소서 항목으로 확장 - app/globals.css — CSS 변수 4개 및 유틸 클래스 20종 추가 (card, chip, btn, row, col, grid-2, doc-*, ai-thinking 등) --- app/globals.css | 236 ++++++++++++++++++++++++++++++++++ components/ui/index.ts | 5 + lib/mock/cover-letter.mock.ts | 66 ++++++++++ lib/mock/job.mock.ts | 35 +++++ lib/mock/matching.mock.ts | 31 +++++ types/job.ts | 14 ++ types/matching.ts | 16 +++ 7 files changed, 403 insertions(+) create mode 100644 lib/mock/cover-letter.mock.ts create mode 100644 lib/mock/job.mock.ts create mode 100644 lib/mock/matching.mock.ts create mode 100644 types/job.ts create mode 100644 types/matching.ts diff --git a/app/globals.css b/app/globals.css index 94966f9..dde80ea 100644 --- a/app/globals.css +++ b/app/globals.css @@ -16,6 +16,8 @@ --bg-base: oklch(12% 0.015 255); /* 전체 배경 */ --bg-card: oklch(16% 0.018 255); /* 카드 배경 */ --bg-subtle: oklch(19% 0.016 255); /* 호버, 입력 배경 */ + --bg-canvas: oklch(10% 0.012 255); /* 최하단 캔버스 배경 */ + --bg-elevated: oklch(20% 0.02 255); /* 떠 있는 패널/모달 배경 */ /* ── 테두리 ── */ --border-subtle: oklch(28% 0.02 255); /* 구분선 */ @@ -48,10 +50,12 @@ /* ── 상태 색상 ── */ --warn: oklch(75% 0.18 60); /* 주의/경고 (앰버 계열) */ + --danger: oklch(62% 0.22 25); /* 에러/삭제 강조 */ /* ── 공통 유틸 ── */ --radius-card: 1rem; /* 16px */ --radius-btn: 0.625rem; /* 10px */ + --r-sm: 0.5rem; /* 8px 소형 radius */ --shadow-card: 0 4px 32px oklch(0% 0 0 / 0.35), 0 1px 4px oklch(0% 0 0 / 0.2); } @@ -60,3 +64,235 @@ .app-bg { background: var(--grad-glow); } + +@layer utilities { + /* ── 레이아웃 ── */ + .row { + display: flex; + align-items: center; + } + .row-between { + display: flex; + align-items: center; + justify-content: space-between; + } + .col { + display: flex; + flex-direction: column; + } + .grid-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } + + /* ── 카드 ── */ + .card { + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + padding: 1.5rem; + } + .card-glow { + box-shadow: + var(--shadow-card), + 0 0 0 1px var(--green-300); + } + + /* ── 배지/칩 ── */ + .chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + border-radius: 999px; + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + font-weight: 500; + } + .chip-sm { + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; + } + .chip-green { + background: oklch(35% 0.12 155 / 0.25); + color: var(--green-200); + } + .chip-mint { + background: oklch(35% 0.1 170 / 0.2); + color: oklch(78% 0.12 170); + } + .chip-outline { + background: transparent; + border: 1px solid var(--border-default); + color: var(--text-3); + } + .chip-glow { + background: oklch(35% 0.12 155 / 0.35); + color: var(--green-200); + box-shadow: 0 0 8px oklch(35% 0.12 155 / 0.4); + } + + /* ── 버튼 ── */ + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: var(--radius-btn); + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + transition: opacity 0.15s; + cursor: pointer; + } + .btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; + } + .btn-primary { + background: var(--green-300); + color: oklch(10% 0.01 155); + } + .btn-primary:hover { + background: var(--green-400); + } + .btn-secondary { + background: var(--bg-subtle); + color: var(--text-2); + border: 1px solid var(--border-default); + } + .btn-secondary:hover { + background: var(--border-subtle); + } + .btn-ghost { + background: transparent; + color: var(--text-3); + } + .btn-ghost:hover { + color: var(--text-2); + background: var(--bg-subtle); + } + + /* ── 타이포그래피 ── */ + .eyebrow { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--green-200); + } + .h-display { + font-size: clamp(1.75rem, 4vw, 2.5rem); + font-weight: 700; + line-height: 1.2; + color: var(--text-1); + } + .h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-1); + } + .body { + font-size: 0.9375rem; + line-height: 1.6; + color: var(--text-2); + } + .caption { + font-size: 0.8125rem; + color: var(--text-3); + } + .grad-text { + background: var(--grad-text); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + /* ── 배경 ── */ + .bg-canvas { + background: var(--bg-canvas); + } + .bg-elevated { + background: var(--bg-elevated); + } + + /* ── 에디터 문서 영역 ── */ + .doc-paper { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-card); + padding: 2rem; + min-height: 60vh; + } + .doc-question { + font-size: 1rem; + font-weight: 600; + color: var(--text-1); + margin-bottom: 1rem; + } + .doc-paragraph { + font-size: 0.9375rem; + line-height: 1.8; + color: var(--text-2); + outline: none; + } + .doc-suggest { + background: oklch(35% 0.12 155 / 0.12); + border-left: 2px solid var(--green-300); + padding: 0.75rem 1rem; + border-radius: 0 var(--r-sm) var(--r-sm) 0; + margin-top: 0.75rem; + } + + /* ── AI 상태 ── */ + .ai-thinking { + display: flex; + gap: 4px; + align-items: center; + } + .ai-thinking span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--green-200); + animation: dot-bounce 1.2s ease-in-out infinite; + } + .ai-thinking span:nth-child(2) { + animation-delay: 0.2s; + } + .ai-thinking span:nth-child(3) { + animation-delay: 0.4s; + } + @keyframes dot-bounce { + 0%, + 80%, + 100% { + transform: scale(0.6); + opacity: 0.4; + } + 40% { + transform: scale(1); + opacity: 1; + } + } + + .dot-pulse { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--green-300); + animation: pulse-dot 1.5s ease-in-out infinite; + } + @keyframes pulse-dot { + 0%, + 100% { + opacity: 0.3; + transform: scale(0.8); + } + 50% { + opacity: 1; + transform: scale(1.2); + } + } +} diff --git a/components/ui/index.ts b/components/ui/index.ts index 9ccab1b..392c036 100644 --- a/components/ui/index.ts +++ b/components/ui/index.ts @@ -1 +1,6 @@ export {LogoGlyph} from './LogoGlyph'; +export {Icon} from './Icon/Icon'; +export type {IconName} from './Icon/Icon'; +export {ScoreRing} from './ScoreRing/ScoreRing'; +export {PageTopBar} from './PageTopBar/PageTopBar'; +export {AppSidebar} from './AppSidebar/AppSidebar'; diff --git a/lib/mock/cover-letter.mock.ts b/lib/mock/cover-letter.mock.ts new file mode 100644 index 0000000..53f21a4 --- /dev/null +++ b/lib/mock/cover-letter.mock.ts @@ -0,0 +1,66 @@ +import type {CoverLetter} from '@/types/cover-letter'; + +export const MOCK_COVER_LETTERS: CoverLetter[] = [ + { + id: 'cl-1', + jobPostingId: 'job-1', + company: '카카오', + jobTitle: '프론트엔드 개발자', + questions: [ + { + id: 'q-1', + question: '지원 동기를 작성해주세요.', + answer: '', + recommendedExperienceIds: ['exp-1'], + maxLength: 500, + }, + ], + createdAt: '2025-05-10T09:00:00Z', + updatedAt: '2025-05-18T14:30:00Z', + }, + { + id: 'cl-2', + jobPostingId: 'job-2', + company: '토스', + jobTitle: 'Product Designer', + questions: [ + { + id: 'q-4', + question: '토스에 지원한 이유를 작성해주세요.', + answer: '', + recommendedExperienceIds: ['exp-2'], + maxLength: 500, + }, + ], + createdAt: '2025-05-12T11:00:00Z', + updatedAt: '2025-05-19T09:00:00Z', + }, + { + id: 'cl-3', + jobPostingId: 'job-1', + company: '네이버', + jobTitle: '서버 개발자', + questions: [], + createdAt: '2025-05-01T08:00:00Z', + updatedAt: '2025-05-01T08:00:00Z', + }, + { + id: 'cl-4', + jobPostingId: 'job-1', + company: '라인', + jobTitle: '프론트엔드 개발자', + questions: [ + { + id: 'q-5', + question: '문제 해결 경험을 서술하세요.', + answer: '초안 작성 중...', + recommendedExperienceIds: ['exp-1', 'exp-3'], + maxLength: 600, + }, + ], + createdAt: '2025-04-28T15:00:00Z', + updatedAt: '2025-05-20T10:30:00Z', + }, +]; + +export const getMockCoverLetters = (): CoverLetter[] => MOCK_COVER_LETTERS; diff --git a/lib/mock/job.mock.ts b/lib/mock/job.mock.ts new file mode 100644 index 0000000..89b2dd0 --- /dev/null +++ b/lib/mock/job.mock.ts @@ -0,0 +1,35 @@ +import type {JobPosting} from '@/types/job'; + +export const MOCK_JOB_POSTINGS: JobPosting[] = [ + { + id: 'job-1', + company: '카카오', + jobTitle: '프론트엔드 개발자', + requiredSkills: ['React', 'TypeScript', 'Next.js'], + preferredSkills: ['GraphQL', 'Storybook'], + competencies: ['문제 해결', '협업', '자기주도성'], + questions: [ + {id: 'q-1', text: '지원 동기를 작성해주세요.', limit: 500}, + { + id: 'q-2', + text: '팀 프로젝트에서 갈등을 해결한 경험을 서술하세요.', + limit: 700, + }, + { + id: 'q-3', + text: '입사 후 이루고 싶은 목표를 작성해주세요.', + limit: 400, + }, + ], + }, + { + id: 'job-2', + company: '토스', + jobTitle: 'Product Designer', + requiredSkills: ['Figma', 'Prototyping'], + preferredSkills: ['Motion Design', 'UX Writing'], + competencies: ['사용자 공감', '세심함', '빠른 실행'], + }, +]; + +export const getMockJobPostings = (): JobPosting[] => MOCK_JOB_POSTINGS; diff --git a/lib/mock/matching.mock.ts b/lib/mock/matching.mock.ts new file mode 100644 index 0000000..c2f1f17 --- /dev/null +++ b/lib/mock/matching.mock.ts @@ -0,0 +1,31 @@ +import type {QuestionMatch} from '@/types/matching'; + +export const MOCK_QUESTION_MATCHES: QuestionMatch[] = [ + { + qId: 'q-1', + primary: 'exp-1', + matched: ['exp-1', 'exp-2'], + score: 91, + keywords: ['AI', '자동화', 'Next.js'], + reason: '자소서 자동화 프로젝트 경험이 지원 동기와 직결됩니다.', + }, + { + qId: 'q-2', + primary: 'exp-3', + matched: ['exp-3', 'exp-2'], + score: 78, + keywords: ['협업', '갈등 해결', '커뮤니케이션'], + reason: '동아리 운영진 경험에서 팀 조율 역량이 잘 드러납니다.', + }, + { + qId: 'q-3', + primary: 'exp-1', + matched: ['exp-1'], + score: 84, + keywords: ['프론트엔드', '리더십', '기술 성장'], + reason: '프론트엔드 리드 경험이 목표와 자연스럽게 연결됩니다.', + }, +]; + +export const getMockQuestionMatches = (): QuestionMatch[] => + MOCK_QUESTION_MATCHES; diff --git a/types/job.ts b/types/job.ts new file mode 100644 index 0000000..c6d813b --- /dev/null +++ b/types/job.ts @@ -0,0 +1,14 @@ +import type {JobQuestion} from '@/types/matching'; + +export type {JobQuestion}; + +export interface JobPosting { + id: string; + company: string; + jobTitle: string; + requiredSkills: string[]; + preferredSkills: string[]; + competencies: string[]; + rawText?: string; + questions?: JobQuestion[]; // 자소서 문항 목록 (채용공고에서 추출) +} diff --git a/types/matching.ts b/types/matching.ts new file mode 100644 index 0000000..aa59172 --- /dev/null +++ b/types/matching.ts @@ -0,0 +1,16 @@ +// 채용공고의 문항 (JobPosting.questions[] 요소) +export interface JobQuestion { + id: string; + text: string; // 문항 텍스트 + limit: number; // 글자 수 제한 +} + +// 문항 1개에 대한 AI 매칭 결과 +export interface QuestionMatch { + qId: string; // JobQuestion.id 참조 + primary: string; // 우선 추천 Experience.id + matched: string[]; // 추천 Experience.id 목록 (primary 포함) + score: number; // 0–100 매칭 점수 + keywords: string[]; // 공통 키워드 + reason: string; // AI 추천 이유 한 줄 설명 +} From d976243612b6b7d472110174fc8d9b501b271c20 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 20 May 2026 11:33:10 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20UI=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=E2=80=94=20?= =?UTF-8?q?Icon,=20ScoreRing,=20AppSidebar,=20PageTopBar=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Icon: SVG 인라인 아이콘 13종 (Heroicons outline 기준) - ScoreRing: 0–100 원형 SVG 점수 시각화 (구간별 색상 분기) - AppSidebar: usePathname 기반 활성 경로 강조 네비게이션 사이드바 - PageTopBar: breadcrumb + 우측 actions 슬롯 페이지 상단 바 - components/ui/index.ts: 신규 4개 컴포넌트 barrel export 추가 --- .../ui/AppSidebar/AppSidebar.stories.tsx | 37 +++++++++ components/ui/AppSidebar/AppSidebar.test.tsx | 64 ++++++++++++++++ components/ui/AppSidebar/AppSidebar.tsx | 74 ++++++++++++++++++ components/ui/AppSidebar/index.ts | 1 + components/ui/Icon/Icon.stories.tsx | 76 +++++++++++++++++++ components/ui/Icon/Icon.test.tsx | 46 +++++++++++ components/ui/Icon/Icon.tsx | 65 ++++++++++++++++ components/ui/Icon/index.ts | 2 + .../ui/PageTopBar/PageTopBar.stories.tsx | 48 ++++++++++++ components/ui/PageTopBar/PageTopBar.test.tsx | 39 ++++++++++ components/ui/PageTopBar/PageTopBar.tsx | 32 ++++++++ components/ui/PageTopBar/index.ts | 1 + components/ui/ScoreRing/ScoreRing.stories.tsx | 44 +++++++++++ components/ui/ScoreRing/ScoreRing.test.tsx | 42 ++++++++++ components/ui/ScoreRing/ScoreRing.tsx | 68 +++++++++++++++++ components/ui/ScoreRing/index.ts | 1 + 16 files changed, 640 insertions(+) create mode 100644 components/ui/AppSidebar/AppSidebar.stories.tsx create mode 100644 components/ui/AppSidebar/AppSidebar.test.tsx create mode 100644 components/ui/AppSidebar/AppSidebar.tsx create mode 100644 components/ui/AppSidebar/index.ts create mode 100644 components/ui/Icon/Icon.stories.tsx create mode 100644 components/ui/Icon/Icon.test.tsx create mode 100644 components/ui/Icon/Icon.tsx create mode 100644 components/ui/Icon/index.ts create mode 100644 components/ui/PageTopBar/PageTopBar.stories.tsx create mode 100644 components/ui/PageTopBar/PageTopBar.test.tsx create mode 100644 components/ui/PageTopBar/PageTopBar.tsx create mode 100644 components/ui/PageTopBar/index.ts create mode 100644 components/ui/ScoreRing/ScoreRing.stories.tsx create mode 100644 components/ui/ScoreRing/ScoreRing.test.tsx create mode 100644 components/ui/ScoreRing/ScoreRing.tsx create mode 100644 components/ui/ScoreRing/index.ts diff --git a/components/ui/AppSidebar/AppSidebar.stories.tsx b/components/ui/AppSidebar/AppSidebar.stories.tsx new file mode 100644 index 0000000..fd55562 --- /dev/null +++ b/components/ui/AppSidebar/AppSidebar.stories.tsx @@ -0,0 +1,37 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {AppSidebar} from './AppSidebar'; + +const meta: Meta = { + title: 'UI/AppSidebar', + component: AppSidebar, + parameters: {layout: 'fullscreen'}, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const ActiveDashboard: Story = { + parameters: { + nextjs: { + navigation: { + pathname: '/dashboard', + }, + }, + }, +}; + +export const ActiveCoverLetters: Story = { + parameters: { + nextjs: { + navigation: { + pathname: '/cover-letters', + }, + }, + }, +}; diff --git a/components/ui/AppSidebar/AppSidebar.test.tsx b/components/ui/AppSidebar/AppSidebar.test.tsx new file mode 100644 index 0000000..ac410a8 --- /dev/null +++ b/components/ui/AppSidebar/AppSidebar.test.tsx @@ -0,0 +1,64 @@ +import {render, screen} from '@testing-library/react'; +import {describe, expect, it, vi} from 'vitest'; + +import {AppSidebar} from './AppSidebar'; + +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(() => '/'), +})); + +describe('AppSidebar', () => { + it('네비게이션 항목 4개가 렌더링된다', async () => { + // Arrange + const {usePathname} = await import('next/navigation'); + vi.mocked(usePathname).mockReturnValue('/'); + + // Act + render(); + + // Assert + const nav = screen.getByRole('navigation', {name: '메인 네비게이션'}); + const links = nav.querySelectorAll('a'); + expect(links).toHaveLength(4); + }); + + it('현재 경로의 링크가 aria-current를 갖는다', async () => { + // Arrange + const {usePathname} = await import('next/navigation'); + vi.mocked(usePathname).mockReturnValue('/dashboard'); + + // Act + render(); + + // Assert + const activeLink = screen.getByRole('link', {name: /대시보드/}); + expect(activeLink).toHaveAttribute('aria-current', 'page'); + }); + + it('각 링크가 올바른 href를 갖는다', async () => { + // Arrange + const {usePathname} = await import('next/navigation'); + vi.mocked(usePathname).mockReturnValue('/'); + + // Act + render(); + + // Assert + expect(screen.getByRole('link', {name: /대시보드/})).toHaveAttribute( + 'href', + '/dashboard' + ); + expect(screen.getByRole('link', {name: /경험/})).toHaveAttribute( + 'href', + '/experiences' + ); + expect(screen.getByRole('link', {name: /채용공고/})).toHaveAttribute( + 'href', + '/jobs' + ); + expect(screen.getByRole('link', {name: /자소서/})).toHaveAttribute( + 'href', + '/cover-letters' + ); + }); +}); diff --git a/components/ui/AppSidebar/AppSidebar.tsx b/components/ui/AppSidebar/AppSidebar.tsx new file mode 100644 index 0000000..8c9d5e0 --- /dev/null +++ b/components/ui/AppSidebar/AppSidebar.tsx @@ -0,0 +1,74 @@ +'use client'; + +import Link from 'next/link'; +import {usePathname} from 'next/navigation'; + +import {Icon} from '@/components/ui/Icon/Icon'; +import type {IconName} from '@/components/ui/Icon/Icon'; +import {LogoGlyph} from '@/components/ui/LogoGlyph/LogoGlyph'; + +interface AppSidebarProps { + className?: string; +} + +interface NavItem { + href: string; + icon: IconName; + label: string; +} + +const NAV_ITEMS: NavItem[] = [ + {href: '/dashboard', icon: 'home', label: '대시보드'}, + {href: '/experiences', icon: 'edit', label: '경험'}, + {href: '/jobs', icon: 'filter', label: '채용공고'}, + {href: '/cover-letters', icon: 'sparkles', label: '자소서'}, +]; + +export const AppSidebar = ({className}: AppSidebarProps) => { + const pathname = usePathname(); + + return ( + + ); +}; diff --git a/components/ui/AppSidebar/index.ts b/components/ui/AppSidebar/index.ts new file mode 100644 index 0000000..9fa8a97 --- /dev/null +++ b/components/ui/AppSidebar/index.ts @@ -0,0 +1 @@ +export {AppSidebar} from './AppSidebar'; diff --git a/components/ui/Icon/Icon.stories.tsx b/components/ui/Icon/Icon.stories.tsx new file mode 100644 index 0000000..4323df8 --- /dev/null +++ b/components/ui/Icon/Icon.stories.tsx @@ -0,0 +1,76 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {Icon} from './Icon'; +import type {IconName} from './Icon'; + +const meta: Meta = { + title: 'UI/Icon', + component: Icon, + parameters: {layout: 'centered'}, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + name: 'sparkles', + }, +}; + +const ALL_ICON_NAMES: IconName[] = [ + 'refresh', + 'arrow_right', + 'sparkles', + 'check', + 'eye', + 'download', + 'filter', + 'sort', + 'plus', + 'copy', + 'edit', + 'trash', + 'home', +]; + +export const AllIcons: Story = { + render: () => ( +
+ {ALL_ICON_NAMES.map((name) => ( +
+ + {name} +
+ ))} +
+ ), +}; + +export const CustomSize: Story = { + args: { + name: 'check', + size: 32, + }, +}; + +export const WithColor: Story = { + args: { + name: 'refresh', + className: 'text-green-300', + }, +}; diff --git a/components/ui/Icon/Icon.test.tsx b/components/ui/Icon/Icon.test.tsx new file mode 100644 index 0000000..0e4962c --- /dev/null +++ b/components/ui/Icon/Icon.test.tsx @@ -0,0 +1,46 @@ +import {render} from '@testing-library/react'; +import {describe, expect, it} from 'vitest'; + +import {Icon} from './Icon'; + +describe('Icon', () => { + it('지정한 name의 SVG를 렌더링한다', () => { + // Arrange & Act + const {container} = render(); + + // Assert + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + }); + + it('기본 size 20이 적용된다', () => { + // Arrange & Act + const {container} = render(); + + // Assert + const svg = container.querySelector('svg'); + expect(svg?.getAttribute('width')).toBe('20'); + expect(svg?.getAttribute('height')).toBe('20'); + }); + + it('커스텀 size가 적용된다', () => { + // Arrange & Act + const {container} = render(); + + // Assert + const svg = container.querySelector('svg'); + expect(svg?.getAttribute('width')).toBe('32'); + expect(svg?.getAttribute('height')).toBe('32'); + }); + + it('className prop이 SVG 요소에 전달된다', () => { + // Arrange & Act + const {container} = render( + + ); + + // Assert + const svg = container.querySelector('svg'); + expect(svg?.classList.contains('text-green-300')).toBe(true); + }); +}); diff --git a/components/ui/Icon/Icon.tsx b/components/ui/Icon/Icon.tsx new file mode 100644 index 0000000..bbdcf64 --- /dev/null +++ b/components/ui/Icon/Icon.tsx @@ -0,0 +1,65 @@ +export type IconName = + | 'refresh' + | 'arrow_right' + | 'sparkles' + | 'check' + | 'eye' + | 'download' + | 'filter' + | 'sort' + | 'plus' + | 'copy' + | 'edit' + | 'trash' + | 'home'; + +interface IconProps { + name: IconName; + size?: number; + className?: string; + 'aria-hidden'?: boolean; +} + +const ICON_PATHS: Record = { + refresh: + 'M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99', + arrow_right: 'M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3', + sparkles: + 'M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456Z', + check: 'm4.5 12.75 6 6 9-13.5', + eye: 'M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z', + download: + 'M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3', + filter: + 'M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z', + sort: 'M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5', + plus: 'M12 4.5v15m7.5-7.5h-15', + copy: 'M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75', + edit: 'm16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10', + trash: + 'm14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0', + home: 'm2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25', +}; + +export const Icon = ({ + name, + size = 20, + className, + 'aria-hidden': ariaHidden = true, +}: IconProps) => { + return ( + + + + ); +}; diff --git a/components/ui/Icon/index.ts b/components/ui/Icon/index.ts new file mode 100644 index 0000000..e7642d7 --- /dev/null +++ b/components/ui/Icon/index.ts @@ -0,0 +1,2 @@ +export {Icon} from './Icon'; +export type {IconName} from './Icon'; diff --git a/components/ui/PageTopBar/PageTopBar.stories.tsx b/components/ui/PageTopBar/PageTopBar.stories.tsx new file mode 100644 index 0000000..45d9a69 --- /dev/null +++ b/components/ui/PageTopBar/PageTopBar.stories.tsx @@ -0,0 +1,48 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {PageTopBar} from './PageTopBar'; + +const meta: Meta = { + title: 'UI/PageTopBar', + component: PageTopBar, + parameters: {layout: 'fullscreen'}, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const TitleOnly: Story = { + args: { + title: '자기소개서 목록', + }, +}; + +export const WithBreadcrumb: Story = { + args: { + title: '경험 매칭', + breadcrumb: ['카카오', '프론트엔드', '매칭'], + }, +}; + +export const WithActions: Story = { + args: { + title: '자기소개서 목록', + actions: , + }, +}; + +export const FullEditor: Story = { + args: { + title: '카카오 · 프론트엔드 개발자', + breadcrumb: ['자기소개서', '카카오'], + actions: ( + <> + 자동 저장됨 + + + + + ), + }, +}; diff --git a/components/ui/PageTopBar/PageTopBar.test.tsx b/components/ui/PageTopBar/PageTopBar.test.tsx new file mode 100644 index 0000000..615bc8e --- /dev/null +++ b/components/ui/PageTopBar/PageTopBar.test.tsx @@ -0,0 +1,39 @@ +import {render, screen} from '@testing-library/react'; +import {describe, expect, it} from 'vitest'; + +import {PageTopBar} from './PageTopBar'; + +describe('PageTopBar', () => { + it('title이 렌더링된다', () => { + // Arrange & Act + render(); + + // Assert + expect(screen.getByText('경험 매칭')).toBeInTheDocument(); + }); + + it('breadcrumb 항목들이 순서대로 렌더링된다', () => { + // Arrange & Act + render(); + + // Assert + expect(screen.getByText('카카오')).toBeInTheDocument(); + expect(screen.getByText('매칭')).toBeInTheDocument(); + }); + + it('actions 슬롯이 렌더링된다', () => { + // Arrange & Act + render(내보내기} />); + + // Assert + expect(screen.getByRole('button', {name: '내보내기'})).toBeInTheDocument(); + }); + + it('breadcrumb 없이도 title만 표시된다', () => { + // Arrange & Act + render(); + + // Assert + expect(screen.getByText('자소서 목록')).toBeInTheDocument(); + }); +}); diff --git a/components/ui/PageTopBar/PageTopBar.tsx b/components/ui/PageTopBar/PageTopBar.tsx new file mode 100644 index 0000000..c4e47f7 --- /dev/null +++ b/components/ui/PageTopBar/PageTopBar.tsx @@ -0,0 +1,32 @@ +interface PageTopBarProps { + title: string; + breadcrumb?: string[]; + actions?: React.ReactNode; +} + +export const PageTopBar = ({title, breadcrumb, actions}: PageTopBarProps) => { + return ( +
+
+ {breadcrumb && breadcrumb.length > 0 && ( + + )} +

{title}

+
+ {actions &&
{actions}
} +
+ ); +}; diff --git a/components/ui/PageTopBar/index.ts b/components/ui/PageTopBar/index.ts new file mode 100644 index 0000000..08422ec --- /dev/null +++ b/components/ui/PageTopBar/index.ts @@ -0,0 +1 @@ +export {PageTopBar} from './PageTopBar'; diff --git a/components/ui/ScoreRing/ScoreRing.stories.tsx b/components/ui/ScoreRing/ScoreRing.stories.tsx new file mode 100644 index 0000000..b076af0 --- /dev/null +++ b/components/ui/ScoreRing/ScoreRing.stories.tsx @@ -0,0 +1,44 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {ScoreRing} from './ScoreRing'; + +const meta: Meta = { + title: 'UI/ScoreRing', + component: ScoreRing, + parameters: {layout: 'centered'}, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const High: Story = { + args: { + value: 91, + }, +}; + +export const Mid: Story = { + args: { + value: 72, + }, +}; + +export const Low: Story = { + args: { + value: 45, + }, +}; + +export const Zero: Story = { + args: { + value: 0, + }, +}; + +export const Large: Story = { + args: { + value: 88, + size: 120, + }, +}; diff --git a/components/ui/ScoreRing/ScoreRing.test.tsx b/components/ui/ScoreRing/ScoreRing.test.tsx new file mode 100644 index 0000000..dab0b32 --- /dev/null +++ b/components/ui/ScoreRing/ScoreRing.test.tsx @@ -0,0 +1,42 @@ +import {render, screen} from '@testing-library/react'; +import {describe, expect, it} from 'vitest'; + +import {ScoreRing} from './ScoreRing'; + +describe('ScoreRing', () => { + it('점수 숫자가 렌더링된다', () => { + // Arrange & Act + render(); + + // Assert + expect(screen.getByText('91')).toBeInTheDocument(); + }); + + it('0점도 올바르게 렌더링된다', () => { + // Arrange & Act + render(); + + // Assert + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('기본 size 80이 적용된다', () => { + // Arrange & Act + const {container} = render(); + + // Assert + const svg = container.querySelector('svg'); + expect(svg?.getAttribute('width')).toBe('80'); + expect(svg?.getAttribute('height')).toBe('80'); + }); + + it('커스텀 size가 적용된다', () => { + // Arrange & Act + const {container} = render(); + + // Assert + const svg = container.querySelector('svg'); + expect(svg?.getAttribute('width')).toBe('120'); + expect(svg?.getAttribute('height')).toBe('120'); + }); +}); diff --git a/components/ui/ScoreRing/ScoreRing.tsx b/components/ui/ScoreRing/ScoreRing.tsx new file mode 100644 index 0000000..4bc2610 --- /dev/null +++ b/components/ui/ScoreRing/ScoreRing.tsx @@ -0,0 +1,68 @@ +interface ScoreRingProps { + value: number; // 0–100 정수 + size?: number; // SVG 너비/높이 px, 기본값: 80 + strokeWidth?: number; // 기본값: 6 + className?: string; +} + +const getScoreColor = (value: number): string => { + if (value >= 80) return 'var(--green-300)'; + if (value >= 60) return 'var(--warn)'; + return 'var(--text-4)'; +}; + +export const ScoreRing = ({ + value, + size = 80, + strokeWidth = 6, + className, +}: ScoreRingProps) => { + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const dashoffset = circumference * (1 - value / 100); + const center = size / 2; + const scoreColor = getScoreColor(value); + + return ( + + {/* 배경 트랙 */} + + {/* 진행 바 */} + + {/* 점수 텍스트 */} + + {value} + + + ); +}; diff --git a/components/ui/ScoreRing/index.ts b/components/ui/ScoreRing/index.ts new file mode 100644 index 0000000..a4bb859 --- /dev/null +++ b/components/ui/ScoreRing/index.ts @@ -0,0 +1 @@ +export {ScoreRing} from './ScoreRing'; From 1ba180fd0746b77bbbe653f5d51e4092757017fb Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 20 May 2026 11:33:49 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20matching=C2=B7cover-letter=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MatchResultCard: 문항별 AI 매칭 결과 카드 (ScoreRing + 키워드 칩 + Primary/Alt 경험 2열) '다른 경험으로' 클릭 시 selectedExpId 순환 (useState) - CoverLetterCard: 저장된 자소서 카드 (진행률 동적 계산, 완성/작성중 배지) - AiChatbot: 정적 AI 챗봇 사이드 패널 (isThinking 애니메이션 포함) - 각 컴포넌트 테스트(31개) 및 Storybook 스토리 작성 --- .../AiChatbot/AiChatbot.stories.tsx | 44 ++++++ .../cover-letter/AiChatbot/AiChatbot.test.tsx | 37 +++++ .../cover-letter/AiChatbot/AiChatbot.tsx | 102 +++++++++++++ components/cover-letter/AiChatbot/index.ts | 1 + .../CoverLetterCard.stories.tsx | 80 ++++++++++ .../CoverLetterCard/CoverLetterCard.test.tsx | 112 ++++++++++++++ .../CoverLetterCard/CoverLetterCard.tsx | 142 ++++++++++++++++++ .../cover-letter/CoverLetterCard/index.ts | 1 + components/cover-letter/index.ts | 2 + .../MatchResultCard.stories.tsx | 62 ++++++++ .../MatchResultCard/MatchResultCard.test.tsx | 133 ++++++++++++++++ .../MatchResultCard/MatchResultCard.tsx | 130 ++++++++++++++++ components/matching/MatchResultCard/index.ts | 1 + components/matching/index.ts | 1 + 14 files changed, 848 insertions(+) create mode 100644 components/cover-letter/AiChatbot/AiChatbot.stories.tsx create mode 100644 components/cover-letter/AiChatbot/AiChatbot.test.tsx create mode 100644 components/cover-letter/AiChatbot/AiChatbot.tsx create mode 100644 components/cover-letter/AiChatbot/index.ts create mode 100644 components/cover-letter/CoverLetterCard/CoverLetterCard.stories.tsx create mode 100644 components/cover-letter/CoverLetterCard/CoverLetterCard.test.tsx create mode 100644 components/cover-letter/CoverLetterCard/CoverLetterCard.tsx create mode 100644 components/cover-letter/CoverLetterCard/index.ts create mode 100644 components/cover-letter/index.ts create mode 100644 components/matching/MatchResultCard/MatchResultCard.stories.tsx create mode 100644 components/matching/MatchResultCard/MatchResultCard.test.tsx create mode 100644 components/matching/MatchResultCard/MatchResultCard.tsx create mode 100644 components/matching/MatchResultCard/index.ts create mode 100644 components/matching/index.ts diff --git a/components/cover-letter/AiChatbot/AiChatbot.stories.tsx b/components/cover-letter/AiChatbot/AiChatbot.stories.tsx new file mode 100644 index 0000000..893157e --- /dev/null +++ b/components/cover-letter/AiChatbot/AiChatbot.stories.tsx @@ -0,0 +1,44 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {AiChatbot} from './AiChatbot'; + +const meta: Meta = { + title: 'CoverLetter/AiChatbot', + component: AiChatbot, + parameters: {layout: 'centered'}, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Thinking: Story = { + args: { + isThinking: true, + }, +}; + +export const EmptyChat: Story = { + args: { + messages: [], + }, +}; + +export const UserHeavy: Story = { + args: { + messages: [ + {role: 'ai', content: '안녕하세요! 무엇을 도와드릴까요?'}, + {role: 'user', content: '지원 동기를 써줘.'}, + {role: 'ai', content: '네, 작성해드릴게요!'}, + {role: 'user', content: '좀 더 구체적으로 부탁해.'}, + {role: 'ai', content: '프로젝트 경험을 중심으로 구체화했습니다.'}, + {role: 'user', content: '좋아, 이 내용으로 할게.'}, + {role: 'user', content: '2번 문항도 부탁해.'}, + {role: 'ai', content: '2번 문항 초안을 작성해드릴게요.'}, + ], + }, +}; diff --git a/components/cover-letter/AiChatbot/AiChatbot.test.tsx b/components/cover-letter/AiChatbot/AiChatbot.test.tsx new file mode 100644 index 0000000..e03cc37 --- /dev/null +++ b/components/cover-letter/AiChatbot/AiChatbot.test.tsx @@ -0,0 +1,37 @@ +import {render, screen} from '@testing-library/react'; +import {describe, expect, it} from 'vitest'; + +import {AiChatbot} from './AiChatbot'; + +describe('AiChatbot', () => { + it('기본 Mock 메시지들이 렌더링된다', () => { + // Arrange & Act + render(); + + // Assert + expect( + screen.getByText( + '안녕하세요! 카카오 프론트엔드 자기소개서 작성을 도와드릴게요.' + ) + ).toBeInTheDocument(); + }); + + it('커스텀 messages가 렌더링된다', () => { + // Arrange + const messages = [{role: 'ai' as const, content: '테스트'}]; + + // Act + render(); + + // Assert + expect(screen.getByText('테스트')).toBeInTheDocument(); + }); + + it('isThinking=true이면 AI 타이핑 인디케이터가 렌더링된다', () => { + // Arrange & Act + const {container} = render(); + + // Assert + expect(container.querySelector('.ai-thinking')).not.toBeNull(); + }); +}); diff --git a/components/cover-letter/AiChatbot/AiChatbot.tsx b/components/cover-letter/AiChatbot/AiChatbot.tsx new file mode 100644 index 0000000..7db561b --- /dev/null +++ b/components/cover-letter/AiChatbot/AiChatbot.tsx @@ -0,0 +1,102 @@ +import {Icon} from '@/components/ui/Icon/Icon'; + +interface ChatMessage { + role: 'ai' | 'user'; + content: string; +} + +interface AiChatbotProps { + messages?: ChatMessage[]; + isThinking?: boolean; +} + +const MOCK_CHAT_MESSAGES: ChatMessage[] = [ + { + role: 'ai', + content: '안녕하세요! 카카오 프론트엔드 자기소개서 작성을 도와드릴게요.', + }, + { + role: 'ai', + content: + '1번 문항에서 자소서 자동화 프로젝트 경험이 매칭됐어요. 이 경험을 중심으로 작성해볼까요?', + }, + {role: 'user', content: '네, 좋아요. 초안 써줄 수 있어요?'}, + {role: 'ai', content: '물론이죠! 아래 초안을 참고해보세요.'}, +]; + +export const AiChatbot = ({ + messages = MOCK_CHAT_MESSAGES, + isThinking = false, +}: AiChatbotProps) => { + return ( + + ); +}; diff --git a/components/cover-letter/AiChatbot/index.ts b/components/cover-letter/AiChatbot/index.ts new file mode 100644 index 0000000..5b4f4dc --- /dev/null +++ b/components/cover-letter/AiChatbot/index.ts @@ -0,0 +1 @@ +export {AiChatbot} from './AiChatbot'; diff --git a/components/cover-letter/CoverLetterCard/CoverLetterCard.stories.tsx b/components/cover-letter/CoverLetterCard/CoverLetterCard.stories.tsx new file mode 100644 index 0000000..0f73465 --- /dev/null +++ b/components/cover-letter/CoverLetterCard/CoverLetterCard.stories.tsx @@ -0,0 +1,80 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {MOCK_COVER_LETTERS} from '@/lib/mock/cover-letter.mock'; + +import {CoverLetterCard} from './CoverLetterCard'; + +const meta: Meta = { + title: 'CoverLetter/CoverLetterCard', + component: CoverLetterCard, + parameters: {layout: 'centered'}, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Complete: Story = { + args: { + coverLetter: { + ...MOCK_COVER_LETTERS[0], + questions: [ + { + id: 'q-1', + question: '지원 동기를 작성해주세요.', + answer: '완성된 답변입니다.', + recommendedExperienceIds: ['exp-1'], + maxLength: 500, + }, + ], + }, + onCopy: (id) => alert(`복사: ${id}`), + onDelete: (id) => alert(`삭제: ${id}`), + onExport: (id) => alert(`내보내기: ${id}`), + }, +}; + +export const InProgress: Story = { + args: { + coverLetter: { + ...MOCK_COVER_LETTERS[0], + questions: [ + { + id: 'q-1', + question: '지원 동기를 작성해주세요.', + answer: '작성 중인 답변입니다.', + recommendedExperienceIds: ['exp-1'], + maxLength: 500, + }, + { + id: 'q-2', + question: '팀 프로젝트 경험을 서술하세요.', + answer: '', + recommendedExperienceIds: [], + maxLength: 700, + }, + ], + }, + onCopy: (id) => alert(`복사: ${id}`), + onDelete: (id) => alert(`삭제: ${id}`), + onExport: (id) => alert(`내보내기: ${id}`), + }, +}; + +export const NotStarted: Story = { + args: { + coverLetter: MOCK_COVER_LETTERS[0], + onCopy: (id) => alert(`복사: ${id}`), + onDelete: (id) => alert(`삭제: ${id}`), + onExport: (id) => alert(`내보내기: ${id}`), + }, +}; + +export const NoQuestions: Story = { + args: { + coverLetter: MOCK_COVER_LETTERS[2], // cl-3, questions: [] + onCopy: (id) => alert(`복사: ${id}`), + onDelete: (id) => alert(`삭제: ${id}`), + onExport: (id) => alert(`내보내기: ${id}`), + }, +}; diff --git a/components/cover-letter/CoverLetterCard/CoverLetterCard.test.tsx b/components/cover-letter/CoverLetterCard/CoverLetterCard.test.tsx new file mode 100644 index 0000000..03b05b4 --- /dev/null +++ b/components/cover-letter/CoverLetterCard/CoverLetterCard.test.tsx @@ -0,0 +1,112 @@ +import {render, screen, fireEvent} from '@testing-library/react'; +import {describe, expect, it, vi} from 'vitest'; + +import {MOCK_COVER_LETTERS} from '@/lib/mock/cover-letter.mock'; + +import {CoverLetterCard} from './CoverLetterCard'; + +const baseCoverLetter = MOCK_COVER_LETTERS[0]; // cl-1, 카카오, 문항 1개 answer 없음 + +describe('CoverLetterCard', () => { + it('회사명과 직무명이 렌더링된다', () => { + // Arrange & Act + render(); + + // Assert + expect(screen.getByText('카카오')).toBeInTheDocument(); + expect(screen.getByText('프론트엔드 개발자')).toBeInTheDocument(); + }); + + it('진행률이 계산되어 표시된다', () => { + // Arrange — 문항 2개 중 1개 answer 있음 + const coverLetter = { + ...baseCoverLetter, + questions: [ + { + id: 'q-a', + question: '질문1', + answer: '답변 있음', + recommendedExperienceIds: [], + }, + { + id: 'q-b', + question: '질문2', + answer: '', + recommendedExperienceIds: [], + }, + ], + }; + + // Act + render(); + + // Assert + expect(screen.getByText('50%')).toBeInTheDocument(); + }); + + it('100% 완성 시 "완성" 배지가 표시된다', () => { + // Arrange — 모든 문항 answer 있음 + const coverLetter = { + ...baseCoverLetter, + questions: [ + { + id: 'q-a', + question: '질문1', + answer: '답변 완성', + recommendedExperienceIds: [], + }, + ], + }; + + // Act + render(); + + // Assert + expect(screen.getByText('완성')).toBeInTheDocument(); + }); + + it('미완성 시 "작성중" 배지가 표시된다', () => { + // Arrange — answer가 하나도 없음 (baseCoverLetter의 answer: '') + render(); + + // Assert + expect(screen.getByText('작성중')).toBeInTheDocument(); + }); + + it('카드 본체가 편집 페이지 링크를 갖는다', () => { + // Arrange & Act + render(); + + // Assert — href="/cover-letters/cl-1/edit" 링크가 존재 + const links = screen + .getAllByRole('link') + .filter((el) => el.getAttribute('href') === '/cover-letters/cl-1/edit'); + expect(links.length).toBeGreaterThan(0); + }); + + it('onDelete 콜백이 삭제 버튼 클릭 시 호출된다', () => { + // Arrange + const onDelete = vi.fn(); + render( + + ); + + // Act + fireEvent.click(screen.getByRole('button', {name: '삭제'})); + + // Assert + expect(onDelete).toHaveBeenCalledWith('cl-1'); + }); + + it('onCopy 콜백이 복사 버튼 클릭 시 호출된다', () => { + // Arrange + const onCopy = vi.fn(); + render(); + + // Act + fireEvent.click(screen.getByRole('button', {name: '복사'})); + + // Assert + expect(onCopy).toHaveBeenCalledWith('cl-1'); + }); +}); diff --git a/components/cover-letter/CoverLetterCard/CoverLetterCard.tsx b/components/cover-letter/CoverLetterCard/CoverLetterCard.tsx new file mode 100644 index 0000000..25d05c7 --- /dev/null +++ b/components/cover-letter/CoverLetterCard/CoverLetterCard.tsx @@ -0,0 +1,142 @@ +'use client'; + +import Link from 'next/link'; + +import type {CoverLetter} from '@/types/cover-letter'; + +import {Icon} from '@/components/ui/Icon/Icon'; + +interface CoverLetterCardProps { + coverLetter: CoverLetter; + onCopy?: (id: string) => void; + onDelete?: (id: string) => void; + onExport?: (id: string) => void; +} + +const COMPANY_COLORS = [ + 'oklch(55% 0.18 280)', + 'oklch(58% 0.21 155)', + 'oklch(60% 0.18 25)', + 'oklch(62% 0.16 200)', + 'oklch(57% 0.2 60)', + 'oklch(56% 0.19 320)', +]; + +const getCompanyColor = (company: string): string => { + const code = company.charCodeAt(0); + return COMPANY_COLORS[code % COMPANY_COLORS.length]; +}; + +const formatDate = (iso: string): string => { + const date = new Date(iso); + return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`; +}; + +export const CoverLetterCard = ({ + coverLetter, + onCopy, + onDelete, + onExport, +}: CoverLetterCardProps) => { + const {id, company, jobTitle, questions, updatedAt} = coverLetter; + + const progress = + questions.length === 0 + ? 0 + : Math.round( + (questions.filter((q) => q.answer.length > 0).length / + questions.length) * + 100 + ); + + const isComplete = progress === 100; + const companyInitial = company.slice(0, 1); + const companyColor = getCompanyColor(company); + + return ( +
+ {/* 카드 본체 — 편집 페이지 링크 */} + + {/* 상단: 회사 아이콘 + 이름 + 직무 */} +
+ +
+

+ {company} +

+

{jobTitle}

+
+ {/* 완성/작성중 배지 */} +
+ {isComplete ? ( + 완성 + ) : ( + 작성중 + )} +
+
+ + {/* 진행률 바 */} +
+
+ 진행률 + {progress}% +
+
+
+
+
+ + + {/* 하단: 날짜 + 액션 버튼 */} +
+ 수정일 {formatDate(updatedAt)} +
+ {onCopy && ( + + )} + {onExport && ( + + )} + + + + {onDelete && ( + + )} +
+
+
+ ); +}; diff --git a/components/cover-letter/CoverLetterCard/index.ts b/components/cover-letter/CoverLetterCard/index.ts new file mode 100644 index 0000000..0d707db --- /dev/null +++ b/components/cover-letter/CoverLetterCard/index.ts @@ -0,0 +1 @@ +export {CoverLetterCard} from './CoverLetterCard'; diff --git a/components/cover-letter/index.ts b/components/cover-letter/index.ts new file mode 100644 index 0000000..bc0d365 --- /dev/null +++ b/components/cover-letter/index.ts @@ -0,0 +1,2 @@ +export {CoverLetterCard} from './CoverLetterCard/CoverLetterCard'; +export {AiChatbot} from './AiChatbot/AiChatbot'; diff --git a/components/matching/MatchResultCard/MatchResultCard.stories.tsx b/components/matching/MatchResultCard/MatchResultCard.stories.tsx new file mode 100644 index 0000000..ab6399a --- /dev/null +++ b/components/matching/MatchResultCard/MatchResultCard.stories.tsx @@ -0,0 +1,62 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {MOCK_EXPERIENCES} from '@/lib/mock/experience.mock'; +import {MOCK_JOB_POSTINGS} from '@/lib/mock/job.mock'; +import {MOCK_QUESTION_MATCHES} from '@/lib/mock/matching.mock'; + +import {MatchResultCard} from './MatchResultCard'; + +const meta: Meta = { + title: 'Matching/MatchResultCard', + component: MatchResultCard, + parameters: {layout: 'padded'}, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const question = MOCK_JOB_POSTINGS[0].questions![0]; +const allExperiences = MOCK_EXPERIENCES; + +export const HighScore: Story = { + args: { + question, + match: MOCK_QUESTION_MATCHES[0], // score: 91 + experiences: allExperiences.filter((e) => + MOCK_QUESTION_MATCHES[0].matched.includes(e.id) + ), + }, +}; + +export const MidScore: Story = { + args: { + question: MOCK_JOB_POSTINGS[0].questions![1], + match: MOCK_QUESTION_MATCHES[1], // score: 78 + experiences: allExperiences.filter((e) => + MOCK_QUESTION_MATCHES[1].matched.includes(e.id) + ), + }, +}; + +export const SingleExperience: Story = { + args: { + question: MOCK_JOB_POSTINGS[0].questions![2], + match: { + ...MOCK_QUESTION_MATCHES[2], + matched: ['exp-1'], + }, + experiences: allExperiences.filter((e) => e.id === 'exp-1'), + }, +}; + +export const MultipleAlts: Story = { + args: { + question, + match: { + ...MOCK_QUESTION_MATCHES[0], + matched: ['exp-1', 'exp-2', 'exp-3'], + }, + experiences: allExperiences, + }, +}; diff --git a/components/matching/MatchResultCard/MatchResultCard.test.tsx b/components/matching/MatchResultCard/MatchResultCard.test.tsx new file mode 100644 index 0000000..5bfe4e0 --- /dev/null +++ b/components/matching/MatchResultCard/MatchResultCard.test.tsx @@ -0,0 +1,133 @@ +import {render, screen, fireEvent} from '@testing-library/react'; +import {describe, expect, it, vi} from 'vitest'; + +import {MOCK_EXPERIENCES} from '@/lib/mock/experience.mock'; +import {MOCK_QUESTION_MATCHES} from '@/lib/mock/matching.mock'; +import {MOCK_JOB_POSTINGS} from '@/lib/mock/job.mock'; + +import {MatchResultCard} from './MatchResultCard'; + +const mockQuestion = MOCK_JOB_POSTINGS[0].questions![0]; +const mockMatch = MOCK_QUESTION_MATCHES[0]; // qId: 'q-1', primary: 'exp-1', matched: ['exp-1', 'exp-2'], score: 91 +const matchedExperiences = MOCK_EXPERIENCES.filter((e) => + mockMatch.matched.includes(e.id) +); + +describe('MatchResultCard', () => { + it('문항 텍스트가 렌더링된다', () => { + // Arrange & Act + render( + + ); + + // Assert + expect(screen.getByText(mockQuestion.text)).toBeInTheDocument(); + }); + + it('ScoreRing에 score가 전달된다', () => { + // Arrange & Act + render( + + ); + + // Assert + expect(screen.getByText('91')).toBeInTheDocument(); + }); + + it('AI 추천 이유가 렌더링된다', () => { + // Arrange + const match = { + ...mockMatch, + reason: '프로젝트 경험이 직결됩니다.', + }; + + // Act + render( + + ); + + // Assert + expect(screen.getByText('프로젝트 경험이 직결됩니다.')).toBeInTheDocument(); + }); + + it('키워드 칩이 렌더링된다', () => { + // Arrange + const match = { + ...mockMatch, + keywords: ['AI', 'Next.js'], + }; + + // Act + render( + + ); + + // Assert + expect(screen.getByText('AI')).toBeInTheDocument(); + expect(screen.getByText('Next.js')).toBeInTheDocument(); + }); + + it('"다른 경험으로" 클릭 시 selectedExpId가 변경된다', () => { + // Arrange + const exp1 = MOCK_EXPERIENCES.find((e) => e.id === 'exp-1')!; + const exp2 = MOCK_EXPERIENCES.find((e) => e.id === 'exp-2')!; + + render( + + ); + + // exp-2 제목이 초기에 alt로 보임을 확인 + expect(screen.getByText(exp2.title)).toBeInTheDocument(); + + // Act + const switchBtn = screen.getByRole('button', {name: '다른 경험으로'}); + fireEvent.click(switchBtn); + + // Assert — alt가 exp-2 하나뿐이므로 순환 후 여전히 exp-2 + expect(screen.getByText(exp2.title)).toBeInTheDocument(); + }); + + it('"이 조합으로 작성" 클릭 시 onSelectExperience가 호출된다', () => { + // Arrange + const onSelectExperience = vi.fn(); + + render( + + ); + + // Act + const writeBtn = screen.getByRole('button', {name: '이 조합으로 작성'}); + fireEvent.click(writeBtn); + + // Assert + expect(onSelectExperience).toHaveBeenCalledWith( + mockMatch.qId, + mockMatch.primary + ); + }); +}); diff --git a/components/matching/MatchResultCard/MatchResultCard.tsx b/components/matching/MatchResultCard/MatchResultCard.tsx new file mode 100644 index 0000000..ff1679c --- /dev/null +++ b/components/matching/MatchResultCard/MatchResultCard.tsx @@ -0,0 +1,130 @@ +'use client'; + +import {useState} from 'react'; + +import {ScoreRing} from '@/components/ui/ScoreRing/ScoreRing'; +import type {Experience} from '@/types/experience'; +import {EXPERIENCE_TYPE_LABEL} from '@/types/experience'; +import type {JobQuestion} from '@/types/matching'; +import type {QuestionMatch} from '@/types/matching'; + +interface MatchResultCardProps { + question: JobQuestion; + match: QuestionMatch; + experiences: Experience[]; + onSelectExperience?: (qId: string, expId: string) => void; +} + +interface ExperiencePreviewProps { + experience: Experience; + isPrimary?: boolean; +} + +const ExperiencePreview = ({ + experience, + isPrimary = false, +}: ExperiencePreviewProps) => { + const typeLabel = EXPERIENCE_TYPE_LABEL[experience.experienceType]; + return ( +
+
+ {typeLabel} + {isPrimary && Primary} +
+

+ {experience.title} +

+

{experience.role}

+
+ ); +}; + +export const MatchResultCard = ({ + question, + match, + experiences, + onSelectExperience, +}: MatchResultCardProps) => { + const [selectedExpId, setSelectedExpId] = useState(match.primary); + + const primaryExp = experiences.find((e) => e.id === match.primary); + const altExpIds = match.matched.filter((id) => id !== match.primary); + const currentAltExpId = + selectedExpId !== match.primary ? selectedExpId : altExpIds[0]; + const altExp = experiences.find((e) => e.id === currentAltExpId); + + const handleSwitchExperience = () => { + if (altExpIds.length === 0) return; + const currentIndex = altExpIds.indexOf(selectedExpId); + if (currentIndex === -1) { + setSelectedExpId(altExpIds[0]); + } else { + const nextIndex = (currentIndex + 1) % altExpIds.length; + setSelectedExpId(altExpIds[nextIndex]); + } + }; + + const handleSelectExperience = () => { + onSelectExperience?.(match.qId, selectedExpId); + }; + + return ( +
+ {/* 문항 텍스트 + ScoreRing */} +
+

{question.text}

+ +
+ + {/* AI 추천 이유 */} +
+

추천 이유

+

{match.reason}

+ {/* 키워드 칩 목록 */} +
+ {match.keywords.map((keyword) => ( + + {keyword} + + ))} +
+
+ + {/* Primary / Alt 경험 2열 */} +
+ {primaryExp && ( + + )} + {altExp && } + {!altExp && altExpIds.length === 0 && ( +
+

대안 경험 없음

+
+ )} +
+ + {/* 액션 버튼 */} +
+ + +
+
+ ); +}; diff --git a/components/matching/MatchResultCard/index.ts b/components/matching/MatchResultCard/index.ts new file mode 100644 index 0000000..4779dea --- /dev/null +++ b/components/matching/MatchResultCard/index.ts @@ -0,0 +1 @@ +export {MatchResultCard} from './MatchResultCard'; diff --git a/components/matching/index.ts b/components/matching/index.ts new file mode 100644 index 0000000..167e892 --- /dev/null +++ b/components/matching/index.ts @@ -0,0 +1 @@ +export {MatchResultCard} from './MatchResultCard/MatchResultCard'; From 8da230f0e39a8fdd6b91f687e62e0ba3d907e373 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 20 May 2026 11:34:36 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EC=9E=90=EA=B8=B0=EC=86=8C?= =?UTF-8?q?=EA=B0=9C=EC=84=9C=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=203=EC=A2=85=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/jobs/[id]/matching/page.tsx: 경험 매칭 추천 페이지 STEP 3/5 헤더, 문항별 MatchResultCard 목록 - app/cover-letters/page.tsx: 저장된 자소서 목록 페이지 2열 그리드, 빈 상태 UI 포함 - app/cover-letters/[id]/edit/page.tsx: 자소서 에디터 페이지 문항 탭 스위처, doc-paper 에디터, AI 제안 블록, AiChatbot 사이드패널 - docs/specs/cover-letter-flow-spec.md: 구현 스펙 문서 --- app/cover-letters/[id]/edit/page.tsx | 158 +++++ app/cover-letters/page.tsx | 64 ++ app/jobs/[id]/matching/page.tsx | 87 +++ docs/specs/cover-letter-flow-spec.md | 960 +++++++++++++++++++++++++++ 4 files changed, 1269 insertions(+) create mode 100644 app/cover-letters/[id]/edit/page.tsx create mode 100644 app/cover-letters/page.tsx create mode 100644 app/jobs/[id]/matching/page.tsx create mode 100644 docs/specs/cover-letter-flow-spec.md diff --git a/app/cover-letters/[id]/edit/page.tsx b/app/cover-letters/[id]/edit/page.tsx new file mode 100644 index 0000000..8b83a5e --- /dev/null +++ b/app/cover-letters/[id]/edit/page.tsx @@ -0,0 +1,158 @@ +import Link from 'next/link'; + +import {AppSidebar} from '@/components/ui/AppSidebar/AppSidebar'; +import {PageTopBar} from '@/components/ui/PageTopBar/PageTopBar'; +import {Icon} from '@/components/ui/Icon/Icon'; +import {AiChatbot} from '@/components/cover-letter/AiChatbot/AiChatbot'; +import {getMockCoverLetters} from '@/lib/mock/cover-letter.mock'; +import type {CoverLetterQuestion} from '@/types/cover-letter'; + +interface PageProps { + params: Promise<{id: string}>; +} + +type QuestionStatus = '작성중' | '초안' | '미작성'; + +const getQuestionStatus = (q: CoverLetterQuestion): QuestionStatus => { + if (q.answer.length === 0) return '미작성'; + if (q.maxLength && q.answer.length >= q.maxLength * 0.8) return '작성중'; + return '초안'; +}; + +const STATUS_CHIP: Record = { + 작성중: 'chip chip-sm chip-green', + 초안: 'chip chip-sm chip-mint', + 미작성: 'chip chip-sm chip-outline', +}; + +const AI_SUGGESTION = + '카카오 프론트엔드 직무에 지원하게 된 계기는 사용자 경험을 코드로 구현하는 과정에서 느끼는 보람에서 시작됩니다. 자소서 자동화 프로젝트를 통해 Next.js와 AI API를 결합하여 실질적인 문제를 해결한 경험은 카카오가 추구하는 사용자 중심 기술 혁신과 맞닿아 있습니다.'; + +export default async function CoverLetterEditPage({params}: PageProps) { + const {id} = await params; + + const coverLetter = getMockCoverLetters().find((cl) => cl.id === id); + + if (!coverLetter) { + return ( +
+

자기소개서를 찾을 수 없습니다.

+
+ ); + } + + const {company, jobTitle, questions} = coverLetter; + const activeQuestion = questions[0] ?? null; + const charCount = activeQuestion?.answer.length ?? 0; + const maxLength = activeQuestion?.maxLength ?? 500; + + return ( +
+ +
+ + +
+ 자동 저장됨 + + + + + + } + /> + +
+ {/* 에디터 영역 */} +
+ {/* 문항 탭 스위처 */} + {questions.length > 0 && ( +
+ {questions.map((q, index) => { + const status = getQuestionStatus(q); + const isActive = q.id === activeQuestion?.id; + return ( + + ); + })} +
+ )} + + {activeQuestion ? ( +
+ {/* 문항 텍스트 */} +

{activeQuestion.question}

+ + {/* 답변 영역 */} +

+ {activeQuestion.answer || '여기에 내용을 작성하세요...'} +

+ + {/* AI 제안 블록 */} +
+
+ +

AI 제안

+
+

{AI_SUGGESTION}

+
+ + +
+
+ + {/* 하단 지표 바 */} +
+ + {charCount} / {maxLength}자 + + 매칭률 91% +
+
+ ) : ( +
+

문항이 없습니다.

+ + 새 자소서 작성 + +
+ )} +
+ + {/* AI 챗봇 사이드 패널 */} + +
+
+
+ ); +} diff --git a/app/cover-letters/page.tsx b/app/cover-letters/page.tsx new file mode 100644 index 0000000..48f2afc --- /dev/null +++ b/app/cover-letters/page.tsx @@ -0,0 +1,64 @@ +import Link from 'next/link'; + +import {AppSidebar} from '@/components/ui/AppSidebar/AppSidebar'; +import {PageTopBar} from '@/components/ui/PageTopBar/PageTopBar'; +import {Icon} from '@/components/ui/Icon/Icon'; +import {CoverLetterCard} from '@/components/cover-letter/CoverLetterCard/CoverLetterCard'; +import {getMockCoverLetters} from '@/lib/mock/cover-letter.mock'; + +export default function CoverLettersPage() { + const coverLetters = getMockCoverLetters(); + + return ( +
+ +
+ + + + + 새 자소서 작성 + + + } + /> +
+ {coverLetters.length === 0 ? ( + /* 빈 상태 UI */ +
+
+ +
+
+

아직 작성한 자기소개서가 없어요

+

+ 채용공고를 분석하고 AI와 함께 자기소개서를 작성해보세요. +

+
+ + 첫 자소서 작성하기 + +
+ ) : ( +
+ {coverLetters.map((cl) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/app/jobs/[id]/matching/page.tsx b/app/jobs/[id]/matching/page.tsx new file mode 100644 index 0000000..7a4a37a --- /dev/null +++ b/app/jobs/[id]/matching/page.tsx @@ -0,0 +1,87 @@ +import Link from 'next/link'; + +import {AppSidebar} from '@/components/ui/AppSidebar/AppSidebar'; +import {PageTopBar} from '@/components/ui/PageTopBar/PageTopBar'; +import {MatchResultCard} from '@/components/matching/MatchResultCard/MatchResultCard'; +import {getMockExperiences} from '@/lib/mock/experience.mock'; +import {getMockJobPostings} from '@/lib/mock/job.mock'; +import {getMockQuestionMatches} from '@/lib/mock/matching.mock'; + +interface PageProps { + params: Promise<{id: string}>; +} + +export default async function MatchingPage({params}: PageProps) { + const {id} = await params; + + const jobPosting = getMockJobPostings().find((j) => j.id === id); + const experiences = getMockExperiences(); + const matches = getMockQuestionMatches(); + + if (!jobPosting) { + return ( +
+

채용공고를 찾을 수 없습니다.

+
+ ); + } + + const questions = jobPosting.questions ?? []; + const questionCount = questions.length; + const experienceCount = experiences.length; + + return ( +
+ +
+ + 이 조합으로 작성 + + } + /> +
+ {/* 헤더 */} +
+

STEP 3/5 · 경험 매칭

+

+ {questionCount}개 문항 · {experienceCount}개 경험의 최적 조합 +

+
+ + {/* MatchResultCard 목록 */} +
+ {matches.map((match) => { + const question = questions.find((q) => q.id === match.qId); + if (!question) return null; + const matchedExperiences = experiences.filter((e) => + match.matched.includes(e.id) + ); + return ( + + ); + })} +
+ + {/* 하단 내비게이션 */} +
+ + ← 이전 + + + 자소서 작성 시작 → + +
+
+
+
+ ); +} diff --git a/docs/specs/cover-letter-flow-spec.md b/docs/specs/cover-letter-flow-spec.md new file mode 100644 index 0000000..2767c1e --- /dev/null +++ b/docs/specs/cover-letter-flow-spec.md @@ -0,0 +1,960 @@ +# 자기소개서 플로우 스펙 + +## 개요 + +채용공고에 연결된 경험 매칭 추천 화면, 문항별 자기소개서 에디터 화면, 저장된 자기소개서 목록 화면의 3개 페이지와 이를 구성하는 공통 UI 컴포넌트를 정의한다. 사용자는 경험 매칭 결과를 검토한 뒤 AI 챗봇의 도움을 받아 자기소개서를 작성하고, 완성된 문서를 목록에서 관리할 수 있다. 백엔드 연동 전에는 `lib/mock/` 데이터로 동작한다. + +--- + +## 컴포넌트 계층 + +``` +types/ + matching.ts ← 신규 도메인 타입 + +lib/mock/ + matching.mock.ts ← 신규 Mock 데이터 + +app/ + globals.css ← 유틸 클래스 추가 + jobs/[id]/matching/ + page.tsx ← 경험 매칭 추천 페이지 (서버 컴포넌트) + cover-letters/ + page.tsx ← 저장된 자소서 목록 페이지 (서버 컴포넌트) + [id]/edit/ + page.tsx ← 자소서 에디터 페이지 (서버 컴포넌트) + +components/ + ui/ + AppSidebar/ + AppSidebar.tsx + AppSidebar.test.tsx + AppSidebar.stories.tsx + index.ts + PageTopBar/ + PageTopBar.tsx + PageTopBar.test.tsx + PageTopBar.stories.tsx + index.ts + ScoreRing/ + ScoreRing.tsx + ScoreRing.test.tsx + ScoreRing.stories.tsx + index.ts + Icon/ + Icon.tsx + Icon.test.tsx + Icon.stories.tsx + index.ts + index.ts ← 기존 파일에 export 추가 + matching/ + MatchResultCard/ + MatchResultCard.tsx + MatchResultCard.test.tsx + MatchResultCard.stories.tsx + index.ts + index.ts ← 신규 + cover-letter/ + CoverLetterCard/ + CoverLetterCard.tsx + CoverLetterCard.test.tsx + CoverLetterCard.stories.tsx + index.ts + AiChatbot/ + AiChatbot.tsx + AiChatbot.test.tsx + AiChatbot.stories.tsx + index.ts + index.ts ← 신규 +``` + +--- + +## 신규 타입 정의 + +### `types/matching.ts` + +```typescript +// 채용공고의 문항 (JobPosting.questions[] 요소) +export interface JobQuestion { + id: string; + text: string; // 문항 텍스트 + limit: number; // 글자 수 제한 +} + +// 문항 1개에 대한 AI 매칭 결과 +export interface QuestionMatch { + qId: string; // JobQuestion.id 참조 + primary: string; // 우선 추천 Experience.id + matched: string[]; // 추천 Experience.id 목록 (primary 포함) + score: number; // 0–100 매칭 점수 + keywords: string[]; // 공통 키워드 + reason: string; // AI 추천 이유 한 줄 설명 +} +``` + +### `types/job.ts` 확장 + +기존 `JobPosting` 인터페이스에 아래 필드를 추가한다. + +```typescript +questions?: JobQuestion[]; // 자소서 문항 목록 (채용공고에서 추출) +``` + +--- + +## Mock 데이터 추가 + +### `lib/mock/job.mock.ts` 확장 + +기존 `MOCK_JOB_POSTINGS`의 첫 번째 항목(`job-1`)에 `questions` 필드를 추가한다. + +```typescript +// 추가 예시 +questions: [ + { id: 'q-1', text: '지원 동기를 작성해주세요.', limit: 500 }, + { id: 'q-2', text: '팀 프로젝트에서 갈등을 해결한 경험을 서술하세요.', limit: 700 }, + { id: 'q-3', text: '입사 후 이루고 싶은 목표를 작성해주세요.', limit: 400 }, +], +``` + +### `lib/mock/matching.mock.ts` (신규) + +```typescript +import type {QuestionMatch} from '@/types/matching'; + +export const MOCK_QUESTION_MATCHES: QuestionMatch[] = [ + { + qId: 'q-1', + primary: 'exp-1', + matched: ['exp-1', 'exp-2'], + score: 91, + keywords: ['AI', '자동화', 'Next.js'], + reason: '자소서 자동화 프로젝트 경험이 지원 동기와 직결됩니다.', + }, + { + qId: 'q-2', + primary: 'exp-3', + matched: ['exp-3', 'exp-2'], + score: 78, + keywords: ['협업', '갈등 해결', '커뮤니케이션'], + reason: '동아리 운영진 경험에서 팀 조율 역량이 잘 드러납니다.', + }, + { + qId: 'q-3', + primary: 'exp-1', + matched: ['exp-1'], + score: 84, + keywords: ['프론트엔드', '리더십', '기술 성장'], + reason: '프론트엔드 리드 경험이 목표와 자연스럽게 연결됩니다.', + }, +]; + +export const getMockQuestionMatches = (): QuestionMatch[] => + MOCK_QUESTION_MATCHES; +``` + +### `lib/mock/cover-letter.mock.ts` 확장 + +기존 `MOCK_COVER_LETTERS`에 3개 항목을 추가하여 총 4개로 확장한다. + +```typescript +// 추가 항목 예시 +{ + id: 'cl-2', + jobPostingId: 'job-2', + company: '토스', + jobTitle: 'Product Designer', + questions: [ + { id: 'q-4', question: '토스에 지원한 이유를 작성해주세요.', answer: '', recommendedExperienceIds: ['exp-2'], maxLength: 500 }, + ], + createdAt: '2025-05-12T11:00:00Z', + updatedAt: '2025-05-19T09:00:00Z', +}, +{ + id: 'cl-3', + jobPostingId: 'job-1', + company: '네이버', + jobTitle: '서버 개발자', + questions: [], + createdAt: '2025-05-01T08:00:00Z', + updatedAt: '2025-05-01T08:00:00Z', +}, +{ + id: 'cl-4', + jobPostingId: 'job-1', + company: '라인', + jobTitle: '프론트엔드 개발자', + questions: [ + { id: 'q-5', question: '문제 해결 경험을 서술하세요.', answer: '초안 작성 중...', recommendedExperienceIds: ['exp-1', 'exp-3'], maxLength: 600 }, + ], + createdAt: '2025-04-28T15:00:00Z', + updatedAt: '2025-05-20T10:30:00Z', +}, +``` + +--- + +## CSS 유틸리티 추가 (`app/globals.css`) + +기존 `:root` 변수 블록에 추가할 CSS 변수: + +```css +--bg-canvas: oklch(10% 0.012 255); /* 최하단 캔버스 배경 */ +--bg-elevated: oklch(20% 0.02 255); /* 떠 있는 패널/모달 배경 */ +--danger: oklch(62% 0.22 25); /* 에러/삭제 강조 */ +--r-sm: 0.5rem; /* 8px 소형 radius */ +``` + +`@layer utilities`에 추가할 유틸 클래스: + +```css +/* ── 레이아웃 ── */ +.row { + display: flex; + align-items: center; +} +.row-between { + display: flex; + align-items: center; + justify-content: space-between; +} +.col { + display: flex; + flex-direction: column; +} +.grid-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} + +/* ── 카드 ── */ +.card { + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + padding: 1.5rem; +} +.card-glow { + box-shadow: + var(--shadow-card), + 0 0 0 1px var(--green-300); +} + +/* ── 배지/칩 ── */ +.chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + border-radius: 999px; + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + font-weight: 500; +} +.chip-sm { + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; +} +.chip-green { + background: oklch(35% 0.12 155 / 0.25); + color: var(--green-200); +} +.chip-mint { + background: oklch(35% 0.1 170 / 0.2); + color: oklch(78% 0.12 170); +} +.chip-outline { + background: transparent; + border: 1px solid var(--border-default); + color: var(--text-3); +} +.chip-glow { + background: oklch(35% 0.12 155 / 0.35); + color: var(--green-200); + box-shadow: 0 0 8px oklch(35% 0.12 155 / 0.4); +} + +/* ── 버튼 ── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: var(--radius-btn); + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + transition: opacity 0.15s; + cursor: pointer; +} +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; +} +.btn-primary { + background: var(--green-300); + color: oklch(10% 0.01 155); +} +.btn-primary:hover { + background: var(--green-400); +} +.btn-secondary { + background: var(--bg-subtle); + color: var(--text-2); + border: 1px solid var(--border-default); +} +.btn-secondary:hover { + background: var(--border-subtle); +} +.btn-ghost { + background: transparent; + color: var(--text-3); +} +.btn-ghost:hover { + color: var(--text-2); + background: var(--bg-subtle); +} + +/* ── 타이포그래피 ── */ +.eyebrow { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--green-200); +} +.h-display { + font-size: clamp(1.75rem, 4vw, 2.5rem); + font-weight: 700; + line-height: 1.2; + color: var(--text-1); +} +.h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-1); +} +.body { + font-size: 0.9375rem; + line-height: 1.6; + color: var(--text-2); +} +.caption { + font-size: 0.8125rem; + color: var(--text-3); +} +.grad-text { + background: var(--grad-text); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ── 배경 ── */ +.bg-canvas { + background: var(--bg-canvas); +} +.bg-elevated { + background: var(--bg-elevated); +} + +/* ── 에디터 문서 영역 ── */ +.doc-paper { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-card); + padding: 2rem; + min-height: 60vh; +} +.doc-question { + font-size: 1rem; + font-weight: 600; + color: var(--text-1); + margin-bottom: 1rem; +} +.doc-paragraph { + font-size: 0.9375rem; + line-height: 1.8; + color: var(--text-2); + outline: none; +} +.doc-suggest { + background: oklch(35% 0.12 155 / 0.12); + border-left: 2px solid var(--green-300); + padding: 0.75rem 1rem; + border-radius: 0 var(--r-sm) var(--r-sm) 0; + margin-top: 0.75rem; +} + +/* ── AI 상태 ── */ +.ai-thinking { + display: flex; + gap: 4px; + align-items: center; +} +.ai-thinking span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--green-200); + animation: dot-bounce 1.2s ease-in-out infinite; +} +.ai-thinking span:nth-child(2) { + animation-delay: 0.2s; +} +.ai-thinking span:nth-child(3) { + animation-delay: 0.4s; +} +@keyframes dot-bounce { + 0%, + 80%, + 100% { + transform: scale(0.6); + opacity: 0.4; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +.dot-pulse { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--green-300); + animation: pulse-dot 1.5s ease-in-out infinite; +} +@keyframes pulse-dot { + 0%, + 100% { + opacity: 0.3; + transform: scale(0.8); + } + 50% { + opacity: 1; + transform: scale(1.2); + } +} +``` + +--- + +## 공통 UI 컴포넌트 상세 + +### Icon + +- **파일**: `components/ui/Icon/Icon.tsx` +- **'use client'**: 불필요 (순수 SVG 렌더링) +- **Props**: + + ```typescript + export type IconName = + | 'refresh' + | 'arrow_right' + | 'sparkles' + | 'check' + | 'eye' + | 'download' + | 'filter' + | 'sort' + | 'plus' + | 'copy' + | 'edit' + | 'trash'; + + interface IconProps { + name: IconName; + size?: number; // px 단위, 기본값: 20 + className?: string; // 색상 등 추가 클래스 + 'aria-hidden'?: boolean; // 기본값: true + } + ``` + +- **로컬 State**: 없음 +- **역할**: `IconName`에 따라 해당 SVG path를 인라인으로 렌더링하는 단순 컴포넌트. Lucide React 미설치이므로 `` + ``를 직접 구현한다. `viewBox="0 0 24 24"` 기준, `stroke="currentColor"` 스타일 적용. +- **구현 방식**: `ICON_PATHS` 상수 맵(`Record`)에 각 아이콘의 `d` 속성 값을 보관하고, 렌더링 시 해당 path만 삽입한다. + +--- + +### ScoreRing + +- **파일**: `components/ui/ScoreRing/ScoreRing.tsx` +- **'use client'**: 불필요 (정적 SVG, 값은 prop으로 수신) +- **Props**: + ```typescript + interface ScoreRingProps { + value: number; // 0–100 정수 + size?: number; // SVG 너비/높이 px, 기본값: 80 + strokeWidth?: number; // 기본값: 6 + className?: string; + } + ``` +- **로컬 State**: 없음 +- **역할**: `value`를 0–100 범위로 받아 SVG ``의 `strokeDashoffset`을 계산하여 원형 진행 바를 렌더링한다. 중앙에 점수 숫자를 ``로 표시. 색상은 값 구간에 따라 다름: 80 이상 `--green-300`, 60–79 `--warn`, 60 미만 `--text-4`. +- **계산 공식**: + - `radius = (size - strokeWidth) / 2` + - `circumference = 2 * Math.PI * radius` + - `dashoffset = circumference * (1 - value / 100)` + +--- + +### AppSidebar + +- **파일**: `components/ui/AppSidebar/AppSidebar.tsx` +- **'use client'**: 필요 (`usePathname`으로 현재 경로 감지) +- **Props**: + ```typescript + interface AppSidebarProps { + className?: string; + } + ``` +- **로컬 State**: 없음 (`usePathname` 훅 사용) +- **역할**: 앱 좌측 고정 네비게이션. 로고 + 4개 메뉴(대시보드, 경험, 채용공고, 자소서)를 세로 아이콘 링크로 나열한다. `usePathname()`으로 현재 경로를 감지하여 활성 메뉴를 강조 표시(`chip-green` 배경). `
    ` 의미론적 마크업 사용. +- **내부 상수**: + ```typescript + const NAV_ITEMS = [ + {href: '/dashboard', icon: 'home', label: '대시보드'}, + {href: '/experiences', icon: 'edit', label: '경험'}, + {href: '/jobs', icon: 'filter', label: '채용공고'}, + {href: '/cover-letters', icon: 'sparkles', label: '자소서'}, + ] as const; + ``` + +--- + +### PageTopBar + +- **파일**: `components/ui/PageTopBar/PageTopBar.tsx` +- **'use client'**: 불필요 (정적 마크업) +- **Props**: + ```typescript + interface PageTopBarProps { + title: string; + breadcrumb?: string[]; // ['채용공고', '카카오 프론트엔드', '경험 매칭'] + actions?: React.ReactNode; // 우측 버튼 슬롯 + } + ``` +- **로컬 State**: 없음 +- **역할**: 페이지 상단 바. 좌측에 breadcrumb + 타이틀, 우측에 `actions` 슬롯을 렌더링한다. 에디터/목록/매칭 페이지에서 공통 사용. + +--- + +## 컴포넌트 상세 — matching 도메인 + +### MatchResultCard + +- **파일**: `components/matching/MatchResultCard/MatchResultCard.tsx` +- **'use client'**: 필요 (`useState`로 선택 경험 전환) +- **Props**: + ```typescript + interface MatchResultCardProps { + question: JobQuestion; + match: QuestionMatch; + experiences: Experience[]; // matched ID에 해당하는 경험 객체 목록 + onSelectExperience?: (qId: string, expId: string) => void; + } + ``` +- **로컬 State**: + ```typescript + const [selectedExpId, setSelectedExpId] = useState(match.primary); + ``` +- **역할**: 문항 1개에 대한 AI 매칭 결과를 카드로 표시. 상단에 문항 텍스트와 ScoreRing, 중단에 AI 추천 이유와 키워드 칩, 하단에 Primary/Alt 경험 2열 레이아웃. "다른 경험으로" 버튼 클릭 시 `selectedExpId`를 alternates 중 다음 항목으로 순환한다. "이 조합으로 작성" 버튼은 `onSelectExperience` 콜백 호출. +- **레이아웃 구조**: + ``` +
    +
    ← 문항 텍스트 + ScoreRing +
    ← AI 추천 이유 +

    추천 이유

    +

    {match.reason}

    +
    ← 키워드 칩 목록 + {keywords.map(k => )} +
    ← Primary / Alt 경험 2열 + + +
    ← 액션 버튼 + + + ``` + +--- + +## 컴포넌트 상세 — cover-letter 도메인 + +### CoverLetterCard + +- **파일**: `components/cover-letter/CoverLetterCard/CoverLetterCard.tsx` +- **'use client'**: 불필요 (정적 표시, 링크로 이동) +- **Props**: + ```typescript + interface CoverLetterCardProps { + coverLetter: CoverLetter; + onCopy?: (id: string) => void; + onDelete?: (id: string) => void; + onExport?: (id: string) => void; + } + ``` +- **로컬 State**: 없음 +- **역할**: 저장된 자소서 1건을 카드로 표시. 상단에 회사 이니셜 아이콘(배경색 결정 규칙: 회사명 첫 글자 charCode % 팔레트 길이), 회사명, 직무명. 중단에 완성/작성중 배지, 진행률 바(`questions`에서 `answer.length > 0`인 비율). 하단에 수정 날짜와 액션 버튼(복사, 내보내기, 편집, 삭제). 카드 본체 클릭 시 편집 페이지(`/cover-letters/{id}/edit`)로 이동. +- **진행률 계산**: + ```typescript + const progress = + questions.length === 0 + ? 0 + : Math.round( + (questions.filter((q) => q.answer.length > 0).length / + questions.length) * + 100 + ); + ``` +- **완성 기준**: `progress === 100`이면 "완성" 배지, 그 외 "작성중" 배지. + +--- + +### AiChatbot + +- **파일**: `components/cover-letter/AiChatbot/AiChatbot.tsx` +- **'use client'**: 불필요 (정적 UI, 인터랙션 없음) +- **Props**: + + ```typescript + interface ChatMessage { + role: 'ai' | 'user'; + content: string; + } + + interface AiChatbotProps { + messages?: ChatMessage[]; // 기본값: MOCK_CHAT_MESSAGES (정적) + isThinking?: boolean; // AI 타이핑 애니메이션 표시 여부, 기본값: false + } + ``` + +- **로컬 State**: 없음 (정적 UI) +- **역할**: 에디터 우측 사이드 패널. AI 메시지와 사용자 메시지를 채팅 버블 형태로 나열. `isThinking=true`이면 `.ai-thinking` 애니메이션 바 표시. 하단에 입력창 UI(비활성 상태, 현 단계에서는 인터랙션 없음). 스펙 범위에서는 정적 UI만 구현. +- **내부 상수 (Mock 메시지)**: + ```typescript + const MOCK_CHAT_MESSAGES: ChatMessage[] = [ + { + role: 'ai', + content: '안녕하세요! 카카오 프론트엔드 자기소개서 작성을 도와드릴게요.', + }, + { + role: 'ai', + content: + '1번 문항에서 자소서 자동화 프로젝트 경험이 매칭됐어요. 이 경험을 중심으로 작성해볼까요?', + }, + {role: 'user', content: '네, 좋아요. 초안 써줄 수 있어요?'}, + {role: 'ai', content: '물론이죠! 아래 초안을 참고해보세요.'}, + ]; + ``` + +--- + +## 페이지 상세 + +### 경험 매칭 추천 페이지 + +- **파일**: `app/jobs/[id]/matching/page.tsx` +- **'use client'**: 불필요 (서버 컴포넌트) +- **URL 파라미터**: `{ params: { id: string } }` — 채용공고 ID +- **역할**: 해당 채용공고의 문항 목록과 AI 매칭 결과를 페이지로 조합. STEP 진행 상황(3/5) 표시. 각 문항에 대한 MatchResultCard를 세로로 나열. +- **데이터 흐름**: + ```typescript + const jobPosting = getMockJobPostings().find((j) => j.id === params.id); + const experiences = getMockExperiences(); + const matches = getMockQuestionMatches(); + ``` +- **레이아웃 구조**: + ``` +
    + +
    + 이 조합으로 작성} /> +
    ← eyebrow + h-display 헤더 +

    STEP 3/5 · 경험 매칭

    +

    {N}개 문항 · {M}개 경험의 최적 조합

    +
    ← MatchResultCard 목록 + {matches.map(m => )} +
    ← 하단 내비게이션 + ← 이전 + 자소서 작성 시작 → + ``` + +--- + +### 자기소개서 에디터 페이지 + +- **파일**: `app/cover-letters/[id]/edit/page.tsx` +- **'use client'**: 불필요 (서버 컴포넌트, 편집 인터랙션은 추후 분리) +- **URL 파라미터**: `{ params: { id: string } }` — 자소서 ID +- **역할**: 문항별 자소서 에디터와 AI 챗봇 사이드패널을 2열 레이아웃으로 구성. 현 스펙에서 편집 기능은 정적 UI로 구현(실제 텍스트 편집은 추후 `'use client'` 클라이언트 컴포넌트로 분리). +- **데이터 흐름**: + ```typescript + const coverLetter = getMockCoverLetters().find((cl) => cl.id === params.id); + const [activeQuestion] = coverLetter.questions; // 첫 번째 문항 기본 표시 + ``` +- **레이아웃 구조**: + ``` +
    + +
    + } + /> +
    +
    ← 에디터 영역 + ← 문항 탭 스위처 +
    +

    {activeQuestion.question}

    +

    {activeQuestion.answer || '여기에 내용을 작성하세요...'}

    +
    ← AI 제안 블록 +

    AI 제안

    +

    {aiSuggestion}

    +
    + + +
    ← 하단 지표 바 + {charCount} / {maxLength}자 + 매칭률 {score}% + ← 우측 사이드 패널 + ``` +- **문항 탭 상태**: + ```typescript + type QuestionStatus = '작성중' | '초안' | '미작성'; + // answer.length > 0 → '초안', answer.length >= maxLength * 0.8 → '작성중', 그 외 → '미작성' + ``` + +--- + +### 저장된 자기소개서 목록 페이지 + +- **파일**: `app/cover-letters/page.tsx` +- **'use client'**: 불필요 (서버 컴포넌트) +- **역할**: 저장된 모든 자소서를 2열 카드 그리드로 표시. 정렬/필터 버튼(현 단계 UI만), 신규 작성 버튼. +- **데이터 흐름**: + ```typescript + const coverLetters = getMockCoverLetters(); + ``` +- **레이아웃 구조**: + ``` +
    + +
    + } + /> +
    + {coverLetters.map(cl => )} + ← 빈 상태: coverLetters.length === 0이면 EmptyState UI + ``` +- **빈 상태 UI**: "아직 작성한 자기소개서가 없어요" + "/cover-letters/new" 링크 버튼 + +--- + +## 테스트 케이스 (Vitest + React Testing Library) + +### Icon.test.tsx + +| # | 테스트명 | Given | When | Then | +| --- | ------------------------------------ | ---------------------------- | ------ | ------------------------- | +| 1 | 지정한 name의 SVG를 렌더링한다 | `name="sparkles"` | 마운트 | `` 요소가 DOM에 존재 | +| 2 | 기본 size 20이 적용된다 | `name="check"` | 마운트 | `width="20" height="20"` | +| 3 | 커스텀 size가 적용된다 | `name="check" size={32}` | 마운트 | `width="32" height="32"` | +| 4 | className prop이 SVG 요소에 전달된다 | `className="text-green-300"` | 마운트 | SVG에 해당 클래스 포함 | + +### ScoreRing.test.tsx + +| # | 테스트명 | Given | When | Then | +| --- | ------------------------- | ----------------------- | ------ | ------------------------------ | +| 5 | 점수 숫자가 렌더링된다 | `value={91}` | 마운트 | "91" 텍스트가 보임 | +| 6 | 0점도 올바르게 렌더링된다 | `value={0}` | 마운트 | "0" 텍스트가 보임 | +| 7 | 기본 size 80이 적용된다 | `value={50}` | 마운트 | SVG `width="80" height="80"` | +| 8 | 커스텀 size가 적용된다 | `value={50} size={120}` | 마운트 | SVG `width="120" height="120"` | + +### AppSidebar.test.tsx + +| # | 테스트명 | Given | When | Then | +| --- | ---------------------------------------- | --------------------- | -------- | ------------------------------------------------------- | +| 9 | 네비게이션 항목 4개가 렌더링된다 | 기본 렌더링 | 마운트 | `role="navigation"` 내 링크 4개 | +| 10 | 현재 경로의 링크가 aria-current를 갖는다 | pathname="/dashboard" | 마운트 | `/dashboard` 링크에 `aria-current="page"` | +| 11 | 각 링크가 올바른 href를 갖는다 | 기본 렌더링 | DOM 조회 | `/dashboard`, `/experiences`, `/jobs`, `/cover-letters` | + +### PageTopBar.test.tsx + +| # | 테스트명 | Given | When | Then | +| --- | --------------------------------------- | ------------------------------------- | ------ | ----------------------------------- | +| 12 | title이 렌더링된다 | `title="경험 매칭"` | 마운트 | "경험 매칭" 텍스트가 보임 | +| 13 | breadcrumb 항목들이 순서대로 렌더링된다 | `breadcrumb={['카카오', '매칭']}` | 마운트 | "카카오", "매칭" 텍스트가 모두 보임 | +| 14 | actions 슬롯이 렌더링된다 | `actions={}` | 마운트 | "내보내기" 버튼이 보임 | +| 15 | breadcrumb 없이도 title만 표시된다 | `title="자소서 목록"` breadcrumb 없음 | 마운트 | "자소서 목록" 보임, 오류 없음 | + +### MatchResultCard.test.tsx + +| # | 테스트명 | Given | When | Then | +| --- | -------------------------------------------------------- | ----------------------------------------- | --------- | ----------------------------------------- | +| 16 | 문항 텍스트가 렌더링된다 | `question.text="지원 동기..."` | 마운트 | 해당 텍스트가 보임 | +| 17 | ScoreRing에 score가 전달된다 | `match.score=91` | 마운트 | "91" 숫자가 보임 | +| 18 | AI 추천 이유가 렌더링된다 | `match.reason="프로젝트 경험이..."` | 마운트 | 해당 텍스트가 보임 | +| 19 | 키워드 칩이 렌더링된다 | `match.keywords=["AI", "Next.js"]` | 마운트 | "AI", "Next.js" 텍스트가 보임 | +| 20 | "다른 경험으로" 클릭 시 selectedExpId가 변경된다 | primary="exp-1" matched=["exp-1","exp-2"] | 버튼 클릭 | 화면에 exp-2 경험 제목이 표시됨 | +| 21 | "이 조합으로 작성" 클릭 시 onSelectExperience가 호출된다 | `onSelectExperience` 모킹 | 버튼 클릭 | 콜백이 (qId, selectedExpId) 인자로 호출됨 | + +### CoverLetterCard.test.tsx + +| # | 테스트명 | Given | When | Then | +| --- | ------------------------------------------ | ------------------------------ | -------------- | -------------------------------------- | +| 22 | 회사명과 직무명이 렌더링된다 | `coverLetter.company="카카오"` | 마운트 | "카카오" 텍스트 보임 | +| 23 | 진행률이 계산되어 표시된다 | 문항 2개 중 1개 answer 있음 | 마운트 | "50%" 또는 진행률 UI 표시 | +| 24 | 100% 완성 시 "완성" 배지가 표시된다 | 모든 문항 answer 있음 | 마운트 | "완성" 텍스트가 보임 | +| 25 | 미완성 시 "작성중" 배지가 표시된다 | answer가 하나도 없음 | 마운트 | "작성중" 텍스트가 보임 | +| 26 | 카드 본체가 편집 페이지 링크를 갖는다 | `coverLetter.id="cl-1"` | DOM 조회 | 링크 href가 `/cover-letters/cl-1/edit` | +| 27 | onDelete 콜백이 삭제 버튼 클릭 시 호출된다 | `onDelete` 모킹 | 삭제 버튼 클릭 | 콜백이 `"cl-1"`을 인자로 호출됨 | +| 28 | onCopy 콜백이 복사 버튼 클릭 시 호출된다 | `onCopy` 모킹 | 복사 버튼 클릭 | 콜백이 `"cl-1"`을 인자로 호출됨 | + +### AiChatbot.test.tsx + +| # | 테스트명 | Given | When | Then | +| --- | ----------------------------------------------------- | ------------------------------------------ | ------ | -------------------------------------------------- | +| 29 | 기본 Mock 메시지들이 렌더링된다 | messages 없이 기본값 | 마운트 | MOCK_CHAT_MESSAGES의 첫 번째 content 텍스트가 보임 | +| 30 | 커스텀 messages가 렌더링된다 | `messages=[{role:'ai', content:'테스트'}]` | 마운트 | "테스트" 텍스트가 보임 | +| 31 | isThinking=true이면 AI 타이핑 인디케이터가 렌더링된다 | `isThinking=true` | 마운트 | `.ai-thinking` 클래스 요소가 DOM에 존재 | + +테스트 수: **31개** + +--- + +## Storybook 스토리 (CSF3) + +### Icon.stories.tsx + +| 스토리명 | args 핵심값 | 설명 | +| ---------- | ------------------------------------------- | ----------------------- | +| Default | `name="sparkles"` | 기본 스파클 아이콘 | +| AllIcons | 각 IconName을 그리드로 렌더링 | 전체 아이콘 팔레트 확인 | +| CustomSize | `name="check" size={32}` | 큰 사이즈 | +| WithColor | `name="refresh" className="text-green-300"` | 색상 커스터마이징 | + +### ScoreRing.stories.tsx + +| 스토리명 | args 핵심값 | 설명 | +| -------- | ----------------------- | -------------------- | +| High | `value={91}` | 80 이상, 그린 색상 | +| Mid | `value={72}` | 60–79, 앰버 색상 | +| Low | `value={45}` | 60 미만, 비활성 색상 | +| Zero | `value={0}` | 0점 | +| Large | `value={88} size={120}` | 큰 사이즈 | + +### AppSidebar.stories.tsx + +| 스토리명 | args 핵심값 | 설명 | +| ------------------ | ----------------------------------- | ------------------------ | +| Default | 기본 (pathname mocking 없음) | 활성 항목 없는 기본 상태 | +| ActiveDashboard | `pathname="/dashboard"` mocking | 대시보드 활성 | +| ActiveCoverLetters | `pathname="/cover-letters"` mocking | 자소서 활성 | + +### PageTopBar.stories.tsx + +| 스토리명 | args 핵심값 | 설명 | +| -------------- | ----------------------------------------------- | ----------------------- | +| TitleOnly | `title="자기소개서 목록"` | 타이틀만 | +| WithBreadcrumb | `breadcrumb={['카카오', '프론트엔드', '매칭']}` | breadcrumb 포함 | +| WithActions | `actions={}` | 우측 버튼 슬롯 | +| FullEditor | title + breadcrumb + 여러 actions 버튼 | 에디터 페이지 전체 상태 | + +### MatchResultCard.stories.tsx + +| 스토리명 | args 핵심값 | 설명 | +| ---------------- | ----------------------------------------- | ------------------- | +| HighScore | `match.score=91` | 91점 매칭 | +| MidScore | `match.score=72` | 72점 매칭 | +| SingleExperience | `match.matched=['exp-1']` | 대안 경험 없는 경우 | +| MultipleAlts | `match.matched=['exp-1','exp-2','exp-3']` | 대안 경험 여러 개 | + +### CoverLetterCard.stories.tsx + +| 스토리명 | args 핵심값 | 설명 | +| ----------- | --------------------- | ----------------------- | +| Complete | 모든 문항 answer 있음 | 완성 상태, 100% 진행률 | +| InProgress | 일부 문항 answer 있음 | 작성중 상태, 50% 진행률 | +| NotStarted | 모든 문항 answer 없음 | 미작성 상태, 0% 진행률 | +| NoQuestions | `questions=[]` | 문항 없는 자소서 | + +### AiChatbot.stories.tsx + +| 스토리명 | args 핵심값 | 설명 | +| --------- | ----------------- | --------------------- | +| Default | messages 기본값 | Mock 메시지 표시 | +| Thinking | `isThinking=true` | AI 타이핑 애니메이션 | +| EmptyChat | `messages=[]` | 메시지 없는 초기 상태 | +| UserHeavy | user 메시지 다수 | 대화가 많은 상태 | + +--- + +## 구현 순서 + +1. `types/matching.ts` — 신규 타입 정의 +2. `types/job.ts` — `JobPosting`에 `questions?: JobQuestion[]` 추가 +3. `app/globals.css` — CSS 변수 및 유틸 클래스 추가 +4. `lib/mock/matching.mock.ts` — 신규 Mock 데이터 및 getter +5. `lib/mock/job.mock.ts` — `questions` 필드 추가 +6. `lib/mock/cover-letter.mock.ts` — 4개 항목으로 확장 +7. `components/ui/Icon/Icon.tsx` — SVG 인라인 아이콘 컴포넌트 +8. `components/ui/Icon/Icon.test.tsx` — 테스트 (4개) +9. `components/ui/Icon/Icon.stories.tsx` — 스토리 (4개) +10. `components/ui/Icon/index.ts` — barrel export +11. `components/ui/ScoreRing/ScoreRing.tsx` — SVG 원형 점수 컴포넌트 +12. `components/ui/ScoreRing/ScoreRing.test.tsx` — 테스트 (4개) +13. `components/ui/ScoreRing/ScoreRing.stories.tsx` — 스토리 (5개) +14. `components/ui/ScoreRing/index.ts` — barrel export +15. `components/ui/PageTopBar/PageTopBar.tsx` — 페이지 상단 바 +16. `components/ui/PageTopBar/PageTopBar.test.tsx` — 테스트 (4개) +17. `components/ui/PageTopBar/PageTopBar.stories.tsx` — 스토리 (4개) +18. `components/ui/PageTopBar/index.ts` — barrel export +19. `components/ui/AppSidebar/AppSidebar.tsx` — 사이드바 ('use client') +20. `components/ui/AppSidebar/AppSidebar.test.tsx` — 테스트 (3개) +21. `components/ui/AppSidebar/AppSidebar.stories.tsx` — 스토리 (3개) +22. `components/ui/AppSidebar/index.ts` — barrel export +23. `components/ui/index.ts` — Icon, ScoreRing, PageTopBar, AppSidebar export 추가 +24. `components/matching/MatchResultCard/MatchResultCard.tsx` — 매칭 카드 ('use client') +25. `components/matching/MatchResultCard/MatchResultCard.test.tsx` — 테스트 (6개) +26. `components/matching/MatchResultCard/MatchResultCard.stories.tsx` — 스토리 (4개) +27. `components/matching/MatchResultCard/index.ts` — barrel export +28. `components/matching/index.ts` — 신규 barrel export +29. `components/cover-letter/CoverLetterCard/CoverLetterCard.tsx` — 자소서 카드 +30. `components/cover-letter/CoverLetterCard/CoverLetterCard.test.tsx` — 테스트 (7개) +31. `components/cover-letter/CoverLetterCard/CoverLetterCard.stories.tsx` — 스토리 (4개) +32. `components/cover-letter/CoverLetterCard/index.ts` — barrel export +33. `components/cover-letter/AiChatbot/AiChatbot.tsx` — 정적 챗봇 패널 +34. `components/cover-letter/AiChatbot/AiChatbot.test.tsx` — 테스트 (3개) +35. `components/cover-letter/AiChatbot/AiChatbot.stories.tsx` — 스토리 (4개) +36. `components/cover-letter/AiChatbot/index.ts` — barrel export +37. `components/cover-letter/index.ts` — 신규 barrel export +38. `app/jobs/[id]/matching/page.tsx` — 경험 매칭 추천 페이지 +39. `app/cover-letters/page.tsx` — 자소서 목록 페이지 +40. `app/cover-letters/[id]/edit/page.tsx` — 자소서 에디터 페이지 + +--- + +## 완료 기준 + +- [ ] 모든 Props가 TypeScript strict 모드로 정의됨 (`any` 사용 금지) +- [ ] `JobPosting`에 `questions?: JobQuestion[]` 필드 추가됨 +- [ ] `types/matching.ts`에 `JobQuestion`, `QuestionMatch` 타입이 정의됨 +- [ ] `app/globals.css`에 `.card`, `.btn`, `.chip`, `.doc-*`, `.ai-thinking` 유틸 클래스 추가됨 +- [ ] 3개 페이지 모두 서버 컴포넌트 유지 (`'use client'` 없음) +- [ ] `AppSidebar`, `MatchResultCard`만 `'use client'` 사용 +- [ ] Lucide React 미설치 — 모든 아이콘은 SVG 인라인으로 구현 +- [ ] `AiChatbot`은 정적 UI만 구현 (인터랙션 없음) +- [ ] `CoverLetterCard`의 진행률이 `questions` 배열에서 동적으로 계산됨 +- [ ] `ScoreRing`의 점수 색상이 구간(80+/60-79/60미만)에 따라 분기됨 +- [ ] Mock 데이터가 4개의 `CoverLetter`와 3개의 `QuestionMatch`를 포함함 +- [ ] 테스트 케이스 31개가 사용자 시나리오를 커버함 +- [ ] Storybook에서 모든 상태(기본·완성·미작성·빈 상태·AI 타이핑)를 확인 가능함 +- [ ] `@/` alias를 사용하고 폴더 외 상대경로 없음 +- [ ] `pnpm type-check` 통과 +- [ ] `pnpm lint` 통과