diff --git a/.gitignore b/.gitignore index 2189378db..2ba50d090 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules # env files (can opt-in for committing if needed) .env* !.env.example +.superset/* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..fda76bc4f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# 프로젝트 개요 + +web25 부스트캠프 그룹 프로젝트. Next.js(프론트엔드) + NestJS(백엔드) 모노레포 구조. + +- 패키지 매니저: pnpm workspace +- 프론트엔드: `frontend/` — Next.js 16, React 19, TailwindCSS 4, Zustand, TanStack Query +- 백엔드: `backend/` — NestJS 11, TypeORM, PostgreSQL +- 모바일: `mobile-app/` — Capacitor 기반 + +## 주요 커맨드 + +```bash +pnpm dev:fe # 프론트 개발 서버 +pnpm dev:be # 백엔드 개발 서버 +pnpm infra:up # 개발용 Docker 인프라 시작 +pnpm test:fe # 프론트 단위 테스트 (Vitest) +pnpm test:fe:e2e # 프론트 E2E 테스트 (Playwright) +pnpm test:be # 백엔드 단위 테스트 (Jest) +pnpm test:be:e2e # 백엔드 E2E 테스트 +``` + +--- + +# 프론트엔드 테스트 가이드라인 + +> 백엔드(`backend/`)는 Jest를 사용하며 별도 가이드라인 적용. 아래는 `frontend/` 전용. + +자세한 내용은 [TESTING.md](./TESTING.md) 참고. + +## 요약 + +- **Vitest**: 순수 함수, 유틸, 훅 단위 테스트. 파일 위치는 소스 옆 (`*.test.ts`) +- **Playwright**: 사용자 흐름 E2E 테스트. `e2e/` 폴더에 `*.spec.ts`로 분리 +- `getByRole` / `getByLabel` 우선, `data-testid`는 최후 수단 +- `waitForTimeout` 금지 — `expect(...).toBeVisible()` 또는 `waitForResponse` 사용 +- 외부 의존성(`Date`, API)은 반드시 mock 격리 +- `it` 설명은 한국어로, 비즈니스 언어 사용 + +--- + +# 프론트엔드 Storybook 가이드라인 + +자세한 내용은 [STORYBOOK.md](./STORYBOOK.md) 참고. + +## 요약 + +- 스토리 파일 위치: `src/**/storybook/*.stories.tsx` +- 모든 스토리에 `docs.description.component`(컴포넌트 용도·동작)와 `docs.description.story`(스토리 상태 설명) 작성 +- `experimentalRSC: true` 사용 금지 — Controls(args) 변경이 화면에 반영되지 않음 +- Controls로 prop을 제어하는 스토리는 `render: (args) => `로 args를 직접 전달 +- QueryClient는 모듈 레벨에서 한 번만 생성 (decorator 안에서 생성 금지) +- mock 데이터는 `@/lib/mocks/mock.ts`에서 가져온다 (`handlers.ts` 직접 import 금지) diff --git a/STORYBOOK.md b/STORYBOOK.md new file mode 100644 index 000000000..1ac36a588 --- /dev/null +++ b/STORYBOOK.md @@ -0,0 +1,331 @@ +# 프론트엔드 Storybook 작성 가이드라인 + +> `frontend/` 전용 가이드라인. 프레임워크: `@storybook/nextjs-vite` + MSW + TanStack Query. + +--- + +## 1. Storybook의 역할 + +Storybook은 컴포넌트를 **실제 페이지 맥락 없이** 독립적으로 확인하고 문서화하는 도구다. + +| 용도 | 설명 | +|------|------| +| **시각적 문서화** | 컴포넌트가 어디서 쓰이는지, 어떤 상태가 존재하는지 한눈에 파악 | +| **인터랙션 확인** | Controls 패널로 props를 바꿔가며 렌더링 결과를 즉시 확인 | +| **회귀 방지** | Chromatic 등 비주얼 회귀 도구와 연동해 의도치 않은 UI 변경 감지 | + +--- + +## 2. 파일 구조 및 네이밍 + +``` +src/ + app/ + (main)/ + _components/ + RecordList.tsx + storybook/ + RecordList.stories.tsx ← 컴포넌트와 같은 도메인 폴더 안 + components/ + BlockContent.tsx + storybook/ + BlockContent.stories.tsx +``` + +- 파일명: `{컴포넌트명}.stories.tsx` +- 위치: 컴포넌트 파일과 같은 디렉토리의 `storybook/` 하위 폴더 +- `title`: `'도메인/컴포넌트명'` 형식 (예: `'Record/RecordList'`, `'Calendar/WeekCalendar'`) + +--- + +## 3. 설명(description) 작성 규칙 + +모든 스토리 파일은 **두 레벨**의 설명을 포함한다. + +### 3-1. 컴포넌트 레벨 — `docs.description.component` (meta에 작성) + +Docs 탭 상단에 표시된다. 다음 내용을 마크다운으로 작성한다. + +- **어디서 쓰이는지**: 어느 페이지·탭·섹션에서 렌더링되는지 +- **주요 동작**: 사용자가 상호작용하면 무슨 일이 일어나는지 +- **주요 prop**: 시각적 변화를 일으키는 prop이 있으면 한 줄 설명 + +```tsx +const meta = { + title: 'Record/RecordList', + component: RecordList, + parameters: { + docs: { + description: { + component: ` +홈 화면과 그룹 홈의 **피드 탭**에서 날짜별 기록 목록을 표시하는 컴포넌트. + +WeekCalendar에서 날짜를 선택하면 해당 날짜의 기록이 이 컴포넌트를 통해 렌더링된다. +카드를 클릭하면 기록 상세 페이지(\`/record/:id\`)로 이동한다. + `, + }, + }, + }, +} satisfies Meta; +``` + +### 3-2. 스토리 레벨 — `docs.description.story` (각 Story에 작성) + +| 스토리 | 설명 내용 | +|--------|-----------| +| **Default** | 컴포넌트의 주요 동작과 클릭 이벤트를 불릿으로 함께 기술 | +| 그 외 상태 | 해당 상태가 언제 보이는지 한 줄 | + +**Interactive 스토리는 따로 만들지 않는다.** 클릭 이벤트·드로어·팝오버 동작 설명은 Default 스토리의 `docs.description.story`에 함께 작성한다. 별도 Interactive 스토리는 Default와 내용이 중복되고, 읽는 사람이 두 곳을 오가야 하는 불편이 생긴다. + +```tsx +export const Default: Story = { + parameters: { + docs: { + description: { + story: ` +오늘 날짜 기준 기록 목록 — 개인/그룹 기록이 혼합된 기본 상태. + +- **카드 클릭**: 기록 상세 페이지(\`/record/:id\`)로 이동 +- **imageLayout 변경** (Controls 패널): + - \`tile\` — 이미지 그리드로 한 번에 표시 + - \`carousel\` — 이미지를 한 장씩 슬라이드로 표시 + `, + }, + }, + }, +}; +``` + +--- + +## 4. Controls(args) 작동 규칙 + +### 4-1. `experimentalRSC` 비활성화 유지 + +`.storybook/main.ts`에서 `experimentalRSC: true`를 설정하면 Controls(args) 변경 시 +서버 재렌더링이 트리거되지 않아 prop 제어가 동작하지 않는다. +**실제 서버 컴포넌트(RSC) 스토리가 없는 한 이 옵션은 활성화하지 않는다.** + +### 4-2. `render(args)` 패턴으로 props 직접 전달 + +Controls로 prop을 제어할 때는 `render` 함수의 첫 번째 인자 `args`를 컴포넌트에 직접 넘긴다. + +```tsx +export const Default: Story = { + args: { imageLayout: 'tile' }, + // ✅ args를 컴포넌트에 직접 전달 — Controls 변경 시 즉시 반영됨 + render: (args) => , +}; +``` + +`useArgs()` 훅은 사용하지 않는다 — Storybook hook 컨텍스트 제약이 까다롭고, `render(args)` 패턴으로 충분히 해결된다. + +--- + +## 5. 다크 모드 패턴 + +### 5-1. DarkMode 스토리를 만들지 않는다 + +`preview.tsx`의 전역 데코레이터가 모든 스토리에 ThemeProvider를 제공하고, +우상단 툴바의 **Light / Dark 토글**로 어떤 스토리든 즉시 테마를 전환할 수 있다. +별도 DarkMode 스토리는 중복이다. + +특정 스토리를 다크 모드로 시작하고 싶다면 `parameters.globals.theme`을 지정한다. + +```tsx +// ✅ 스토리 초기 테마 지정 +export const SomeDarkFirst: Story = { + parameters: { + globals: { theme: 'dark' }, + backgrounds: { default: 'dark' }, + }, +}; + +// ❌ DarkMode 스토리를 별도로 만들지 않는다 +export const DarkMode: Story = { ... }; +``` + +### 5-2. 전역 ThemeProvider — 스토리에서 따로 감싸지 않는다 + +`preview.tsx`가 전역으로 `ThemeProvider`를 제공하므로, 개별 스토리에서 `ThemeProvider`를 다시 감쌀 필요 없다. `forcedTheme={context.globals.theme}`으로 스토리 간 `html.dark` 오염도 차단된다. + +```tsx +// preview.tsx — 전역 설정 (건드리지 않는다) +const withTheme: Decorator = (Story, context) => { + const theme = context.globals.theme ?? 'light'; + return ( + + + + ); +}; +``` + +### 5-3. wrapper div에 `dark:bg-` 추가 + +스토리의 배경이 툴바 테마 전환에 반응하도록 wrapper div에 항상 dark 배경을 함께 지정한다. + +```tsx +// ✅ 툴바 전환 시 배경도 함께 반응 +
+``` + +--- + +## 6. MSW + TanStack Query 패턴 + +### 6-1. QueryClient는 모듈 레벨에서 한 번만 생성 + +decorator 함수 안에서 QueryClient를 생성하면 args 변경 때마다 새 인스턴스가 만들어져 loading 플래시가 발생한다. + +```tsx +// ✅ 모듈 레벨에서 한 번만 생성 +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0 } }, +}); +``` + +### 6-2. 내부에서 데이터를 fetch하는 컴포넌트 — pre-seed 패턴 + +`useSuspenseQuery` / `useQuery` / `useInfiniteQuery`를 **컴포넌트 내부에서 직접 호출**하는 경우, +MSW 인터셉트 대신 `client.setQueryData`로 캐시를 미리 채운다. +Storybook에서 인증 헤더가 없어 API 호출이 실패하는 문제를 피할 수 있고, +데이터가 즉시 표시되어 로딩 플래시도 없다. + +```tsx +// ✅ pre-seed 패턴 (useSuspenseQuery 컴포넌트) +function makeClient() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + // queryKey는 실제 queryOptions의 queryKey와 정확히 일치해야 한다 + client.setQueryData(['profile', 'me'], mockProfile); + return client; +} + +const clients = { + default: makeClient(), + // 스토리마다 독립적인 QueryClient 인스턴스 사용 +}; + +export const Default: Story = { + decorators: [ + (Story) => ( + + 로딩 중...
}> + + + + ), + ], +}; +``` + +`useSuspenseQuery`를 사용하는 컴포넌트는 반드시 ``로 감싼다. + +### 6-3. MSW 핸들러는 각 스토리의 `parameters.msw.handlers`에 선언 + +외부 API를 mock할 때(인증이 필요 없는 경우)는 MSW를 사용한다. +스토리별로 다른 응답이 필요하면 각 스토리에서 핸들러를 덮어쓴다. + +```tsx +export const EmptyRecords: Story = { + parameters: { + msw: { + handlers: [ + http.get('/api/feed', () => + HttpResponse.json({ success: true, data: [], error: null }), + ), + ], + }, + }, +}; +``` + +### 6-4. mock 데이터는 `src/lib/mocks/mock.ts`에서 가져온다 + +```tsx +// ✅ +import { createMockRecordPreviews } from '@/lib/mocks/mock'; + +// ❌ handlers.ts는 mock 함수를 re-export하지 않음 +import { createMockRecordPreviews } from '@/lib/mocks/handlers'; +``` + +--- + +## 7. 스토리 구성 순서 + +같은 파일 안에서 스토리를 아래 순서로 배치한다. + +1. **Default** — 가장 일반적인 상태 + 주요 인터랙션 설명 포함 +2. **상태 변형** — 데이터 개수, 조건 분기, 권한 차이 등 (예: `EmptyRecords`, `ViewerRole`) +3. **그룹/개인 분기** — 그룹/개인 모드가 있는 경우 (예: `GroupRecords`) + +--- + +## 8. 공통 원칙 + +| 원칙 | 내용 | +|------|------| +| **한국어 설명** | `docs.description`은 한국어로 작성 — 비즈니스 언어 사용 | +| **mock은 현실에 가깝게** | 실제 API 응답 타입과 필드명을 정확히 사용 | +| **props 변화가 보이게** | imageLayout 같은 prop은 2장 이상의 이미지 mock으로 차이가 보이도록 | +| **`inline: false` 지양** | Docs 뷰에서 iframe이 생성되어 툴바 globals(theme 등)가 전달되지 않을 수 있음 | + +--- + +## 9. 실전 교훈 + +### 9-1. 컴포넌트 props 타입을 반드시 확인한다 + +스토리 작성 전에 컴포넌트의 실제 props 인터페이스를 확인한다. +타입이 다르면 런타임 에러가 발생하거나 데이터가 렌더링되지 않는다. + +```ts +// ❌ ProfileTag(객체)를 넘겼으나 컴포넌트는 TagStatSummary를 기대함 +args: { tags: { recent: [...], frequent: [...], all: [] } } + +// ✅ 실제 타입 확인 후 적용 +args: { tags: { recentTags: [...], frequentTags: [...] } } +``` + +필드명도 컴포넌트 코드에서 직접 확인한다 (`tag.name` vs `tag.tag` 등). + +### 9-2. 필수 props 누락은 즉시 런타임 에러로 나타난다 + +`me`, `groupId` 같은 필수 prop을 빠뜨리면 컴포넌트 내부에서 `undefined.role` 형태의 에러가 발생한다. 에러 메시지의 컴포넌트명을 보고 해당 컴포넌트의 props 인터페이스를 확인한다. + +### 9-3. `groupName` 등 선택적 필드를 빠뜨리면 UI가 비어 보인다 + +`scope: 'GROUP'`인 기록의 mock에 `groupName`을 넣지 않으면 `{record.groupName}` 렌더가 빈 문자열로 표시된다. mock 작성 시 컴포넌트가 실제로 렌더링하는 필드를 빠짐없이 채운다. + +### 9-4. `__dirname is not defined` — `viteFinal`에서 define 설정 + +`next-auth` 내부 의존(`ua-parser-js`)이 CJS 빌드에서 `__dirname`을 사용한다. +`.storybook/main.ts`의 `viteFinal`에서 `__dirname`을 빈 문자열로 주입한다. + +```ts +viteFinal: async (config) => { + config.define = { ...config.define, __dirname: JSON.stringify('') }; + return config; +}, +``` + +### 9-5. SVG 기반 차트는 `resolvedTheme` + `key`로 다크 모드 적용 + +Recharts 등 SVG 기반 차트의 tick/label 색상은 Tailwind `dark:` 클래스로 제어할 수 없다. +`useTheme().resolvedTheme`으로 색상을 분기하고, `key={resolvedTheme}`으로 테마 변경 시 차트를 리마운트한다. + +```tsx +const { resolvedTheme } = useTheme(); + + + + + + +``` diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 000000000..5bd2b06b2 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,241 @@ +# 프론트엔드 테스트 코드 작성 가이드라인 + +> `frontend/` 전용 가이드라인 (Vitest + Playwright). 백엔드는 Jest 기반으로 별도 적용. + +## 1. 도구별 역할 분리 + +| 도구 | 대상 | +|------|------| +| **Vitest** | 순수 함수, 유틸, 훅, 비즈니스 로직 단위 테스트 | +| **Playwright** | 사용자 흐름 기반 E2E 테스트 (페이지 이동, 실제 인터랙션) | + +Playwright를 단위 비즈니스 로직 검증에 쓰지 않는다. 렌더링 없이 검증 가능한 로직은 Vitest로 먼저 커버한다. + +--- + +## 2. 파일 구조 및 네이밍 + +``` +src/ + lib/ + utils/ + filterLabels.ts + filterLabels.test.ts ← 소스 파일 옆에 위치 + date.ts + date.test.ts + hooks/ + useDebounce.ts + useDebounce.test.ts +e2e/ + record-create.spec.ts ← Playwright E2E + group-invite.spec.ts +``` + +- Vitest: `*.test.ts` — 소스 파일과 같은 폴더 +- Playwright: `*.spec.ts` — `e2e/` 폴더에 분리 + +--- + +## 3. Vitest 작성 규칙 + +### 3-1. describe/it 구조 + +```typescript +// src/lib/utils/filterLabels.test.ts +import { makeTagLabel } from './filterLabels'; + +describe('makeTagLabel', () => { + it('태그가 없으면 기본값 "태그" 반환', () => { + expect(makeTagLabel([])).toBe('태그'); + }); + + it('태그가 1개이면 태그명 그대로 반환', () => { + expect(makeTagLabel(['여행'])).toBe('여행'); + }); + + it('태그가 2개 이상이면 "첫번째 외 N" 형식 반환', () => { + expect(makeTagLabel(['여행', '맛집', '카페'])).toBe('여행 외 2'); + }); +}); +``` + +- `describe`: 함수/모듈 단위 +- `it`: 단일 시나리오. **한국어로** 무엇을 기대하는지 명시 +- 하나의 `it` 블록에 하나의 `expect` 원칙 (불가피한 경우 예외) + +### 3-2. 경계값과 예외 케이스를 반드시 포함 + +```typescript +describe('makeDateLabel', () => { + it('start, end 둘 다 없으면 "날짜" 반환', () => { ... }); + it('start만 있으면 start 반환', () => { ... }); + it('end만 있으면 end 반환', () => { ... }); + it('start와 end 모두 있으면 "start ~ end" 반환', () => { ... }); + it('null을 받으면 기본값 반환', () => { ... }); // ← 경계값 +}); +``` + +### 3-3. 외부 의존성은 Mock으로 격리 + +```typescript +// API 호출, 날짜(Date), 랜덤값은 반드시 mock +vi.useFakeTimers(); +vi.setSystemTime(new Date('2025-01-15')); + +// fetch/API mock +vi.mock('@/lib/api/record', () => ({ + fetchRecord: vi.fn().mockResolvedValue({ id: '1', title: '테스트' }), +})); +``` + +- `Date`, `Math.random`, 외부 API는 테스트마다 결과가 달라지므로 mock 필수 +- `afterEach(() => vi.restoreAllMocks())` 로 상태 초기화 + +### 3-4. 훅 테스트 + +```typescript +import { renderHook, act } from '@testing-library/react'; +import { useDebounce } from './useDebounce'; + +describe('useDebounce', () => { + it('delay 이전에는 초기값 유지', async () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useDebounce('초기', 500)); + expect(result.current).toBe('초기'); + vi.clearAllTimers(); + }); +}); +``` + +--- + +## 4. Playwright 작성 규칙 + +### 4-1. 사용자 관점으로 작성 + +```typescript +// e2e/record-create.spec.ts +test('기록 생성 후 목록에 나타난다', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: '기록 추가' }).click(); + await page.getByLabel('제목').fill('제주도 여행'); + await page.getByRole('button', { name: '저장' }).click(); + + await expect(page.getByText('제주도 여행')).toBeVisible(); +}); +``` + +- `getByRole`, `getByLabel`, `getByText` 우선 — `data-testid`는 최후 수단 +- 구현 세부사항(클래스명, DOM 구조)에 의존하지 않음 + +### 4-2. 페이지 객체 패턴 (Page Object Model) + +반복되는 인터랙션은 POM으로 추출: + +```typescript +// e2e/pages/RecordPage.ts +export class RecordPage { + constructor(private page: Page) {} + + async createRecord(title: string) { + await this.page.getByRole('button', { name: '기록 추가' }).click(); + await this.page.getByLabel('제목').fill(title); + await this.page.getByRole('button', { name: '저장' }).click(); + } +} +``` + +### 4-3. 인증 상태 재사용 + +```typescript +// playwright.config.ts에서 storageState로 로그인 상태 저장 +setup('로그인', async ({ page }) => { + await page.goto('/login'); + // ... 로그인 처리 + await page.context().storageState({ path: 'e2e/.auth/user.json' }); +}); +``` + +매 테스트마다 로그인하지 않고 `storageState`를 재사용한다. + +### 4-4. 불안정한 패턴 금지 + +```typescript +// ❌ 금지 +await page.waitForTimeout(2000); + +// ✅ 대신 +await expect(page.getByText('저장됨')).toBeVisible(); +await page.waitForResponse('**/api/records'); +``` + +--- + +## 5. 공통 원칙 + +| 원칙 | 내용 | +|------|------| +| **AAA 패턴** | Arrange(준비) → Act(실행) → Assert(검증) 순서 유지 | +| **테스트 독립성** | 각 테스트는 다른 테스트의 실행 순서나 상태에 의존하지 않음 | +| **비즈니스 언어 사용** | `it('userId가 null이면...')` 보다 `it('로그인하지 않은 사용자는...')` | +| **커버리지보다 신뢰성** | 100% 커버리지보다 핵심 경로의 신뢰할 수 있는 테스트가 우선 | +| **테스트도 코드다** | 중복 제거, 헬퍼 추출 — 단, 과도한 추상화는 가독성을 해침 | + +--- + +## 6. 우선순위 (무엇부터 테스트할 것인가) + +1. **순수 유틸 함수** — `filterLabels`, `date`, `record` 유틸 (가장 쉽고 ROI가 높음) +2. **복잡한 비즈니스 훅** — `useGroupActions`, `useRecordCollaboration` +3. **핵심 사용자 흐름** — 기록 생성/조회, 그룹 초대 (Playwright) +4. **엣지 케이스** — 권한 없는 접근, 네트워크 오류 처리 + +--- + +## 7. 실전 교훈 + +### 7-1. fake timer + React state는 `act`를 반드시 분리 + +`vi.useFakeTimers()`와 `rerender`를 같은 `act` 블록에 넣으면 타이머 실행 후 React state flush 순서가 꼬여 값이 반영되지 않는다. + +```typescript +// ❌ 같은 act 안에서 rerender + advanceTimersByTime +act(() => { + rerender({ value: '변경' }); + vi.advanceTimersByTime(500); +}); +expect(result.current).toBe('변경'); // 실패 — 값이 '초기' 그대로 + +// ✅ act를 분리 +act(() => { rerender({ value: '변경' }); }); +act(() => { vi.advanceTimersByTime(500); }); // 타이머 실행 후 React state flush +expect(result.current).toBe('변경'); // 통과 +``` + +### 7-2. DOM ref에 의존하는 훅은 `renderHook` 대신 `render` 사용 + +`useEffect(fn, [])` 내부에서 `ref.current`를 읽는 훅은 `renderHook`으로 테스트하면 ref가 항상 `null`이다. `renderHook`은 실제 DOM 요소를 렌더링하지 않기 때문이다. + +```typescript +// ❌ renderHook — useEffect 시점에 containerRef.current가 null +const { result } = renderHook(() => useScrollDirection()); +result.current.containerRef.current = container; // 이미 effect가 종료된 후라 이벤트 리스너가 붙지 않음 + +// ✅ render로 실제 DOM에 ref 연결 — effect 실행 전에 ref가 채워짐 +function TestComponent() { + const { containerRef, isVisible } = useScrollDirection(); + return
; +} +render(); +``` + +### 7-3. JSX를 포함하는 테스트 파일은 `.tsx` 확장자 + +`.ts` 파일에 JSX를 작성하면 esbuild transform 오류가 발생한다. + +``` +// ❌ useScrollDirection.test.ts → "Expected '>' but found 'data'" 오류 +// ✅ useScrollDirection.test.tsx → 정상 변환 +``` + +JSX가 없는 순수 로직 테스트는 `.test.ts`, 컴포넌트나 JSX를 포함하면 `.test.tsx`로 작성한다. diff --git a/backend/src/modules/map/map.service.ts b/backend/src/modules/map/map.service.ts index 2ba46a7f2..6df8a5e94 100644 --- a/backend/src/modules/map/map.service.ts +++ b/backend/src/modules/map/map.service.ts @@ -6,6 +6,7 @@ import { Post } from '@/modules/post/entity/post.entity'; import { PostBlock } from '@/modules/post/entity/post-block.entity'; import { PostBlockType } from '@/enums/post-block-type.enum'; import { BlockValueMap } from '@/modules/post/types/post-block.types'; +import { DateTime } from 'luxon'; import { MapPostsQueryDto, PaginatedMapPostsResponseDto, @@ -70,10 +71,14 @@ export class MapService { // Filters if (from) { - query.andWhere('post.eventAt >= :from', { from }); + query.andWhere('post.eventAt >= :from', { + from: this.normalizeDateBoundary(from, 'start'), + }); } if (to) { - query.andWhere('post.eventAt <= :to', { to }); + query.andWhere('post.eventAt <= :to', { + to: this.normalizeDateBoundary(to, 'end'), + }); } if (tags) { const tagList = tags.split(',').map((t) => t.trim()); @@ -195,4 +200,12 @@ export class MapService { const value = `${eventAt.toISOString()}|${id}`; return Buffer.from(value).toString('base64'); } + + // 날짜만 있는 문자열(YYYY-MM-DD)을 하루의 시작/끝 시각으로 정규화 + // search.service.ts의 normalizeDateOnlyBoundary와 동일한 방식 + private normalizeDateBoundary(value: string, boundary: 'start' | 'end'): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return value; + const dt = DateTime.fromISO(value, { zone: 'Asia/Seoul' }); + return (boundary === 'start' ? dt.startOf('day') : dt.endOf('day')).toISO() ?? value; + } } diff --git a/frontend/.gitignore b/frontend/.gitignore index e92bdf67d..05d766ca6 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -54,4 +54,9 @@ storybook-static .env.sentry-build-plugin # lighthouse -.lighthouseci/ \ No newline at end of file +.lighthouseci/e2e/.auth/ +playwright-report/ +test-results/ + +# playwright auth state (setup 단계에서 생성되는 세션 토큰) +e2e/.auth/ diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 9d5519c99..7e5ca2ec8 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -12,8 +12,17 @@ const config: StorybookConfig = { ], framework: '@storybook/nextjs-vite', staticDirs: ['../public'], - features: { - experimentalRSC: true, + // experimentalRSC: true 는 Controls(args) 변경 시 서버 재렌더링이 트리거되지 않아 + // imageLayout 같은 prop 제어가 동작하지 않는 문제를 일으킨다. + // 실제 RSC 스토리가 없으므로 비활성화한다. + viteFinal: async (config) => { + // next-auth의 ua-parser-js(CJS 빌드)가 __dirname을 사용하는데, + // Vite가 브라우저 번들로 변환할 때 정의되지 않아 ReferenceError가 발생함. + config.define = { + ...config.define, + __dirname: JSON.stringify(''), + }; + return config; }, }; export default config; diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts deleted file mode 100644 index b6a84dc03..000000000 --- a/frontend/.storybook/preview.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Preview } from '@storybook/nextjs-vite'; -import { initialize, mswLoader } from 'msw-storybook-addon'; -import '../src/app/globals.css'; - -// MSW 초기화 -initialize(); - -const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - - nextjs: { - appDirectory: true, - navigation: { - segments: ['groupId', 'month', 'year', 'date', 'userId', 'recordId'], - }, - }, - - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely - test: 'todo', - }, - }, - loaders: [mswLoader], -}; - -export default preview; diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx new file mode 100644 index 000000000..864bd3387 --- /dev/null +++ b/frontend/.storybook/preview.tsx @@ -0,0 +1,57 @@ +import type { Preview, Decorator } from '@storybook/nextjs-vite'; +import { ThemeProvider } from 'next-themes'; +import { initialize, mswLoader } from 'msw-storybook-addon'; +import '../src/app/globals.css'; + +initialize(); + +// 모든 스토리를 ThemeProvider로 감싸 useTheme()이 어디서든 동작하게 한다. +// forcedTheme을 Storybook 글로벌 theme 값으로 고정해 스토리 간 html.dark 오염을 차단한다. +// 각 스토리는 parameters.globals.theme으로 초기 테마를 지정하고, +// Storybook 툴바(우상단 테마 토글)로 언제든 전환할 수 있다. +const withTheme: Decorator = (Story, context) => { + const theme = (context.globals.theme as string) ?? 'light'; + return ( + + + + ); +}; + +const preview: Preview = { + globalTypes: { + theme: { + name: 'Theme', + defaultValue: 'light', + toolbar: { + icon: 'circlehollow', + items: [ + { value: 'light', title: 'Light' }, + { value: 'dark', title: 'Dark' }, + ], + dynamicTitle: true, + }, + }, + }, + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + nextjs: { + appDirectory: true, + navigation: { + segments: ['groupId', 'month', 'year', 'date', 'userId', 'recordId'], + }, + }, + a11y: { + test: 'todo', + }, + }, + decorators: [withTheme], + loaders: [mswLoader], +}; + +export default preview; diff --git a/frontend/e2e/announcement-modal.spec.ts b/frontend/e2e/announcement-modal.spec.ts new file mode 100644 index 000000000..b655ba359 --- /dev/null +++ b/frontend/e2e/announcement-modal.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; + +// 백엔드 .env.local의 ADMIN_KEY (개발 환경 기본값) +const ADMIN_KEY = process.env.E2E_ADMIN_KEY ?? 'ittda_admin'; +const ANNOUNCEMENT_TITLE = 'E2E 테스트 공지사항'; + +// /api/admin/announcements → Next.js 프록시 → 백엔드 /v1/admin/announcements +async function createAnnouncement( + request: import('@playwright/test').APIRequestContext, +): Promise { + const res = await request.post('/api/admin/announcements', { + headers: { 'x-admin-key': ADMIN_KEY, 'Content-Type': 'application/json' }, + data: { + title: ANNOUNCEMENT_TITLE, + content: 'E2E 테스트 중 생성된 공지입니다.', + isActive: true, + }, + }); + const body = await res.json(); + if (!body.data?.id) throw new Error(`공지 생성 실패: ${JSON.stringify(body)}`); + return body.data.id; +} + +async function deleteAnnouncement( + request: import('@playwright/test').APIRequestContext, + id: string, +): Promise { + await request.delete(`/api/admin/announcements/${id}`, { + headers: { 'x-admin-key': ADMIN_KEY }, + }); +} + +test.describe('홈 공지사항 팝업', () => { + let announcementId: string; + + test.use({ storageState: path.join(__dirname, '.auth/guest.json') }); + + test.beforeAll(async ({ request }) => { + // isActive: true 생성 시 기존 활성 공지가 자동 비활성화됨 + announcementId = await createAnnouncement(request); + }); + + test.afterAll(async ({ request }) => { + await deleteAnnouncement(request, announcementId); + }); + + test('활성 공지사항이 있으면 홈 진입 시 팝업 모달이 표시된다', async ({ page }) => { + await page.goto('/'); + await expect( + page.getByText(ANNOUNCEMENT_TITLE), + ).toBeVisible({ timeout: 8000 }); + // 확인 버튼도 함께 표시된다 + await expect(page.getByRole('button', { name: '확인' })).toBeVisible(); + }); + + test('"확인" 버튼을 누르면 모달이 닫히고 다시 홈에 와도 팝업이 뜨지 않는다', async ({ page }) => { + // 각 테스트는 test.use({ storageState })로 독립된 컨텍스트를 가지므로 + // dismiss 쿠키가 없는 초기 상태로 시작됨 + await page.goto('/'); + await expect(page.getByText(ANNOUNCEMENT_TITLE)).toBeVisible({ timeout: 8000 }); + + // 2) 확인 버튼 클릭 → dismiss 쿠키 설정 + router.refresh() + await page.getByRole('button', { name: '확인' }).click(); + await expect(page.getByText(ANNOUNCEMENT_TITLE)).not.toBeVisible({ timeout: 5000 }); + + // 3) 홈 재진입 → 쿠키 기반으로 SSR에서 모달 렌더링 제외 + await page.goto('/'); + await expect(page.getByText(ANNOUNCEMENT_TITLE)).not.toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/frontend/e2e/announcements.spec.ts b/frontend/e2e/announcements.spec.ts new file mode 100644 index 000000000..ea75de579 --- /dev/null +++ b/frontend/e2e/announcements.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; + +const ADMIN_KEY = 'ittda_admin'; +const BACKEND = 'http://localhost:4000/v1'; +const TEST_TITLE = 'E2E 테스트 공지사항'; + +// 공지사항 페이지는 서버 컴포넌트(RSC)로 백엔드에서 직접 fetch. +// beforeAll에서 admin API로 활성 공지를 생성하고 afterAll에서 삭제한다. +test.describe('공지사항', () => { + let announcementId: string; + + test.beforeAll(async ({ request }) => { + const res = await request.post(`${BACKEND}/admin/announcements`, { + headers: { 'x-admin-key': ADMIN_KEY }, + data: { title: TEST_TITLE, isActive: true }, + }); + const body = await res.json(); + announcementId = body?.id ?? body?.data?.id; + }); + + test.afterAll(async ({ request }) => { + if (!announcementId) return; + await request.delete(`${BACKEND}/admin/announcements/${announcementId}`, { + headers: { 'x-admin-key': ADMIN_KEY }, + }); + }); + + test('공지사항 페이지가 로드된다', async ({ page }) => { + await page.goto('/announcements'); + await expect(page.locator('body')).toBeVisible(); + await expect(page).toHaveURL('/announcements'); + }); + + test('헤더에 "공지사항" 제목이 표시된다', async ({ page }) => { + await page.goto('/announcements'); + await expect(page.getByRole('heading', { name: '공지사항', exact: true })).toBeVisible(); + }); + + test('공지사항이 없을 때 안내 메시지가 표시된다', async ({ page }) => { + await page.goto('/announcements'); + + const isEmpty = await page.getByText('공지사항이 없습니다.').isVisible({ timeout: 3000 }).catch(() => false); + const hasAnnouncements = await page.locator('main > div').count() > 0; + + // 공지사항이 없거나 있거나 - 둘 중 하나의 상태여야 한다 + expect(isEmpty || hasAnnouncements).toBe(true); + }); + + test('뒤로가기 버튼을 클릭하면 프로필 페이지로 이동한다', async ({ page }) => { + await page.goto('/profile'); + await page.goto('/announcements'); + + // Back 컴포넌트는 이전 페이지 또는 fallback(/profile)으로 이동 + const backButton = page.locator('button').first(); + await backButton.click(); + + // /profile로 이동하거나 브라우저 히스토리의 이전 페이지로 이동 + await expect(page).toHaveURL(/\/(profile|announcements)/, { timeout: 3000 }); + }); + + test('공지사항이 있을 때 각 항목에 제목과 날짜가 표시된다', async ({ page }) => { + await page.goto('/announcements'); + + const firstCard = page.locator('main > div').first(); + await expect(firstCard.locator('h3').first()).toBeVisible({ timeout: 8000 }); + await expect(firstCard.locator('h3').first()).not.toBeEmpty(); + await expect(firstCard.getByText(/\d{4}년/).first()).toBeVisible(); + }); + + test('진행 중인 공지에는 "진행 중" 배지가 표시된다', async ({ page }) => { + await page.goto('/announcements'); + + await expect(page.getByText('진행 중').first()).toBeVisible({ timeout: 8000 }); + }); +}); diff --git a/frontend/e2e/date-restriction.spec.ts b/frontend/e2e/date-restriction.spec.ts new file mode 100644 index 000000000..02889e865 --- /dev/null +++ b/frontend/e2e/date-restriction.spec.ts @@ -0,0 +1,180 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { RecordEditorPage } from './pages/RecordEditorPage'; +import { createTestRecord, deleteTestRecord } from './fixtures/api'; + +// 로컬 날짜 기준으로 어제 계산 (WeekCalendar는 로컬 날짜를 사용) +const yesterdayDate = new Date(); +yesterdayDate.setDate(yesterdayDate.getDate() - 1); +const YESTERDAY = `${yesterdayDate.getFullYear()}-${String(yesterdayDate.getMonth() + 1).padStart(2, '0')}-${String(yesterdayDate.getDate()).padStart(2, '0')}`; +const YESTERDAY_DAY = String(yesterdayDate.getDate()); + +const TODAY = new Date().toISOString().split('T')[0]; + +test.describe('미래 날짜 선택 불가', () => { + test('기록 생성 시 미래 날짜는 선택할 수 없다', async ({ page }) => { + // 에디터 진입 + const editor = new RecordEditorPage(page); + await editor.goto({ mode: 'add', date: TODAY }); + + // 날짜 블록의 DateField 버튼 클릭 → DateDrawer 오픈 + // DateField: + const dateBlock = page.locator('[data-block-id]').first(); + const dateFieldButton = dateBlock.getByRole('button').first(); + await expect(dateFieldButton).toBeVisible({ timeout: 5000 }); + + // Drawer 열기 전에 현재 날짜 텍스트 저장 + const selectedDateText = await dateFieldButton.textContent(); + await dateFieldButton.click(); + + // DateDrawer 열림 확인 — DrawerTitle: "언제의 기록인가요?" + await expect(page.getByText('언제의 기록인가요?')).toBeVisible({ timeout: 5000 }); + + // 캘린더 그리드 안의 disabled 버튼 = 미래 날짜 + // DateDrawer: isFuture → disabled={true} + opacity-20 + cursor-not-allowed + const futureDateButton = page.locator('button[disabled]').filter({ hasText: /^\d+$/ }).first(); + await expect(futureDateButton).toBeVisible({ timeout: 5000 }); + + // disabled 속성 확인 → 선택 불가 상태 + await expect(futureDateButton).toBeDisabled(); + + // disabled 버튼 클릭 시도 — HTML disabled는 이벤트를 차단하므로 날짜가 바뀌지 않음 + await futureDateButton.scrollIntoViewIfNeeded(); + await futureDateButton.click({ force: true }); + + // Drawer가 닫히지 않고 여전히 열려있어야 함 → 날짜 선택이 발생하지 않았음을 증명 + await expect(page.getByText('언제의 기록인가요?')).toBeVisible(); + + // Drawer 닫기 후 날짜 텍스트가 변경되지 않았는지 확인 + await page.keyboard.press('Escape'); + await expect(dateFieldButton).toBeVisible({ timeout: 3000 }); + await expect(dateFieldButton).toHaveText(selectedDateText!); + }); + + test('날짜 drawer로 기록 탐색 시 미래 날짜는 선택할 수 없다', async ({ page }) => { + await page.goto('/'); + + // WeekCalendar — 주간 날짜 버튼들이 있는 영역 + // 미래 날짜: disabled={true} + opacity-40 + cursor-not-allowed + const weekCalendar = page.locator('div[class*="overflow-hidden"]').filter({ + has: page.locator('button[disabled]'), + }).first(); + + // 미래 날짜 버튼 (disabled) 이 존재하는지 확인 + // 오늘이 주말 마지막 날이 아닌 이상 반드시 미래 날짜가 있음 + const futureWeekButton = weekCalendar.locator('button[disabled]').first(); + const hasFutureButton = await futureWeekButton.isVisible({ timeout: 5000 }).catch(() => false); + + if (!hasFutureButton) { + // 오늘이 토요일(주의 마지막 날) — 다음 주로 이동해 미래 날짜 disabled 검증 + const nextWeekButton = page.locator('button').filter({ has: page.locator('[data-lucide="chevron-right"]') }).last(); + const hasNext = await nextWeekButton.isVisible({ timeout: 3000 }).catch(() => false); + if (!hasNext) { + test.skip(); // 다음 주 이동 버튼이 없는 예외 상황 + return; + } + await nextWeekButton.click(); + + // 다음 주는 모든 날짜가 미래 → 첫 번째 버튼이 disabled 상태여야 함 + const nextFutureButton = weekCalendar.locator('button[disabled]').first(); + await expect(nextFutureButton).toBeVisible({ timeout: 5000 }); + await expect(nextFutureButton).toBeDisabled(); + await nextFutureButton.click({ force: true }); + await expect(nextFutureButton).toBeDisabled(); + return; + } + + // disabled 속성 확인 → 선택 불가 상태 + await expect(futureWeekButton).toBeDisabled(); + + // 선택된 날짜 확인 (오늘 날짜가 기본 선택) + // 클릭 시도 후 날짜가 변경되지 않는지 확인 + const todayButton = weekCalendar.locator('button').filter({ + has: page.locator('[class*="itta-point"], [class*="green"]'), + }).first(); + const todayVisible = await todayButton.isVisible({ timeout: 2000 }).catch(() => false); + + // 미래 날짜 클릭 시도 + await futureWeekButton.click({ force: true }); + + // 미래 날짜를 클릭해도 오늘 날짜가 여전히 선택 상태 유지 + if (todayVisible) { + await expect(todayButton).toBeVisible(); + } + + // disabled 버튼이 여전히 disabled 상태임을 확인 + await expect(futureWeekButton).toBeDisabled(); + }); +}); + +const PAST_RECORD_TITLE = 'E2E 과거날짜 기록'; + +test.describe('과거 날짜 기록 홈 노출', () => { + let postId: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const record = await createTestRecord(page, PAST_RECORD_TITLE, YESTERDAY); + postId = record.id; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestRecord(page, postId); + await ctx.close(); + }); + + // WeekCalendar 컨테이너 — 요일명 span이 있는 overflow-hidden div + const getWeekCalendar = (page: Parameters[1] extends { page: infer P } ? P : import('@playwright/test').Page) => + page.locator('div[class*="overflow-hidden"]').filter({ + has: page.locator('span', { hasText: /^[일월화수목금토]$/ }), + }).first(); + + test('과거 날짜로 저장한 기록은 오늘 날짜 홈에서 표시되지 않는다', async ({ page }) => { + await page.goto('/'); + + // WeekCalendar가 보이면 홈이 렌더링된 상태 + await expect(getWeekCalendar(page)).toBeVisible({ timeout: 5000 }); + + // 오늘 기록 목록에 과거 날짜 기록이 없어야 함 + await expect(page.getByText(PAST_RECORD_TITLE)).not.toBeVisible(); + }); + + test('홈 주간 달력에서 과거 날짜를 선택하면 해당 기록이 표시된다', async ({ page }) => { + await page.goto('/'); + + const weekCalendar = getWeekCalendar(page); + await expect(weekCalendar).toBeVisible({ timeout: 5000 }); + + // 어제 날짜 버튼 찾기 (오늘이 일요일이면 어제는 이전 주) + const yesterdayButton = weekCalendar.locator('button').filter({ + hasText: new RegExp(`^${YESTERDAY_DAY}$`), + }).first(); + + const isInCurrentWeek = await yesterdayButton.isVisible({ timeout: 3000 }).catch(() => false); + + if (!isInCurrentWeek) { + // 오늘이 일요일 — 어제(토요일)는 이전 주 + // cursor-grab div(Framer Motion 드래그 영역)를 오른쪽으로 드래그해 이전 주로 이동 + const swipeArea = weekCalendar.locator('[class*="cursor-grab"]').first(); + const box = await swipeArea.boundingBox(); + if (box) { + const y = box.y + box.height / 2; + await page.mouse.move(box.x + box.width * 0.25, y); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.75, y, { steps: 20 }); + await page.mouse.up(); + } + // 이전 주가 렌더링되어 어제 버튼이 나타날 때까지 대기 + await expect(yesterdayButton).toBeVisible({ timeout: 3000 }); + } + + await yesterdayButton.click(); + + // 해당 날짜의 기록이 표시됨 (.first()로 이전 테스트 실행 잔여 데이터 허용) + await expect(page.getByText(PAST_RECORD_TITLE).first()).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/frontend/e2e/date-selector-drawer.spec.ts b/frontend/e2e/date-selector-drawer.spec.ts new file mode 100644 index 000000000..c53717f3a --- /dev/null +++ b/frontend/e2e/date-selector-drawer.spec.ts @@ -0,0 +1,99 @@ +import { test, expect, type Page } from '@playwright/test'; +import path from 'path'; +import { createTestRecord, deleteTestRecord } from './fixtures/api'; + +const TODAY = new Date(); +const CURRENT_YEAR = TODAY.getFullYear(); +const CURRENT_YEAR_MONTH = `${CURRENT_YEAR}-${String(TODAY.getMonth() + 1).padStart(2, '0')}`; +const TODAY_STR = `${CURRENT_YEAR_MONTH}-${String(TODAY.getDate()).padStart(2, '0')}`; +const TODAY_DAY = TODAY.getDate(); + +const PREV_MONTH = new Date(CURRENT_YEAR, TODAY.getMonth() - 1, 1); +const PREV_YEAR_MONTH = `${PREV_MONTH.getFullYear()}-${String(PREV_MONTH.getMonth() + 1).padStart(2, '0')}`; + +const RECORD_TITLE = 'E2E 날짜 찾기 테스트 기록'; + +async function openDateDrawer(page: Page) { + await page.goto('/my'); + await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {}); + // "내 기록함" heading과 같은 flex-between 행에 있는 달력 버튼 + const calendarBtn = page + .locator('.flex.items-center.justify-between') + .filter({ has: page.getByRole('heading', { name: '내 기록함' }) }) + .getByRole('button'); + await expect(calendarBtn).toBeVisible({ timeout: 8000 }); + await calendarBtn.click(); + await expect(page.getByText('날짜로 찾기')).toBeVisible({ timeout: 5000 }); +} + +test.describe('내 기록함 날짜 찾기 drawer', () => { + let postId: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const record = await createTestRecord(page, RECORD_TITLE, TODAY_STR); + postId = record.id; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + if (!postId) return; + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestRecord(page, postId); + await ctx.close(); + }); + + test('날짜를 선택하면 해당 날짜의 기록을 타임라인으로 확인할 수 있다', async ({ page }) => { + await openDateDrawer(page); + + // 달력에서 오늘 날짜 버튼 클릭 — drawer 내 dialog 영역으로 스코프 + // "전월"/"후월" 레이블 span과 구분하기 위해 exact: true 사용 + await Promise.all([ + page.waitForURL(new RegExp(`/my/detail/${TODAY_STR}`), { timeout: 10000 }), + page + .locator('[role="dialog"]') + .getByRole('button', { name: String(TODAY_DAY), exact: true }) + .click(), + ]); + + // 오늘 생성한 기록 타이틀 확인 (타임라인에서 같은 타이틀이 여러 요소로 렌더링될 수 있어 first() 사용) + await expect(page.getByText(RECORD_TITLE).first()).toBeVisible({ timeout: 8000 }); + }); + + test('전월로 이동 후 월 기록 전체보기를 클릭하면 해당 월의 기록함으로 이동한다', async ({ + page, + }) => { + await openDateDrawer(page); + + // 전월 버튼: "전월" span의 직접 부모 div 안의 버튼 + // div.filter({has: span}) 은 모든 조상 div에 매칭되므로 xpath=.. 으로 span의 부모만 타겟 + await page + .locator('span') + .filter({ hasText: /^전월$/ }) + .locator('xpath=..') + .getByRole('button') + .click(); + + // 헤더 월 레이블이 전월로 변경됐는지 확인 + await expect( + page.getByText(`${PREV_MONTH.getFullYear()}년 ${PREV_MONTH.getMonth() + 1}월`), + ).toBeVisible({ timeout: 3000 }); + + // 월 기록 전체보기 클릭 + await Promise.all([ + page.waitForURL(new RegExp(`/my/month/${PREV_YEAR_MONTH}`), { timeout: 10000 }), + page.getByText('월 기록 전체보기').click(), + ]); + }); + + test('년도 기록 전체보기를 클릭하면 해당 년도의 기록함으로 이동한다', async ({ page }) => { + await openDateDrawer(page); + + await Promise.all([ + page.waitForURL(new RegExp(`/my/year/${CURRENT_YEAR}`), { timeout: 10000 }), + page.getByText(`${CURRENT_YEAR}년 기록 전체보기`).click(), + ]); + }); +}); diff --git a/frontend/e2e/fixtures/api.ts b/frontend/e2e/fixtures/api.ts new file mode 100644 index 000000000..6b34cb407 --- /dev/null +++ b/frontend/e2e/fixtures/api.ts @@ -0,0 +1,282 @@ +import { Page, Browser, BrowserContext } from '@playwright/test'; + +const TODAY = new Date().toISOString().split('T')[0]; + +export interface CreatedRecord { + id: string; + title: string; +} + +export interface CreatedGroup { + id: string; + inviteCode: string; +} + +async function getAuthHeaders(page: Page): Promise> { + const cookies = await page.context().cookies(); + const token = cookies.find((c) => c.name === 'x-guest-access-token')?.value; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +/** + * 테스트용 기록을 실 백엔드에 생성하고 id를 반환한다. + * @param options.extraBlocks - 기본 DATE/TIME/TEXT 블록 뒤에 추가할 블록 (row 3+부터 지정) + * 태그 추가 예시: { type: 'tags', value: { tags: ['tag1'] }, layout: { row: 3, col: 1, span: 2 } } + */ +export async function createTestRecord( + page: Page, + title = 'E2E 테스트 기록', + date = TODAY, + options?: { + extraBlocks?: Array<{ type: string; value: object; layout: { row: number; col: number; span: number } }>; + }, +): Promise { + const headers = await getAuthHeaders(page); + const blocks = [ + { type: 'DATE', value: { date }, layout: { row: 1, col: 1, span: 1 } }, + { type: 'TIME', value: { time: '12:00' }, layout: { row: 1, col: 2, span: 1 } }, + { type: 'TEXT', value: { text: 'E2E 테스트 내용입니다.' }, layout: { row: 2, col: 1, span: 2 } }, + ...(options?.extraBlocks ?? []), + ]; + const res = await page.request.post('/api/posts', { + headers, + data: { title, blocks, scope: 'PERSONAL' }, + }); + + const body = await res.json(); + if (!body.success) throw new Error(`기록 생성 실패: ${JSON.stringify(body.error)}`); + return { id: body.data.id, title }; +} + +/** + * 테스트용 기록을 삭제한다. + */ +export async function deleteTestRecord( + page: Page, + recordId: string, +): Promise { + const headers = await getAuthHeaders(page); + await page.request.delete(`/api/posts/${recordId}`, { headers }); +} + +// 1×1 투명 PNG (68 bytes) +const MINIMAL_PNG_B64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg=='; + +/** + * 1×1 PNG를 MinIO에 업로드하고 mediaId를 반환한다. + * presign → PUT → complete 3단계 플로우. + */ +export async function uploadTestImage(page: Page): Promise { + const headers = await getAuthHeaders(page); + + const presignRes = await page.request.post('/api/media/presign', { + headers, + data: { files: [{ contentType: 'image/png', size: 68 }] }, + }); + const presignBody = await presignRes.json(); + if (!presignBody.success) throw new Error(`presign 실패: ${JSON.stringify(presignBody.error)}`); + + const { mediaId, uploadUrl } = presignBody.data.items[0] as { + mediaId: string; + uploadUrl: string; + }; + + const imageData = Buffer.from(MINIMAL_PNG_B64, 'base64'); + const uploadRes = await page.request.put(uploadUrl, { + data: imageData, + headers: { 'Content-Type': 'image/png' }, + }); + if (!uploadRes.ok()) throw new Error(`MinIO 업로드 실패: ${uploadRes.status()}`); + + const completeRes = await page.request.post('/api/media/complete', { + headers, + data: { mediaIds: [mediaId] }, + }); + const completeBody = await completeRes.json(); + if (!completeBody.success) throw new Error(`complete 실패: ${JSON.stringify(completeBody.error)}`); + + return mediaId; +} + +/** + * 테스트용 그룹을 생성하고 초대 코드를 반환한다. + */ +export async function createTestGroup( + page: Page, + name = 'E2E 테스트 그룹', +): Promise { + const headers = await getAuthHeaders(page); + + const groupRes = await page.request.post('/api/groups', { + headers, + data: { name }, + }); + const groupBody = await groupRes.json(); + if (!groupBody.success) throw new Error(`그룹 생성 실패: ${JSON.stringify(groupBody.error)}`); + + const groupId = groupBody.data.id; + + const inviteRes = await page.request.post(`/api/groups/${groupId}/invites`, { + headers, + data: { permission: 'EDITOR', expiresInSeconds: 86400 }, + }); + const inviteBody = await inviteRes.json(); + if (!inviteBody.success) throw new Error(`초대 코드 생성 실패: ${JSON.stringify(inviteBody.error)}`); + + return { id: groupId, inviteCode: inviteBody.data.code }; +} + +/** + * 새 게스트 계정을 생성하고 auth setup과 동일하게 쿠키 + localStorage를 세팅한 BrowserContext를 반환한다. + */ +export async function createGuestSession(browser: Browser): Promise { + const ctx = await browser.newContext({ baseURL: 'http://localhost:3000' }); + const page = await ctx.newPage(); + + const res = await page.request.post('/api/auth/guest'); + const body = await res.json(); + const authHeader = res.headers()['authorization'] ?? ''; + const token = authHeader.replace('Bearer ', ''); + const sessionId = body?.data?.guestSessionId ?? ''; + + if (!body.success || !token || !sessionId) throw new Error('게스트 세션 생성 실패'); + + // 쿠키 설정 (auth.setup.ts와 동일) + await ctx.addCookies([ + { name: 'x-guest-session-id', value: sessionId, domain: 'localhost', path: '/' }, + { name: 'x-guest-access-token', value: token, domain: 'localhost', path: '/' }, + ]); + + // localStorage에 auth-storage 주입 (Zustand persist 스토어가 이 값을 읽음) + await page.goto('/login'); + await page.evaluate( + ({ sessionId, token }) => { + const authState = { + state: { + userType: 'guest', + userId: null, + guestSessionId: sessionId, + guestAccessToken: token, + guestSessionExpiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), + isLoggedIn: true, + }, + version: 0, + }; + localStorage.setItem('auth-storage', JSON.stringify(authState)); + }, + { sessionId, token }, + ); + + await page.close(); + return ctx; +} + +/** + * 초대 코드로 그룹에 참여한다. + */ +export async function joinTestGroup(page: Page, inviteCode: string): Promise { + const headers = await getAuthHeaders(page); + const res = await page.request.post(`/api/groups/invites/${inviteCode}/join`, { headers, data: {} }); + const body = await res.json(); + if (!body.success) throw new Error(`그룹 참여 실패: ${JSON.stringify(body.error)}`); +} + +/** + * 공동 기록 draft를 생성하고 draftId를 반환한다. + */ +export async function createTestDraft(page: Page, groupId: string): Promise { + const headers = await getAuthHeaders(page); + const res = await page.request.get(`/api/groups/${groupId}/posts/new`, { headers }); + const body = await res.json(); + if (!body.success) throw new Error(`드래프트 생성 실패: ${JSON.stringify(body.error)}`); + const redirectUrl: string = body.data.redirectUrl; + return redirectUrl.split('/').pop() ?? ''; +} + +/** + * 테스트용 그룹을 삭제한다. + */ +export async function deleteTestGroup( + page: Page, + groupId: string, +): Promise { + const headers = await getAuthHeaders(page); + await page.request.delete(`/api/groups/${groupId}`, { headers }); +} + +/** + * 그룹에서 나간다 (DELETE /api/groups/{groupId}/members/me). + */ +export async function leaveTestGroup(page: Page, groupId: string): Promise { + const headers = await getAuthHeaders(page); + const res = await page.request.delete(`/api/groups/${groupId}/members/me`, { headers }); + const body = await res.json(); + if (!body.success) throw new Error(`그룹 나가기 실패: ${JSON.stringify(body.error)}`); +} + +/** + * 그룹 내 닉네임을 설정한다 (PATCH /api/groups/{groupId}/members/me). + */ +export async function updateGroupMemberProfile( + page: Page, + groupId: string, + nickname: string, +): Promise { + const headers = await getAuthHeaders(page); + const res = await page.request.patch(`/api/groups/${groupId}/members/me`, { + headers, + data: { nicknameInGroup: nickname }, + }); + const body = await res.json(); + if (!body.success) throw new Error(`그룹 프로필 변경 실패: ${JSON.stringify(body.error)}`); +} + +/** + * 특정 멤버의 역할을 변경한다 (PATCH /api/groups/{groupId}/members/{userId}/role). + */ +export async function changeMemberRole( + page: Page, + groupId: string, + userId: string, + role: 'ADMIN' | 'EDITOR' | 'VIEWER', +): Promise { + const headers = await getAuthHeaders(page); + const res = await page.request.patch(`/api/groups/${groupId}/members/${userId}/role`, { + headers, + data: { role }, + }); + const body = await res.json(); + if (!body.success) throw new Error(`역할 변경 실패: ${JSON.stringify(body.error)}`); +} + +export interface GroupMemberInfo { + userId: string; + role: string; + name: string; + nicknameInGroup: string; +} + +/** + * 그룹 설정(멤버 목록 포함)을 조회한다 (GET /api/groups/{groupId}/settings). + */ +export async function getGroupSettings( + page: Page, + groupId: string, +): Promise<{ members: GroupMemberInfo[] }> { + const headers = await getAuthHeaders(page); + const res = await page.request.get(`/api/groups/${groupId}/settings`, { headers }); + const body = await res.json(); + if (!body.success) throw new Error(`그룹 설정 조회 실패: ${JSON.stringify(body.error)}`); + return body.data; +} + +/** + * 현재 계정을 탈퇴 처리한다 (DELETE /api/me). + */ +export async function withdrawTestUser(page: Page): Promise { + const headers = await getAuthHeaders(page); + const res = await page.request.delete('/api/me', { headers }); + const body = await res.json(); + if (!body.success) throw new Error(`회원 탈퇴 실패: ${JSON.stringify(body.error)}`); +} diff --git a/frontend/e2e/group-delete.spec.ts b/frontend/e2e/group-delete.spec.ts new file mode 100644 index 000000000..58e711bea --- /dev/null +++ b/frontend/e2e/group-delete.spec.ts @@ -0,0 +1,122 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { + createTestGroup, + createGuestSession, + joinTestGroup, +} from './fixtures/api'; + +const GROUP_NAME = 'E2E 그룹 삭제 테스트'; + +/** + * 그룹 삭제 시나리오 + * + * 1. Non-admin은 그룹 삭제 UI에 접근할 수 없다 (popover에 그룹 정보 수정 버튼 없음) + * 2. VIEWER는 /edit 페이지의 삭제 버튼이 비활성화된다 + * 3. Admin이 그룹을 삭제하면 본인의 /shared에서 그룹이 사라진다 + * 4. Admin이 그룹을 삭제하면 다른 멤버(B)의 /shared에서도 그룹이 사라진다 + * + * 주의: 삭제 테스트는 afterAll 정리가 필요 없다 (그룹이 테스트 내에서 삭제됨). + * 그러나 삭제가 실패하면 잔여 그룹이 남을 수 있으므로 각 describe에서 독립적인 그룹을 생성한다. + */ + +test.describe('그룹 삭제 - Non-admin 접근 제한', () => { + let groupId: string; + let inviteCode: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const group = await createTestGroup(page, 'E2E 삭제 접근제한 테스트'); + groupId = group.id; + inviteCode = group.inviteCode; + await ctx.close(); + }); + + // 이 describe의 그룹은 테스트 중 삭제되지 않으므로 afterAll에서 정리 + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await page.request.delete(`/api/groups/${groupId}`); + await ctx.close(); + }); + + test('EDITOR 멤버는 그룹 홈 Popover에서 그룹 정보 수정 버튼이 없어 삭제 페이지에 접근할 수 없다', async ({ browser }) => { + const ctxB = await createGuestSession(browser); + const pageB = await ctxB.newPage(); + try { + await joinTestGroup(pageB, inviteCode); + await pageB.goto(`/group/${groupId}`); + await expect(pageB.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await pageB.getByRole('button', { name: '그룹 메뉴' }).click(); + + // EDITOR는 그룹 정보 수정 메뉴가 없으므로 삭제 페이지 진입 불가 + await expect(pageB.getByRole('button', { name: '그룹 정보 수정' })).not.toBeVisible({ timeout: 3000 }); + } finally { + await ctxB.close(); + } + }); +}); + +test.describe('그룹 삭제 - Admin이 삭제 후 /shared 비노출 확인', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + // B의 context는 시나리오 전체에서 살아있어야 한다 + let ctxBRef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + // Admin A(guest.json)가 그룹 생성 + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, GROUP_NAME); + groupId = group.id; + inviteCode = group.inviteCode; + + // B가 그룹 참여 + ctxBRef = await createGuestSession(browser); + const pageB = await ctxBRef.newPage(); + await joinTestGroup(pageB, inviteCode); + await pageB.close(); + + await ctxA.close(); + }); + + test.afterAll(async () => { + await ctxBRef?.close(); + ctxBRef = null; + }); + + test('Admin이 그룹 삭제 drawer를 열고 삭제하면 /shared 페이지로 리다이렉트된다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit`); + + // 삭제 drawer 오픈 + await page.getByRole('button', { name: '그룹 삭제하기' }).click(); + await expect(page.getByText(`정말 '${GROUP_NAME}' 그룹을 삭제하시겠습니까?`)).toBeVisible({ timeout: 5000 }); + + // 삭제 확인 + await page.getByRole('dialog').getByRole('button', { name: '삭제하기' }).click(); + + // 삭제 후 /shared로 이동 + await expect(page).toHaveURL(/\/shared/, { timeout: 10000 }); + }); + + test('삭제된 그룹은 Admin A의 /shared에서 더 이상 표시되지 않는다', async ({ page }) => { + await page.goto('/shared'); + // 그룹 이름이 포함된 카드가 없어야 한다 + await expect(page.getByRole('button', { name: GROUP_NAME })).not.toBeVisible({ timeout: 8000 }); + }); + + test('삭제된 그룹은 멤버였던 B의 /shared에서도 표시되지 않는다', async () => { + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + await pageB.goto('/shared'); + await expect(pageB.getByRole('button', { name: GROUP_NAME })).not.toBeVisible({ timeout: 8000 }); + } finally { + await pageB.close(); + } + }); +}); diff --git a/frontend/e2e/group-draft-collaboration.spec.ts b/frontend/e2e/group-draft-collaboration.spec.ts new file mode 100644 index 000000000..9fc8e1ca9 --- /dev/null +++ b/frontend/e2e/group-draft-collaboration.spec.ts @@ -0,0 +1,246 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { + createTestGroup, + deleteTestGroup, + createGuestSession, + joinTestGroup, + createTestDraft, + deleteTestRecord, +} from './fixtures/api'; + +const DRAFT_TEXT = 'E2E 공동 기록 실시간 텍스트'; +const LOCK_RELEASE_TEXT = 'B가 락 해제 후 편집한 텍스트'; +const TITLE_TEXT = 'E2E 공동기록 제목 동기화'; + +async function openDraftAsTwoMembers( + browser: import('@playwright/test').Browser, + groupId: string, + inviteCode: string, + draftId: string, +) { + const ctxA = await browser.newContext({ + storageState: path.join(__dirname, '.auth/guest.json'), + }); + const ctxB = await createGuestSession(browser); + const pageA = await ctxA.newPage(); + const pageB = await ctxB.newPage(); + + await joinTestGroup(pageB, inviteCode); + + const draftUrl = `/group/${groupId}/post/${draftId}`; + await Promise.all([pageA.goto(draftUrl), pageB.goto(draftUrl)]); + + const textInputA = pageA.getByPlaceholder('어떤 기억이 있으신가요?'); + const textInputB = pageB.getByPlaceholder('어떤 기억이 있으신가요?'); + await expect(textInputA).toBeVisible({ timeout: 15000 }); + await expect(textInputB).toBeVisible({ timeout: 15000 }); + + // 두 소켓이 같은 draft room에 완전히 연결됨을 보장 + await expect(pageA.getByText(/2명.*편집 중/)).toBeVisible({ timeout: 15000 }); + await expect(pageB.getByText(/2명.*편집 중/)).toBeVisible({ timeout: 15000 }); + + return { ctxA, ctxB, pageA, pageB, textInputA, textInputB }; +} + +test.describe('공동 기록 실시간 협업', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + let draftId: string; + + test.beforeAll(async ({ browser }) => { + // 멤버 A: 기존 게스트 계정으로 그룹 생성 + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + + const group = await createTestGroup(pageA, 'E2E 공동기록 테스트 그룹'); + groupId = group.id; + inviteCode = group.inviteCode; + + // 멤버 B: 새 게스트 계정으로 그룹 참여 + const ctxB = await createGuestSession(browser); + const pageB = await ctxB.newPage(); + await joinTestGroup(pageB, inviteCode); + + // 멤버 A가 draft 생성 + draftId = await createTestDraft(pageA, groupId); + + await ctxA.close(); + await ctxB.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + await deleteTestGroup(pageA, groupId); + await ctxA.close(); + }); + + test('공동 기록 draft 생성 시 그룹 홈 floating button에 진행 중인 draft 수가 표시된다', async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + + try { + await page.goto(`/group/${groupId}`); + await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {}); + + // floating button에 draft 개수 badge가 표시되는지 확인 + const badge = page.locator('span').filter({ hasText: /^[1-9]\d*$/ }).first(); + await expect(badge).toBeVisible({ timeout: 10000 }); + } finally { + await ctx.close(); + } + }); + + test('한 멤버가 블록 내용을 수정하면 다른 멤버 화면에 실시간으로 반영된다', async ({ browser }) => { + const { ctxA, ctxB, pageA, pageB, textInputA } = await openDraftAsTwoMembers( + browser, groupId, inviteCode, draftId, + ); + try { + // 멤버 A가 텍스트 블록에 실제 타이핑으로 입력 (BLOCK_VALUE_STREAM 트리거) + await textInputA.click(); + await textInputA.pressSequentially(DRAFT_TEXT, { delay: 50 }); + + // 멤버 B 화면에 실시간으로 반영 확인 (BLOCK_VALUE_STREAM) + await expect(pageB.getByText(DRAFT_TEXT)).toBeVisible({ timeout: 10000 }); + + // A가 blur → PATCH_APPLY → PATCH_COMMITTED, B에서 확정된 값으로 표시 + await textInputA.press('Tab'); + await expect(pageB.getByText(DRAFT_TEXT)).toBeVisible({ timeout: 5000 }); + } finally { + await ctxA.close(); + await ctxB.close(); + } + }); + + test('한 멤버가 블록을 편집 중이면 다른 멤버 화면에 잠금 아이콘이 표시된다', async ({ browser }) => { + const { ctxA, ctxB, pageA, pageB, textInputA } = await openDraftAsTwoMembers( + browser, groupId, inviteCode, draftId, + ); + try { + // 멤버 A가 블록 클릭 → LOCK_ACQUIRE → LOCK_GRANTED → LOCK_CHANGED 브로드캐스트 + await textInputA.click(); + + // 멤버 B 화면에 락 아이콘(animate-pulse 원형 아바타)이 나타나는지 확인 + const lockIndicator = pageB.locator('[class*="animate-pulse"]'); + await expect(lockIndicator).toBeVisible({ timeout: 10000 }); + } finally { + await ctxA.close(); + await ctxB.close(); + } + }); + + test('편집 중인 멤버가 블록을 떠나면 잠금이 해제되어 다른 멤버가 편집할 수 있다', async ({ browser }) => { + const { ctxA, ctxB, pageA, pageB, textInputA, textInputB } = await openDraftAsTwoMembers( + browser, groupId, inviteCode, draftId, + ); + try { + // A가 블록 클릭 → 락 획득, B에서 lock indicator 확인 + await textInputA.click(); + const lockIndicator = pageB.locator('[class*="animate-pulse"]'); + await expect(lockIndicator).toBeVisible({ timeout: 10000 }); + + // A가 blur → LOCK_RELEASE, B에서 lock indicator 사라짐 + await textInputA.press('Tab'); + await expect(lockIndicator).not.toBeVisible({ timeout: 8000 }); + + // B가 이제 같은 블록을 편집할 수 있음 (LOCK_GRANTED) + await textInputB.click(); + await textInputB.pressSequentially(LOCK_RELEASE_TEXT, { delay: 50 }); + + // A 화면에 B가 입력한 내용이 반영됨 + await expect(pageA.getByText(LOCK_RELEASE_TEXT)).toBeVisible({ timeout: 10000 }); + } finally { + await ctxA.close(); + await ctxB.close(); + } + }); + + test('멤버가 draft 페이지를 떠나면 다른 멤버의 편집 중 인원 수가 감소한다', async ({ browser }) => { + const { ctxA, ctxB, pageA, pageB } = await openDraftAsTwoMembers( + browser, groupId, inviteCode, draftId, + ); + try { + // B가 draft 페이지를 떠남 → LEAVE_DRAFT 이벤트 발생 + await pageB.goto('/'); + + // A 화면에서 인원 수가 1명으로 줄어듦 (PRESENCE_LEFT 수신) + await expect(pageA.getByText(/1명.*편집 중/)).toBeVisible({ timeout: 10000 }); + } finally { + await ctxA.close(); + await ctxB.close(); + } + }); + + test('한 멤버가 제목을 입력하면 다른 멤버 화면에 실시간으로 반영된다', async ({ browser }) => { + const { ctxA, ctxB, pageA, pageB } = await openDraftAsTwoMembers( + browser, groupId, inviteCode, draftId, + ); + try { + const titleInputA = pageA.getByPlaceholder('제목을 입력하세요'); + const titleInputB = pageB.getByPlaceholder('제목을 입력하세요'); + await expect(titleInputA).toBeVisible({ timeout: 10000 }); + await expect(titleInputB).toBeVisible({ timeout: 10000 }); + + // A가 제목 입력 (BLOCK_SET_TITLE 패치) + await titleInputA.click(); + await titleInputA.pressSequentially(TITLE_TEXT, { delay: 50 }); + + // B 화면에 제목 반영 확인 (input value는 toHaveValue로 검증) + await expect(titleInputB).toHaveValue(TITLE_TEXT, { timeout: 10000 }); + } finally { + await ctxA.close(); + await ctxB.close(); + } + }); + + test('한 멤버가 저장하면 모든 멤버 화면에 저장 로더가 표시되고, 저장 완료 후 기록 상세에서 편집 참여자가 역할과 함께 표시된다', async ({ browser }) => { + const { ctxA, ctxB, pageA, pageB, textInputB } = await openDraftAsTwoMembers( + browser, groupId, inviteCode, draftId, + ); + let postId: string | null = null; + try { + // B가 블록을 편집해 contributor로 등록 (PATCH_COMMITTED) + await textInputB.click(); + await textInputB.pressSequentially('B참여자등록텍스트', { delay: 50 }); + await textInputB.press('Tab'); + + // B의 패치가 서버에 커밋되어 A 화면에 반영될 때까지 대기 + // → 이 시점에 draftStateService.getTouchedBy()에 B의 userId가 등록됨 + await expect(pageA.getByText('B참여자등록텍스트')).toBeVisible({ timeout: 10000 }); + + // A가 저장 버튼 클릭 → DRAFT_PUBLISH_STARTED 브로드캐스트 + const saveBtn = pageA.getByRole('button', { name: '저장' }); + await expect(saveBtn).toBeVisible({ timeout: 10000 }); + + // 오버레이 리스너를 먼저 등록한 후 저장 — 빠른 저장으로 오버레이를 놓치지 않도록 + const overlayAppeared = pageB.locator('[data-auth-loading]').waitFor({ state: 'visible', timeout: 8000 }).catch(() => {}); + await saveBtn.click(); + await overlayAppeared; // DRAFT_PUBLISH_STARTED → isPublishing = true + + // 저장 완료 후 A와 B 모두 기록 상세 페이지로 이동 (DRAFT_PUBLISHED) + await expect(pageA).toHaveURL(/\/record\/[a-z0-9-]+/, { timeout: 20000 }); + await expect(pageB).toHaveURL(/\/record\/[a-z0-9-]+/, { timeout: 20000 }); + expect(pageA.url()).toBe(pageB.url()); + + postId = pageA.url().split('/record/')[1]?.split('?')[0] ?? null; + + // 기록 상세 페이지의 작성자 섹션이 렌더링됐는지 확인 + const contributorsHeading = pageA.getByRole('heading', { name: '작성자' }); + await expect(contributorsHeading).toBeVisible({ timeout: 8000 }); + + // 역할 배지(span)로 기여자 등록 여부 검증 — h2 헤딩 "작성자"와 구분되어 span만 매칭됨 + // 서버 정책: 모든 기여자에게 AUTHOR 역할 할당 → UI에서 "작성자" 배지로 표시됨 + const roleBadges = pageA.locator('span').filter({ hasText: /^(작성자|편집자)$/ }); + await expect(roleBadges.first()).toBeVisible({ timeout: 8000 }); + // 저장자(A)와 편집 참여자(B) 모두 기여자로 표시됨 + await expect(roleBadges.nth(1)).toBeVisible({ timeout: 5000 }); + } finally { + if (postId) await deleteTestRecord(pageA, postId).catch(() => {}); + await ctxA.close(); + await ctxB.close(); + } + }); +}); diff --git a/frontend/e2e/group-invite.spec.ts b/frontend/e2e/group-invite.spec.ts new file mode 100644 index 000000000..e10d2ac6e --- /dev/null +++ b/frontend/e2e/group-invite.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { createTestGroup, deleteTestGroup } from './fixtures/api'; + +test.describe('그룹 초대', () => { + let groupId: string; + let inviteCode: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const group = await createTestGroup(page); + groupId = group.id; + inviteCode = group.inviteCode; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('초대 코드로 접근하면 그룹 초대 UI와 그룹 정보(이름, 인원수)가 표시된다', async ({ page }) => { + // 초대 페이지는 커버 이미지 로드로 'load' 이벤트가 느릴 수 있어 domcontentloaded 사용 + await page.goto(`/invite?inviteCode=${inviteCode}`, { waitUntil: 'domcontentloaded' }); + await expect(page.getByText('회원님을 그룹에 초대했습니다.')).toBeVisible({ timeout: 10000 }); + + // 그룹 이름 표시 확인 + await expect(page.getByText('E2E 테스트 그룹')).toBeVisible({ timeout: 10000 }); + + // 인원수 1명 표시 확인 + await expect(page.getByText(/1명/)).toBeVisible({ timeout: 5000 }); + }); + + test('게스트로 인증된 사용자에게는 게스트 계정으로 참여하기 버튼이 표시된다', async ({ page }) => { + await page.goto(`/invite?inviteCode=${inviteCode}`); + // auth 스토어 hydration 완료 대기 — guest 상태면 "게스트 계정으로 참여하기" + const joinButton = page.getByRole('button', { name: /게스트 계정으로 참여하기/i }); + await expect(joinButton).toBeVisible({ timeout: 5000 }); + }); + + test('미인증 사용자에게는 로그인하고 참여하기 버튼이 표시된다', async ({ browser }) => { + // 명시적 빈 storageState로 완전한 비인증 상태 보장 + const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const anonPage = await ctx.newPage(); + try { + await anonPage.goto(`/invite?inviteCode=${inviteCode}`); + await expect(anonPage.getByRole('button', { name: '로그인하고 참여하기' })).toBeVisible({ timeout: 8000 }); + } finally { + await ctx.close(); + } + }); + + test('미인증 사용자가 로그인하고 참여하기 버튼을 클릭하면 로그인 페이지로 이동한다', async ({ browser }) => { + const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const anonPage = await ctx.newPage(); + try { + await anonPage.goto(`/invite?inviteCode=${inviteCode}`, { waitUntil: 'domcontentloaded' }); + // API 응답 및 React hydration 완료 대기 (이 텍스트는 useQuery 응답 후에만 렌더링됨) + await expect(anonPage.getByText('회원님을 그룹에 초대했습니다.')).toBeVisible({ timeout: 10000 }); + await expect(anonPage.getByRole('button', { name: '로그인하고 참여하기' })).toBeVisible({ timeout: 8000 }); + await anonPage.getByRole('button', { name: '로그인하고 참여하기' }).click(); + await expect(anonPage).toHaveURL(/\/login/, { timeout: 15000 }); + } finally { + await ctx.close(); + } + }); + + test('초대 코드 없이 접근하면 유효하지 않은 초대 링크 메시지가 표시된다', async ({ page }) => { + await page.goto('/invite'); + await expect(page.getByText('유효하지 않은 초대 링크')).toBeVisible({ timeout: 8000 }); + }); +}); diff --git a/frontend/e2e/group-leave.spec.ts b/frontend/e2e/group-leave.spec.ts new file mode 100644 index 000000000..aeae9dee2 --- /dev/null +++ b/frontend/e2e/group-leave.spec.ts @@ -0,0 +1,244 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { + createTestGroup, + deleteTestGroup, + createGuestSession, + joinTestGroup, + updateGroupMemberProfile, + getGroupSettings, + changeMemberRole, +} from './fixtures/api'; + +/** + * 그룹 나가기 시나리오 + * + * 1. EDITOR(B)가 나가면 + * - B의 /shared에서 그룹이 사라진다 + * - Admin(A)의 멤버 관리에서 B가 사라진다 + * + * 2. 마지막 Admin(A)이 나가면 → 그룹이 자동 삭제된다 + * - 나간 A도, 다른 멤버였던 B도 그룹에 접근할 수 없다 + * + * 3. Admin(C)이 나가도 다른 Admin(A)이 남아있으면 → C만 나가고 그룹은 유지된다 + * - C의 /shared에서 그룹이 사라진다 + * - A의 멤버 관리에서 C가 사라지고 그룹은 여전히 존재한다 + */ + +// ── 시나리오 1: EDITOR 멤버 나가기 ────────────────────────────────────────── + +test.describe('그룹 나가기 - EDITOR 멤버 나가기', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + const MEMBER_B_NICKNAME = 'E2E나가기멤버'; + let ctxBRef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, 'E2E EDITOR 나가기 테스트'); + groupId = group.id; + inviteCode = group.inviteCode; + + ctxBRef = await createGuestSession(browser); + const pageB = await ctxBRef.newPage(); + await joinTestGroup(pageB, inviteCode); + await updateGroupMemberProfile(pageB, groupId, MEMBER_B_NICKNAME); + await pageB.close(); + + await ctxA.close(); + }); + + test.afterAll(async ({ browser }) => { + await ctxBRef?.close(); + ctxBRef = null; + // B가 나간 후 그룹은 A(guest.json)가 소유 → 정리 + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('B가 그룹 나가기를 실행하면 B의 /shared에서 그룹이 사라진다', async () => { + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + // 나가기 버튼 클릭 → drawer 확인 → 나가기 + await pageB.goto(`/group/${groupId}`); + await expect(pageB.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await pageB.getByRole('button', { name: '그룹 메뉴' }).click(); + await pageB.getByRole('button', { name: '그룹 나가기' }).click(); + + // 확인 drawer + await expect(pageB.getByRole('dialog').getByRole('button', { name: '그룹 나가기' })).toBeVisible({ timeout: 5000 }); + await pageB.getByRole('dialog').getByRole('button', { name: '그룹 나가기' }).click(); + + // /shared로 리다이렉트 + await expect(pageB).toHaveURL(/\/shared/, { timeout: 10000 }); + + // B의 /shared에서 그룹 카드 비노출 + await expect(pageB.getByRole('button', { name: 'E2E EDITOR 나가기 테스트' })).not.toBeVisible({ timeout: 8000 }); + } finally { + await pageB.close(); + } + }); + + test('B가 나가면 Admin A의 멤버 관리 페이지에서 B가 목록에 표시되지 않는다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit/members`); + await expect(page.getByText(MEMBER_B_NICKNAME)).not.toBeVisible({ timeout: 8000 }); + }); +}); + +// ── 시나리오 2: 마지막 Admin이 나가면 그룹 삭제 ────────────────────────────── +// TODO: 백엔드에서 다른 멤버가 있을 때 마지막 Admin의 나가기 허용 + 그룹 자동 삭제 구현 후 활성화 + +test.describe.skip('그룹 나가기 - 마지막 Admin 나가기 → 그룹 자동 삭제', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + let ctxARef: import('@playwright/test').BrowserContext | null = null; + let ctxBRef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + // 마지막 Admin: 새 게스트 A (guest.json 보호를 위해 fresh session 사용) + ctxARef = await createGuestSession(browser); + const pageA = await ctxARef.newPage(); + const group = await createTestGroup(pageA, 'E2E 마지막Admin 나가기'); + groupId = group.id; + inviteCode = group.inviteCode; + + // B도 참여 (EDITOR) + ctxBRef = await createGuestSession(browser); + const pageB = await ctxBRef.newPage(); + await joinTestGroup(pageB, inviteCode); + await pageB.close(); + await pageA.close(); + }); + + test.afterAll(async () => { + await ctxARef?.close(); + ctxARef = null; + await ctxBRef?.close(); + ctxBRef = null; + // 그룹은 나가기로 삭제되므로 별도 cleanup 불필요 + }); + + test('마지막 Admin이 나가면 /shared로 이동하고 그룹이 사라진다', async () => { + if (!ctxARef) throw new Error('A context not initialized'); + const pageA = await ctxARef.newPage(); + try { + await pageA.goto(`/group/${groupId}`); + await expect(pageA.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await pageA.getByRole('button', { name: '그룹 메뉴' }).click(); + await pageA.getByRole('button', { name: '그룹 나가기' }).click(); + + // 확인 drawer에서 나가기 + await pageA.getByRole('dialog').getByRole('button', { name: '그룹 나가기' }).click(); + + // /shared 이동 + await expect(pageA).toHaveURL(/\/shared/, { timeout: 10000 }); + await expect(pageA.getByRole('button', { name: 'E2E 마지막Admin 나가기' })).not.toBeVisible({ timeout: 8000 }); + } finally { + await pageA.close(); + } + }); + + test('그룹 삭제 후 다른 멤버였던 B도 해당 그룹에 접근할 수 없다', async () => { + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + await pageB.goto('/shared'); + await expect(pageB.getByRole('button', { name: 'E2E 마지막Admin 나가기' })).not.toBeVisible({ timeout: 8000 }); + + // 그룹 페이지 직접 접근 시 그룹 없음 처리 (리다이렉트 또는 404) + await pageB.goto(`/group/${groupId}`); + // 그룹이 없으므로 /shared 또는 에러 페이지로 이동 + await expect(pageB).not.toHaveURL(new RegExp(`/group/${groupId}$`), { timeout: 8000 }); + } finally { + await pageB.close(); + } + }); +}); + +// ── 시나리오 3: Admin 나가기 (다른 Admin 존재) → 해당 멤버만 나가기 ────────── + +test.describe('그룹 나가기 - Admin 나가기 (다른 Admin 존재) → 그룹 유지', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + let ctxCRef: import('@playwright/test').BrowserContext | null = null; + const GROUP_NAME = 'E2E Admin나가기 그룹유지'; + + test.beforeAll(async ({ browser }) => { + // Admin A: guest.json (남을 사람) + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, GROUP_NAME); + groupId = group.id; + inviteCode = group.inviteCode; + + // C: 새 게스트 (나갈 사람, ADMIN으로 승격) + ctxCRef = await createGuestSession(browser); + const pageC = await ctxCRef.newPage(); + await joinTestGroup(pageC, inviteCode); + + // A가 C를 ADMIN으로 승격 + const settings = await getGroupSettings(pageA, groupId); + const memberC = settings.members.find((m) => { + // C는 A(guest.json)가 아닌 멤버 + return m.role === 'EDITOR'; + }); + if (memberC) { + await changeMemberRole(pageA, groupId, memberC.userId, 'ADMIN'); + } + + await pageC.close(); + await ctxA.close(); + }); + + test.afterAll(async ({ browser }) => { + await ctxCRef?.close(); + ctxCRef = null; + // A(guest.json)가 남아있으므로 그룹 정리 + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('Admin C가 나가면 C의 /shared에서 그룹이 사라지지만 그룹은 삭제되지 않는다', async () => { + if (!ctxCRef) throw new Error('C context not initialized'); + const pageC = await ctxCRef.newPage(); + try { + await pageC.goto(`/group/${groupId}`); + await expect(pageC.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await pageC.getByRole('button', { name: '그룹 메뉴' }).click(); + await pageC.getByRole('button', { name: '그룹 나가기' }).click(); + await pageC.getByRole('dialog').getByRole('button', { name: '그룹 나가기' }).click(); + + await expect(pageC).toHaveURL(/\/shared/, { timeout: 10000 }); + await expect(pageC.getByRole('button', { name: GROUP_NAME })).not.toBeVisible({ timeout: 8000 }); + } finally { + await pageC.close(); + } + }); + + test('C가 나간 후에도 Admin A의 /shared에는 그룹이 남아있다', async ({ page }) => { + await page.goto('/shared'); + await expect(page.getByRole('button', { name: GROUP_NAME })).toBeVisible({ timeout: 8000 }); + }); + + test('C가 나간 후 Admin A의 멤버 관리에서 C가 목록에 없고 그룹에는 A만 남는다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit/members`); + + // 멤버 수 label: "멤버 관리 (N)" + await expect(page.getByText(/멤버 관리 \(1\)/)).toBeVisible({ timeout: 8000 }); + }); +}); diff --git a/frontend/e2e/group-management.spec.ts b/frontend/e2e/group-management.spec.ts new file mode 100644 index 000000000..8fa24e3bc --- /dev/null +++ b/frontend/e2e/group-management.spec.ts @@ -0,0 +1,363 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { + createTestGroup, + deleteTestGroup, + createGuestSession, + joinTestGroup, + updateGroupMemberProfile, +} from './fixtures/api'; + +// ── 그룹 홈 탭 및 popover 메뉴 ─────────────────────────────────────────────── + +test.describe('그룹 홈 탭 및 popover 메뉴', () => { + let groupId: string; + let inviteCode: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const group = await createTestGroup(page, 'E2E 그룹 탭 테스트'); + groupId = group.id; + inviteCode = group.inviteCode; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('그룹 홈에서 피드/보관함 탭을 전환할 수 있다', async ({ page }) => { + await page.goto(`/group/${groupId}`); + await expect(page.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + // 보관함 탭 전환 (router.replace는 history.replaceState라 waitForURL 대신 waitForFunction으로 URL 직접 polling) + await page.getByRole('button', { name: '보관함' }).click(); + await page.waitForFunction(() => window.location.search.includes('tab=archive'), { timeout: 8000 }); + + // 피드 탭 복귀 + await page.getByRole('button', { name: '피드' }).click(); + await page.waitForFunction(() => !window.location.search.includes('tab=archive'), { timeout: 8000 }); + }); + + test('Admin은 Popover에서 그룹 정보 수정, 멤버 관리, 나의 그룹 프로필, 그룹 나가기 버튼이 모두 표시된다', async ({ page }) => { + await page.goto(`/group/${groupId}`); + await expect(page.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await page.getByRole('button', { name: '그룹 메뉴' }).click(); + + await expect(page.getByRole('button', { name: '그룹 정보 수정' })).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole('button', { name: '멤버 관리' })).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole('button', { name: '나의 그룹 프로필' })).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole('button', { name: '그룹 나가기' })).toBeVisible({ timeout: 5000 }); + }); + + test('EDITOR 멤버는 Popover에서 그룹 정보 수정, 멤버 관리 버튼이 표시되지 않는다', async ({ browser }) => { + const ctxB = await createGuestSession(browser); + const pageB = await ctxB.newPage(); + try { + await joinTestGroup(pageB, inviteCode); + await pageB.goto(`/group/${groupId}`); + await expect(pageB.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await pageB.getByRole('button', { name: '그룹 메뉴' }).click(); + + await expect(pageB.getByRole('button', { name: '그룹 정보 수정' })).not.toBeVisible({ timeout: 3000 }); + await expect(pageB.getByRole('button', { name: '멤버 관리' })).not.toBeVisible({ timeout: 3000 }); + // 모든 멤버에게 표시되는 항목 + await expect(pageB.getByRole('button', { name: '나의 그룹 프로필' })).toBeVisible({ timeout: 3000 }); + await expect(pageB.getByRole('button', { name: '그룹 나가기' })).toBeVisible({ timeout: 3000 }); + } finally { + await ctxB.close(); + } + }); + + test('그룹 정보 수정 버튼 클릭 시 /edit 페이지로 이동한다', async ({ page }) => { + await page.goto(`/group/${groupId}`); + await expect(page.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await page.getByRole('button', { name: '그룹 메뉴' }).click(); + await page.getByRole('button', { name: '그룹 정보 수정' }).click(); + + await expect(page).toHaveURL(new RegExp(`/group/${groupId}/edit`), { timeout: 5000 }); + }); + + test('멤버 관리 버튼 클릭 시 /edit/members 페이지로 이동한다', async ({ page }) => { + await page.goto(`/group/${groupId}`); + await expect(page.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await page.getByRole('button', { name: '그룹 메뉴' }).click(); + await page.getByRole('button', { name: '멤버 관리' }).click(); + + await expect(page).toHaveURL(new RegExp(`/group/${groupId}/edit/members`), { timeout: 5000 }); + }); +}); + +// ── 그룹 이름 수정 ─────────────────────────────────────────────────────────── + +test.describe('그룹 정보 수정 페이지 - 그룹 이름 변경', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + const ORIGINAL_NAME = 'E2E 이름수정 전'; + const UPDATED_NAME = 'E2E 이름수정 후'; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const group = await createTestGroup(page, ORIGINAL_NAME); + groupId = group.id; + inviteCode = group.inviteCode; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('Admin이 그룹 이름을 수정하면 저장 후 변경된 이름이 표시된다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit`); + + await page.getByRole('button', { name: /편집/ }).click(); + + const nameInput = page.getByPlaceholder('그룹명을 작성해주세요.'); + await expect(nameInput).toBeEnabled({ timeout: 5000 }); + await nameInput.clear(); + await nameInput.fill(UPDATED_NAME); + + await page.getByRole('button', { name: /저장/ }).click(); + await expect(page.getByText('그룹 이름이 변경되었습니다.')).toBeVisible({ timeout: 5000 }); + await expect(nameInput).toHaveValue(UPDATED_NAME, { timeout: 5000 }); + }); + + test('Admin이 그룹 이름을 변경하면 다른 멤버가 그룹 홈에 접속할 때 변경된 이름이 표시된다', async ({ browser }) => { + const ctxB = await createGuestSession(browser); + const pageB = await ctxB.newPage(); + try { + await joinTestGroup(pageB, inviteCode); + // 그룹 홈 헤더의 그룹 이름 확인 (GroupHeaderActions의 span) + await pageB.goto(`/group/${groupId}`); + await expect(pageB.locator('span').filter({ hasText: UPDATED_NAME }).first()).toBeVisible({ timeout: 8000 }); + // /shared 카드에서도 확인 + await pageB.goto('/shared'); + await expect(pageB.getByRole('button', { name: UPDATED_NAME })).toBeVisible({ timeout: 8000 }); + } finally { + await ctxB.close(); + } + }); +}); + +// ── 멤버 역할 변경 ─────────────────────────────────────────────────────────── + +test.describe('멤버 관리 - 역할 변경', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + const MEMBER_B_NICKNAME = 'E2E역할변경멤버'; + let ctxBRef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, 'E2E 역할 변경 테스트'); + groupId = group.id; + inviteCode = group.inviteCode; + + ctxBRef = await createGuestSession(browser); + const pageB = await ctxBRef.newPage(); + await joinTestGroup(pageB, inviteCode); + await updateGroupMemberProfile(pageB, groupId, MEMBER_B_NICKNAME); + await pageB.close(); + + await ctxA.close(); + }); + + test.afterAll(async ({ browser }) => { + await ctxBRef?.close(); + ctxBRef = null; + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('Admin이 EDITOR 멤버의 역할을 뷰어로 변경할 수 있다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit/members`); + await expect(page.getByText(MEMBER_B_NICKNAME)).toBeVisible({ timeout: 8000 }); + + const bCard = page.locator('[data-testid="member-card"]').filter({ has: page.getByText(MEMBER_B_NICKNAME) }); + await bCard.getByRole('button', { name: '편집자' }).click(); + + await expect(page.getByText(`${MEMBER_B_NICKNAME}님의 권한 변경`)).toBeVisible({ timeout: 5000 }); + + // 뷰어 역할 선택 + await page.getByRole('button', { name: '뷰어' }).first().click(); + await page.getByRole('button', { name: '닫기' }).click(); + + // '닫기'는 역할 변경 API를 트리거함 → API 완료 후 낙관적 업데이트 대기 + const bCardOptimistic = page.locator('[data-testid="member-card"]').filter({ has: page.getByText(MEMBER_B_NICKNAME) }); + await expect(bCardOptimistic.locator('span').filter({ hasText: '뷰어' })).toBeVisible({ timeout: 8000 }); + + // 서버 반영 확인을 위해 reload 후 재검증 + await page.reload(); + await expect(page.getByText(MEMBER_B_NICKNAME)).toBeVisible({ timeout: 8000 }); + const bCardAfter = page.locator('[data-testid="member-card"]').filter({ has: page.getByText(MEMBER_B_NICKNAME) }); + await expect(bCardAfter.locator('span').filter({ hasText: '뷰어' })).toBeVisible({ timeout: 5000 }); + }); + + test('VIEWER로 변경된 멤버는 그룹 홈에서 초대 버튼이 표시되지 않는다', async () => { + // VIEWER는 GroupHeaderActions에서 초대 버튼(GroupInviteDrawer)이 숨겨진다 + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + await pageB.goto(`/group/${groupId}`); + await expect(pageB.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + // 초대 버튼이 없어야 함 + await expect(pageB.getByRole('button', { name: '멤버 초대' })).not.toBeVisible({ timeout: 5000 }); + } finally { + await pageB.close(); + } + }); +}); + +// ── 멤버 내보내기 ──────────────────────────────────────────────────────────── + +test.describe('멤버 관리 - 멤버 내보내기', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + const MEMBER_B_NICKNAME = 'E2E내보내기멤버'; + const GROUP_NAME = 'E2E 내보내기 테스트'; + let ctxBRef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, GROUP_NAME); + groupId = group.id; + inviteCode = group.inviteCode; + + ctxBRef = await createGuestSession(browser); + const pageB = await ctxBRef.newPage(); + await joinTestGroup(pageB, inviteCode); + await updateGroupMemberProfile(pageB, groupId, MEMBER_B_NICKNAME); + await pageB.close(); + + await ctxA.close(); + }); + + test.afterAll(async ({ browser }) => { + await ctxBRef?.close(); + ctxBRef = null; + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('Admin이 멤버를 내보내면 멤버 목록에서 해당 멤버가 사라진다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit/members`); + await expect(page.getByText(MEMBER_B_NICKNAME)).toBeVisible({ timeout: 8000 }); + + // B의 내보내기 버튼 클릭 (aria-label로 식별) + await page.getByRole('button', { name: `${MEMBER_B_NICKNAME} 내보내기` }).click(); + + // 확인 drawer + await expect(page.getByText(`정말 '${MEMBER_B_NICKNAME}'님을 그룹에서 내보내시겠습니까?`)).toBeVisible({ timeout: 5000 }); + await page.getByRole('button', { name: '내보내기' }).click(); + + // B가 목록에서 사라짐 (drawer 제목과 멤버 카드 모두 있을 수 있으므로 member-card 기준으로 확인) + await expect(page.locator('[data-testid="member-card"]').filter({ has: page.getByText(MEMBER_B_NICKNAME, { exact: true }) })).not.toBeVisible({ timeout: 8000 }); + }); + + test('내보내진 멤버는 /shared에서 해당 그룹을 볼 수 없다', async () => { + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + await pageB.goto('/shared'); + await expect(pageB.getByRole('button', { name: GROUP_NAME })).not.toBeVisible({ timeout: 8000 }); + } finally { + await pageB.close(); + } + }); +}); + +// ── 그룹 삭제 버튼 접근 ────────────────────────────────────────────────────── + +test.describe('그룹 정보 수정 페이지 - 그룹 삭제 버튼 접근', () => { + let groupId: string; + let inviteCode: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const group = await createTestGroup(page, 'E2E 삭제버튼 접근 테스트'); + groupId = group.id; + inviteCode = group.inviteCode; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('Admin은 그룹 삭제하기 버튼이 활성화 상태로 표시된다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit`); + + const deleteBtn = page.getByRole('button', { name: '그룹 삭제하기' }); + await expect(deleteBtn).toBeVisible({ timeout: 8000 }); + await expect(deleteBtn).not.toBeDisabled(); + }); + + test('EDITOR 멤버는 /edit 접근 시 /edit/profile로 리다이렉트된다', async ({ browser }) => { + const ctxB = await createGuestSession(browser); + const pageB = await ctxB.newPage(); + try { + await joinTestGroup(pageB, inviteCode); + await pageB.goto(`/group/${groupId}/edit`); + + // 서버 컴포넌트에서 ADMIN이 아닌 경우 /edit/profile로 리다이렉트함 + await expect(pageB).toHaveURL(new RegExp(`/group/${groupId}/edit/profile`), { timeout: 8000 }); + } finally { + await ctxB.close(); + } + }); + + test('VIEWER 멤버는 /edit 접근 시 /edit/profile로 리다이렉트된다', async ({ browser }) => { + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + + const inviteRes = await pageA.request.post(`/api/groups/${groupId}/invites`, { + data: { permission: 'VIEWER', expiresInSeconds: 86400 }, + }); + const inviteBody = await inviteRes.json(); + const viewerCode = inviteBody.data?.code; + await ctxA.close(); + + const ctxB = await createGuestSession(browser); + const pageB = await ctxB.newPage(); + try { + if (viewerCode) { + await joinTestGroup(pageB, viewerCode); + await pageB.goto(`/group/${groupId}/edit`); + + // 서버 컴포넌트에서 ADMIN이 아닌 경우 /edit/profile로 리다이렉트함 + await expect(pageB).toHaveURL(new RegExp(`/group/${groupId}/edit/profile`), { timeout: 8000 }); + } + } finally { + await ctxB.close(); + } + }); +}); diff --git a/frontend/e2e/group-member-profile.spec.ts b/frontend/e2e/group-member-profile.spec.ts new file mode 100644 index 000000000..2311aad79 --- /dev/null +++ b/frontend/e2e/group-member-profile.spec.ts @@ -0,0 +1,105 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { + createTestGroup, + deleteTestGroup, + createGuestSession, + joinTestGroup, + updateGroupMemberProfile, + createTestRecord, + deleteTestRecord, +} from './fixtures/api'; + +/** + * 그룹 멤버 프로필(닉네임) 변경 후 여러 화면에서 반영 여부를 확인한다. + * + * 확인 위치: + * 1. 멤버 관리 페이지 (/group/{id}/edit/members) + * 2. 그룹 홈 멤버 아바타 영역 (GroupMainTabs 상단) + * + * 기록 상세 / 타임라인의 기여자 프로필 확인은 그룹 기록(draft → publish 플로우) + * 생성이 선행되어야 하므로 별도 테스트에서 다룬다. + */ +test.describe('그룹 멤버 프로필 변경 반영', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + const NICKNAME_BEFORE = 'E2E변경전닉네임'; + const NICKNAME_AFTER = 'E2E변경후닉네임'; + let ctxBRef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, 'E2E 프로필 변경 테스트'); + groupId = group.id; + inviteCode = group.inviteCode; + + // B가 그룹 참여 후 초기 닉네임 설정 — 테스트 전체에서 같은 B 세션 재사용 + ctxBRef = await createGuestSession(browser); + const pageB = await ctxBRef.newPage(); + await joinTestGroup(pageB, inviteCode); + await updateGroupMemberProfile(pageB, groupId, NICKNAME_BEFORE); + await pageB.close(); + + await ctxA.close(); + }); + + test.afterAll(async ({ browser }) => { + await ctxBRef?.close(); + ctxBRef = null; + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('B가 그룹 닉네임을 변경하면 Admin의 멤버 관리 페이지에 새 닉네임이 표시된다', async ({ browser }) => { + // 동일한 B가 닉네임을 NICKNAME_AFTER로 변경 (새 세션 생성 시 B2가 추가 가입되는 문제 방지) + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + await updateGroupMemberProfile(pageB, groupId, NICKNAME_AFTER); + } finally { + await pageB.close(); + } + + // A(admin)가 멤버 관리 페이지에서 새 닉네임 확인 + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + try { + await pageA.goto(`/group/${groupId}/edit/members`); + await expect(pageA.getByText(NICKNAME_AFTER)).toBeVisible({ timeout: 8000 }); + await expect(pageA.getByText(NICKNAME_BEFORE)).not.toBeVisible({ timeout: 3000 }); + } finally { + await ctxA.close(); + } + }); + + test('B가 그룹 닉네임을 변경하면 그룹 홈 멤버 아바타 목록에 반영된다', async ({ page }) => { + // 그룹 홈(GroupMainTabs)의 멤버 아바타는 alt 텍스트로 구분 불가하므로 + // 아바타 개수(멤버 수)가 올바른지로 간접 검증한다. + await page.goto(`/group/${groupId}`); + await expect(page.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + // 그룹 멤버는 A + B = 2명이므로 아바타가 2개 표시되어야 한다 + // GroupMainTabs: members.slice(0, 4).map((m) =>
...) + const avatars = page.locator('[data-app-root] .rounded-full').filter({ hasNot: page.locator('svg') }); + await expect(avatars).toHaveCount(2, { timeout: 8000 }); + }); +}); + +/** + * 그룹 기록 상세 페이지에서 기여자 프로필 변경 반영 확인. + * 그룹 기록이 생성된 경우에만 유효하다. + * + * 현재 그룹 기록 생성은 draft → publish UI 플로우가 필요하기 때문에 + * 개인 기록을 PERSONAL scope로 생성하는 방법으로 대체 불가하여 + * 이 테스트 블록은 별도 마킹 없이 추가한다. + * 필요 시 createGroupRecord fixture가 완성되면 활성화한다. + */ +test.describe.skip('그룹 기록 상세 및 타임라인 - 기여자 프로필 변경 반영', () => { + // TODO: createGroupRecord fixture 완성 후 활성화 + // 기록 상세 페이지 작성자 섹션, 타임라인의 기여자 아바타 검증 추가 예정 +}); diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts new file mode 100644 index 000000000..d994c91af --- /dev/null +++ b/frontend/e2e/home.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { createTestRecord, deleteTestRecord } from './fixtures/api'; + +const TODAY = new Date().toISOString().split('T')[0]; + +test.describe('홈 → 기록 상세 이동', () => { + let postId: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const record = await createTestRecord(page, 'E2E 홈 테스트 기록'); + postId = record.id; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestRecord(page, postId); + await ctx.close(); + }); + + test('홈 화면이 로드된다', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('body')).toBeVisible(); + await expect(page).toHaveURL(/localhost:3000\/?$/); + }); + + test('날짜 상세 페이지에서 기록 카드를 클릭하면 기록 상세로 이동한다', async ({ page }) => { + await page.goto(`/my/detail/${TODAY}`); + + // DailyDetailRecordItem은 div[onClick]으로 렌더링됨 — 제목으로 찾기 + const recordCard = page.locator('div[class*="cursor-pointer"]').filter({ hasText: 'E2E 홈 테스트 기록' }).first(); + await expect(recordCard).toBeVisible({ timeout: 8000 }); + await recordCard.click(); + + await expect(page).toHaveURL(/\/record\/[a-z0-9-]+/, { timeout: 20000 }); + }); +}); + +test.describe('홈 → 개인 기록(/my) 이동', () => { + test('하단 내 기록 탭을 클릭하면 /my로 이동한다', async ({ page }) => { + await page.goto('/'); + + //
+ ); +} + +// ─── Meta ──────────────────────────────────────────────────────────────────── +const meta = { + title: 'RecordEditor/RecordEditorDrawers', + component: EmotionDrawer, + parameters: { + layout: 'centered', + nextjs: { appDirectory: true }, + docs: { + description: { + component: ` +기록 에디터에서 각 블록 필드를 클릭하거나 툴바에서 블록을 추가할 때 열리는 드로어 모음. + +각 스토리의 버튼을 클릭하면 실제 드로어가 열린다. + +| 드로어 | 트리거 | +|--------|--------| +| \`EmotionDrawer\` | 감정 블록 클릭 | +| \`TagDrawer\` | 태그 블록 클릭 | +| \`RatingDrawer\` | 별점 블록 클릭 | +| \`TimePickerDrawer\` | 날짜·시간 블록 클릭 | +| \`DateDrawer\` | 날짜 블록 클릭 | +| \`PhotoDrawer\` | 사진 블록 클릭 | +| \`MediaDrawer\` | 미디어 블록 클릭 | + `, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── EmotionDrawer ──────────────────────────────────────────────────────────── +export const Emotion: Story = { + name: 'EmotionDrawer', + render: () => { + const [selected, setSelected] = useState({ mood: '' }); + return ( + + {(open, setOpen) => ( + setSelected({ mood })} + onClose={() => setOpen(false)} + /> + )} + + ); + }, + parameters: { + docs: { + description: { + story: ` +감정 블록을 클릭했을 때 열리는 드로어 — 5열 그리드로 19가지 감정 이모지를 선택할 수 있다. + +- **감정 클릭**: 해당 감정 선택 (링 강조 표시) +- **확인 버튼**: 드로어 닫기 + `, + }, + }, + }, +}; + +export const EmotionWithSelected: Story = { + name: 'EmotionDrawer — 선택된 상태', + render: () => { + const [selected, setSelected] = useState({ mood: '행복' }); + return ( + + {(open, setOpen) => ( + setSelected({ mood })} + onClose={() => setOpen(false)} + /> + )} + + ); + }, + parameters: { + docs: { + description: { + story: '이미 "행복"이 선택된 상태로 드로어가 열린다.', + }, + }, + }, +}; + +// ─── TagDrawer ──────────────────────────────────────────────────────────────── +export const Tag: Story = { + name: 'TagDrawer', + render: () => { + const [tags, setTags] = useState({ tags: [] }); + return ( + + {(open, setOpen) => + open && ( + setTags({ tags: newTags })} + onClose={() => setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + msw: { handlers: [mockTagsHandler] }, + docs: { + description: { + story: ` +태그 블록을 클릭했을 때 열리는 드로어 — 직접 입력하거나 이전에 사용한 태그를 선택할 수 있다. + +- **# 입력창 + Enter / + 버튼**: 태그 추가 (최대 4개) +- **이전 사용 태그 클릭**: 토글 선택 +- **4개 초과 시**: 경고 메시지 표시 + `, + }, + }, + }, +}; + +export const TagWithExisting: Story = { + name: 'TagDrawer — 기존 태그 있음', + render: () => { + const [tags, setTags] = useState({ tags: ['성수동', '카페'] }); + return ( + + {(open, setOpen) => + open && ( + setTags({ tags: newTags })} + onClose={() => setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + msw: { handlers: [mockTagsHandler] }, + docs: { + description: { + story: '이미 태그 2개가 선택된 상태로 드로어가 열린다.', + }, + }, + }, +}; + +// ─── RatingDrawer ───────────────────────────────────────────────────────────── +export const Rating: Story = { + name: 'RatingDrawer', + render: () => { + const [rating, setRating] = useState({ rating: 0 }); + return ( + + {(open, setOpen) => + open && ( + setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + docs: { + description: { + story: ` +별점 블록을 클릭했을 때 열리는 드로어 — 별 위를 드래그하거나 클릭해 0.1 단위로 별점을 설정한다. + +- **별 영역 드래그/클릭**: 0.1점 단위 별점 입력 +- **숫자 표시**: 현재 선택된 점수가 큰 숫자로 실시간 표시 +- **확인 버튼**: 별점 저장 후 드로어 닫기 + `, + }, + }, + }, +}; + +export const RatingWithValue: Story = { + name: 'RatingDrawer — 3.5점 상태', + render: () => { + const [rating, setRating] = useState({ rating: 3.5 }); + return ( + + {(open, setOpen) => + open && ( + setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + docs: { + description: { + story: '3.5점이 미리 입력된 상태로 드로어가 열린다.', + }, + }, + }, +}; + +// ─── DateDrawer ─────────────────────────────────────────────────────────────── +export const DateSingle: Story = { + name: 'DateDrawer — 날짜 선택', + render: () => { + const [date, setDate] = useState('2026-01-15'); + return ( + + {(open, setOpen) => + open && ( + { setDate(d); setOpen(false); }} + onClose={() => setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + docs: { + description: { + story: ` +날짜 블록을 클릭했을 때 열리는 달력 드로어. + +- **날짜 클릭**: 해당 날짜 선택 후 드로어 닫기 +- **← / → 버튼**: 월 이동 +- **↺ 버튼**: 오늘 날짜로 초기화 + `, + }, + }, + }, +}; + +// ─── TimePickerDrawer ───────────────────────────────────────────────────────── +export const TimePicker: Story = { + name: 'TimePickerDrawer', + render: () => { + const [time, setTime] = useState({ time: '14:30' }); + return ( + + {(open, setOpen) => + open && ( + { setTime({ time: t }); setOpen(false); }} + onClose={() => setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + docs: { + description: { + story: ` +시간 블록을 클릭했을 때 열리는 드럼롤 타임피커 드로어. + +- **스크롤 / 드래그**: 시·분 선택 +- **직접 입력 모드**: 키보드 아이콘 클릭 시 텍스트 입력으로 전환 +- **확인 버튼**: 시간 저장 후 드로어 닫기 + `, + }, + }, + }, +}; + +// ─── PhotoDrawer ────────────────────────────────────────────────────────────── +export const Photo: Story = { + name: 'PhotoDrawer — 사진 있음', + render: () => { + const [photos, setPhotos] = useState({ + mediaIds: [], + tempUrls: [ + 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&q=80&w=400', + 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?auto=format&fit=crop&q=80&w=400', + 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + ], + }); + return ( + + + {(open, setOpen) => + open && ( + + setPhotos((prev) => ({ + ...prev, + tempUrls: prev.tempUrls?.filter((_, i) => i !== idx), + })) + } + onRemoveAll={() => setPhotos({ mediaIds: [], tempUrls: [] })} + onClose={() => setOpen(false)} + /> + ) + } + + + ); + }, + parameters: { + msw: { handlers: [mockMediaResolveHandler] }, + docs: { + description: { + story: ` +사진 블록을 클릭했을 때 열리는 드로어 — 추가된 사진 목록과 관리 기능을 제공한다. + +- **+ 버튼**: 사진 추가 (업로드 플로우) +- **사진 우측 X**: 해당 사진 개별 삭제 +- **전체 삭제 버튼**: 모든 사진 제거 +- **메타데이터 수정**: 촬영 날짜·시간·위치를 기록 블록에 자동 반영 + `, + }, + }, + }, +}; + +export const PhotoEmpty: Story = { + name: 'PhotoDrawer — 사진 없음', + render: () => ( + + + {(open, setOpen) => + open && ( + setOpen(false)} + /> + ) + } + + + ), + parameters: { + msw: { handlers: [mockMediaResolveHandler] }, + docs: { + description: { + story: '사진이 한 장도 없는 초기 상태 — "+ 사진 추가" 버튼만 표시된다.', + }, + }, + }, +}; + +// ─── MediaDrawer ────────────────────────────────────────────────────────────── +export const Media: Story = { + name: 'MediaDrawer', + render: () => { + const [, setSelected] = useState(null); + return ( + + {(open, setOpen) => + open && ( + { setSelected(media); setOpen(false); }} + onClose={() => setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + msw: { handlers: [mockMovieSearchHandler] }, + docs: { + description: { + story: ` +미디어 블록을 클릭했을 때 열리는 드로어 — 영화·연극·뮤지컬을 검색해 기록에 첨부한다. + +- **카테고리 칩**: 영화 / 연극 / 뮤지컬 전환 +- **검색창 입력**: 자동으로 검색 결과 표시 (MSW mock) +- **검색 결과 클릭**: 해당 미디어 선택 후 드로어 닫기 +- **직접 입력**: 검색 결과에 없는 경우 수동으로 제목·종류·연도 입력 + `, + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/_components/editor/storybook/RecordEditorHeader.stories.tsx b/frontend/src/app/(post)/_components/editor/storybook/RecordEditorHeader.stories.tsx new file mode 100644 index 000000000..fdd42c85b --- /dev/null +++ b/frontend/src/app/(post)/_components/editor/storybook/RecordEditorHeader.stories.tsx @@ -0,0 +1,156 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import RecordEditorHeader from '../RecordEditorHeader'; +import { PresenceMember } from '@/hooks/useDraftPresence'; + +const mockMembers: PresenceMember[] = [ + { + actorId: 'actor-1', + sessionId: 'session-1', + displayName: '도비', + permissionRole: 'EDITOR', + profileImageId: '/profile-ex.jpeg', + lastSeenAt: new Date().toISOString(), + }, + { + actorId: 'actor-2', + sessionId: 'session-2', + displayName: '루피', + permissionRole: 'EDITOR', + profileImageId: null, + lastSeenAt: new Date().toISOString(), + }, + { + actorId: 'actor-3', + sessionId: 'session-3', + displayName: '하니', + permissionRole: 'VIEWER', + profileImageId: '/profile-ex.jpeg', + lastSeenAt: new Date().toISOString(), + }, +]; + +const manyMembers: PresenceMember[] = [ + ...mockMembers, + { + actorId: 'actor-4', + sessionId: 'session-4', + displayName: '새벽', + permissionRole: 'EDITOR', + profileImageId: null, + lastSeenAt: new Date().toISOString(), + }, + { + actorId: 'actor-5', + sessionId: 'session-5', + displayName: '민영', + permissionRole: 'EDITOR', + profileImageId: '/profile-ex.jpeg', + lastSeenAt: new Date().toISOString(), + }, + { + actorId: 'actor-6', + sessionId: 'session-6', + displayName: '수연', + permissionRole: 'ADMIN', + profileImageId: null, + lastSeenAt: new Date().toISOString(), + }, +]; + +const meta = { + title: 'RecordEditor/RecordEditorHeader', + component: RecordEditorHeader, + parameters: { + layout: 'fullscreen', + nextjs: { appDirectory: true }, + docs: { + description: { + component: ` +기록 작성·수정 페이지 상단에 고정되는 헤더 컴포넌트. + +좌측에 뒤로가기 버튼과 페이지 제목(기록 작성/기록 수정), 우측에 현재 편집 중인 멤버 아바타 목록과 저장 버튼이 표시된다. +멤버 아바타에 마우스를 올리면 편집 중인 멤버 목록 팝오버가 나타난다. + +- **mode**: \`'add'\` — "기록 작성" / \`'edit'\` — "기록 수정" +- **members**: 실시간 협업 중인 멤버 목록 (presence 데이터) — 빈 배열이면 멤버 표시 영역 숨김 +- **onSave**: 우측 저장 버튼 클릭 시 호출 + `, + }, + }, + }, + tags: ['autodocs'], + args: { + onSave: () => {}, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + mode: 'add', + members: [], + }, + parameters: { + docs: { + description: { + story: ` +기록 작성 모드 — 협업 멤버 없이 혼자 작성하는 기본 상태. + +- **← 뒤로가기 버튼**: 이전 페이지로 이동 +- **저장 버튼**: \`onSave\` 호출 → 기록 저장 처리 + `, + }, + }, + }, +}; + +export const EditMode: Story = { + args: { + mode: 'edit', + members: [], + }, + parameters: { + docs: { + description: { + story: '기록 수정 모드 — 헤더 제목이 "기록 수정"으로 변경된다.', + }, + }, + }, +}; + +export const WithCollaborators: Story = { + args: { + mode: 'edit', + members: mockMembers, + }, + parameters: { + docs: { + description: { + story: '3명이 실시간으로 함께 편집 중인 상태 — 멤버 아바타가 우측에 표시된다. 아바타에 마우스를 올리면 멤버 목록 팝오버가 나타난다.', + }, + }, + }, +}; + +export const ManyCollaborators: Story = { + args: { + mode: 'edit', + members: manyMembers, + }, + parameters: { + docs: { + description: { + story: '5명 초과(6명)가 편집 중인 상태 — 최대 4명의 아바타가 표시되고 나머지는 "+N" 숫자 배지로 표시된다.', + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/_components/editor/storybook/RecordFields.stories.tsx b/frontend/src/app/(post)/_components/editor/storybook/RecordFields.stories.tsx new file mode 100644 index 000000000..d766658ff --- /dev/null +++ b/frontend/src/app/(post)/_components/editor/storybook/RecordFields.stories.tsx @@ -0,0 +1,539 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; + +import { DateField, TimeField, ContentField } from '../core/CoreField'; +import { EmotionField } from '../emotion/EmotionField'; +import { PhotoField } from '../photo/PhotoField'; +import { TagField } from '../tag/TagField'; +import { RatingField } from '../rating/RatingField'; +import { TableField } from '../table/TableField'; +import MediaField from '../media/MediaField'; +import { LocationField } from '@/components/map/LocationField'; + +import type { + DateValue, + TimeValue, + TextValue, + MoodValue, + TagValue, + RatingValue, + LocationValue, + ImageValue, + TableValue, + MediaInfoValue, +} from '@/lib/types/record'; + +const noop = () => {}; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +// ─── 비어 있는 기본값 ─────────────────────────────────────────────────────── +const emptyDate: DateValue = { date: '' }; +const emptyTime: TimeValue = { time: '' }; +const emptyText: TextValue = { text: '' }; +const emptyEmotion: MoodValue = { mood: '' }; +const emptyTags: TagValue = { tags: [] }; +const emptyRating: RatingValue = { rating: 0 }; +const emptyLocation: LocationValue = { lat: 0, lng: 0, address: '' }; +const emptyPhoto: ImageValue = { mediaIds: [], tempUrls: [] }; +const emptyTable: TableValue = { + rows: 2, + cols: 2, + cells: [ + ['', ''], + ['', ''], + ], +}; +const emptyMedia: MediaInfoValue = { + title: '', + type: '', + externalId: '', + year: '', + imageUrl: '', +}; + +// ─── 데이터 있는 값 ────────────────────────────────────────────────────────── +const filledDate: DateValue = { date: '2026-01-15' }; +const filledTime: TimeValue = { time: '14:30' }; +const filledText: TextValue = { + text: '성수동 카페에서 오랜만에 친구와 만났다. 날씨도 좋고 커피도 맛있었다.', +}; +const filledEmotion: MoodValue = { mood: '행복' }; +const filledTags: TagValue = { tags: ['성수동', '카페', '친구'] }; +const filledRating: RatingValue = { rating: 4 }; +const filledLocation: LocationValue = { + lat: 37.5444, + lng: 127.0557, + address: '서울 성동구 성수동2가', + placeName: '성수 카페거리', +}; +const filledPhoto: ImageValue = { + mediaIds: [], + tempUrls: [ + 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&q=80&w=400', + 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?auto=format&fit=crop&q=80&w=400', + 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + 'https://images.unsplash.com/photo-1516715094483-75da7dee9758?auto=format&fit=crop&q=80&w=400', + ], +}; +const filledTable: TableValue = { + rows: 3, + cols: 2, + cells: [ + ['항목', '내용'], + ['이름', '성수 카페'], + ['가격', '7,000원'], + ], +}; +const filledMedia: MediaInfoValue = { + title: '인터스텔라', + type: '영화', + externalId: 'tt0816692', + year: '2014', + imageUrl: + 'https://images.unsplash.com/photo-1534447677768-be436bb09401?auto=format&fit=crop&q=80&w=56', +}; + +// ─── 레이아웃 래퍼 ─────────────────────────────────────────────────────────── +function FieldSection({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +function EditorCanvas({ children }: { children: React.ReactNode }) { + return ( + +
+ {children} +
+
+ ); +} + +// ─── Meta ──────────────────────────────────────────────────────────────────── +const meta = { + title: 'RecordEditor/RecordFields', + parameters: { + layout: 'padded', + nextjs: { appDirectory: true }, + msw: { + handlers: [ + // PhotoField 내부의 useMediaResolveMulti가 빈 배열로 호출할 때 빈 결과 반환 + http.post('/api/media/resolve', () => + HttpResponse.json({ success: true, data: { items: [], failed: [] } }), + ), + ], + }, + docs: { + description: { + component: ` +기록 작성·수정 에디터에서 툴바 버튼을 클릭하면 추가되는 블록 필드 컴포넌트 모음. + +각 블록은 **빈 상태(방금 추가됨)**와 **데이터 있는 상태** 두 가지로 표시된다. +- 빈 상태: 점선 테두리의 플레이스홀더 버튼 → 클릭하면 해당 드로어/입력 열림 +- 데이터 있는 상태: 입력된 값이 카드/칩 형태로 표시, 우측 X로 삭제 가능 + +| 툴바 아이콘 | 블록 타입 | 컴포넌트 | +|------------|-----------|---------| +| T | 텍스트 | \`ContentField\` | +| 사진 | 이미지 | \`PhotoField\` | +| 이모지 | 감정 | \`EmotionField\` | +| 태그 | 태그 | \`TagField\` | +| 위치 | 위치 | \`LocationField\` | +| 표 | 테이블 | \`TableField\` | +| 별 | 별점 | \`RatingField\` | +| 돋보기 | 미디어 정보 | \`MediaField\` | + +날짜/시간 블록은 툴바가 아닌 기록 생성 시 자동으로 추가된다. + `, + }, + }, + }, + tags: ['autodocs'], + // component는 단일 컴포넌트가 아니므로 대표용으로 ContentField 지정 + component: ContentField, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Stories ───────────────────────────────────────────────────────────────── + +export const AllEmpty: Story = { + render: () => ( + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ), + parameters: { + docs: { + description: { + story: ` +툴바에서 각 버튼을 클릭했을 때 **처음 추가되는 빈 상태**. + +점선 테두리의 플레이스홀더 버튼이 표시되며, 버튼 클릭 시 해당 입력 드로어가 열린다. +우측 X 버튼으로 해당 블록을 에디터에서 제거할 수 있다. + +- **날짜·시간**: 툴바가 아닌 기록 생성 시 현재 날짜·시간으로 자동 삽입됨 +- **텍스트**: 좌측 초록색 세로선이 포커스 시 나타남 +- **테이블**: 빈 셀부터 시작해 열/행 추가 가능 + `, + }, + }, + }, +}; + +export const AllFilled: Story = { + render: () => ( + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ), + parameters: { + docs: { + description: { + story: ` +데이터가 입력된 상태. 플레이스홀더 대신 실제 값이 카드·칩 형태로 표시된다. + +- **사진**: 최대 3장이 겹쳐 보이고 초과 수는 "+N"으로 표시됨 +- **태그**: 최대 4개까지 추가 가능하며, 4개 미만이면 + 버튼으로 더 추가 +- **미디어**: 포스터 이미지·제목·종류·연도가 카드로 표시됨 + `, + }, + }, + }, +}; + +export const DateTimeField: Story = { + render: () => ( + + +
+ + +
+
+ +
+ + +
+
+
+ ), + parameters: { + docs: { + description: { + story: '날짜·시간 필드 — 기록 생성 시 현재 시각으로 자동 채워지며, 클릭하면 타임피커 드로어가 열린다.', + }, + }, + }, +}; + +export const TextField: Story = { + render: () => ( + + + + + + + + + + + + ), + parameters: { + docs: { + description: { + story: '텍스트 블록 — 여러 개 추가할 수 있으며, 마지막 텍스트 블록에는 삭제(X) 버튼이 숨겨진다.', + }, + }, + }, +}; + +export const PhotoFieldStory: Story = { + name: 'PhotoField', + render: () => ( + + + + + + + + + + + + ), + parameters: { + docs: { + description: { + story: '사진 블록 — 최대 3장이 겹쳐 표시되고 초과분은 "+N"으로 표시된다. 클릭하면 사진 선택 드로어가 열린다.', + }, + }, + }, +}; + +export const EmotionFieldStory: Story = { + name: 'EmotionField', + render: () => ( + + + + +
+ {(['행복', '좋음', '만족', '재미', '피곤', '화남', '슬픔', '보통'] as const).map( + (mood) => ( + + + + ), + )} +
+
+ ), + parameters: { + docs: { + description: { + story: '감정 블록 — 선택된 감정에 맞는 이모지와 이름이 표시된다. 클릭하면 감정 선택 드로어가 열린다.', + }, + }, + }, +}; + +export const TagFieldStory: Story = { + name: 'TagField', + render: () => ( + + + + + + + + + = 4 ? filledTags : { tags: ['성수동', '카페', '친구', '주말'] }} onRemove={noop} onAdd={noop} onRemoveField={noop} /> + + + ), + parameters: { + docs: { + description: { + story: '태그 블록 — 최대 4개까지 추가 가능. 4개 미만이면 + 버튼이 표시되어 태그 추가 드로어를 열 수 있다.', + }, + }, + }, +}; + +export const RatingFieldStory: Story = { + name: 'RatingField', + render: () => ( + + + + +
+ {([1, 2, 3, 4, 5] as const).map((rating) => ( + + + + ))} +
+
+ ), + parameters: { + docs: { + description: { + story: '별점 블록 — 1~5점 범위. 클릭하면 별점 선택 드로어가 열린다.', + }, + }, + }, +}; + +export const LocationFieldStory: Story = { + name: 'LocationField', + render: () => ( + + + + + + + + + + + + ), + parameters: { + docs: { + description: { + story: '위치 블록 — 장소명이 있으면 장소명을, 없으면 주소를 표시한다. 클릭하면 지도 위치 선택 화면으로 이동한다.', + }, + }, + }, +}; + +export const MediaFieldStory: Story = { + name: 'MediaField', + render: () => ( + + + + + + + + + + + + ), + parameters: { + docs: { + description: { + story: '미디어 정보 블록 (영화·연극 등) — 포스터 이미지·제목·종류·연도가 카드로 표시된다. 이미지가 없으면 필름 아이콘으로 대체된다.', + }, + }, + }, +}; + +export const TableFieldStory: Story = { + name: 'TableField', + render: () => ( + + + + + + + + + ), + parameters: { + docs: { + description: { + story: '테이블 블록 — 셀을 직접 편집할 수 있으며 최대 4×4까지 행/열을 추가할 수 있다.', + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/_components/editor/storybook/Toolbar.stories.tsx b/frontend/src/app/(post)/_components/editor/storybook/Toolbar.stories.tsx new file mode 100644 index 000000000..62db65a65 --- /dev/null +++ b/frontend/src/app/(post)/_components/editor/storybook/Toolbar.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import Toolbar from '../Toolbar'; + +const meta = { + title: 'RecordEditor/Toolbar', + component: Toolbar, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +기록 작성·수정 페이지 하단에 고정되는 툴바 컴포넌트. + +기록에 추가할 수 있는 블록 타입 버튼들이 나열된다. +각 버튼을 클릭하면 해당 타입의 새 블록이 에디터에 추가된다. + +| 아이콘 | 블록 타입 | 설명 | +|--------|-----------|------| +| T (텍스트) | \`content\` | 텍스트 블록 | +| 사진 | \`photos\` | 이미지 블록 | +| 이모지 | \`emotion\` | 감정 블록 | +| 태그 | \`tags\` | 태그 블록 | +| 위치 | \`location\` | 위치 블록 | +| 표 | \`table\` | 테이블 블록 | +| 별 | \`rating\` | 별점 블록 | +| 돋보기 | \`media\` | 미디어 검색 블록 | + `, + }, + }, + }, + tags: ['autodocs'], + args: { + onAddBlock: () => {}, + onOpenDrawer: () => {}, + }, + decorators: [ + (Story) => ( +
+
+ 에디터 콘텐츠 영역 +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + parameters: { + docs: { + description: { + story: ` +기록 에디터 하단 툴바 기본 상태. + +- **각 아이콘 버튼 클릭**: \`onAddBlock(type)\` 호출 → 에디터에 해당 타입 블록 추가 +- 아이콘에 호버 시 초록색(\`itta-point\`)으로 강조됨 + `, + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/_components/storybook/GalleryDrawer.stories.tsx b/frontend/src/app/(post)/_components/storybook/GalleryDrawer.stories.tsx index 457e6e3ef..1b3c4fb24 100644 --- a/frontend/src/app/(post)/_components/storybook/GalleryDrawer.stories.tsx +++ b/frontend/src/app/(post)/_components/storybook/GalleryDrawer.stories.tsx @@ -1,19 +1,21 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; -import { useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState, useEffect } from 'react'; import GalleryDrawer from '../GalleryDrawer'; -import { MonthRecord } from '@/lib/types/record'; import { Drawer, - DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger, + DrawerClose, } from '@/components/ui/drawer'; import { ImageIcon, X } from 'lucide-react'; -import { cn } from '@/lib/utils'; // cn 유틸리티 함수가 있다고 가정합니다. -const mockPhotos = [ +// AssetImage는 assetId가 https://로 시작하면 직접 URL로 사용한다. +// 실제 UUID를 쓰면 /api/media-image/:id 프록시를 거쳐 Storybook에서 이미지가 깨지므로 +// mediaId 자리에 Unsplash URL을 넣어 직접 로드되도록 한다. +const PHOTO_URLS = [ 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', 'https://images.unsplash.com/photo-1418985991508-e47386d96a71?auto=format&fit=crop&q=80&w=400', 'https://images.unsplash.com/photo-1517487881594-2787fef5ebf7?auto=format&fit=crop&q=80&w=400', @@ -25,40 +27,78 @@ const mockPhotos = [ 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?auto=format&fit=crop&q=80&w=400', ]; -const mockMonthRecords: MonthRecord[] = [ - { - id: '2025-01', - name: '2025년 1월', - count: 12, - latestTitle: '새해 첫 일출', - latestLocation: '정동진', - coverUrl: mockPhotos[0], - }, -]; +function buildCoverPage(groupId: string) { + return { + groupId, + sections: [ + { + date: '2026-01-14', + items: PHOTO_URLS.map((url, i) => ({ + mediaId: url, + assetId: url, + postId: `post-${i}`, + postTitle: `기록 ${i + 1}`, + eventAt: '2026-01-14T10:00:00Z', + width: 400, + height: 400, + mimeType: 'image/jpeg', + })), + }, + ], + pageInfo: { hasNext: false, nextCursor: null }, + }; +} + +/** + * QueryClient에 infinite query 데이터를 직접 주입한다. + * + * GalleryDrawer는 내부에서 useInfiniteQuery + 인증 헤더가 필요한 fetch를 사용하므로 + * Storybook에서 MSW 핸들러로 인터셉트해도 auth 처리에서 실패할 수 있다. + * setQueryData로 캐시를 미리 채우면 네트워크 요청 없이 즉시 데이터를 표시한다. + */ +function makeClient(queryKey: unknown[], pageData: unknown) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(queryKey, { pages: [pageData], pageParams: [null] }); + return client; +} + +const clients = { + // queryKey는 각 infiniteQueryOptions의 queryKey와 일치해야 한다 + default: makeClient(['cover', 'group-123'], buildCoverPage('group-123')), + groupMonthly: makeClient(['cover', 'group-123', '2025-01'], buildCoverPage('group-123')), + personal: makeClient(['cover', 'my', '2025-01'], buildCoverPage('personal')), + noPhotos: makeClient(['cover', 'group-empty'], { + groupId: 'group-empty', + sections: [], + pageInfo: { hasNext: false, nextCursor: null }, + }), +}; -// GalleryDrawer를 Drawer로 감싸는 래퍼 컴포넌트 function GalleryDrawerWrapper({ - recordPhotos, - initialRecords, - className, // DrawerContent에 전달할 다크모드용 클래스 + type, + groupId, + month, + className, }: { - recordPhotos: string[]; - initialRecords: MonthRecord[]; + type: 'group' | 'personal' | 'other'; + groupId?: string; + month?: string; className?: string; }) { - const [months, setMonths] = useState(initialRecords); + const [selectedId, setSelectedId] = useState(null); return ( - - {/* 💡 Portal 위치는 유지하되, className에 'dark'를 직접 넣어 다크모드 적용 */} - +
@@ -76,10 +116,11 @@ function GalleryDrawerWrapper({ setSelectedId(mediaId)} /> @@ -91,61 +132,100 @@ const meta = { component: GalleryDrawerWrapper, parameters: { layout: 'centered', + docs: { + description: { + component: ` +그룹/개인 아카이브에서 커버 이미지를 선택하는 드로어 컴포넌트. + +"커버 사진 선택 열기" 버튼을 클릭하면 드로어가 열리고, 기록에 포함된 이미지 목록이 그리드로 표시된다. +이미지를 클릭하면 해당 이미지가 커버로 설정되고 드로어가 자동으로 닫힌다. +"기본 커버로 변경" 버튼으로 커버를 초기화할 수 있다. + +- \`type="group"\`: 그룹 대표 커버 또는 월별 커버 선택 +- \`type="personal"\`: 개인 아카이브 월별 커버 선택 + `, + }, + }, }, tags: ['autodocs'], - decorators: [ - (Story) => ( -
- -
- ), - ], } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { - args: { - recordPhotos: mockPhotos, - initialRecords: mockMonthRecords, + args: { type: 'group', groupId: 'group-123' }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + parameters: { + docs: { + description: { + story: '그룹 대표 커버 선택. "커버 사진 선택 열기" 버튼을 클릭해 드로어를 연다.', + }, + }, }, }; -export const FewPhotos: Story = { - args: { - recordPhotos: mockPhotos.slice(0, 3), - initialRecords: mockMonthRecords, +export const GroupMonthly: Story = { + args: { type: 'group', groupId: 'group-123', month: '2025-01' }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + parameters: { + docs: { + description: { story: '그룹의 특정 월 커버 선택.' }, + }, }, }; -export const NoPhotos: Story = { - args: { - recordPhotos: [], - initialRecords: [ - { - ...mockMonthRecords[0], - coverUrl: null, - }, - ], +export const Personal: Story = { + args: { type: 'personal', month: '2025-01' }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + parameters: { + docs: { + description: { story: '개인 아카이브의 월별 커버 선택.' }, + }, }, }; -// 💡 다크 모드 스토리 -export const DarkMode: Story = { - args: { - recordPhotos: mockPhotos, - initialRecords: mockMonthRecords, - className: 'dark', // DrawerContent 내부의 다크모드 활성화 - }, - parameters: { - backgrounds: { default: 'dark' }, - }, +export const NoPhotos: Story = { + args: { type: 'group', groupId: 'group-empty' }, decorators: [ (Story) => ( -
- -
+ +
+ +
+
), ], + parameters: { + docs: { + description: { + story: '커버로 설정할 이미지가 없는 경우 — "이미지가 포함된 기록이 없어요" 안내 메시지가 표시된다.', + }, + }, + }, }; + diff --git a/frontend/src/app/(post)/_components/storybook/MonthRecords.stories.tsx b/frontend/src/app/(post)/_components/storybook/MonthRecords.stories.tsx index 1fd136813..0968fa4a2 100644 --- a/frontend/src/app/(post)/_components/storybook/MonthRecords.stories.tsx +++ b/frontend/src/app/(post)/_components/storybook/MonthRecords.stories.tsx @@ -1,154 +1,133 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Suspense } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MonthRecords from '../MonthRecords'; -import { MonthRecord } from '@/lib/types/record'; +import { createMockMonthlyRecord, createMockGroupMonthlyRecords } from '@/lib/mocks/mock'; -const mockMonthRecords: MonthRecord[] = [ - { - id: '2025-01', - name: '2025년 1월', - count: 12, - latestTitle: '새해 첫 일출 보기', - latestLocation: '정동진 해변', - coverUrl: - 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', - }, - { - id: '2024-12', - name: '2024년 12월', - count: 8, - latestTitle: '크리스마스 마켓', - latestLocation: '명동 거리', - coverUrl: - 'https://images.unsplash.com/photo-1418985991508-e47386d96a71?auto=format&fit=crop&q=80&w=400', - }, - { - id: '2024-11', - name: '2024년 11월', - count: 5, - latestTitle: '단풍 구경', - latestLocation: '설악산', - coverUrl: - 'https://images.unsplash.com/photo-1517487881594-2787fef5ebf7?auto=format&fit=crop&q=80&w=400', - }, - { - id: '2024-10', - name: '2024년 10월', - count: 3, - latestTitle: '할로윈 파티', - latestLocation: '이태원', - coverUrl: null, - }, -]; +// MonthRecords는 내부에서 useSuspenseQuery로 데이터를 직접 fetching한다. +// Storybook에서 인증/네트워크 없이 렌더링하려면 queryClient에 데이터를 미리 주입한다. +// setQueryData의 키는 각 queryOptions의 queryKey와 정확히 일치해야 한다. + +// 컴포넌트는 useParams().year || 현재연도 로 year를 결정한다. +// Storybook에서 useParams()는 {} 반환 → year = 현재 연도 +const CURRENT_YEAR = new Date().getFullYear().toString(); +const GROUP_ID = 'group-123'; + +function makeClient({ + isGroup = false, + empty = false, +}: { isGroup?: boolean; empty?: boolean } = {}) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + + const monthData = empty ? [] : isGroup ? createMockGroupMonthlyRecords() : createMockMonthlyRecord(); + + if (isGroup) { + // groupMonthlyRecordListOptions의 queryKey + client.setQueryData(['group', GROUP_ID, 'records', 'month', CURRENT_YEAR], monthData); + // groupMyRoleOptions의 queryKey — ADMIN으로 설정해 커버 변경 버튼 표시 + client.setQueryData(['group', GROUP_ID, 'me', 'role'], { role: 'ADMIN' }); + } else { + // myMonthlyRecordListOptions의 queryKey + client.setQueryData(['my', 'records', 'month', CURRENT_YEAR], monthData); + } + + return client; +} + +const clients = { + default: makeClient(), + group: makeClient({ isGroup: true }), + empty: makeClient({ empty: true }), +}; const meta = { title: 'Record/MonthRecords', component: MonthRecords, parameters: { layout: 'padded', - docs: {}, + docs: { + description: { + component: ` +아카이브 페이지의 월별 기록 카드 그리드 컴포넌트. + +각 월의 커버 이미지, 기록 수, 최신 제목과 위치를 카드 형태로 표시한다. +카드 클릭 시 해당 월의 상세 페이지(\`cardRoute/{월}\`)로 이동한다. +카드 우상단의 커버 변경 버튼을 클릭하면 GalleryDrawer가 열려 커버 이미지를 선택할 수 있다. +기록이 없는 경우 빈 상태 UI를 표시한다. + `, + }, + }, }, tags: ['autodocs'], - decorators: [ - (Story) => ( -
- -
- ), - ], argTypes: { - monthRecords: { - description: '월별 기록 데이터 배열', - }, - cardRoute: { - description: '카드 클릭 시 이동할 라우트 경로', - }, + cardRoute: { description: '카드 클릭 시 이동할 기본 경로. 뒤에 /{월ID}가 붙는다.' }, + groupId: { description: '그룹 기록함이면 그룹 ID를 전달. 없으면 개인 기록함.' }, + drawerClassName: { description: 'GalleryDrawer DrawerContent에 추가할 클래스.' }, }, } satisfies Meta; export default meta; type Story = StoryObj; -// 기본: 내 기록함 export const Default: Story = { - args: { - monthRecords: mockMonthRecords, - cardRoute: '/my/month', - }, + args: { cardRoute: '/my/month' }, + decorators: [ + (Story) => ( + +
+ 로딩 중...
}> + + +
+ + ), + ], parameters: { docs: { - description: { - story: '내 기록함 - 월별 기록 카드 그리드', - }, + description: { story: '개인 기록함 — 월별 기록 카드 그리드' }, }, }, }; -// 그룹 기록함 export const GroupRecords: Story = { - args: { - monthRecords: mockMonthRecords, - cardRoute: '/group/group-123/month', - }, + args: { cardRoute: `/group/${GROUP_ID}/month`, groupId: GROUP_ID }, + decorators: [ + (Story) => ( + +
+ 로딩 중...
}> + + +
+ + ), + ], parameters: { docs: { - description: { - story: '그룹 기록함 - 월별 기록 카드 그리드', - }, + description: { story: '그룹 기록함 — 그룹 월별 기록 카드 그리드' }, }, }, }; -// 기록이 없는 경우 export const EmptyRecords: Story = { - args: { - monthRecords: [], - cardRoute: '/my/month', - }, - parameters: { - docs: { - description: { - story: '기록이 없는 경우 - 아이콘과 버튼이 포함된 빈 상태 UI 표시', - }, - }, - }, -}; - -// 커버 이미지가 없는 기록들 -export const NoCoverImages: Story = { - args: { - monthRecords: mockMonthRecords.map((r) => ({ ...r, coverUrl: null })), - cardRoute: '/my/month', - }, - parameters: { - docs: { - description: { - story: '커버 이미지가 없는 기록들 - 기본 배경 표시', - }, - }, - }, -}; - -// 다크 모드 -export const DarkMode: Story = { - args: { - monthRecords: mockMonthRecords, - cardRoute: '/my/month', - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, - }, + args: { cardRoute: '/my/month' }, decorators: [ (Story) => ( -
-
- + +
+ 로딩 중...
}> + +
-
+ ), ], + parameters: { + docs: { + description: { story: '기록이 없는 경우 — 빈 상태 UI(아이콘 + 기록 추가하기 버튼) 표시' }, + }, + }, }; + diff --git a/frontend/src/app/(post)/_components/storybook/MonthlyDetailHeaderActions.stories.tsx b/frontend/src/app/(post)/_components/storybook/MonthlyDetailHeaderActions.stories.tsx index e02805e97..307b654b6 100644 --- a/frontend/src/app/(post)/_components/storybook/MonthlyDetailHeaderActions.stories.tsx +++ b/frontend/src/app/(post)/_components/storybook/MonthlyDetailHeaderActions.stories.tsx @@ -9,14 +9,14 @@ const meta = { docs: { description: { component: - '월별 상세 페이지 헤더 액션 - 뒤로가기, 월 표시, 정렬 버튼을 포함합니다. 정렬 버튼을 클릭하여 Drawer를 열어보세요.', + '월별 상세 페이지의 헤더 액션 영역 컴포넌트입니다. 뒤로가기 버튼, 라벨·월 표시 영역, 정렬 버튼을 포함합니다. 정렬 버튼 클릭 시 드로어가 열려 기록 정렬 옵션을 선택할 수 있으며, 개인/그룹 기록함 모두에서 공통으로 사용됩니다.', }, }, }, tags: ['autodocs'], decorators: [ (Story) => ( -
+
@@ -51,7 +51,12 @@ export const Default: Story = { docs: { description: { story: - '내 기록함 월별 상세 헤더 - 정렬 버튼을 클릭하면 정렬 옵션 Drawer가 열립니다.', + ` +내 기록함 월별 상세 헤더 - 정렬 버튼을 클릭하면 정렬 옵션 Drawer가 열립니다. +- **뒤로가기 버튼**: 클릭 시 이전 페이지(아카이브)로 이동 +- **정렬 버튼**: 클릭 시 정렬 옵션 드로어 표시 (최신순 / 오래된순 등) +- **연월 표시**: \`month\` prop(YYYY-MM)을 읽기 쉬운 형식으로 변환하여 표시 + `, }, }, }, @@ -72,43 +77,3 @@ export const GroupRecords: Story = { }, }; -// 다른 월 -export const DifferentMonth: Story = { - args: { - month: '2024-06', - title: 'MY RECORDS', - }, - parameters: { - docs: { - description: { - story: '다른 월 표시 예시', - }, - }, - }, -}; - -// 다크 모드 -export const DarkMode: Story = { - args: { - month: '2025-01', - title: 'MY RECORDS', - className: 'dark', - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, - }, - decorators: [ - (Story) => ( -
-
- -
-
- ), - ], -}; diff --git a/frontend/src/app/(post)/_components/storybook/MonthlyDetailRecords.stories.tsx b/frontend/src/app/(post)/_components/storybook/MonthlyDetailRecords.stories.tsx index f435108eb..2ee9b75d6 100644 --- a/frontend/src/app/(post)/_components/storybook/MonthlyDetailRecords.stories.tsx +++ b/frontend/src/app/(post)/_components/storybook/MonthlyDetailRecords.stories.tsx @@ -1,172 +1,128 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Suspense } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MonthlyDetailRecords from '../MonthlyDetailRecords'; -import { DayRecord } from '@/lib/types/record'; +import { createMockDailyRecord, createMockGroupDailyRecords } from '@/lib/mocks/mock'; -const mockDayRecords: DayRecord[] = [ - { - date: '2025-01-15', - dayName: '수', - title: '한강 산책', - author: '나', - count: 3, - coverUrl: - 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', - }, - { - date: '2025-01-12', - dayName: '일', - title: '북한산 등산', - author: '나', - count: 5, - coverUrl: - 'https://images.unsplash.com/photo-1418985991508-e47386d96a71?auto=format&fit=crop&q=80&w=400', - }, - { - date: '2025-01-08', - dayName: '수', - title: '카페 투어', - author: '나', - count: 2, - coverUrl: - 'https://images.unsplash.com/photo-1517487881594-2787fef5ebf7?auto=format&fit=crop&q=80&w=400', - }, - { - date: '2025-01-03', - dayName: '금', - title: '연극 관람', - author: '나', - count: 1, - coverUrl: - 'https://images.unsplash.com/photo-1467003909585-2f8a72700288?auto=format&fit=crop&q=80&w=400', - }, - { - date: '2025-01-01', - dayName: '수', - title: '새해 일출', - author: '나', - count: 4, - coverUrl: - 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?auto=format&fit=crop&q=80&w=400', - }, -]; +// MonthlyDetailRecords는 내부에서 useSuspenseQuery로 데이터를 fetching한다. +// setQueryData의 키는 각 queryOptions의 queryKey와 정확히 일치해야 한다. +// select 변환(convertDayRecords)은 TanStack Query가 자동으로 적용한다. + +const MONTH = '2025-01'; +const GROUP_ID = 'group-123'; + +function makeClient({ isGroup = false, empty = false }: { isGroup?: boolean; empty?: boolean } = {}) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + + const data = empty ? [] : isGroup ? createMockGroupDailyRecords() : createMockDailyRecord(); + + if (isGroup) { + client.setQueryData(['group', GROUP_ID, 'records', 'daily', MONTH], data); + } else { + client.setQueryData(['my', 'records', 'daily', MONTH], data); + } + + return client; +} + +const clients = { + default: makeClient(), + group: makeClient({ isGroup: true }), + few: (() => { + const c = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + c.setQueryData(['my', 'records', 'daily', MONTH], createMockDailyRecord().slice(0, 2)); + return c; + })(), + single: (() => { + const c = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + c.setQueryData(['my', 'records', 'daily', MONTH], createMockDailyRecord().slice(0, 1)); + return c; + })(), + empty: makeClient({ empty: true }), +}; const meta = { title: 'Record/MonthlyDetailRecords', component: MonthlyDetailRecords, parameters: { - layout: 'padded', - docs: {}, + layout: 'fullscreen', + docs: { + description: { + component: ` +월별 상세 페이지의 일별 기록 카드 그리드 컴포넌트. + +해당 월의 기록을 날짜별 카드로 표시하며, 카드 클릭 시 해당 날짜의 일별 상세 페이지(\`routePath/{날짜}\`)로 이동한다. +하단의 지도 보기 버튼으로 해당 월의 기록을 지도에서 확인할 수 있다. +기록이 없는 경우 빈 상태 UI를 표시한다. + `, + }, + }, }, tags: ['autodocs'], - decorators: [ - (Story) => ( -
- -
- ), - ], argTypes: { - dayRecords: { - description: '일별 기록 데이터 배열', - }, - routePath: { - description: '일별 상세 페이지 라우트 경로', - }, - viewMapRoutePath: { - description: '지도 보기 라우트 경로', - }, + month: { description: '조회할 월 (YYYY-MM 형식)' }, + routePath: { description: '날짜 카드 클릭 시 이동할 기본 경로. 뒤에 /{날짜}가 붙는다.' }, + viewMapRoutePath: { description: '지도 보기 버튼 클릭 시 이동할 경로.' }, + groupId: { description: '그룹 기록함이면 그룹 ID를 전달. 없으면 개인 기록함.' }, }, } satisfies Meta; export default meta; type Story = StoryObj; -// 기본: 내 기록함 월별 상세 export const Default: Story = { - args: { - dayRecords: mockDayRecords, - routePath: '/my/detail', - viewMapRoutePath: '/my/map/month/2025-01', - }, + args: { month: MONTH, routePath: '/my/detail', viewMapRoutePath: `/my/map/month/${MONTH}` }, + decorators: [ + (Story) => ( + +
+ 로딩 중...
}> + + +
+ + ), + ], parameters: { - docs: { - description: { - story: '내 기록함 - 월별 상세 (일별 카드 그리드)', - }, - }, + docs: { description: { story: '개인 기록함 — 월별 상세 일별 카드 그리드' } }, }, }; -// 그룹 기록함 월별 상세 export const GroupRecords: Story = { - args: { - dayRecords: mockDayRecords, - routePath: '/group/group-123/detail', - viewMapRoutePath: '/group/group-123/map/month/2025-01', - }, - parameters: { - docs: { - description: { - story: '그룹 기록함 - 월별 상세 (일별 카드 그리드)', - }, - }, - }, -}; - -// 기록이 적은 경우 -export const FewRecords: Story = { - args: { - dayRecords: mockDayRecords.slice(0, 2), - routePath: '/my/detail', - viewMapRoutePath: '/my/map/month/2025-01', - }, - parameters: { - docs: { - description: { - story: '기록이 적은 경우 (2개)', - }, - }, - }, -}; - -// 기록이 하나인 경우 -export const SingleRecord: Story = { - args: { - dayRecords: [mockDayRecords[0]], - routePath: '/my/detail', - viewMapRoutePath: '/my/map/month/2025-01', - }, + args: { month: MONTH, routePath: `/group/${GROUP_ID}/detail`, viewMapRoutePath: `/group/${GROUP_ID}/map/month/${MONTH}`, groupId: GROUP_ID }, + decorators: [ + (Story) => ( + +
+ 로딩 중...
}> + + +
+ + ), + ], parameters: { - docs: { - description: { - story: '기록이 하나만 있는 경우', - }, - }, + docs: { description: { story: '그룹 기록함 — 월별 상세 일별 카드 그리드' } }, }, }; -// 다크 모드 -export const DarkMode: Story = { - args: { - dayRecords: mockDayRecords, - routePath: '/my/detail', - viewMapRoutePath: '/my/map/month/2025-01', - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, - }, +export const EmptyRecords: Story = { + args: { month: MONTH, routePath: '/my/detail', viewMapRoutePath: `/my/map/month/${MONTH}` }, decorators: [ (Story) => ( -
-
- + +
+ 로딩 중...
}> + +
-
+ ), ], + parameters: { + docs: { description: { story: '기록이 없는 경우 — 빈 상태 UI 표시' } }, + }, }; + diff --git a/frontend/src/app/(post)/group/[groupId]/(root)/_components/storybook/GroupMainTabs.stories.tsx b/frontend/src/app/(post)/group/[groupId]/(root)/_components/storybook/GroupMainTabs.stories.tsx new file mode 100644 index 000000000..7575d67b9 --- /dev/null +++ b/frontend/src/app/(post)/group/[groupId]/(root)/_components/storybook/GroupMainTabs.stories.tsx @@ -0,0 +1,142 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { http, HttpResponse } from 'msw'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import GroupMainTabs from '../GroupMainTabs'; +import { createMockRecordPreviews } from '@/lib/mocks/mock'; + +const GROUP_ID = 'group-1'; + +const mockMonthlyRecords = [ + { month: '2026-06', count: 5, coverAssetId: null, latestTitle: '가족 나들이', latestLocation: '서울 성동구' }, + { month: '2026-05', count: 3, coverAssetId: null, latestTitle: '생일 파티', latestLocation: null }, + { month: '2026-04', count: 8, coverAssetId: null, latestTitle: '벚꽃 나들이', latestLocation: '여의도' }, +]; + +const mockFeedRecords = { + success: true, + data: createMockRecordPreviews('2026-06-05').slice(0, 2), + error: null, +}; + +const mockRole = { role: 'ADMIN' }; + +const currentYear = new Date().getFullYear().toString(); + +function makeClient(members: { memberId: string; profileImageId: string | null }[], groupName: string) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + client.setQueryData(['currentMembers', GROUP_ID], { + groupName, + groupMemberCount: members.length, + members, + }); + client.setQueryData(['group', GROUP_ID, 'me', 'role'], mockRole); + // MonthRecords는 useParams().year || currentYear 를 queryKey에 포함함 + client.setQueryData(['group', GROUP_ID, 'records', 'month', currentYear], mockMonthlyRecords); + return client; +} + +const smallMembers = [ + { memberId: 'user-1', profileImageId: null }, + { memberId: 'user-2', profileImageId: null }, + { memberId: 'user-3', profileImageId: null }, +]; + +const manyMembers = Array.from({ length: 6 }, (_, i) => ({ + memberId: `user-${i + 1}`, + profileImageId: null, +})); + +const clients = { + feed: makeClient(smallMembers, '우리 가족 추억함'), + archive: makeClient(smallMembers, '우리 가족 추억함'), + manyMembers: makeClient(manyMembers, '대가족 모임'), +}; + +const feedHandlers = [ + http.get('/api/feed/groups/:groupId', () => HttpResponse.json(mockFeedRecords)), +]; + +const meta = { + title: 'Group/GroupMainTabs', + component: GroupMainTabs, + parameters: { + layout: 'padded', + docs: { + description: { + component: + '그룹 홈의 메인 컨텐츠 영역입니다. 상단에 그룹 멤버 아바타 목록과 피드/보관함 탭 전환 버튼이 있으며, 탭에 따라 주간 캘린더+기록 피드(피드 탭) 또는 올해 월별 기록(보관함 탭)을 표시합니다.', + }, + }, + msw: { handlers: feedHandlers }, + nextjs: { navigation: { pathname: `/group/${GROUP_ID}` } }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const FeedTab: Story = { + args: { groupId: GROUP_ID }, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: ` +피드 탭 — 주간 캘린더 + 날짜별 기록 목록. + +- **피드/보관함 탭**: 탭 클릭 시 URL의 \`tab\` 쿼리 파라미터가 변경되어 뷰가 전환됨 (보관함 탭은 ArchiveTab 스토리에서 확인) +- **멤버 아바타**: 최대 4명 표시, 초과 시 +N 배지 (ManyMembers 스토리에서 확인) + `, + }, + }, + }, +}; + +export const ArchiveTab: Story = { + args: { groupId: GROUP_ID }, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: '보관함 탭 — 올해의 월별 기록 카드 목록. 월 카드를 클릭하면 해당 월의 기록 상세로 이동', + }, + }, + nextjs: { + navigation: { + pathname: `/group/${GROUP_ID}`, + query: { tab: 'archive' }, + }, + }, + }, +}; + +export const ManyMembers: Story = { + args: { groupId: GROUP_ID }, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: '멤버가 4명을 초과하는 경우 — 아바타 4개 + +N 배지 표시', + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupDangerousZone.tsx b/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupDangerousZone.tsx index 92689717a..e1d2f88f4 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupDangerousZone.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupDangerousZone.tsx @@ -37,7 +37,7 @@ export default function GroupDangerousZone({
diff --git a/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupMemberManagement.tsx b/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupMemberManagement.tsx index 437d5df6e..0a1d4a977 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupMemberManagement.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupMemberManagement.tsx @@ -38,7 +38,7 @@ const MemberCard = memo(function MemberCard({ member: GroupMember; me: GroupEditResponse['me']; onOpenRoleDrawer: (member: GroupMember) => void; - onConfirmRemove: (userId: string, name: string) => void; + onConfirmRemove: (userId: string, name: string, nicknameInGroup: string) => void; }) { const canManage = me.userId !== member.userId && me.role === 'ADMIN'; @@ -49,11 +49,11 @@ const MemberCard = memo(function MemberCard({ }, [canManage, onOpenRoleDrawer, member]); const handleRemoveClick = useCallback(() => { - onConfirmRemove(member.userId, member.name); - }, [onConfirmRemove, member.userId, member.name]); + onConfirmRemove(member.userId, member.name, member.nicknameInGroup || ''); + }, [onConfirmRemove, member.userId, member.name, member.nicknameInGroup]); return ( -
+
{member.profileImage?.assetId ? ( @@ -106,6 +106,7 @@ const MemberCard = memo(function MemberCard({ {canManage && (
- {`정말 '${deleteMember?.name}'님을 그룹에서 내보내시겠습니까?`} + {`정말 '${deleteMember?.nicknameInGroup || deleteMember?.name}'님을 그룹에서 내보내시겠습니까?`}
@@ -297,7 +299,7 @@ const GroupMemberManagement = memo(function GroupMemberManagement({ Set Permissions - {editingMember?.name}님의 권한 변경 + {editingMember?.nicknameInGroup || editingMember?.name}님의 권한 변경
diff --git a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupDangerousZone.stories.tsx b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupDangerousZone.stories.tsx index 8456ec588..5e8e61dc3 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupDangerousZone.stories.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupDangerousZone.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; import GroupDangerousZone from '../GroupDangerousZone'; import { http, HttpResponse } from 'msw'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { GroupMember } from '@/lib/types/groupResponse'; const queryClient = new QueryClient({ defaultOptions: { @@ -16,6 +17,12 @@ const meta = { component: GroupDangerousZone, parameters: { layout: 'padded', + docs: { + description: { + component: + '그룹 설정 페이지의 그룹 삭제 영역 컴포넌트입니다. 그룹 이름을 표시하고 "그룹 삭제" 버튼을 통해 확인 드로어를 열어 최종 삭제를 진행합니다. 관리자(admin)만 접근 가능한 영역입니다.', + }, + }, msw: { handlers: [ http.delete('/api/groups/:groupId', () => { @@ -28,7 +35,7 @@ const meta = { decorators: [ (Story) => ( -
+
@@ -39,57 +46,35 @@ const meta = { export default meta; type Story = StoryObj; -export const Default: Story = { - args: { - groupName: '우리 가족', - groupId: 'group-1', - }, - parameters: { - docs: { - description: { - story: '그룹 삭제 영역 - 기본 상태', - }, - }, - }, +const meAdmin: GroupMember = { + userId: 'user-1', + name: '나', + profileImage: null, + role: 'ADMIN', + nicknameInGroup: '관리자', + joinedAt: '2024-01-01T00:00:00Z', }; -export const LongGroupName: Story = { - args: { - groupName: '우리 가족의 소중한 추억 모음집', - groupId: 'group-1', - }, - parameters: { - docs: { - description: { - story: '긴 그룹 이름인 경우', - }, - }, - }, +const meViewer: GroupMember = { + ...meAdmin, + role: 'VIEWER', + nicknameInGroup: '뷰어', }; -export const DarkMode: Story = { +export const Default: Story = { args: { groupName: '우리 가족', groupId: 'group-1', - className: 'dark', + me: meAdmin, }, parameters: { - backgrounds: { default: 'dark' }, docs: { description: { - story: '다크 모드', + story: '그룹 삭제 영역 - 기본 상태', }, }, }, - decorators: [ - (Story) => ( - -
-
- -
-
-
- ), - ], }; + + + diff --git a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupInfo.stories.tsx b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupInfo.stories.tsx index fdf6d04ff..ba64222f6 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupInfo.stories.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupInfo.stories.tsx @@ -1,33 +1,61 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import GroupInfo from '../GroupInfo'; import { GroupEditProvider } from '../GroupEditContext'; -import { Member } from '@/lib/types/group'; +import { GroupMember } from '@/lib/types/groupResponse'; -const mockMembers: Member[] = [ - { id: 1, name: '도비', avatar: '/profile-ex.jpeg', role: 'admin' }, - { id: 2, name: '하니', avatar: '/profile-ex.jpeg', role: 'member' }, +// 커버 이미지 클릭 시 GalleryDrawer가 열리고 내부에서 useInfiniteQuery를 사용하므로 +// QueryClientProvider가 필요하다. + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, +}); + +const mockMembers: GroupMember[] = [ + { userId: 'user-1', name: '도비', profileImage: null, role: 'ADMIN', nicknameInGroup: '도비', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'user-2', name: '하니', profileImage: null, role: 'EDITOR', nicknameInGroup: '하니', joinedAt: '2024-01-01T00:00:00Z' }, ]; +const meAdmin: GroupMember = { + userId: 'user-1', + name: '도비', + profileImage: null, + role: 'ADMIN', + nicknameInGroup: '도비', + joinedAt: '2024-01-01T00:00:00Z', +}; + +const meViewer: GroupMember = { + ...meAdmin, + role: 'VIEWER', + nicknameInGroup: '뷰어', +}; + +function Wrapper({ name, children }: { name: string; children: React.ReactNode }) { + return ( + + +
+ {children} +
+
+
+ ); +} + const meta = { title: 'Group/GroupInfo', component: GroupInfo, parameters: { layout: 'padded', + docs: { + description: { + component: + '그룹 설정 페이지의 그룹명/커버 편집 폼 컴포넌트입니다. 그룹 커버 이미지 변경, 그룹명 입력(유효성 검사 포함), 저장 버튼을 제공합니다. GroupEditProvider 컨텍스트를 통해 편집 상태를 관리합니다.', + }, + }, }, tags: ['autodocs'], - decorators: [ - (Story) => ( - -
- -
-
- ), - ], } satisfies Meta; export default meta; @@ -36,27 +64,25 @@ type Story = StoryObj; export const Default: Story = { args: { groupId: 'group-1', - groupThumnail: - 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', - nickname: '도비', + groupThumnail: 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + me: meAdmin, }, decorators: [ (Story) => ( - -
- -
-
+ + + ), ], parameters: { docs: { description: { - story: '그룹 정보 수정 - 기본 상태', + story: ` +그룹 정보 수정 — 기본 상태 (ADMIN 권한) +- **커버 이미지 클릭**: 이미지 선택 드로어가 열려 커버 변경 가능 +- **그룹명 편집 버튼 클릭**: 입력 필드 활성화, 빈 값 또는 2자 미만이면 저장 버튼 비활성화 +- **저장 버튼**: 변경 사항을 서버에 저장 + `, }, }, }, @@ -65,27 +91,20 @@ export const Default: Story = { export const LongGroupName: Story = { args: { groupId: 'group-1', - groupThumnail: - 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', - nickname: '도비', + groupThumnail: 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + me: meAdmin, }, decorators: [ (Story) => ( - -
- -
-
+ + + ), ], parameters: { docs: { description: { - story: '긴 그룹 이름', + story: '긴 그룹 이름 — 입력 길이 초과 시 유효성 에러 표시', }, }, }, @@ -94,60 +113,43 @@ export const LongGroupName: Story = { export const EmptyGroupName: Story = { args: { groupId: 'group-1', - groupThumnail: - 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', - nickname: '도비', + groupThumnail: 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + me: meAdmin, }, decorators: [ (Story) => ( - -
- -
-
+ + + ), ], parameters: { docs: { description: { - story: '그룹 이름이 비어있는 경우', + story: '그룹 이름이 비어있는 경우 — 저장 버튼 비활성화', }, }, }, }; -export const DarkMode: Story = { +export const ViewerRole: Story = { args: { groupId: 'group-1', - groupThumnail: - 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', - nickname: '도비', + groupThumnail: 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + me: meViewer, }, + decorators: [ + (Story) => ( + + + + ), + ], parameters: { - backgrounds: { default: 'dark' }, docs: { description: { - story: '다크 모드', + story: 'VIEWER 권한 — 커버 이미지 클릭 불가, 그룹명 편집 버튼 비표시', }, }, }, - decorators: [ - (Story) => ( -
- -
- -
-
-
- ), - ], }; diff --git a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupMemberManagement.stories.tsx b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupMemberManagement.stories.tsx index 906fcd674..5630a73a6 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupMemberManagement.stories.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupMemberManagement.stories.tsx @@ -1,23 +1,28 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; import GroupMemberManagement from '../GroupMemberManagement'; import { GroupEditProvider } from '../GroupEditContext'; -import { Member } from '@/lib/types/group'; +import { GroupMember } from '@/lib/types/groupResponse'; import { http, HttpResponse } from 'msw'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, + defaultOptions: { queries: { retry: false } }, }); -const mockMembers: Member[] = [ - { id: 1, name: '도비', avatar: '/profile-ex.jpeg', role: 'admin' }, - { id: 2, name: '하니', avatar: '/profile-ex.jpeg', role: 'member' }, - { id: 3, name: '루피', avatar: '/profile-ex.jpeg', role: 'member' }, - { id: 4, name: '미키', avatar: '/profile-ex.jpeg', role: 'member' }, +const mockMe: GroupMember = { + userId: 'user-1', + name: '도비', + profileImage: null, + role: 'ADMIN', + nicknameInGroup: '도비', + joinedAt: '2024-01-01T00:00:00Z', +}; + +const mockMembers: GroupMember[] = [ + { userId: 'user-1', name: '도비', profileImage: null, role: 'ADMIN', nicknameInGroup: '도비', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'user-2', name: '하니', profileImage: null, role: 'EDITOR', nicknameInGroup: '하니', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'user-3', name: '루피', profileImage: null, role: 'EDITOR', nicknameInGroup: '루피', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'user-4', name: '미키', profileImage: null, role: 'VIEWER', nicknameInGroup: '미키', joinedAt: '2024-01-01T00:00:00Z' }, ]; const meta = { @@ -25,9 +30,18 @@ const meta = { component: GroupMemberManagement, parameters: { layout: 'padded', + docs: { + description: { + component: + '그룹 설정 페이지의 멤버 목록과 역할 관리 컴포넌트입니다. 그룹 멤버를 아바타/닉네임/역할과 함께 목록으로 표시하며, 관리자는 멤버 역할 변경 및 내보내기를 할 수 있습니다.', + }, + }, msw: { handlers: [ - http.delete('/api/:groupId/members/:memberId', () => { + http.delete('/api/groups/:groupId/members/:memberId', () => { + return HttpResponse.json({ success: true }); + }), + http.patch('/api/groups/:groupId/members/:memberId/role', () => { return HttpResponse.json({ success: true }); }), ], @@ -41,15 +55,15 @@ type Story = StoryObj; export const Default: Story = { args: { - members: mockMembers, groupId: 'group-1', + me: mockMe, }, decorators: [ (Story) => (
@@ -62,7 +76,13 @@ export const Default: Story = { parameters: { docs: { description: { - story: '그룹 멤버 관리 - 기본 상태 (관리자 뷰)', + story: ` +그룹 멤버 관리 — ADMIN 권한 기본 상태. + +- **역할 텍스트 클릭**: 역할 변경 드로어가 열려 ADMIN/멤버/뷰어 선택 가능 +- **내보내기 버튼 클릭**: 해당 멤버 내보내기 확인 드로어 표시 +- **관리자 본인**: 내보내기 버튼 비표시 (자기 자신은 내보낼 수 없음) + `, }, }, }, @@ -70,15 +90,15 @@ export const Default: Story = { export const FewMembers: Story = { args: { - members: mockMembers.slice(0, 2), groupId: 'group-1', + me: mockMe, }, decorators: [ (Story) => (
@@ -99,27 +119,22 @@ export const FewMembers: Story = { export const ManyMembers: Story = { args: { - members: [ - ...mockMembers, - { id: 5, name: '피카츄', avatar: '/profile-ex.jpeg', role: 'member' }, - { id: 6, name: '라이츄', avatar: '/profile-ex.jpeg', role: 'member' }, - { id: 7, name: '파이리', avatar: '/profile-ex.jpeg', role: 'member' }, - ], groupId: 'group-1', + me: mockMe, }, decorators: [ (Story) => { - const manyMembers: Member[] = [ + const manyMembers: GroupMember[] = [ ...mockMembers, - { id: 5, name: '피카츄', avatar: '/profile-ex.jpeg', role: 'member' }, - { id: 6, name: '라이츄', avatar: '/profile-ex.jpeg', role: 'member' }, - { id: 7, name: '파이리', avatar: '/profile-ex.jpeg', role: 'member' }, + { userId: 'user-5', name: '피카츄', profileImage: null, role: 'EDITOR', nicknameInGroup: '피카츄', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'user-6', name: '라이츄', profileImage: null, role: 'EDITOR', nicknameInGroup: '라이츄', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'user-7', name: '파이리', profileImage: null, role: 'VIEWER', nicknameInGroup: '파이리', joinedAt: '2024-01-01T00:00:00Z' }, ]; return (
@@ -133,41 +148,8 @@ export const ManyMembers: Story = { parameters: { docs: { description: { - story: '멤버가 많은 경우', - }, - }, - }, -}; - -export const DarkMode: Story = { - args: { - members: mockMembers, - groupId: 'group-1', - className: 'dark', - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', + story: '멤버가 많은 경우 (7명)', }, }, }, - decorators: [ - (Story) => ( - -
- -
- -
-
-
-
- ), - ], }; diff --git a/frontend/src/app/(post)/group/[groupId]/edit/profile/_components/storybook/GroupProfileEdit.stories.tsx b/frontend/src/app/(post)/group/[groupId]/edit/profile/_components/storybook/GroupProfileEdit.stories.tsx index 2e9af7d2f..e6ee68790 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/profile/_components/storybook/GroupProfileEdit.stories.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/profile/_components/storybook/GroupProfileEdit.stories.tsx @@ -1,10 +1,45 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Suspense } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import GroupProfileEditClient from '../GroupProfileEditClient'; -import { BaseUser } from '@/lib/types/profile'; +import { GroupEditResponse } from '@/lib/types/groupResponse'; -const mockGroupProfile: Omit = { - nickname: '도비', - profileImageUrl: '/profile-ex.jpeg', +const baseMockData: GroupEditResponse = { + group: { + groupId: 'group-1', + name: '우리 가족', + createdAt: '2024-01-01T00:00:00Z', + ownerUserId: 'user-1', + cover: null, + }, + me: { + userId: 'user-1', + name: '도비', + profileImage: null, + role: 'EDITOR', + nicknameInGroup: '도비', + joinedAt: '2024-01-01T00:00:00Z', + }, + members: [], +}; + +function makeClient(overrideMe?: Partial) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(['group', 'group-1', 'edit'], { + ...baseMockData, + me: { ...baseMockData.me, ...overrideMe }, + }); + return client; +} + +// 스토리별 독립적인 QueryClient 인스턴스 +const clients = { + default: makeClient(), + emptyNickname: makeClient({ nicknameInGroup: '' }), + shortNickname: makeClient({ nicknameInGroup: '도' }), + longNickname: makeClient({ nicknameInGroup: '아주아주아주긴닉네임입니다' }), }; const meta = { @@ -12,6 +47,12 @@ const meta = { component: GroupProfileEditClient, parameters: { layout: 'fullscreen', + docs: { + description: { + component: + '그룹 내 내 프로필(닉네임/이미지) 편집 페이지 컴포넌트입니다. 그룹 전용 닉네임과 프로필 이미지를 변경할 수 있으며, 닉네임 유효성 검사(2~10자)를 포함합니다. groupId를 받아 내부적으로 그룹 데이터를 조회합니다.', + }, + }, nextjs: { appDirectory: true, navigation: { @@ -33,90 +74,88 @@ export default meta; type Story = StoryObj; export const Default: Story = { - args: { - groupId: 'group-1', - groupProfile: mockGroupProfile, - }, + args: { groupId: 'group-1' }, + decorators: [ + (Story) => ( + + 로딩 중...
}> + + +
+ ), + ], parameters: { docs: { description: { - story: '그룹 프로필 수정 페이지 - 기본 상태', + story: ` +그룹 프로필 수정 페이지 — 기본 상태. + +- **프로필 이미지 클릭**: 이미지 파일 선택 피커가 열리며, 선택 즉시 미리보기 업데이트 +- **닉네임 입력**: 실시간 입력, 2자 미만 또는 10자 초과 시 에러 메시지 표시 +- **저장 버튼**: 유효성 통과 시 활성화, 클릭 시 변경 사항을 서버에 저장 +- **뒤로가기**: 저장하지 않고 이전 페이지로 이동 + `, }, }, }, }; export const EmptyNickname: Story = { - args: { - groupId: 'group-1', - groupProfile: { - ...mockGroupProfile, - nickname: '', - }, - }, + args: { groupId: 'group-1' }, + decorators: [ + (Story) => ( + + 로딩 중...
}> + + +
+ ), + ], parameters: { docs: { description: { - story: '닉네임이 비어있는 경우 (저장 불가)', + story: '닉네임이 비어있는 경우 — 저장 불가', }, }, }, }; export const ShortNickname: Story = { - args: { - groupId: 'group-1', - groupProfile: { - ...mockGroupProfile, - nickname: '도', - }, - }, + args: { groupId: 'group-1' }, + decorators: [ + (Story) => ( + + 로딩 중...
}> + + +
+ ), + ], parameters: { docs: { description: { - story: '닉네임이 너무 짧은 경우 (1자, 에러 표시)', + story: '닉네임이 너무 짧은 경우 (1자) — 에러 표시', }, }, }, }; export const LongNickname: Story = { - args: { - groupId: 'group-1', - groupProfile: { - ...mockGroupProfile, - nickname: '아주아주아주긴닉네임입니다', - }, - }, - parameters: { - docs: { - description: { - story: '닉네임이 너무 긴 경우 (10자 초과, 에러 표시)', - }, - }, - }, -}; - -export const DarkMode: Story = { - args: { - groupId: 'group-1', - groupProfile: mockGroupProfile, - }, + args: { groupId: 'group-1' }, + decorators: [ + (Story) => ( + + 로딩 중...
}> + + +
+ ), + ], parameters: { - backgrounds: { default: 'dark' }, docs: { description: { - story: '다크 모드', + story: '닉네임이 너무 긴 경우 (10자 초과) — 에러 표시', }, }, }, - decorators: [ - (Story) => ( -
-
- -
-
- ), - ], }; diff --git a/frontend/src/app/(post)/group/_components/GroupHeaderActions.tsx b/frontend/src/app/(post)/group/_components/GroupHeaderActions.tsx index 0393b6165..edbe7c5cc 100644 --- a/frontend/src/app/(post)/group/_components/GroupHeaderActions.tsx +++ b/frontend/src/app/(post)/group/_components/GroupHeaderActions.tsx @@ -64,6 +64,9 @@ export default function GroupHeaderActions({ enabled: !!groupId, }); + // roleData가 undefined(로딩 중)일 때 false로 평가되면 VIEWER에게 초대 버튼이 잠깐 표시되는 문제가 있음. + // role이 확정된 후에만 버튼 가시성을 결정하기 위해 undefined 체크를 포함한다. + const roleLoaded = roleData !== undefined; const isViewer = roleData?.role === 'VIEWER'; const isAdmin = roleData?.role === 'ADMIN'; @@ -78,7 +81,7 @@ export default function GroupHeaderActions({ {groupInfo.groupName}
- {!isViewer && } + {roleLoaded && !isViewer && } - + diff --git a/frontend/src/app/(post)/group/_components/GroupInviteDrawer.tsx b/frontend/src/app/(post)/group/_components/GroupInviteDrawer.tsx index dde0a1446..7879036ae 100644 --- a/frontend/src/app/(post)/group/_components/GroupInviteDrawer.tsx +++ b/frontend/src/app/(post)/group/_components/GroupInviteDrawer.tsx @@ -124,7 +124,7 @@ export default function GroupInviteDrawer({ groupId }: GroupInviteDrawerProps) { onOpenChange={setIsOpen} shouldScaleBackground={false} > - + diff --git a/frontend/src/app/(post)/group/_components/storybook/AddRecordDrawer.stories.tsx b/frontend/src/app/(post)/group/_components/storybook/AddRecordDrawer.stories.tsx new file mode 100644 index 000000000..49f92344d --- /dev/null +++ b/frontend/src/app/(post)/group/_components/storybook/AddRecordDrawer.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AddRecordDrawer } from '../AddRecordDrawer'; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +function AddRecordTrigger({ groupId }: { groupId?: string }) { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + + + ); +} + +const meta = { + title: 'Group/AddRecordDrawer', + component: AddRecordDrawer, + parameters: { + layout: 'padded', + docs: { + description: { + component: + '그룹 홈에서 기록 방식을 선택하는 드로어 컴포넌트입니다. 혼자 기록(개인 기록 페이지 이동)과 공동 기록(새 그룹 드래프트 생성 후 편집 페이지 이동)을 선택할 수 있습니다. groupId가 없으면 공동 기록 버튼이 비활성화됩니다.', + }, + }, + nextjs: { + navigation: { pathname: '/group/group-1' }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , + parameters: { + docs: { + description: { + story: ` +기록 방식 선택 드로어 기본 상태 — 그룹 ID가 있어 공동 기록 버튼이 활성화된 상태. + +- **혼자 기록 클릭**: \`/add?groupId={groupId}\`로 이동 +- **공동 기록 클릭**: 새 공동 작성 드래프트를 생성 후 편집 페이지로 이동 + `, + }, + }, + }, +}; + +export const WithoutGroup: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'groupId가 없는 경우 — 공동 기록 버튼이 비활성화(흐림 처리)되어 클릭 불가', + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/group/_components/storybook/GroupDraftList.stories.tsx b/frontend/src/app/(post)/group/_components/storybook/GroupDraftList.stories.tsx new file mode 100644 index 000000000..40feaa1f7 --- /dev/null +++ b/frontend/src/app/(post)/group/_components/storybook/GroupDraftList.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import GroupDraftList from '../GroupDraftList'; +import { GroupDraftListItem } from '@/lib/types/recordCollaboration'; + +const GROUP_ID = 'group-1'; + +function makeDraft(override: Partial & { draftId: string }): GroupDraftListItem { + return { + kind: 'CREATE', + targetPostId: null, + title: '제목 없음', + createdAt: '2026-06-01T10:00:00Z', + updatedAt: '2026-06-01T11:00:00Z', + participantCount: 1, + isPublishing: false, + ...override, + }; +} + +const mockDrafts: GroupDraftListItem[] = [ + makeDraft({ draftId: 'draft-1', kind: 'CREATE', title: '가족 나들이 기록', participantCount: 3 }), + makeDraft({ draftId: 'draft-2', kind: 'EDIT', targetPostId: 'post-1', title: '설날 가족 모임', participantCount: 2 }), + makeDraft({ draftId: 'draft-3', kind: 'CREATE', title: '', participantCount: 1 }), +]; + +const meta = { + title: 'Group/GroupDraftList', + component: GroupDraftList, + parameters: { + layout: 'padded', + docs: { + description: { + component: + '그룹 홈 플로팅 버튼 팝오버 내부에서 공동 작성 중인 드래프트 목록을 표시하는 컴포넌트입니다. 드래프트는 공동 작성(CREATE)과 공동 수정(EDIT)으로 구분되며, VIEWER는 카드를 클릭할 수 없습니다.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { groupId: GROUP_ID, drafts: [], isViewer: false }, + parameters: { + docs: { + description: { + story: ` +공동 작성 중인 드래프트가 없는 상태 — 빈 상태 안내 문구 표시. + +- **새 드래프트 생성**: 플로팅 버튼 팝오버 상단의 '새 드래프트' 버튼으로 진입 + `, + }, + }, + }, +}; + +export const WithDrafts: Story = { + args: { groupId: GROUP_ID, drafts: mockDrafts, isViewer: false }, + parameters: { + docs: { + description: { + story: ` +공동 작성(CREATE)·공동 수정(EDIT) 드래프트가 혼합된 상태. + +- **드래프트 클릭**: 해당 공동 작성/수정 페이지로 이동 +- **공동 작성** 배지: 초록색, **공동 수정** 배지: 노란색 +- title이 빈 문자열인 경우 '제목 없음'으로 표시 + `, + }, + }, + }, +}; + +export const ViewerRole: Story = { + args: { groupId: GROUP_ID, drafts: mockDrafts, isViewer: true }, + parameters: { + docs: { + description: { + story: 'VIEWER 권한 — 드래프트 카드가 흐림 처리되어 클릭 불가', + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/group/_components/storybook/GroupHeaderActions.stories.tsx b/frontend/src/app/(post)/group/_components/storybook/GroupHeaderActions.stories.tsx index aab2fc0b8..d3f806a83 100644 --- a/frontend/src/app/(post)/group/_components/storybook/GroupHeaderActions.stories.tsx +++ b/frontend/src/app/(post)/group/_components/storybook/GroupHeaderActions.stories.tsx @@ -1,135 +1,138 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; -import { http, HttpResponse } from 'msw'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import GroupHeader from '../GroupHeader'; +import GroupHeaderActions from '../GroupHeaderActions'; +import { GroupMembersResponse, GroupMemberRoleResponse } from '@/lib/types/groupResponse'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}); +const GROUP_ID = 'group-1'; + +function makeClient(groupId: string, role: GroupMemberRoleResponse['role']) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + // groupMyRoleOptions queryKey: ['group', groupId, 'me', 'role'] + client.setQueryData(['group', groupId, 'me', 'role'], { role }); + return client; +} + +const LONG_NAME_GROUP_ID = 'long-name'; + +// 스토리별 독립 QueryClient — 공유 캐시로 인한 role 오염 방지 +const clients = { + admin: makeClient(GROUP_ID, 'ADMIN'), + viewer: makeClient(GROUP_ID, 'VIEWER'), + longName: makeClient(LONG_NAME_GROUP_ID, 'ADMIN'), + manyMembers: makeClient(GROUP_ID, 'ADMIN'), +}; -// --- Mock Data 생성 --- const baseMembers = [ - { id: 1, name: '나', avatar: '/profile-ex.jpeg' }, - { id: 2, name: '엄마', avatar: '/profile-ex.jpeg' }, - { id: 3, name: '아빠', avatar: '/profile-ex.jpeg' }, + { memberId: 'user-1', profileImageId: null }, + { memberId: 'user-2', profileImageId: null }, + { memberId: 'user-3', profileImageId: null }, ]; -const manyMembers = Array.from({ length: 12 }, (_, i) => ({ - id: i + 1, - name: `멤버 ${i + 1}`, - avatar: '/profile-ex.jpeg', +const manyMembersList = Array.from({ length: 12 }, (_, i) => ({ + memberId: `user-${i + 1}`, + profileImageId: null, })); +const defaultGroupInfo: GroupMembersResponse = { + groupName: '우리 가족 추억함', + groupMemberCount: 3, + members: baseMembers, +}; + +const longNameGroupInfo: GroupMembersResponse = { + groupName: '우리 가족의 아주아주 길고 소중한 2026년 추억 보관함 (말줄임표 확인용)', + groupMemberCount: 3, + members: baseMembers, +}; + +const manyMembersGroupInfo: GroupMembersResponse = { + groupName: '대가족 모임', + groupMemberCount: 12, + members: manyMembersList, +}; + const meta = { title: 'Group/GroupHeader', - component: GroupHeader, + component: GroupHeaderActions, parameters: { layout: 'padded', - msw: { - handlers: [ - // 호출되는 API 주소에 따라 다른 데이터를 반환하도록 설정 - http.get('/api/groups/:groupId', ({ params }) => { - const { groupId } = params; - - if (groupId === 'long-name') { - return HttpResponse.json({ - name: '우리 가족의 아주아주 길고 소중한 2026년 추억 보관함 (말줄임표 확인용)', - inviteCode: 'LONG-NAME-TEST', - members: baseMembers, - }); - } - - if (groupId === 'many-members') { - return HttpResponse.json({ - name: '대가족 모임', - inviteCode: 'BIG-FAMILY-77', - members: manyMembers, - }); - } - - // Default 데이터 - return HttpResponse.json({ - name: '우리 가족 추억함', - inviteCode: 'DLOG-FAMILY-99', - members: baseMembers, - }); - }), - ], + docs: { + description: { + component: + '그룹 홈 헤더 컴포넌트입니다. 그룹 이름, 멤버 초대(비뷰어만), 날짜 선택, 그룹 메뉴(설정/프로필/탈퇴)를 포함합니다. groupInfo prop으로 그룹 정보를 받고, 내부적으로 현재 사용자의 role을 조회해 UI를 분기합니다.', + }, + }, + nextjs: { + navigation: { pathname: `/group/${GROUP_ID}` }, }, }, tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { groupInfo: defaultGroupInfo }, decorators: [ (Story) => ( - + ), ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { parameters: { docs: { description: { - story: '그룹 헤더 - 기본 뷰', + story: ` +그룹 헤더 기본 뷰 — ADMIN 권한으로 모든 메뉴 표시. + +- **초대 버튼 클릭**: 멤버 초대 드로어가 열려 초대 코드 생성 및 공유 가능 +- **달력 아이콘 클릭**: 날짜 선택 드로어로 특정 날짜의 기록 이동 +- **더보기(⋮) 클릭**: 그룹 정보 수정 / 나의 그룹 프로필 / 멤버 관리 / 그룹 나가기 팝오버 표시 + `, }, }, }, }; export const LongGroupName: Story = { + args: { groupInfo: longNameGroupInfo }, + decorators: [ + (Story) => ( + + + + ), + ], parameters: { docs: { description: { - story: '그룹 이름이 매우 긴 경우', + story: '그룹 이름이 매우 긴 경우 — 말줄임표 처리 확인', }, }, - layout: 'padded', nextjs: { - navigation: { pathname: '/group/long-name' }, + navigation: { pathname: `/group/${LONG_NAME_GROUP_ID}` }, }, }, -}; +} -export const ManyMembers: Story = { - parameters: { - layout: 'padded', - nextjs: { - navigation: { pathname: '/group/many-members' }, - }, - docs: { - description: { - story: '멤버가 5명 이상인 경우', - }, - }, - }, -}; - -export const DarkMode: Story = { - args: { - className: 'dark', - }, +export const ViewerRole: Story = { + args: { groupInfo: defaultGroupInfo }, + decorators: [ + (Story) => ( + + + + ), + ], parameters: { - layout: 'padded', - backgrounds: { default: 'dark' }, docs: { description: { - story: '다크 모드', + story: 'VIEWER 권한 — 초대 버튼이 숨겨지고 그룹 정보 수정·멤버 관리 메뉴가 비표시', }, }, }, - render: (args) => ( -
-
- -
-
- ), }; diff --git a/frontend/src/app/(post)/record/_components/storybook/RecordDetail.stories.tsx b/frontend/src/app/(post)/record/_components/storybook/RecordDetail.stories.tsx index d0ef59b41..a6699cd0a 100644 --- a/frontend/src/app/(post)/record/_components/storybook/RecordDetail.stories.tsx +++ b/frontend/src/app/(post)/record/_components/storybook/RecordDetail.stories.tsx @@ -44,7 +44,8 @@ const mockBlocks: Block[] = [ id: 'block-4', type: 'IMAGE', value: { - mediaIds: ['media-1', 'media-2'], + // mediaIds를 넣으면 /api/media-image/:id 프록시를 우선 사용해 Storybook에서 이미지가 깨짐. + // tempUrls만 두면 Unsplash URL을 직접 사용한다. tempUrls: [ 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', 'https://images.unsplash.com/photo-1418985991508-e47386d96a71?auto=format&fit=crop&q=80&w=400', @@ -72,7 +73,7 @@ const mockBlocks: Block[] = [ { id: 'block-7', type: 'MOOD', - value: { mood: '😊' }, + value: { mood: '행복' }, layout: { row: 6, col: 1, span: 1 }, }, { @@ -123,6 +124,12 @@ const meta = { component: RecordDetail, parameters: { layout: 'padded', + docs: { + description: { + component: + '기록 상세 페이지 컴포넌트입니다. 블록 기반 기록 본문(날짜, 시간, 텍스트, 이미지, 위치, 태그, 무드, 평점)과 작성자/기여자 섹션을 표시합니다.', + }, + }, nextjs: { appDirectory: true, }, @@ -145,10 +152,12 @@ const meta = { decorators: [ (Story) => ( -
- }> - - +
+
+ }> + + +
), @@ -165,7 +174,23 @@ export const Default: Story = { parameters: { docs: { description: { - story: '내 기록함 상세 페이지 - 기본 뷰', + story: ` +내 기록함 상세 페이지 - 기본 뷰 +- **블록 렌더링**: 블록 타입(DATE, TIME, TEXT, IMAGE, LOCATION, TAG, MOOD, RATING)에 따라 기록 본문이 동적으로 구성됨 +- **이미지 블록**: 여러 이미지를 갤러리 형태로 표시 +- **기여자 섹션**: 그룹 기록의 경우 기여자(EDITOR) 아바타 목록 표시 + +**헤더 우측 ⋯ 버튼 (Popover)** + +작성자 본인 / 그룹 EDITOR 이상인 경우에만 표시됩니다. 버튼을 클릭하면 Popover가 열리며 아래 옵션이 나타납니다. + +| 옵션 | 동작 | +|---|---| +| 공유하기 | 공유 링크가 이미 있으면 공유 드로어 오픈, 없으면 링크 생성 후 드로어 오픈 | +| 공유 링크 생성 / 해제 | 공유 토큰 유무에 따라 토글 — 링크 생성 또는 링크 해제 API 호출 | +| 수정하기 | 개인 기록: \`/add?mode=edit&postId={id}\` 이동 · 그룹 기록: 편집 세션 시작 후 편집 페이지 이동 | +| 삭제하기 | 삭제 확인 Drawer 오픈 → "삭제하기" 클릭 시 기록 삭제 후 이전 페이지로 이동 | + ` }, }, }, @@ -197,29 +222,3 @@ export const MinimalBlocks: Story = { }, }; -export const DarkMode: Story = { - args: { - recordId: 'record-1', - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, - }, - decorators: [ - (Story) => ( - -
-
- }> - - -
-
-
- ), - ], -}; diff --git a/frontend/src/app/(search)/_components/TagSearchDrawer.tsx b/frontend/src/app/(search)/_components/TagSearchDrawer.tsx index 2bd357c36..79048a7dc 100644 --- a/frontend/src/app/(search)/_components/TagSearchDrawer.tsx +++ b/frontend/src/app/(search)/_components/TagSearchDrawer.tsx @@ -58,7 +58,7 @@ export default function TagSearchDrawer({ !open && onClose()}>
-
+
diff --git a/frontend/src/app/(search)/_components/storybook/FilterDrawers.stories.tsx b/frontend/src/app/(search)/_components/storybook/FilterDrawers.stories.tsx new file mode 100644 index 000000000..bf98dbe50 --- /dev/null +++ b/frontend/src/app/(search)/_components/storybook/FilterDrawers.stories.tsx @@ -0,0 +1,287 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { http, HttpResponse } from 'msw'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { FilterChip } from '@/components/search/FilterChip'; +import EmotionDrawer from '@/app/(post)/_components/editor/emotion/EmotionDrawer'; +import DateDrawer from '@/components/DateDrawer'; +import TagSearchDrawer from '../TagSearchDrawer'; + +import { createMockTagStats } from '@/lib/mocks/mock'; + +const mockTagsQueryHandler = http.get('/api/stats/tags', () => + HttpResponse.json({ success: true, data: createMockTagStats() }), +); + +function makeTagClient() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(['profile', 'tags', 'summary', 10], createMockTagStats()); + return client; +} + +const tagClient = makeTagClient(); + +type ActiveDrawer = 'tag' | 'emotion' | 'date' | null; + +// ─── 필터 바 전체 흐름 래퍼 ────────────────────────────────────────────────── +function SearchFilterBar({ + initialTags = [], + initialEmotions = [], + initialDateRange = { start: null, end: null }, +}: { + initialTags?: string[]; + initialEmotions?: string[]; + initialDateRange?: { start: string | null; end: string | null }; +}) { + const [tags, setTags] = useState(initialTags); + const [emotions, setEmotions] = useState(initialEmotions); + const [dateRange, setDateRange] = useState(initialDateRange); + const [activeDrawer, setActiveDrawer] = useState(null); + + const tagLabel = tags.length > 0 ? tags.map((t) => `#${t}`).join(', ') : '태그'; + const emotionLabel = emotions.length > 0 ? emotions.join(', ') : '감정'; + const dateLabel = + dateRange.start + ? dateRange.end + ? `${dateRange.start} ~ ${dateRange.end}` + : dateRange.start + : '날짜'; + + return ( + +
+ {/* 필터 칩 바 */} +
+ 0} + onClick={() => setActiveDrawer('tag')} + onClear={() => setTags([])} + /> + 0} + onClick={() => setActiveDrawer('emotion')} + onClear={() => setEmotions([])} + /> + setActiveDrawer('date')} + onClear={() => setDateRange({ start: null, end: null })} + /> +
+ + {/* 선택된 값 표시 */} +
+

태그: {tags.length > 0 ? tags.join(', ') : '없음'}

+

감정: {emotions.length > 0 ? emotions.join(', ') : '없음'}

+

날짜: {dateRange.start ?? '없음'}{dateRange.end ? ` ~ ${dateRange.end}` : ''}

+
+
+ + {/* 드로어들 */} + {activeDrawer === 'tag' && ( + + setTags((prev) => + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], + ) + } + onReset={() => setTags([])} + onClose={() => setActiveDrawer(null)} + /> + )} + + + setEmotions((prev) => + prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e], + ) + } + onReset={() => setEmotions([])} + onClose={() => setActiveDrawer(null)} + mode="search" + /> + + {activeDrawer === 'date' && ( + { + setDateRange(r); + setActiveDrawer(null); + }} + onClose={() => setActiveDrawer(null)} + /> + )} +
+ ); +} + +// ─── Meta ──────────────────────────────────────────────────────────────────── +const meta = { + title: 'Search/FilterDrawers', + component: SearchFilterBar, + parameters: { + layout: 'centered', + nextjs: { appDirectory: true }, + msw: { handlers: [mockTagsQueryHandler] }, + docs: { + description: { + component: ` +검색 페이지의 필터 칩(\`FilterChip\`)을 클릭하면 열리는 드로어 모음. + +각 칩 클릭 → 드로어 열림 → 선택/확인 → 칩 활성화의 전체 흐름을 확인할 수 있다. + +| 칩 타입 | 드로어 | 특이사항 | +|---------|--------|---------| +| 태그 | \`TagSearchDrawer\` | 다중 선택, 직접 입력 가능 | +| 감정 | \`EmotionDrawer\` (mode="search") | 다중 선택, 초기화 버튼 추가 | +| 날짜 | \`DateDrawer\` (mode="range") | 시작~종료 기간 선택 | +| 위치 | — | Google Maps — 스토리 제외 | + `, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Stories ───────────────────────────────────────────────────────────────── + +export const Default: Story = { + args: {}, + parameters: { + docs: { + description: { + story: ` +필터가 모두 비활성화된 초기 상태. + +각 칩을 클릭하면 해당 드로어가 열리고, 선택 후 칩이 활성 상태(초록색)로 바뀐다. +활성 칩의 X 버튼을 클릭하면 해당 필터가 초기화된다. + `, + }, + }, + }, +}; + +export const WithActiveFilters: Story = { + args: { + initialTags: ['성수동', '카페'], + initialEmotions: ['행복', '재미'], + initialDateRange: { start: '2026-01-01', end: '2026-01-31' }, + }, + parameters: { + docs: { + description: { + story: '태그·감정·날짜 필터가 모두 적용된 상태 — 각 칩이 활성화되어 선택값이 표시된다.', + }, + }, + }, +}; + +export const EmotionFilterDrawer: Story = { + name: 'EmotionDrawer (검색 모드)', + render: () => { + const [emotions, setEmotions] = useState([]); + const [open, setOpen] = useState(false); + return ( +
+ 0 ? emotions.join(', ') : '감정'} + isActive={emotions.length > 0} + onClick={() => setOpen(true)} + onClear={() => setEmotions([])} + /> + + setEmotions((prev) => + prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e], + ) + } + onReset={() => setEmotions([])} + onClose={() => setOpen(false)} + mode="search" + /> +
+ ); + }, + parameters: { + docs: { + description: { + story: ` +검색 필터용 감정 드로어 — 에디터의 단일 선택과 달리 **여러 감정을 동시에 선택**할 수 있다. + +- **감정 클릭**: 토글 선택 (여러 개 선택 가능) +- **초기화 버튼**: 선택된 감정 전체 해제 +- **확인 버튼**: 드로어 닫기 + `, + }, + }, + }, +}; + +export const DateRangeFilterDrawer: Story = { + name: 'DateDrawer (기간 선택 모드)', + render: () => { + const [dateRange, setDateRange] = useState<{ start: string | null; end: string | null }>({ + start: null, + end: null, + }); + const [open, setOpen] = useState(false); + return ( +
+ setOpen(true)} + onClear={() => setDateRange({ start: null, end: null })} + /> + {open && ( + { setDateRange(r); setOpen(false); }} + onClose={() => setOpen(false)} + /> + )} +
+ ); + }, + parameters: { + docs: { + description: { + story: ` +검색 필터용 날짜 기간 드로어 — 시작일과 종료일을 선택해 기간 필터를 설정한다. + +- **날짜 클릭**: 첫 번째 클릭은 시작일, 두 번째는 종료일 설정 +- **← / → 버튼**: 월 이동 +- **↺ 버튼**: 기간 초기화 +- **완료 버튼**: 드로어 닫기 + `, + }, + }, + }, +}; diff --git a/frontend/src/app/(search)/_components/storybook/SearchItem.stories.tsx b/frontend/src/app/(search)/_components/storybook/SearchItem.stories.tsx new file mode 100644 index 000000000..f27299572 --- /dev/null +++ b/frontend/src/app/(search)/_components/storybook/SearchItem.stories.tsx @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import SearchItem from '../SearchItem'; +import { RecordSearchItem } from '@/lib/types/record'; + +const mockRecord: RecordSearchItem = { + id: 'record-1', + title: '성수동 카페 투어', + address: '서울 성동구 성수동2가', + date: '2026-01-15T10:00:00Z', + content: '오늘 성수동에서 카페를 여러 곳 방문했다. 분위기도 좋고 커피도 맛있었다.', + thumbnailMediaId: 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&q=80&w=400', + snippet: '오늘 성수동에서 카페를 여러 곳 방문했다.', +}; + +const mockRecordNoThumbnail: RecordSearchItem = { + id: 'record-2', + title: '한강 산책', + address: '서울 영등포구 여의도동', + date: '2026-01-10T14:00:00Z', + content: '오랜만에 한강에서 산책을 했다. 날씨가 맑고 상쾌했다.', + thumbnailMediaId: '', + snippet: '오랜만에 한강에서 산책을 했다.', +}; + +const mockRecordMinimal: RecordSearchItem = { + id: 'record-3', + title: '짧은 메모', + address: '', + date: '2026-01-05T08:00:00Z', + content: '간단한 메모만 남긴 기록.', + thumbnailMediaId: '', +}; + +const noop = () => {}; + +const meta = { + title: 'Search/SearchItem', + component: SearchItem, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +검색 페이지(\`/search\`)에서 검색 결과 목록의 각 기록 아이템을 표시하는 컴포넌트. + +썸네일 이미지(있는 경우), 제목, 스니펫, 날짜, 위치 정보를 카드 형태로 표시한다. +클릭 시 \`onClick(id)\`가 호출되어 기록 상세 페이지로 이동한다. + +- **thumbnailMediaId**: 값이 있으면 썸네일 이미지 표시, 없으면 이미지 아이콘으로 대체 +- **snippet**: 검색 키워드가 포함된 본문 발췌문 +- **address**: 위치 정보가 없으면 위치 영역 숨김 + `, + }, + }, + }, + tags: ['autodocs'], + args: { onClick: noop }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { record: mockRecord }, + parameters: { + docs: { + description: { + story: ` +썸네일·스니펫·주소가 모두 있는 기본 검색 결과 아이템. + +- **카드 클릭**: \`onClick(record.id)\` 호출 → 기록 상세 페이지(\`/record/:id\`)로 이동 +- **priorityLoad**: 첫 번째 결과에 true를 설정해 이미지 우선 로드 가능 + `, + }, + }, + }, +}; + +export const NoThumbnail: Story = { + args: { record: mockRecordNoThumbnail }, + parameters: { + docs: { + description: { + story: '썸네일 이미지가 없는 경우 — 이미지 자리에 이미지 아이콘이 표시된다.', + }, + }, + }, +}; + +export const Minimal: Story = { + args: { record: mockRecordMinimal }, + parameters: { + docs: { + description: { + story: '스니펫·주소가 없는 최소 구성의 기록 아이템 — 제목과 날짜만 표시된다.', + }, + }, + }, +}; + +export const ResultList: Story = { + render: (args) => ( +
+ + + +
+ ), + parameters: { + docs: { + description: { + story: '실제 검색 결과 목록처럼 여러 아이템이 나열된 상태.', + }, + }, + }, +}; diff --git a/frontend/src/app/(search)/_components/storybook/TagSearchDrawer.stories.tsx b/frontend/src/app/(search)/_components/storybook/TagSearchDrawer.stories.tsx new file mode 100644 index 000000000..e9341a534 --- /dev/null +++ b/frontend/src/app/(search)/_components/storybook/TagSearchDrawer.stories.tsx @@ -0,0 +1,122 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState } from 'react'; +import TagSearchDrawer from '../TagSearchDrawer'; +import { createMockTagStats } from '@/lib/mocks/mock'; + +function makeClient(tags?: ReturnType) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + if (tags) { + client.setQueryData(['profile', 'tags', 'summary', 10], tags); + } + return client; +} + +const clients = { + withTags: makeClient(createMockTagStats()), + noTags: makeClient({ recentTags: [], frequentTags: [] }), + withSelected: makeClient(createMockTagStats()), +}; + +function TagSearchDrawerWrapper({ + initialTags = [], + hasTags = true, +}: { + initialTags?: string[]; + hasTags?: boolean; +}) { + const [open, setOpen] = useState(false); + const [selectedTags, setSelectedTags] = useState(initialTags); + const client = hasTags ? (initialTags.length ? clients.withSelected : clients.withTags) : clients.noTags; + + if (!open) { + return ( +
+
+ {selectedTags.length > 0 + ? `선택된 태그: ${selectedTags.map((t) => `#${t}`).join(', ')}` + : '선택된 태그 없음'} +
+ +
+ ); + } + + return ( + + + setSelectedTags((prev) => + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], + ) + } + onReset={() => setSelectedTags([])} + onClose={() => setOpen(false)} + /> + + ); +} + +const meta = { + title: 'Search/TagSearchDrawer', + component: TagSearchDrawerWrapper, + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +검색 페이지의 태그 필터 칩을 클릭하면 열리는 드로어 컴포넌트. + +직접 태그를 입력하거나 자주 사용한 태그 목록에서 클릭해 여러 태그를 선택할 수 있다. +Enter 키 또는 + 버튼으로 입력한 태그를 추가하고, 선택된 태그는 상단에 배지로 표시된다. + +- **onToggleTag**: 태그 선택/해제 시 호출 +- **onReset**: 초기화 버튼 클릭 시 전체 선택 해제 +- **onClose**: 완료 또는 드로어 닫기 시 호출 + `, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { initialTags: [], hasTags: true }, + parameters: { + docs: { + description: { + story: ` +태그가 선택되지 않은 초기 상태. + +- **"태그 검색 드로어 열기" 버튼** 클릭 시 드로어 표시 +- **태그 입력 후 Enter 또는 + 버튼**: 태그 추가 +- **자주 사용한 태그 클릭**: 태그 선택/해제 토글 +- **초기화 버튼**: 선택된 태그 전체 해제 +- **완료 버튼**: 드로어 닫기 + `, + }, + }, + }, +}; + +export const NoFrequentTags: Story = { + args: { initialTags: [], hasTags: false }, + parameters: { + docs: { + description: { + story: '자주 사용한 태그가 없는 경우 — "아직 사용한 태그가 없어요" 안내 메시지가 표시된다.', + }, + }, + }, +}; diff --git a/frontend/src/components/map/LocationField.tsx b/frontend/src/components/map/LocationField.tsx index efc958152..f52dbcc50 100644 --- a/frontend/src/components/map/LocationField.tsx +++ b/frontend/src/components/map/LocationField.tsx @@ -23,7 +23,7 @@ export function LocationField({ const address = location?.address; const placeName = location?.placeName; return ( -
+
{!address ? ( diff --git a/frontend/src/components/storybook/AnnouncementModal.stories.tsx b/frontend/src/components/storybook/AnnouncementModal.stories.tsx new file mode 100644 index 000000000..94939572a --- /dev/null +++ b/frontend/src/components/storybook/AnnouncementModal.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { http, HttpResponse } from 'msw'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import AnnouncementModalClient from '../AnnouncementModalClient'; +import type { AnnouncementData } from '../AnnouncementModal'; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +const mockHandlers = [ + http.patch('/api/me/settings', () => HttpResponse.json({ success: true })), +]; + +const withImageAnnouncement: AnnouncementData = { + id: 'ann-1', + title: '[업데이트] 공동 기록 실시간 협업 기능 출시', + content: + '그룹 기록을 여러 명이 동시에 편집할 수 있는 실시간 협업 기능이 추가되었습니다.\n함께 기억을 남겨보세요!', + imageUrl: + 'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?auto=format&fit=crop&q=80&w=600', +}; + +const textOnlyAnnouncement: AnnouncementData = { + id: 'ann-2', + title: '[공지] 서비스 점검 안내 (1/20 02:00 ~ 04:00)', + content: + '서버 안정화 작업으로 인해 서비스 점검이 진행됩니다.\n점검 시간 동안 서비스 이용이 일시 중단됩니다.', + imageUrl: null, +}; + +const titleOnlyAnnouncement: AnnouncementData = { + id: 'ann-3', + title: '[이벤트] 잇다- 베타 출시 기념 이벤트가 시작되었습니다!', + content: null, + imageUrl: null, +}; + +const meta = { + title: 'Pages/AnnouncementModal', + component: AnnouncementModalClient, + parameters: { + layout: 'fullscreen', + nextjs: { appDirectory: true }, + msw: { handlers: mockHandlers }, + docs: { + description: { + component: ` +홈 페이지 진입 시 활성 공지사항이 있으면 자동으로 표시되는 모달 컴포넌트. + +배경 오버레이 위에 공지 카드가 중앙 팝업으로 나타나며, X 버튼 또는 "확인" 버튼으로 닫을 수 있다. +닫으면 해당 공지 ID를 서버 설정(로그인) 또는 쿠키(게스트)에 저장해 재표시하지 않는다. + +- **imageUrl**: 이미지가 있으면 카드 상단에 16:9 비율로 표시 +- **content**: 내용이 없으면 제목만 표시 +- **X 버튼 / 확인 버튼 / 배경 클릭**: 모두 닫기 동작 + `, + }, + }, + }, + tags: ['autodocs'], + decorators: [ + (Story) => ( + +
+
홈 페이지 콘텐츠 영역
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { announcement: withImageAnnouncement }, + parameters: { + docs: { + description: { + story: ` +이미지와 내용이 모두 있는 공지 모달 — 가장 일반적인 형태. + +- **배경 클릭 / X 버튼 / 확인 버튼**: 모달 닫기 → API로 dismissed 상태 저장 + `, + }, + }, + }, +}; + +export const TextOnly: Story = { + args: { announcement: textOnlyAnnouncement }, + parameters: { + docs: { + description: { + story: '이미지 없이 제목·내용만 있는 공지 모달.', + }, + }, + }, +}; + +export const TitleOnly: Story = { + args: { announcement: titleOnlyAnnouncement }, + parameters: { + docs: { + description: { + story: '제목만 있고 내용이 없는 공지 모달 — 제목과 확인 버튼만 표시된다.', + }, + }, + }, +}; diff --git a/frontend/src/components/storybook/BottomNavigation.stories.tsx b/frontend/src/components/storybook/BottomNavigation.stories.tsx new file mode 100644 index 000000000..16d427faf --- /dev/null +++ b/frontend/src/components/storybook/BottomNavigation.stories.tsx @@ -0,0 +1,163 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import BottomNavigation from '../BottomNavigation'; +import { createMockGroupList } from '@/lib/mocks/mock'; + +const GROUP_ID = 'group-123'; + +function makeClient(role?: 'ADMIN' | 'EDITOR' | 'VIEWER') { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + // /shared 경로에서 그룹 목록을 prefetch하므로 미리 주입 + client.setQueryData(['shared'], createMockGroupList().items); + // 그룹 네비 스토리에서 role 주입 + if (role) { + client.setQueryData(['group', GROUP_ID, 'me', 'role'], { role }); + } + return client; +} + +const clients = { + personal: makeClient(), + groupEditor: makeClient('EDITOR'), + groupViewer: makeClient('VIEWER'), +}; + +function NavWrapper({ + children, + client, +}: { + children: React.ReactNode; + client: QueryClient; +}) { + return ( + +
+

페이지 콘텐츠 영역

+ {children} +
+
+ ); +} + +const meta = { + title: 'Layout/BottomNavigation', + component: BottomNavigation, + parameters: { + layout: 'fullscreen', + nextjs: { appDirectory: true }, + docs: { + story: { height: '220px' }, + description: { + component: ` +모바일 화면 하단에 고정되는 네비게이션 바. + +현재 경로에 따라 **개인 네비**와 **그룹 네비** 두 가지 형태로 전환된다. +일부 경로(\`/add\`, \`/search\` 등)에서는 숨겨진다. + +**개인 네비** (기본): 홈 · 기록함 · + · 그룹함 · 지도 + +**그룹 네비** (\`/group/:id\` 경로): 그룹홈 · 지도 · + · 알림 · 나가기 + +- **+ 버튼**: 개인 — 기록 작성(\`/add\`) 이동 · 그룹함(\`/shared\`)에서는 그룹 선택 드로어 +- **+ 버튼 (그룹)**: EDITOR 이상만 활성화, VIEWER는 비활성(회색) + `, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── 개인 네비게이션 ─────────────────────────────────────────────────────────── + +export const PersonalHome: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: '/' } }, + docs: { + description: { story: '개인 네비 — 홈 탭 활성 상태.' }, + }, + }, +}; + +export const PersonalRecords: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: '/my' } }, + docs: { + description: { story: '개인 네비 — 기록함 탭 활성 상태.' }, + }, + }, +}; + +export const PersonalShared: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: '/shared' } }, + docs: { + description: { + story: '개인 네비 — 그룹함 탭 활성 상태. + 버튼이 초록색으로 변경되며 클릭 시 그룹 선택 드로어가 열린다.', + }, + }, + }, +}; + +export const PersonalMap: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: '/map' } }, + docs: { + description: { story: '개인 네비 — 지도 탭 활성 상태.' }, + }, + }, +}; + +// ─── 그룹 네비게이션 ─────────────────────────────────────────────────────────── + +export const GroupEditor: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: `/group/${GROUP_ID}` } }, + docs: { + description: { + story: '그룹 네비 — EDITOR 권한. + 버튼이 활성화되어 그룹 기록 작성 드로어를 열 수 있다.', + }, + }, + }, +}; + +export const GroupViewer: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: `/group/${GROUP_ID}` } }, + docs: { + description: { + story: '그룹 네비 — VIEWER 권한. + 버튼이 회색으로 비활성화된다.', + }, + }, + }, +}; + +export const GroupMap: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: `/group/${GROUP_ID}/map` } }, + docs: { + description: { story: '그룹 네비 — 지도 탭 활성 상태.' }, + }, + }, +}; + +export const GroupNotifications: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: `/group/${GROUP_ID}/notifications` } }, + docs: { + description: { story: '그룹 네비 — 알림 탭 활성 상태.' }, + }, + }, +}; diff --git a/frontend/src/components/storybook/DailyDetailRecordItem.stories.tsx b/frontend/src/components/storybook/DailyDetailRecordItem.stories.tsx index e0c402cf7..c1a187ae2 100644 --- a/frontend/src/components/storybook/DailyDetailRecordItem.stories.tsx +++ b/frontend/src/components/storybook/DailyDetailRecordItem.stories.tsx @@ -1,23 +1,23 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import DailyDetailRecordItem from '../DailyDetailRecordItem'; -import { RecordPreview } from '@/lib/types/recordResponse'; -import { Member } from '@/lib/types/group'; +import { Contributor, RecordPreview } from '@/lib/types/recordResponse'; -const createQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: 0, - }, +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 0, }, - }); + }, +}); const mockRecord: RecordPreview = { postId: 'record-1', scope: 'ME', groupId: null, + contributors: [], + emotion: [], title: '성수동 카페 투어', eventAt: '2025-01-15T14:30:00Z', createdAt: '2025-01-15T15:00:00Z', @@ -68,77 +68,35 @@ const mockRecord: RecordPreview = { ], }; -const mockRecordWithLocation: RecordPreview = { - ...mockRecord, - postId: 'record-2', - title: '한남동 브런치', - blocks: [ - { - id: 'time-2', - type: 'TIME', - value: { time: '11:00' }, - layout: { row: 0, col: 1, span: 1 }, - }, - { - id: 'text-2', - type: 'TEXT', - value: { text: '에그 베네딕트가 정말 맛있었다!' }, - layout: { row: 1, col: 1, span: 2 }, - }, - { - id: 'loc-2', - type: 'LOCATION', - value: { - lat: 37.5347, - lng: 127.0008, - address: '서울 용산구 한남동', - placeName: '카페 라떼', - }, - layout: { row: 2, col: 1, span: 2 }, - }, - { - id: 'rating-2', - type: 'RATING', - value: { rating: 5 }, - layout: { row: 3, col: 1, span: 2 }, - }, - ], -}; +// 그룹 기록 참여자 (3명) — groupProfileImageId가 null이면 기본 프로필 이미지 표시 +const mockContributors: Contributor[] = [ + { userId: 'user-1', role: 'AUTHOR', nickname: '김철수', groupNickname: '철수', groupProfileImageId: null, profileImageId: null }, + { userId: 'user-2', role: 'CONTRIBUTOR', nickname: '이영희', groupNickname: '영희', groupProfileImageId: null, profileImageId: null }, + { userId: 'user-3', role: 'CONTRIBUTOR', nickname: '박지민', groupNickname: '지민', groupProfileImageId: null, profileImageId: null }, +]; + +// 참여자 6명 — 앞 4개 아바타 + +2 축약 표시 확인용 +const mockManyContributors: Contributor[] = [ + ...mockContributors, + { userId: 'user-4', role: 'CONTRIBUTOR', nickname: '최수진', groupNickname: '수진', groupProfileImageId: null, profileImageId: null }, + { userId: 'user-5', role: 'CONTRIBUTOR', nickname: '정민준', groupNickname: '민준', groupProfileImageId: null, profileImageId: null }, + { userId: 'user-6', role: 'CONTRIBUTOR', nickname: '한지수', groupNickname: '지수', groupProfileImageId: null, profileImageId: null }, +]; const mockGroupRecord: RecordPreview = { ...mockRecord, - postId: 'record-3', + postId: 'record-2', scope: 'GROUP', groupId: 'group-123', title: '팀 회식', + contributors: mockContributors, }; -const mockMembers: Member[] = [ - { - id: 1, - name: '김철수', - avatar: - 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=100', - }, - { - id: 2, - name: '이영희', - avatar: - 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&q=80&w=100', - }, - { - id: 3, - name: '박지민', - avatar: - 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&q=80&w=100', - }, - { - id: 4, - name: '최수진', - avatar: - 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=100', - }, -]; +const mockGroupRecordManyMembers: RecordPreview = { + ...mockGroupRecord, + postId: 'record-3', + contributors: mockManyContributors, +}; const meta = { title: 'Record/DailyDetailRecordItem', @@ -146,18 +104,18 @@ const meta = { parameters: { layout: 'padded', docs: { - story: { inline: false, height: '450px' }, + story: { height: '450px' }, description: { component: - '일별 상세 기록 아이템 - 타임라인의 각 기록 카드를 표시합니다. 블록 레이아웃에 따라 동적으로 렌더링됩니다.', + '일별 상세 페이지에서 기록 하나를 표시하는 카드 컴포넌트입니다. 블록 타입(IMAGE, TEXT, TAG, RATING, LOCATION 등)에 따라 카드 내용이 동적으로 구성됩니다.', }, }, }, tags: ['autodocs'], decorators: [ (Story) => ( - -
+ +
@@ -168,13 +126,10 @@ const meta = { ], argTypes: { record: { - description: '기록 데이터 (RecordPreview)', + description: '기록 데이터 (RecordPreview). contributors 배열에 참여자가 있고 groupId prop이 전달될 때 아바타가 표시됩니다.', }, groupId: { - description: '그룹 ID (그룹 기록일 때)', - }, - members: { - description: '그룹 멤버 목록 (그룹 기록일 때 아바타 표시)', + description: '그룹 ID. 전달 시 record.contributors 아바타와 그룹 상세 경로(/record/{id}?scope=group&groupId=…)로 이동합니다.', }, }, } satisfies Meta; @@ -182,7 +137,6 @@ const meta = { export default meta; type Story = StoryObj; -// 기본: 개인 기록 export const Default: Story = { args: { record: mockRecord, @@ -190,83 +144,27 @@ export const Default: Story = { parameters: { docs: { description: { - story: '개인 기록 - 이미지, 텍스트, 태그, 평점 블록 포함', - }, - }, - }, -}; - -// 위치 정보가 있는 기록 -export const WithLocation: Story = { - args: { - record: mockRecordWithLocation, - }, - parameters: { - docs: { - description: { - story: '위치 정보가 포함된 기록', + story: ` +개인 기록 — 이미지, 텍스트, 태그, 평점 블록 포함 +- **카드 클릭**: 기록 상세 페이지(\`/record/{id}\`)로 이동 +- **블록 조건부 렌더링**: 블록 타입에 따라 카드 내용이 달라짐 + `, }, }, }, }; -// 그룹 기록 (멤버 아바타 표시) -export const GroupRecord: Story = { - args: { - record: mockGroupRecord, - groupId: 'group-123', - members: mockMembers, - }, - parameters: { - docs: { - description: { - story: '그룹 기록 - 참여 멤버 아바타가 표시됩니다', - }, - }, - }, -}; - -// 그룹 기록 (멤버가 3명 이상) export const GroupRecordManyMembers: Story = { args: { - record: mockGroupRecord, + record: mockGroupRecordManyMembers, groupId: 'group-123', - members: mockMembers, }, parameters: { docs: { description: { - story: '그룹 기록 - 멤버가 3명 이상일 때 +N 표시', + story: '그룹 기록 — 참여자가 4명을 초과할 때 앞 4개 아바타 + +N으로 축약 표시됩니다.', }, }, }, }; -// 다크 모드 -export const DarkMode: Story = { - args: { - record: mockRecord, - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, - }, - decorators: [ - (Story) => ( - -
-
-
-
- -
-
-
- - ), - ], -}; diff --git a/frontend/src/components/storybook/DailyDetailRecords.stories.tsx b/frontend/src/components/storybook/DailyDetailRecords.stories.tsx index 1f74d80b5..e050b0644 100644 --- a/frontend/src/components/storybook/DailyDetailRecords.stories.tsx +++ b/frontend/src/components/storybook/DailyDetailRecords.stories.tsx @@ -1,45 +1,41 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Suspense } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import DailyDetailRecords from '../DailyDetailRecords'; -import { RecordPreview } from '@/lib/types/recordResponse'; -import { ActiveMember } from '@/lib/types/group'; +import { Contributor, RecordPreview } from '@/lib/types/recordResponse'; -const createQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: 0, - }, - }, - }); +// DailyDetailRecords는 내부에서 useSuspenseQuery(recordPreviewListOptions)로 데이터를 가져온다. +// setQueryData의 키는 recordPreviewListOptions의 queryKey와 정확히 일치해야 한다. +// - personal: ['my', 'records', 'preview', date, 'personal'] +// - groups: ['group', groupId, 'records', 'daily', date] + +const DATE = '2025-01-15'; +const GROUP_ID = 'group-123'; + +const mockContributors: Contributor[] = [ + { userId: 'user-1', role: 'AUTHOR', nickname: '김철수', groupNickname: '철수', groupProfileImageId: null, profileImageId: null }, + { userId: 'user-2', role: 'CONTRIBUTOR', nickname: '이영희', groupNickname: '영희', groupProfileImageId: null, profileImageId: null }, +]; -const mockMemories: RecordPreview[] = [ +const mockRecords: RecordPreview[] = [ { postId: 'record-1', scope: 'ME', groupId: null, + contributors: [], + emotion: [], title: '성수동 카페 투어', eventAt: '2025-01-15T14:30:00Z', createdAt: '2025-01-15T15:00:00Z', updatedAt: '2025-01-15T15:00:00Z', - location: { - lat: 37.5445, - lng: 127.0567, - address: '서울 성동구 성수동2가', - placeName: '어니언 성수', - }, + location: { lat: 37.5445, lng: 127.0567, address: '서울 성동구 성수동2가', placeName: '어니언 성수' }, tags: ['카페', '성수', '디저트'], rating: 4, blocks: [ { id: 'image-1', type: 'IMAGE', - value: { - tempUrls: [ - 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&q=80&w=400', - ], - }, + value: { tempUrls: ['https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&q=80&w=400'] }, layout: { row: 1, col: 1, span: 2 }, }, { @@ -66,16 +62,13 @@ const mockMemories: RecordPreview[] = [ postId: 'record-2', scope: 'ME', groupId: null, + contributors: [], + emotion: [], title: '점심 브런치', eventAt: '2025-01-15T11:00:00Z', createdAt: '2025-01-15T12:00:00Z', updatedAt: '2025-01-15T12:00:00Z', - location: { - lat: 37.5347, - lng: 127.0008, - address: '서울 용산구 한남동', - placeName: '브런치 카페', - }, + location: { lat: 37.5347, lng: 127.0008, address: '서울 용산구 한남동', placeName: '브런치 카페' }, tags: ['브런치', '한남동'], rating: 5, blocks: [ @@ -97,16 +90,13 @@ const mockMemories: RecordPreview[] = [ postId: 'record-3', scope: 'ME', groupId: null, + contributors: [], + emotion: [], title: '아침 조깅', eventAt: '2025-01-15T07:00:00Z', createdAt: '2025-01-15T08:00:00Z', updatedAt: '2025-01-15T08:00:00Z', - location: { - lat: 37.5171, - lng: 127.0416, - address: '서울 송파구 잠실동', - placeName: '한강공원', - }, + location: { lat: 37.5171, lng: 127.0416, address: '서울 송파구 잠실동', placeName: '한강공원' }, tags: ['운동', '조깅'], rating: null, blocks: [ @@ -119,49 +109,34 @@ const mockMemories: RecordPreview[] = [ { id: 'loc-3', type: 'LOCATION', - value: { - lat: 37.5171, - lng: 127.0416, - address: '서울 송파구 잠실동', - placeName: '한강공원', - }, + value: { lat: 37.5171, lng: 127.0416, address: '서울 송파구 잠실동', placeName: '한강공원' }, layout: { row: 2, col: 1, span: 2 }, }, ], }, ]; -const mockGroupMemories: RecordPreview[] = [ - { - ...mockMemories[0], - scope: 'GROUP', - groupId: 'group-123', - }, - { - ...mockMemories[1], - scope: 'GROUP', - groupId: 'group-123', - }, -]; +const mockGroupRecords: RecordPreview[] = mockRecords.map((r, i) => ({ + ...r, + scope: 'GROUP', + groupId: GROUP_ID, + contributors: i === 0 ? mockContributors : [mockContributors[0]], +})); -const mockMembers: ActiveMember[] = [ - { - id: 1, - name: '김철수', - avatar: - 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=100', - role: 'admin', - recordId: 'record-1', - }, - { - id: 2, - name: '이영희', - avatar: - 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&q=80&w=100', - role: 'member', - recordId: 'record-2', - }, -]; +function makeClient(queryKey: unknown[], data: RecordPreview[]) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(queryKey, data); + return client; +} + +const clients = { + default: makeClient(['my', 'records', 'preview', DATE, 'personal'], mockRecords), + group: makeClient(['group', GROUP_ID, 'records', 'daily', DATE], mockGroupRecords), + single: makeClient(['my', 'records', 'preview', DATE, 'personal'], [mockRecords[0]]), + empty: makeClient(['my', 'records', 'preview', DATE, 'personal'], []), +}; const meta = { title: 'Record/DailyDetailRecords', @@ -169,111 +144,89 @@ const meta = { parameters: { layout: 'padded', docs: { - story: { inline: false, height: '700px' }, + story: { height: '700px' }, + description: { + component: + '일별 상세 페이지에서 해당 날짜의 기록 목록을 타임라인 형태로 표시하는 컴포넌트입니다. 내부에서 `recordPreviewListOptions`로 데이터를 직접 fetching하며, `scope="personal"` 또는 `scope="groups"` + `groupId`에 따라 개인/그룹 기록을 구분합니다. 기록이 없을 경우 빈 상태 UI를 표시합니다.', + }, }, }, tags: ['autodocs'], - decorators: [ - (Story) => ( - -
- -
-
- ), - ], argTypes: { - memories: { - description: '기록 목록 (RecordPreview[])', - }, - members: { - description: '그룹 멤버 목록 (그룹 기록일 때만)', - }, + date: { description: '조회할 날짜 (YYYY-MM-DD 형식)', control: 'text' }, + scope: { description: '"personal" | "groups"', control: 'inline-radio', options: ['personal', 'groups'] }, + groupId: { description: '그룹 기록일 때 그룹 ID', control: 'text' }, }, } satisfies Meta; export default meta; type Story = StoryObj; -// 기본: 개인 기록 export const Default: Story = { - args: { - memories: mockMemories, - }, + args: { date: DATE, scope: 'personal' }, + decorators: [ + (Story) => ( + +
+ 로딩 중...
}> + + +
+
+ ), + ], parameters: { docs: { description: { - story: '개인 일별 상세 - 타임라인 형태의 기록 목록', + story: ` +개인 일별 상세 — 타임라인 형태의 기록 목록 +- **카드 클릭**: 기록 상세 페이지(\`/record/{id}\`)로 이동 +- **타임라인 구조**: 세로 줄과 원형 마커로 시간순 기록 흐름을 시각화 + `, }, }, }, }; -// 그룹 기록 export const GroupRecords: Story = { - args: { - memories: mockGroupMemories, - members: mockMembers, - }, - parameters: { - docs: { - description: { - story: '그룹 일별 상세 - 멤버 정보와 함께 표시', - }, - }, - }, -}; - -// 기록이 하나인 경우 -export const SingleRecord: Story = { - args: { - memories: [mockMemories[0]], - }, - parameters: { - docs: { - description: { - story: '기록이 하나만 있는 경우', - }, - }, - }, -}; - -// 기록이 많은 경우 -export const ManyRecords: Story = { - args: { - memories: [...mockMemories, ...mockMemories], - }, + args: { date: DATE, scope: 'groups', groupId: GROUP_ID }, + decorators: [ + (Story) => ( + +
+ 로딩 중...
}> + + +
+ + ), + ], parameters: { docs: { description: { - story: '기록이 많은 경우 - 스크롤 확인', + story: '그룹 일별 상세 — 참여자 아바타가 각 카드에 표시됩니다.', }, }, }, }; -// 다크 모드 -export const DarkMode: Story = { - args: { - memories: mockMemories, - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, - }, +export const EmptyRecords: Story = { + args: { date: DATE, scope: 'personal' }, decorators: [ (Story) => ( - -
-
+ +
+ 로딩 중...
}> -
+
), ], + parameters: { + docs: { + description: { story: '기록이 없는 경우 — 빈 상태 UI 표시' }, + }, + }, }; + diff --git a/frontend/src/components/storybook/DateSelectorDrawer.stories.tsx b/frontend/src/components/storybook/DateSelectorDrawer.stories.tsx index efb44efb9..8f2dbb6d6 100644 --- a/frontend/src/components/storybook/DateSelectorDrawer.stories.tsx +++ b/frontend/src/components/storybook/DateSelectorDrawer.stories.tsx @@ -1,6 +1,36 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import DateSelectorDrawer from '../DateSelectorDrawer'; +// DateSelectorDrawer는 내부에서 useQuery(myDailyRecordedDatesOption | groupDailyRecordedDatesOption)를 호출한다. +// enabled: isOpen 이지만 useQueryClient() 자체는 항상 실행되므로 QueryClientProvider가 필요하다. +// 드로어를 열었을 때 캘린더에 기록 점이 표시되도록 현재 월 데이터를 미리 주입한다. +// - personal 키: ['recordedDates', '/api/user/archives/record-days?month={YEAR}-{MONTH}'] +// - group 키: ['recordedDates', '/api/groups/{groupId}/archives/record-days?month={YEAR}-{MONTH}'] + +const NOW = new Date(); +const YEAR = NOW.getFullYear(); +const MONTH = String(NOW.getMonth() + 1).padStart(2, '0'); +// 현재 월의 기록된 날짜 (캘린더에 초록 점으로 표시됨) +const mockRecordedDates: string[] = [5, 10, 14, 20].map( + (d) => `${YEAR}-${MONTH}-${String(d).padStart(2, '0')}`, +); + +function makeClient(queryKey: unknown[], data: string[]) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(queryKey, data); + return client; +} + +const clients = { + default: makeClient( + ['recordedDates', `/api/user/archives/record-days?month=${YEAR}-${MONTH}`], + mockRecordedDates, + ), +}; + const meta = { title: 'Record/DateSelectorDrawer', component: DateSelectorDrawer, @@ -9,69 +39,51 @@ const meta = { docs: { description: { component: - '날짜 선택 Drawer - 캘린더 뷰와 월 선택 뷰를 제공합니다. 버튼을 클릭하여 Drawer를 열어보세요.', + '홈/아카이브 헤더에서 날짜를 선택하는 드로어 컴포넌트입니다. 캘린더 아이콘 버튼을 클릭하면 드로어가 열리며, 날짜 클릭 시 `dayRoute`로, 월 전체보기 클릭 시 `monthRoute`로, 연도 전체보기 클릭 시 `yearRoute`로 이동합니다. 기록이 있는 날짜에는 초록 점이 표시되며, 미래 날짜는 선택이 비활성화됩니다. 월 헤더 클릭 시 연도·월 선택 피커로 전환됩니다.', }, }, }, tags: ['autodocs'], - decorators: [ - (Story) => ( -
- -
- ), - ], argTypes: { - dayRoute: { description: '일별 상세 페이지 라우트' }, - monthRoute: { description: '월별 상세 페이지 라우트' }, - yearRoute: { description: '연도별 페이지 라우트' }, + dayRoute: { description: '날짜 클릭 시 이동할 기본 경로. 뒤에 /{YYYY-MM-DD}가 붙습니다.', control: 'text' }, + monthRoute: { description: '"월 기록 전체보기" 클릭 시 이동할 기본 경로. 뒤에 /{YYYY-MM}이 붙습니다.', control: 'text' }, + yearRoute: { description: '"연도 기록 전체보기" 클릭 시 이동할 기본 경로. 뒤에 /{YYYY}가 붙습니다.', control: 'text' }, + groupId: { description: '그룹 기록함이면 그룹 ID를 전달. 없으면 개인 기록함 API를 사용합니다.', control: 'text' }, }, } satisfies Meta; export default meta; type Story = StoryObj; -// 기본: 내 기록함용 export const Default: Story = { args: { dayRoute: '/my/detail', monthRoute: '/my/month', yearRoute: '/my/year', }, -}; - -// 그룹 기록함용 -export const GroupRecords: Story = { - args: { - dayRoute: '/group/group-123/detail', - monthRoute: '/group/group-123/month', - yearRoute: '/group/group-123/year', - }, -}; - -// 💡 다크 모드 스토리 수정 -export const DarkMode: Story = { - args: { - dayRoute: '/my/detail', - monthRoute: '/my/month', - yearRoute: '/my/year', - className: 'dark', - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '포털 오염 없이 구현된 다크 모드 스토리입니다.', - }, - }, - }, decorators: [ (Story) => ( -
-
+ +
-
+ ), ], + parameters: { + docs: { + description: { + story: ` +개인 기록함 — 캘린더 아이콘을 클릭하면 드로어가 열립니다. +- **날짜 클릭**: \`dayRoute/{YYYY-MM-DD}\`로 이동 +- **월 기록 전체보기**: \`monthRoute/{YYYY-MM}\`으로 이동 +- **연도 기록 전체보기**: \`yearRoute/{YYYY}\`으로 이동 +- **월 헤더 클릭**: 연도·월 선택 피커(스크롤 휠)로 전환 +- **기록 있는 날짜**: 초록 점 표시 (드로어 열면 확인 가능) +- **미래 날짜**: 선택 비활성화 + `, + }, + }, + }, }; + diff --git a/frontend/src/components/storybook/Header.stories.tsx b/frontend/src/components/storybook/Header.stories.tsx new file mode 100644 index 000000000..7a1ec681c --- /dev/null +++ b/frontend/src/components/storybook/Header.stories.tsx @@ -0,0 +1,124 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import Header from '../Header'; +import { UserProfileResponse } from '@/lib/types/profileResponse'; + +const mockProfileBase: UserProfileResponse = { + userId: 'user-1', + user: { + id: 'user-1', + email: 'hello@example.com', + nickname: '도비', + profileImageId: null, + provider: 'google', + providerId: 'google-123', + profileImage: undefined, + settings: {}, + createdAt: '2024-01-01T00:00:00Z', + }, + stats: { + recentTags: ['카페', '산책'], + frequentTags: ['카페'], + recentEmotions: [], + frequentEmotions: [], + totalPosts: 42, + totalImages: 15, + frequentLocations: [], + monthlyCounts: [], + }, +}; + +const PROFILE_IMAGE_URL = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&q=80&w=80'; + +const mockProfileWithImage: UserProfileResponse = { + ...mockProfileBase, + user: { + ...mockProfileBase.user, + profileImageId: PROFILE_IMAGE_URL, + profileImage: { url: PROFILE_IMAGE_URL }, + }, +}; + +function makeClient(profile: UserProfileResponse) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(['profile', 'me'], profile); + return client; +} + +const clients = { + noImage: makeClient(mockProfileBase), + withImage: makeClient(mockProfileWithImage), +}; + +const meta = { + title: 'Layout/Header', + component: Header, + parameters: { + layout: 'fullscreen', + nextjs: { appDirectory: true }, + docs: { + description: { + component: ` +모든 주요 페이지 상단에 고정되는 헤더 컴포넌트. + +좌측 "잇다-" 로고 클릭 시 홈으로 이동하고, +우측에 검색 아이콘과 프로필 이미지(원형) 버튼이 표시된다. + +- **검색 아이콘**: \`/search\` 페이지로 이동 +- **프로필 아이콘**: \`/profile\` 페이지로 이동 +- 프로필 이미지가 없으면 기본 이미지(\`profile_base.png\`)가 표시된다 + `, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + parameters: { + docs: { + description: { + story: ` +프로필 이미지가 없는 기본 상태 — 기본 이미지로 표시된다. + +- **"잇다-" 클릭**: 홈(\`/\`)으로 이동 +- **돋보기 클릭**: 검색(\`/search\`)으로 이동 +- **프로필 아이콘 클릭**: 프로필(\`/profile\`)로 이동 + `, + }, + }, + }, +}; + +export const WithProfileImage: Story = { + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + parameters: { + docs: { + description: { + story: '프로필 이미지가 있는 상태 — 우측 아이콘에 실제 프로필 사진이 표시된다.', + }, + }, + }, +}; diff --git a/frontend/src/hooks/useDebounce.test.ts b/frontend/src/hooks/useDebounce.test.ts new file mode 100644 index 000000000..fca57e701 --- /dev/null +++ b/frontend/src/hooks/useDebounce.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useDebouncedValue } from './useDebounce'; + +describe('useDebouncedValue', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('초기값을 즉시 반환한다', () => { + const { result } = renderHook(() => useDebouncedValue('초기', 500)); + expect(result.current).toBe('초기'); + }); + + it('delay 이전에는 값이 변경되지 않는다', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: '초기' } }, + ); + + act(() => { + rerender({ value: '변경' }); + }); + + expect(result.current).toBe('초기'); + }); + + it('delay 이후에 변경된 값을 반환한다', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: '초기' } }, + ); + + act(() => { rerender({ value: '변경' }); }); + act(() => { vi.advanceTimersByTime(500); }); + + expect(result.current).toBe('변경'); + }); + + it('연속으로 값이 바뀌면 마지막 값만 반영된다', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: '초기' } }, + ); + + act(() => { rerender({ value: '첫 번째' }); }); + act(() => { rerender({ value: '두 번째' }); }); + act(() => { rerender({ value: '세 번째' }); }); + act(() => { vi.advanceTimersByTime(500); }); + + expect(result.current).toBe('세 번째'); + }); + + it('unmount 시 타이머가 정리된다', () => { + const { result, rerender, unmount } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: '초기' } }, + ); + + act(() => { + rerender({ value: '변경' }); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current).toBe('초기'); + }); +}); diff --git a/frontend/src/hooks/useGroupActions.ts b/frontend/src/hooks/useGroupActions.ts index 20ba0b9f5..766a417a8 100644 --- a/frontend/src/hooks/useGroupActions.ts +++ b/frontend/src/hooks/useGroupActions.ts @@ -12,11 +12,8 @@ export const useDeleteGroup = (groupId: string, groupName: string) => { return useApiDelete(`/api/groups/${groupId}`, { onSuccess: () => { toast.success(`${groupName}이 삭제되었습니다.`); - queryClient.invalidateQueries({ queryKey: ['share'] }); - - setTimeout(() => { - router.push('/shared'); - }, 1000); + queryClient.invalidateQueries({ queryKey: ['shared'] }); + router.push('/shared'); }, }); }; diff --git a/frontend/src/hooks/useScrollDirection.test.tsx b/frontend/src/hooks/useScrollDirection.test.tsx new file mode 100644 index 000000000..347b421af --- /dev/null +++ b/frontend/src/hooks/useScrollDirection.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { render, act, screen } from '@testing-library/react'; +import React from 'react'; +import { useScrollDirection } from './useScrollDirection'; + +// renderHook은 DOM 요소 없이 실행되어 useEffect 시점에 containerRef.current가 null이 됨. +// render로 실제 DOM 요소에 ref를 연결한 뒤 scroll 이벤트를 시뮬레이션한다. +function ScrollTestComponent() { + const { containerRef, isVisible } = useScrollDirection(); + return ( +
+ ); +} + +function getVisible(): boolean { + return screen.getByTestId('container').getAttribute('data-visible') === 'true'; +} + +function scrollTo(scrollTop: number) { + const el = screen.getByTestId('container'); + Object.defineProperty(el, 'scrollTop', { + value: scrollTop, + writable: true, + configurable: true, + }); + act(() => { + el.dispatchEvent(new Event('scroll')); + }); +} + +describe('useScrollDirection', () => { + it('초기 상태는 isVisible이 true다', () => { + render(); + expect(getVisible()).toBe(true); + }); + + it('아래로 스크롤하면 isVisible이 false가 된다', () => { + render(); + scrollTo(100); + expect(getVisible()).toBe(false); + }); + + it('아래로 스크롤 후 위로 스크롤하면 isVisible이 true로 돌아온다', () => { + render(); + scrollTo(100); + scrollTo(30); + expect(getVisible()).toBe(true); + }); + + it('최상단(scrollTop <= 10)에서는 항상 isVisible이 true다', () => { + render(); + scrollTo(100); + expect(getVisible()).toBe(false); + scrollTo(5); + expect(getVisible()).toBe(true); + }); + + it('5px 미만 미세 스크롤은 상태를 변경하지 않는다', () => { + render(); + scrollTo(50); + expect(getVisible()).toBe(false); + scrollTo(53); // 3px — 임계값 미만 + expect(getVisible()).toBe(false); + }); +}); diff --git a/frontend/src/hooks/useSessionStorage.test.ts b/frontend/src/hooks/useSessionStorage.test.ts new file mode 100644 index 000000000..6c37556bf --- /dev/null +++ b/frontend/src/hooks/useSessionStorage.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useSessionStorage } from './useSessionStorage'; + +vi.mock('@sentry/nextjs', () => ({ captureException: vi.fn() })); +vi.mock('@/lib/utils/logger', () => ({ logger: { error: vi.fn() } })); + +describe('useSessionStorage', () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + it('초기값을 반환한다', () => { + const { result } = renderHook(() => useSessionStorage('test-key', '초기값')); + const [value] = result.current; + expect(value).toBe('초기값'); + }); + + it('setValue로 값을 변경하면 상태와 sessionStorage가 모두 업데이트된다', () => { + const { result } = renderHook(() => useSessionStorage('test-key', '초기값')); + + act(() => { + result.current[1]('변경된 값'); + }); + + expect(result.current[0]).toBe('변경된 값'); + expect(JSON.parse(sessionStorage.getItem('test-key')!)).toBe('변경된 값'); + }); + + it('함수형 업데이트를 지원한다', () => { + const { result } = renderHook(() => useSessionStorage('count', 0)); + + act(() => { + result.current[1]((prev) => prev + 1); + }); + + expect(result.current[0]).toBe(1); + }); + + it('removeValue 호출 시 초기값으로 돌아가고 sessionStorage에서 제거된다', () => { + const { result } = renderHook(() => useSessionStorage('test-key', '초기값')); + + act(() => { + result.current[1]('저장된 값'); + }); + + act(() => { + result.current[2](); + }); + + expect(result.current[0]).toBe('초기값'); + expect(sessionStorage.getItem('test-key')).toBeNull(); + }); + + it('객체 값을 JSON으로 직렬화해서 저장한다', () => { + const initialValue = { name: '테스트', count: 0 }; + const { result } = renderHook(() => + useSessionStorage('test-obj', initialValue), + ); + + act(() => { + result.current[1]({ name: '변경', count: 1 }); + }); + + const stored = JSON.parse(sessionStorage.getItem('test-obj')!); + expect(stored).toEqual({ name: '변경', count: 1 }); + }); + + it('sessionStorage에 기존 값이 있으면 초기값 대신 저장된 값을 반환한다', () => { + sessionStorage.setItem('test-key', JSON.stringify('기존 값')); + + const { result } = renderHook(() => useSessionStorage('test-key', '초기값')); + + expect(result.current[0]).toBe('기존 값'); + }); + + it('sessionStorage 읽기 실패 시 초기값을 반환한다', () => { + sessionStorage.setItem('bad-key', 'invalid json {{{'); + + const { result } = renderHook(() => useSessionStorage('bad-key', '초기값')); + + expect(result.current[0]).toBe('초기값'); + }); +}); diff --git a/frontend/src/lib/date.test.ts b/frontend/src/lib/date.test.ts new file mode 100644 index 000000000..8e168acb7 --- /dev/null +++ b/frontend/src/lib/date.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + formatRelativeTime, + getMonthRange, + getWeekDays, + getStartOfWeek, + formatDateISO, + parseLocalDate, + formatDotDateString, + getWeekdayFromDotString, +} from './date'; + +describe('formatRelativeTime', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-15T12:00:00')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('60초 미만이면 "방금 전" 반환', () => { + const date = new Date('2024-06-15T11:59:10'); + expect(formatRelativeTime(date)).toBe('방금 전'); + }); + + it('1분~59분이면 "N분 전" 반환', () => { + const date = new Date('2024-06-15T11:55:00'); + expect(formatRelativeTime(date)).toBe('5분 전'); + }); + + it('1시간~23시간이면 "N시간 전" 반환', () => { + const date = new Date('2024-06-15T09:00:00'); + expect(formatRelativeTime(date)).toBe('3시간 전'); + }); + + it('정확히 1일 전이면 "어제" 반환', () => { + const date = new Date('2024-06-14T12:00:00'); + expect(formatRelativeTime(date)).toBe('어제'); + }); + + it('2일~29일이면 "N일 전" 반환', () => { + const date = new Date('2024-06-10T12:00:00'); + expect(formatRelativeTime(date)).toBe('5일 전'); + }); + + it('1개월~11개월이면 "N개월 전" 반환', () => { + const date = new Date('2024-03-15T12:00:00'); + expect(formatRelativeTime(date)).toBe('3개월 전'); + }); + + it('12개월 이상이면 "N년 전" 반환', () => { + const date = new Date('2023-06-15T12:00:00'); + expect(formatRelativeTime(date)).toBe('1년 전'); + }); +}); + +describe('getMonthRange', () => { + it('일반 달의 시작일과 종료일을 반환한다', () => { + expect(getMonthRange('2024-03')).toEqual({ + startDate: '2024-03-01', + endDate: '2024-03-31', + }); + }); + + it('윤년 2월의 마지막 날은 29일이다', () => { + expect(getMonthRange('2024-02')).toEqual({ + startDate: '2024-02-01', + endDate: '2024-02-29', + }); + }); + + it('평년 2월의 마지막 날은 28일이다', () => { + expect(getMonthRange('2023-02')).toEqual({ + startDate: '2023-02-01', + endDate: '2023-02-28', + }); + }); + + it('30일짜리 달의 종료일은 30일이다', () => { + expect(getMonthRange('2024-04')).toEqual({ + startDate: '2024-04-01', + endDate: '2024-04-30', + }); + }); + + it('12월의 마지막 날은 31일이다', () => { + expect(getMonthRange('2024-12')).toEqual({ + startDate: '2024-12-01', + endDate: '2024-12-31', + }); + }); +}); + +describe('getStartOfWeek', () => { + it('주의 시작은 일요일이다', () => { + const wednesday = new Date('2024-06-12'); // 수요일 + const startOfWeek = getStartOfWeek(wednesday); + expect(startOfWeek.getDay()).toBe(0); // 일요일 + }); + + it('일요일을 입력하면 같은 날을 반환한다', () => { + const sunday = new Date('2024-06-09'); + const startOfWeek = getStartOfWeek(sunday); + expect(formatDateISO(startOfWeek)).toBe('2024-06-09'); + }); + + it('시간을 00:00:00으로 초기화한다', () => { + const date = new Date('2024-06-12T15:30:00'); + const startOfWeek = getStartOfWeek(date); + expect(startOfWeek.getHours()).toBe(0); + expect(startOfWeek.getMinutes()).toBe(0); + expect(startOfWeek.getSeconds()).toBe(0); + }); +}); + +describe('getWeekDays', () => { + it('7개의 요일을 반환한다', () => { + const days = getWeekDays(new Date('2024-06-12')); + expect(days).toHaveLength(7); + }); + + it('첫 번째 날은 일요일이다', () => { + const days = getWeekDays(new Date('2024-06-12')); + expect(days[0].dayName).toBe('일'); + }); + + it('마지막 날은 토요일이다', () => { + const days = getWeekDays(new Date('2024-06-12')); + expect(days[6].dayName).toBe('토'); + }); + + it('오늘 날짜에 isToday가 true다', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-12')); + const days = getWeekDays(new Date('2024-06-12')); + const today = days.find((d) => d.isToday); + expect(today).toBeDefined(); + expect(today?.dateStr).toBe('2024-06-12'); + vi.useRealTimers(); + }); +}); + +describe('parseLocalDate', () => { + it('YYYY-MM-DD 문자열을 로컬 Date로 변환한다', () => { + const date = parseLocalDate('2024-06-15'); + expect(date.getFullYear()).toBe(2024); + expect(date.getMonth()).toBe(5); // 0-indexed + expect(date.getDate()).toBe(15); + }); + + it('UTC 해석 없이 로컬 자정을 반환한다', () => { + const date = parseLocalDate('2024-01-01'); + expect(date.getHours()).toBe(0); + expect(date.getMinutes()).toBe(0); + }); +}); + +describe('formatDotDateString', () => { + it('ISO 날짜 문자열을 YYYY.MM.DD로 변환한다', () => { + expect(formatDotDateString('2025-12-20T20:00:00Z')).toBe('2025.12.20'); + }); + + it('날짜만 있는 문자열도 변환한다', () => { + expect(formatDotDateString('2025-12-21')).toBe('2025.12.21'); + }); +}); + +describe('getWeekdayFromDotString', () => { + it('수요일 날짜 문자열에서 "수"를 반환한다', () => { + expect(getWeekdayFromDotString('2024.06.12')).toBe('수'); + }); + + it('일요일 날짜 문자열에서 "일"을 반환한다', () => { + expect(getWeekdayFromDotString('2024.06.09')).toBe('일'); + }); +}); diff --git a/frontend/src/lib/mocks/mock.ts b/frontend/src/lib/mocks/mock.ts index eb0dce7d8..673e149d0 100644 --- a/frontend/src/lib/mocks/mock.ts +++ b/frontend/src/lib/mocks/mock.ts @@ -176,6 +176,7 @@ export const createMockRecordPreviews = (date: string): RecordPreview[] => [ }, ], groupId: 'group-123', + groupName: '고3 전우들', title: '홍대 버스킹 구경', eventAt: `${date}T19:00:00Z`, createdAt: `${date}T21:00:00Z`, diff --git a/frontend/src/lib/utils/cookie.test.ts b/frontend/src/lib/utils/cookie.test.ts new file mode 100644 index 000000000..6ee3ce828 --- /dev/null +++ b/frontend/src/lib/utils/cookie.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { getCookieFromString } from './cookie'; + +describe('getCookieFromString', () => { + it('단일 쿠키에서 값을 반환한다', () => { + expect(getCookieFromString('token=abc123', 'token')).toBe('abc123'); + }); + + it('여러 쿠키 중 해당하는 값을 반환한다', () => { + const cookieStr = 'token=abc123; userId=42; theme=dark'; + expect(getCookieFromString(cookieStr, 'userId')).toBe('42'); + }); + + it('쿠키 이름이 없으면 null을 반환한다', () => { + expect(getCookieFromString('token=abc123', 'notExist')).toBeNull(); + }); + + it('빈 쿠키 문자열이면 null을 반환한다', () => { + expect(getCookieFromString('', 'token')).toBeNull(); + }); + + it('URL 인코딩된 값을 디코딩해서 반환한다', () => { + const encoded = encodeURIComponent('hello world'); + const name = encodeURIComponent('key'); + expect(getCookieFromString(`${name}=${encoded}`, 'key')).toBe('hello world'); + }); + + it('쿠키 이름이 다른 쿠키의 접두사여도 정확히 매칭한다', () => { + const cookieStr = 'token=abc; tokenExtra=xyz'; + expect(getCookieFromString(cookieStr, 'token')).toBe('abc'); + }); + + it('공백이 포함된 쿠키 문자열을 파싱한다', () => { + const cookieStr = ' token=abc123 ; userId=42'; + expect(getCookieFromString(cookieStr, 'token')).toBe('abc123'); + }); +}); diff --git a/frontend/src/lib/utils/errorHandler.test.ts b/frontend/src/lib/utils/errorHandler.test.ts new file mode 100644 index 000000000..85121e7b9 --- /dev/null +++ b/frontend/src/lib/utils/errorHandler.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { + isAuthError, + getErrorMessage, + createApiError, + ERROR_CODES, +} from './errorHandler'; +import type { ApiResponse } from '../types/response'; + +describe('isAuthError', () => { + it('TOKEN_EXPIRED는 인증 에러다', () => { + expect(isAuthError(ERROR_CODES.TOKEN_EXPIRED)).toBe(true); + }); + + it('INVALID_TOKEN은 인증 에러다', () => { + expect(isAuthError(ERROR_CODES.INVALID_TOKEN)).toBe(true); + }); + + it('UNAUTHORIZED는 인증 에러다', () => { + expect(isAuthError(ERROR_CODES.UNAUTHORIZED)).toBe(true); + }); + + it('NETWORK_ERROR는 인증 에러가 아니다', () => { + expect(isAuthError(ERROR_CODES.NETWORK_ERROR)).toBe(false); + }); + + it('알 수 없는 에러 코드는 인증 에러가 아니다', () => { + expect(isAuthError('SOME_OTHER_ERROR')).toBe(false); + }); + + it('undefined이면 인증 에러가 아니다', () => { + expect(isAuthError(undefined)).toBe(false); + }); +}); + +describe('getErrorMessage', () => { + it('ApiError 객체의 message를 반환한다', () => { + const error = { message: '토큰이 만료되었습니다.' }; + expect(getErrorMessage(error)).toBe('토큰이 만료되었습니다.'); + }); + + it('Error 인스턴스의 message를 반환한다', () => { + const error = new Error('네트워크 오류'); + expect(getErrorMessage(error)).toBe('네트워크 오류'); + }); + + it('문자열 에러를 그대로 반환한다', () => { + expect(getErrorMessage('서버 오류')).toBe('서버 오류'); + }); + + it('null이면 기본 메시지를 반환한다', () => { + expect(getErrorMessage(null)).toBe('알 수 없는 오류가 발생했습니다.'); + }); + + it('undefined이면 기본 메시지를 반환한다', () => { + expect(getErrorMessage(undefined)).toBe('알 수 없는 오류가 발생했습니다.'); + }); + + it('빈 메시지 객체이면 기본 메시지를 반환한다', () => { + expect(getErrorMessage({ message: ' ' })).toBe( + '알 수 없는 오류가 발생했습니다.', + ); + }); + + it('빈 문자열이면 기본 메시지를 반환한다', () => { + expect(getErrorMessage('')).toBe('알 수 없는 오류가 발생했습니다.'); + }); +}); + +describe('createApiError', () => { + it('에러 응답에서 에러 객체를 생성한다', () => { + const response: ApiResponse = { + success: false, + error: { message: '권한이 없습니다.', code: 'UNAUTHORIZED' }, + }; + const error = createApiError(response); + + expect(error.message).toBe('권한이 없습니다.'); + expect(error.code).toBe('UNAUTHORIZED'); + expect(error.isAuthError).toBe(true); + }); + + it('NETWORK_ERROR 코드이면 isAuthError가 false다', () => { + const response: ApiResponse = { + success: false, + error: { message: '네트워크 오류', code: 'NETWORK_ERROR' }, + }; + const error = createApiError(response); + + expect(error.isAuthError).toBe(false); + }); + + it('success 응답이 들어오면 기본 에러를 반환한다', () => { + const response: ApiResponse = { + success: true, + data: {}, + }; + const error = createApiError(response); + + expect(error.message).toBe('알 수 없는 서버 응답입니다.'); + }); + + it('에러 코드가 없으면 INTERNAL_SERVER_ERROR로 설정된다', () => { + const response: ApiResponse = { + success: false, + error: { message: '서버 오류' }, + }; + const error = createApiError(response); + + expect(error.code).toBe('INTERNAL_SERVER_ERROR'); + }); +}); diff --git a/frontend/src/lib/utils/filterLabels.test.ts b/frontend/src/lib/utils/filterLabels.test.ts new file mode 100644 index 000000000..e1f9e9cd4 --- /dev/null +++ b/frontend/src/lib/utils/filterLabels.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { + makeTagLabel, + makeEmotionLabel, + makeDateLabel, + makeLocationLabel, +} from './filterLabels'; + +describe('makeTagLabel', () => { + it('태그가 없으면 "태그" 반환', () => { + expect(makeTagLabel([])).toBe('태그'); + }); + + it('태그가 1개이면 태그명 그대로 반환', () => { + expect(makeTagLabel(['여행'])).toBe('여행'); + }); + + it('태그가 2개이면 "첫번째 외 1" 반환', () => { + expect(makeTagLabel(['여행', '맛집'])).toBe('여행 외 1'); + }); + + it('태그가 3개 이상이면 "첫번째 외 N" 반환', () => { + expect(makeTagLabel(['여행', '맛집', '카페'])).toBe('여행 외 2'); + }); +}); + +describe('makeEmotionLabel', () => { + it('감정이 없으면 "감정" 반환', () => { + expect(makeEmotionLabel([])).toBe('감정'); + }); + + it('감정이 1개이면 감정명 그대로 반환', () => { + expect(makeEmotionLabel(['행복'])).toBe('행복'); + }); + + it('감정이 2개 이상이면 "첫번째 외 N" 반환', () => { + expect(makeEmotionLabel(['행복', '설렘', '평온'])).toBe('행복 외 2'); + }); +}); + +describe('makeDateLabel', () => { + it('start, end 모두 없으면 "날짜" 반환', () => { + expect(makeDateLabel()).toBe('날짜'); + }); + + it('start, end 모두 null이면 "날짜" 반환', () => { + expect(makeDateLabel(null, null)).toBe('날짜'); + }); + + it('start만 있으면 start 반환', () => { + expect(makeDateLabel('2024-01-01')).toBe('2024-01-01'); + }); + + it('end만 있으면 end 반환', () => { + expect(makeDateLabel(null, '2024-12-31')).toBe('2024-12-31'); + }); + + it('start, end 모두 있으면 "start ~ end" 반환', () => { + expect(makeDateLabel('2024-01-01', '2024-12-31')).toBe( + '2024-01-01 ~ 2024-12-31', + ); + }); +}); + +describe('makeLocationLabel', () => { + it('주소가 없으면 "장소" 반환', () => { + expect(makeLocationLabel()).toBe('장소'); + }); + + it('주소가 null이면 "장소" 반환', () => { + expect(makeLocationLabel(null)).toBe('장소'); + }); + + it('주소가 있으면 주소 그대로 반환', () => { + expect(makeLocationLabel('서울특별시 강남구')).toBe('서울특별시 강남구'); + }); +}); diff --git a/frontend/src/lib/utils/mapBlocksToPayload.test.ts b/frontend/src/lib/utils/mapBlocksToPayload.test.ts new file mode 100644 index 000000000..b13384b6a --- /dev/null +++ b/frontend/src/lib/utils/mapBlocksToPayload.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { mapBlocksToPayload, RecordFieldtypeMap, ServerToFieldTypeMap } from './mapBlocksToPayload'; +import type { RecordBlock } from '../types/record'; + +const layout = { row: 0, col: 0, span: 2 }; + +const sampleBlocks: RecordBlock[] = [ + { id: 'block-1', type: 'content', value: { text: '내용' }, layout }, + { id: 'block-2', type: 'emotion', value: { mood: '행복' }, layout }, + { id: 'block-3', type: 'tags', value: { tags: ['여행'] }, layout }, + { id: 'block-4', type: 'rating', value: { rating: 5 }, layout }, + { id: 'block-5', type: 'date', value: { date: '2024-01-01' }, layout }, +]; + +describe('mapBlocksToPayload', () => { + it('프론트 타입을 서버 타입으로 변환한다', () => { + const result = mapBlocksToPayload(sampleBlocks); + expect(result[0].type).toBe('TEXT'); + expect(result[1].type).toBe('MOOD'); + expect(result[2].type).toBe('TAG'); + expect(result[3].type).toBe('RATING'); + expect(result[4].type).toBe('DATE'); + }); + + it('includeId가 false이면 id를 포함하지 않는다', () => { + const result = mapBlocksToPayload(sampleBlocks, false); + result.forEach((block) => { + expect(block.id).toBeUndefined(); + }); + }); + + it('includeId가 true이면 id를 포함한다', () => { + const result = mapBlocksToPayload(sampleBlocks, true); + expect(result[0].id).toBe('block-1'); + expect(result[1].id).toBe('block-2'); + }); + + it('기본값은 includeId false다', () => { + const result = mapBlocksToPayload(sampleBlocks); + result.forEach((block) => { + expect(block.id).toBeUndefined(); + }); + }); + + it('value와 layout을 그대로 전달한다', () => { + const result = mapBlocksToPayload(sampleBlocks); + expect(result[0].value).toEqual({ text: '내용' }); + expect(result[0].layout).toEqual(layout); + }); + + it('알 수 없는 블록 타입이면 에러를 던진다', () => { + const invalidBlock = [ + { id: 'x', type: 'unknown', value: {}, layout }, + ] as unknown as RecordBlock[]; + + expect(() => mapBlocksToPayload(invalidBlock)).toThrowError( + 'Unknown block type: unknown', + ); + }); + + it('빈 배열이면 빈 배열을 반환한다', () => { + expect(mapBlocksToPayload([])).toEqual([]); + }); +}); + +describe('RecordFieldtypeMap', () => { + it('content는 TEXT로 매핑된다', () => { + expect(RecordFieldtypeMap['content']).toBe('TEXT'); + }); + + it('emotion은 MOOD로 매핑된다', () => { + expect(RecordFieldtypeMap['emotion']).toBe('MOOD'); + }); + + it('photos는 IMAGE로 매핑된다', () => { + expect(RecordFieldtypeMap['photos']).toBe('IMAGE'); + }); +}); + +describe('ServerToFieldTypeMap', () => { + it('TEXT는 content로 매핑된다', () => { + expect(ServerToFieldTypeMap['TEXT']).toBe('content'); + }); + + it('MOOD는 emotion으로 매핑된다', () => { + expect(ServerToFieldTypeMap['MOOD']).toBe('emotion'); + }); + + it('IMAGE는 photos로 매핑된다', () => { + expect(ServerToFieldTypeMap['IMAGE']).toBe('photos'); + }); +}); diff --git a/frontend/src/lib/utils/record.test.ts b/frontend/src/lib/utils/record.test.ts new file mode 100644 index 000000000..ce4b41a81 --- /dev/null +++ b/frontend/src/lib/utils/record.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { getBlockValue, getBlockValues } from './record'; +import type { RecordBlock } from '../types/record'; + +const layout = { row: 0, col: 0, span: 2 }; + +const blocks: RecordBlock[] = [ + { id: '1', type: 'content', value: { text: '오늘의 기록' }, layout }, + { id: '2', type: 'emotion', value: { mood: '행복' }, layout }, + { id: '3', type: 'tags', value: { tags: ['여행', '맛집'] }, layout }, + { id: '4', type: 'content', value: { text: '두 번째 내용' }, layout }, + { id: '5', type: 'rating', value: { rating: 4 }, layout }, +]; + +describe('getBlockValue', () => { + it('존재하는 타입의 첫 번째 블록 값을 반환한다', () => { + expect(getBlockValue(blocks, 'content')).toEqual({ text: '오늘의 기록' }); + }); + + it('같은 타입이 여러 개면 첫 번째만 반환한다', () => { + const result = getBlockValue(blocks, 'content'); + expect(result).toEqual({ text: '오늘의 기록' }); + }); + + it('emotion 타입의 값을 반환한다', () => { + expect(getBlockValue(blocks, 'emotion')).toEqual({ mood: '행복' }); + }); + + it('존재하지 않는 타입이면 undefined를 반환한다', () => { + expect(getBlockValue(blocks, 'date')).toBeUndefined(); + }); + + it('블록 배열이 비어 있으면 undefined를 반환한다', () => { + expect(getBlockValue([], 'content')).toBeUndefined(); + }); +}); + +describe('getBlockValues', () => { + it('존재하는 타입의 모든 블록 값을 배열로 반환한다', () => { + const result = getBlockValues(blocks, 'content'); + expect(result).toEqual([{ text: '오늘의 기록' }, { text: '두 번째 내용' }]); + }); + + it('타입이 1개만 있으면 요소 1개짜리 배열을 반환한다', () => { + const result = getBlockValues(blocks, 'emotion'); + expect(result).toEqual([{ mood: '행복' }]); + }); + + it('존재하지 않는 타입이면 빈 배열을 반환한다', () => { + expect(getBlockValues(blocks, 'location')).toEqual([]); + }); + + it('블록 배열이 비어 있으면 빈 배열을 반환한다', () => { + expect(getBlockValues([], 'content')).toEqual([]); + }); +}); diff --git a/frontend/src/lib/utils/time.test.ts b/frontend/src/lib/utils/time.test.ts new file mode 100644 index 000000000..784a9f3eb --- /dev/null +++ b/frontend/src/lib/utils/time.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { convertTo12Hour, convertTo24Hour, getPastDate } from './time'; + +describe('convertTo12Hour', () => { + it('오전 시간을 변환한다', () => { + expect(convertTo12Hour('09:30')).toBe('오전 9:30'); + }); + + it('오후 시간을 변환한다', () => { + expect(convertTo12Hour('23:23')).toBe('오후 11:23'); + }); + + it('자정(00:00)은 오전 12시로 변환한다', () => { + expect(convertTo12Hour('00:15')).toBe('오전 12:15'); + }); + + it('정오(12:00)는 오후 12시로 변환한다', () => { + expect(convertTo12Hour('12:00')).toBe('오후 12:00'); + }); + + it('정오 이후(12:30)는 오후 12시로 변환한다', () => { + expect(convertTo12Hour('12:30')).toBe('오후 12:30'); + }); + + it('빈 문자열이면 그대로 반환한다', () => { + expect(convertTo12Hour('')).toBe(''); + }); + + it('콜론이 없는 잘못된 형식이면 그대로 반환한다', () => { + expect(convertTo12Hour('1230')).toBe('1230'); + }); +}); + +describe('convertTo24Hour', () => { + it('오전 시간을 변환한다', () => { + expect(convertTo24Hour('오전 9:30')).toBe('09:30'); + }); + + it('오후 시간을 변환한다', () => { + expect(convertTo24Hour('오후 11:23')).toBe('23:23'); + }); + + it('오전 12시는 00시로 변환한다', () => { + expect(convertTo24Hour('오전 12:05')).toBe('00:05'); + }); + + it('오후 12시는 12시로 변환한다', () => { + expect(convertTo24Hour('오후 12:00')).toBe('12:00'); + }); + + it('이미 24시간 형식이면 그대로 반환한다', () => { + expect(convertTo24Hour('23:30')).toBe('23:30'); + }); + + it('한 자리 시간도 0 패딩하여 반환한다', () => { + expect(convertTo24Hour('9:05')).toBe('09:05'); + }); +}); + +describe('convertTo12Hour → convertTo24Hour 왕복 변환', () => { + const times = ['00:00', '00:30', '09:15', '12:00', '12:30', '23:59']; + + times.forEach((time) => { + it(`${time}은 12시간 변환 후 다시 24시간으로 복원된다`, () => { + expect(convertTo24Hour(convertTo12Hour(time))).toBe(time); + }); + }); +}); + +describe('getPastDate', () => { + it('1일 전 날짜를 반환한다', () => { + expect(getPastDate('2024-03-15', 1)).toBe('2024-03-14'); + }); + + it('월 경계를 넘어서 계산한다', () => { + expect(getPastDate('2024-03-01', 1)).toBe('2024-02-29'); // 2024년은 윤년 + }); + + it('연도 경계를 넘어서 계산한다', () => { + expect(getPastDate('2024-01-01', 1)).toBe('2023-12-31'); + }); + + it('0일 전이면 같은 날짜를 반환한다', () => { + expect(getPastDate('2024-06-15', 0)).toBe('2024-06-15'); + }); +}); diff --git a/frontend/src/lib/utils/useDebounce.test.ts b/frontend/src/lib/utils/useDebounce.test.ts new file mode 100644 index 000000000..00cad2840 --- /dev/null +++ b/frontend/src/lib/utils/useDebounce.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useDebounce } from './useDebounce'; + +describe('useDebounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('delay가 지나기 전에는 fn이 호출되지 않는다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, 500)); + + act(() => { + result.current.debounced('첫 번째 호출'); + }); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('delay가 지나면 fn이 호출된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, 500)); + + act(() => { + result.current.debounced('인자값'); + vi.advanceTimersByTime(500); + }); + + expect(fn).toHaveBeenCalledWith('인자값'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('연속 호출 시 마지막 호출만 실행된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, 500)); + + act(() => { + result.current.debounced('첫 번째'); + result.current.debounced('두 번째'); + result.current.debounced('세 번째'); + vi.advanceTimersByTime(500); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith('세 번째'); + }); + + it('cancel 호출 시 대기 중인 실행이 취소된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, 500)); + + act(() => { + result.current.debounced('호출'); + result.current.cancel(); + vi.advanceTimersByTime(500); + }); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('unmount 시 타이머가 정리된다', () => { + const fn = vi.fn(); + const { result, unmount } = renderHook(() => useDebounce(fn, 500)); + + act(() => { + result.current.debounced('호출'); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(fn).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/utils/useThrottle.test.ts b/frontend/src/lib/utils/useThrottle.test.ts new file mode 100644 index 000000000..e7f7e45df --- /dev/null +++ b/frontend/src/lib/utils/useThrottle.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useThrottle } from './useThrottle'; + +describe('useThrottle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('첫 번째 호출은 즉시 실행된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, 500)); + + act(() => { + result.current.throttled('첫 번째'); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith('첫 번째'); + }); + + it('delay 이내 연속 호출은 한 번만 실행된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, 500)); + + act(() => { + result.current.throttled('첫 번째'); + result.current.throttled('두 번째'); + result.current.throttled('세 번째'); + }); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('delay 후 trailing edge에서 마지막 인자로 한 번 더 실행된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, 500)); + + act(() => { + result.current.throttled('첫 번째'); + result.current.throttled('두 번째'); + result.current.throttled('세 번째'); + vi.advanceTimersByTime(500); + }); + + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenLastCalledWith('세 번째'); + }); + + it('delay가 지난 후 다시 호출하면 즉시 실행된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, 500)); + + act(() => { + result.current.throttled('첫 번째'); + vi.advanceTimersByTime(500); + result.current.throttled('두 번째'); + }); + + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith(2, '두 번째'); + }); + + it('flush 호출 시 대기 중인 실행을 즉시 처리한다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, 500)); + + act(() => { + result.current.throttled('첫 번째'); + result.current.throttled('두 번째'); + result.current.flush(); + }); + + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenLastCalledWith('두 번째'); + }); +}); diff --git a/frontend/src/store/useLocationPermissionStore.test.ts b/frontend/src/store/useLocationPermissionStore.test.ts new file mode 100644 index 000000000..6f640c839 --- /dev/null +++ b/frontend/src/store/useLocationPermissionStore.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useLocationPermissionStore } from './useLocationPermissionStore'; + +describe('canShowToast', () => { + beforeEach(() => { + useLocationPermissionStore.setState({ + permissionStatus: 'unknown', + hasAskedPermission: false, + lastToastShownAt: null, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('lastToastShownAt이 null이면 토스트를 표시할 수 있다', () => { + const { canShowToast } = useLocationPermissionStore.getState(); + expect(canShowToast()).toBe(true); + }); + + it('24시간이 지나지 않았으면 토스트를 표시하지 않는다', () => { + vi.useFakeTimers(); + const now = new Date('2024-06-15T12:00:00').getTime(); + vi.setSystemTime(now); + + useLocationPermissionStore.setState({ + lastToastShownAt: now - 23 * 60 * 60 * 1000, // 23시간 전 + }); + + const { canShowToast } = useLocationPermissionStore.getState(); + expect(canShowToast()).toBe(false); + }); + + it('정확히 24시간이 지났으면 토스트를 표시할 수 있다', () => { + vi.useFakeTimers(); + const now = new Date('2024-06-15T12:00:00').getTime(); + vi.setSystemTime(now); + + useLocationPermissionStore.setState({ + lastToastShownAt: now - 24 * 60 * 60 * 1000, // 정확히 24시간 전 + }); + + const { canShowToast } = useLocationPermissionStore.getState(); + expect(canShowToast()).toBe(true); + }); + + it('24시간 이상 지났으면 토스트를 표시할 수 있다', () => { + vi.useFakeTimers(); + const now = new Date('2024-06-15T12:00:00').getTime(); + vi.setSystemTime(now); + + useLocationPermissionStore.setState({ + lastToastShownAt: now - 25 * 60 * 60 * 1000, // 25시간 전 + }); + + const { canShowToast } = useLocationPermissionStore.getState(); + expect(canShowToast()).toBe(true); + }); +}); + +describe('setPermissionStatus', () => { + beforeEach(() => { + useLocationPermissionStore.setState({ permissionStatus: 'unknown' }); + }); + + it('권한 상태를 변경한다', () => { + useLocationPermissionStore.getState().setPermissionStatus('granted'); + expect(useLocationPermissionStore.getState().permissionStatus).toBe('granted'); + }); + + it('denied 상태로 변경한다', () => { + useLocationPermissionStore.getState().setPermissionStatus('denied'); + expect(useLocationPermissionStore.getState().permissionStatus).toBe('denied'); + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index c0b316175..b994515d0 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -16,11 +16,22 @@ const dirname = export default defineConfig({ test: { projects: [ + { + test: { + name: 'unit', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + environment: 'jsdom', + globals: true, + }, + resolve: { + alias: { + '@': path.resolve(dirname, 'src'), + }, + }, + }, { extends: true, plugins: [ - // The plugin will run tests for the stories defined in your Storybook config - // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest storybookTest({ configDir: path.join(dirname, '.storybook') }), ], test: { diff --git a/package.json b/package.json index 3586fd313..100a68496 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ "lint:fix:all": "pnpm -r lint", "test:fe": "pnpm --filter frontend test", "test:fe:e2e": "pnpm --filter frontend test:e2e", + "test:fe:e2e:ui": "pnpm --filter frontend test:e2e:ui", + "test:fe:e2e:view": "pnpm --filter frontend test:e2e:view", + "show-report:fe": "pnpm --filter frontend show-report", "test:be": "pnpm --filter backend test", "test:be:e2e": "pnpm --filter backend test:e2e", "test:be:e2e:debug": "pnpm --filter backend test:e2e:debug", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 904927127..dcb2f9e22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,7 +260,7 @@ importers: version: 0.15.1 '@next/third-parties': specifier: ^16.2.1 - version: 16.2.1(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) + version: 16.2.1(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -272,7 +272,7 @@ importers: version: 1.2.4(@types/react@19.2.7)(react@19.2.1) '@sentry/nextjs': specifier: ^10.38.0 - version: 10.38.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.103.0(esbuild@0.27.2)) + version: 10.38.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.103.0(esbuild@0.27.2)) '@tanstack/react-query': specifier: ^5.90.12 version: 5.90.12(react@19.2.1) @@ -308,10 +308,10 @@ importers: version: 0.561.0(react@19.2.1) next: specifier: 16.0.11 - version: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) next-auth: specifier: 5.0.0-beta.30 - version: 5.0.0-beta.30(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) + version: 5.0.0-beta.30(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -367,6 +367,9 @@ importers: '@next/bundle-analyzer': specifier: ^16.2.4 version: 16.2.4 + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 '@storybook/addon-a11y': specifier: ^10.1.11 version: 10.1.11(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) @@ -381,10 +384,16 @@ importers: version: 10.1.11(@vitest/browser-playwright@4.0.16)(@vitest/browser@4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16))(@vitest/runner@4.0.16)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vitest@4.0.16) '@storybook/nextjs-vite': specifier: ^10.1.11 - version: 10.1.11(@babel/core@7.28.5)(esbuild@0.27.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(rollup@4.55.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(webpack@5.103.0(esbuild@0.27.2)) + version: 10.1.11(@babel/core@7.28.5)(esbuild@0.27.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(rollup@4.55.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(webpack@5.103.0(esbuild@0.27.2)) '@tailwindcss/postcss': specifier: ^4 version: 4.1.18 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/google.maps': specifier: ^3.58.1 version: 3.58.1 @@ -399,7 +408,7 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitest/browser-playwright': specifier: ^4.0.16 - version: 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) + version: 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.60.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) '@vitest/coverage-v8': specifier: ^4.0.16 version: 4.0.16(@vitest/browser@4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16))(vitest@4.0.16) @@ -415,6 +424,9 @@ importers: eslint-plugin-storybook: specifier: ^10.1.11 version: 10.1.11(eslint@9.39.1(jiti@2.6.1))(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3) + jsdom: + specifier: ^29.1.1 + version: 29.1.1(@noble/hashes@1.8.0) msw: specifier: ^2.12.4 version: 2.12.4(@types/node@20.19.27)(typescript@5.9.3) @@ -422,8 +434,8 @@ importers: specifier: ^2.0.6 version: 2.0.6(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3)) playwright: - specifier: ^1.57.0 - version: 1.57.0 + specifier: ^1.60.0 + version: 1.60.0 storybook: specifier: ^10.1.11 version: 10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -441,7 +453,7 @@ importers: version: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2) vitest: specifier: ^4.0.16 - version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) mobile-app: dependencies: @@ -529,6 +541,21 @@ packages: '@apm-js-collab/tracing-hooks@0.3.1': resolution: {integrity: sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@auth/core@0.41.0': resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==} peerDependencies: @@ -918,6 +945,10 @@ packages: '@borewit/text-codec@0.1.1': resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@capacitor/android@7.5.0': resolution: {integrity: sha512-VjESuJYzQQH4vxvrOQV0yXuDv3Gx1bfb6YpAkoGfUHe0kiw0LY5nh5Kn7I5bI9hlht9SJ8YmSsCgFeMNyzKoWQ==} peerDependencies: @@ -989,6 +1020,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.0': + resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} @@ -1202,6 +1269,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@faker-js/faker@10.1.0': resolution: {integrity: sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} @@ -2304,6 +2380,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -3422,6 +3503,21 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@testing-library/user-event@14.6.1': resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} engines: {node: '>=12', npm: '>=6'} @@ -3762,6 +3858,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -4396,6 +4493,9 @@ packages: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} @@ -4850,6 +4950,10 @@ packages: csp_evaluator@1.1.5: resolution: {integrity: sha512-EL/iN9etCTzw/fBnp0/uj0f5BOOGvZut2mzsiiBZ/FdT6gFQCKRO/tmcKOxn5drWZ2Ndm/xBb1SI4zwWbGtmIw==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -4907,6 +5011,10 @@ packages: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -5175,6 +5283,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -5856,6 +5968,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -6116,6 +6232,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -6414,6 +6533,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -6692,6 +6820,10 @@ packages: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -6750,6 +6882,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -7203,6 +7338,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -7364,13 +7502,13 @@ packages: peerDependencies: sharp: '>= 0.30.6' - playwright-core@1.57.0: - resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true - playwright@1.57.0: - resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -7785,6 +7923,10 @@ packages: resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -8214,6 +8356,9 @@ packages: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.11: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -8365,9 +8510,17 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -8617,6 +8770,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unique-string@2.0.0: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} @@ -8697,10 +8854,12 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -8816,6 +8975,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -8832,6 +8995,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-bundle-analyzer@4.10.1: resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} engines: {node: '>= 10.13.0'} @@ -8864,6 +9031,14 @@ packages: whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -8991,6 +9166,10 @@ packages: resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==} engines: {node: '>=8'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -9003,6 +9182,9 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} @@ -9168,6 +9350,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@auth/core@0.41.0': dependencies: '@panva/hkdf': 1.2.1 @@ -9931,6 +10133,10 @@ snapshots: '@borewit/text-codec@0.1.1': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@capacitor/android@7.5.0(@capacitor/core@7.5.0)': dependencies: '@capacitor/core': 7.5.0 @@ -10014,6 +10220,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@dabh/diagnostics@2.0.8': dependencies: '@so-ric/colorspace': 1.1.6 @@ -10164,6 +10394,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.0(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@faker-js/faker@10.1.0': {} '@faker-js/faker@9.9.0': {} @@ -11186,9 +11420,9 @@ snapshots: '@next/swc-win32-x64-msvc@16.0.11': optional: true - '@next/third-parties@16.2.1(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)': + '@next/third-parties@16.2.1(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)': dependencies: - next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 third-party-capital: 1.0.20 @@ -11487,6 +11721,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@polka/url@1.0.0-next.29': {} '@prisma/instrumentation@7.2.0(@opentelemetry/api@1.9.0)': @@ -11944,7 +12182,7 @@ snapshots: '@sentry/utils': 7.120.4 localforage: 1.10.0 - '@sentry/nextjs@10.38.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.103.0(esbuild@0.27.2))': + '@sentry/nextjs@10.38.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.103.0(esbuild@0.27.2))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.39.0 @@ -11957,7 +12195,7 @@ snapshots: '@sentry/react': 10.38.0(react@19.2.1) '@sentry/vercel-edge': 10.38.0 '@sentry/webpack-plugin': 4.9.0(webpack@5.103.0(esbuild@0.27.2)) - next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) rollup: 4.55.1 stacktrace-parser: 0.1.11 transitivePeerDependencies: @@ -12470,9 +12708,9 @@ snapshots: storybook: 10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) optionalDependencies: '@vitest/browser': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) - '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) + '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.60.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) '@vitest/runner': 4.0.16 - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) transitivePeerDependencies: - react - react-dom @@ -12507,18 +12745,18 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - '@storybook/nextjs-vite@10.1.11(@babel/core@7.28.5)(esbuild@0.27.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(rollup@4.55.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(webpack@5.103.0(esbuild@0.27.2))': + '@storybook/nextjs-vite@10.1.11(@babel/core@7.28.5)(esbuild@0.27.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(rollup@4.55.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(webpack@5.103.0(esbuild@0.27.2))': dependencies: '@storybook/builder-vite': 10.1.11(esbuild@0.27.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(rollup@4.55.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(webpack@5.103.0(esbuild@0.27.2)) '@storybook/react': 10.1.11(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3) '@storybook/react-vite': 10.1.11(esbuild@0.27.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(rollup@4.55.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(webpack@5.103.0(esbuild@0.27.2)) - next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) storybook: 10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.1) vite: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2) - vite-plugin-storybook-nextjs: 3.1.8(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)) + vite-plugin-storybook-nextjs: 3.1.8(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -12747,6 +12985,16 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -13227,13 +13475,13 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - '@vitest/browser-playwright@4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16)': + '@vitest/browser-playwright@4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.60.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16)': dependencies: '@vitest/browser': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)) - playwright: 1.57.0 + playwright: 1.60.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) transitivePeerDependencies: - bufferutil - msw @@ -13249,7 +13497,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) ws: 8.19.0 transitivePeerDependencies: - bufferutil @@ -13270,7 +13518,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) optionalDependencies: '@vitest/browser': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) transitivePeerDependencies: @@ -13909,6 +14157,10 @@ snapshots: basic-ftp@5.2.0: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + big-integer@1.6.52: {} bin-version-check@5.1.0: @@ -14386,6 +14638,11 @@ snapshots: csp_evaluator@1.1.5: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css.escape@1.5.1: {} csstype@3.2.3: {} @@ -14432,6 +14689,13 @@ snapshots: data-uri-to-buffer@6.0.2: {} + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -14658,6 +14922,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@8.0.0: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -15606,6 +15872,12 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} http-cache-semantics@4.2.0: {} @@ -15854,6 +16126,8 @@ snapshots: is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-reference@1.2.1: @@ -16405,6 +16679,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.6 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -16701,6 +17001,8 @@ snapshots: lru-cache@11.2.4: {} + lru-cache@11.3.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -16753,6 +17055,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@0.3.0: {} media-typer@1.1.0: {} @@ -16940,10 +17244,10 @@ snapshots: netmask@2.0.2: {} - next-auth@5.0.0-beta.30(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1): + next-auth@5.0.0-beta.30(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1): dependencies: '@auth/core': 0.41.0 - next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 next-themes@0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1): @@ -16951,7 +17255,7 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: '@next/env': 16.0.11 '@swc/helpers': 0.5.15 @@ -16970,6 +17274,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.0.11 '@next/swc-win32-x64-msvc': 16.0.11 '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.60.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -17179,6 +17484,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} passport-google-oauth20@2.0.0: @@ -17321,11 +17630,11 @@ snapshots: dependencies: sharp: 0.34.5 - playwright-core@1.57.0: {} + playwright-core@1.60.0: {} - playwright@1.57.0: + playwright@1.60.0: dependencies: - playwright-core: 1.57.0 + playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 @@ -17803,6 +18112,10 @@ snapshots: sax@1.5.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} schema-utils@3.3.0: @@ -18372,6 +18685,8 @@ snapshots: symbol-observable@4.0.0: {} + symbol-tree@3.2.4: {} + synckit@0.11.11: dependencies: '@pkgr/core': 0.2.9 @@ -18539,8 +18854,16 @@ snapshots: dependencies: tldts: 7.0.19 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.19 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} triple-beam@1.4.1: {} @@ -18763,6 +19086,8 @@ snapshots: undici-types@7.18.2: {} + undici@7.25.0: {} + unique-string@2.0.0: dependencies: crypto-random-string: 2.0.0 @@ -18892,13 +19217,13 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-storybook-nextjs@3.1.8(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)): + vite-plugin-storybook-nextjs@3.1.8(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)): dependencies: '@next/env': 16.0.0 image-size: 2.0.2 magic-string: 0.30.21 module-alias: 2.2.3 - next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) storybook: 10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) ts-dedent: 2.2.0 vite: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2) @@ -18934,7 +19259,7 @@ snapshots: terser: 5.44.1 yaml: 2.8.2 - vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2): + vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.16 '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)) @@ -18959,7 +19284,8 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 20.19.27 - '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) + '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.60.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) + jsdom: 29.1.1(@noble/hashes@1.8.0) transitivePeerDependencies: - jiti - less @@ -18973,6 +19299,10 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -18990,6 +19320,8 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + webpack-bundle-analyzer@4.10.1: dependencies: '@discoveryjs/json-ext': 0.5.7 @@ -19083,6 +19415,16 @@ snapshots: whatwg-fetch@3.6.20: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -19228,6 +19570,8 @@ snapshots: xdg-basedir@4.0.0: {} + xml-name-validator@5.0.0: {} + xml2js@0.6.2: dependencies: sax: 1.5.0 @@ -19237,6 +19581,8 @@ snapshots: xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} xtend@4.0.2: {}