diff --git a/docs/26-03-11-bot-qa-sheet.md b/docs/26-03-11-bot-qa-sheet.md new file mode 100644 index 0000000..bcaedda --- /dev/null +++ b/docs/26-03-11-bot-qa-sheet.md @@ -0,0 +1,159 @@ +# Bot QA Sheet + +**작성일:** 2026-03-11 +**대상:** `packages/bot` +**목적:** 디스코드 봇의 주요 동작을 기능 단위로 수동 점검하기 위한 QA 시트 + +--- + +## 사용 방법 + +- 각 항목은 `사전조건 -> 실행 -> 기대결과` 순서로 확인한다. +- 가능하면 테스트 서버/테스트 채널/테스트 계정으로 수행한다. +- 결과 기록은 아래 상태값 중 하나를 사용한다. + - `PASS`: 기대결과와 동일 + - `FAIL`: 재현 가능한 문제 확인 + - `N/A`: 현재 데이터나 권한상 확인 불가 + +### 기록 템플릿 + +| ID | 결과 | 비고 | +|---|---|---| +| B-BOOT-01 | PASS | | + +--- + +## 1. 봇 기동 / 기본 상태 + +| ID | 동작 | 사전조건 | 실행 | 기대결과 | +|---|---|---|---|---| +| B-BOOT-01 | 봇 기동 | 환경변수 정상 | `pnpm --filter @blog-study/bot dev` 또는 운영 기동 | 프로세스가 정상 시작되고 즉시 종료되지 않는다 | +| B-BOOT-02 | Discord 연결 | 봇 토큰 유효 | 기동 로그 확인 | Discord 로그인 성공 로그가 보인다 | +| B-BOOT-03 | 핸들러 등록 | 봇 기동 완료 | 메시지/리액션 이벤트 대기 | activity handler, DM handler 등 주요 핸들러가 정상 등록된다 | +| B-BOOT-04 | 스케줄러 등록 | 봇 기동 완료 | 시작 로그 확인 | RSS, 출석, 벌금 리마인드, 라운드 리포트, 주간 랭킹, 큐레이션 크롤러가 등록된다 | +| B-BOOT-05 | 중복 실행 방지 | 동일 잡 중복 트리거 가능 | 같은 작업을 연속 호출 | `already in progress` 류 방어가 동작해 중복 처리되지 않는다 | + +--- + +## 2. RSS 수집 / 포스트 반영 + +| ID | 동작 | 사전조건 | 실행 | 기대결과 | +|---|---|---|---|---| +| B-RSS-01 | active 멤버만 RSS 대상 포함 | active/inactive/pending 멤버 혼재 | RSS poll 실행 | active 이면서 RSS URL 있는 멤버만 수집 대상이 된다 | +| B-RSS-02 | RSS 미동의 제외 | rssConsent=false 멤버 존재 | RSS poll 실행 | 미동의 멤버는 제외된다 | +| B-RSS-03 | 피드 수집 성공 | 유효 RSS URL 보유 멤버 존재 | `pnpm --filter @blog-study/bot test-rss-poll` 또는 실제 poll | 새 글이 감지되고 처리 결과가 로그에 남는다 | +| B-RSS-04 | 중복 글 방지 | 동일 글이 이미 저장됨 | RSS poll 재실행 | 기존 글이 중복 생성되지 않는다 | +| B-RSS-05 | 일부 피드 실패 허용 | 실패하는 RSS URL 포함 | RSS poll 실행 | 실패한 피드만 에러로 기록되고 나머지는 계속 처리된다 | +| B-RSS-06 | 글 저장 후 출석 연동 | 현재 라운드 기간 중 새 글 존재 | RSS poll 실행 | 포스트 저장 후 출석 상태가 적절히 갱신된다 | +| B-RSS-07 | 지각 처리 | grace period 내 제출 글 존재 | RSS poll 실행 | 출석이 `late`로 기록되고 관련 후속 동작이 이어진다 | + +--- + +## 3. 출석 / 벌금 연동 + +| ID | 동작 | 사전조건 | 실행 | 기대결과 | +|---|---|---|---|---| +| B-ATT-01 | 정시 제출 출석 처리 | 라운드 진행 중, 제출 글 존재 | RSS 또는 서비스 호출 | 출석이 `submitted`로 기록된다 | +| B-ATT-02 | 지각 제출 출석 처리 | grace period 내 제출 | RSS 또는 서비스 호출 | 출석이 `late`로 기록된다 | +| B-ATT-03 | grace period 종료 체크 | pending 출석 존재 | `pnpm --filter @blog-study/bot test-attendance` 또는 스케줄 실행 | pending 이 `absent`로 바뀐다 | +| B-ATT-04 | 결석 벌금 생성 | 결석 처리 대상 존재 | 출석 체크 실행 | 5,000원 벌금이 생성된다 | +| B-ATT-05 | 지각 벌금 생성 | 지각 처리 대상 존재 | 지각 제출 반영 | 3,000원 벌금이 생성된다 | +| B-ATT-06 | 동일 회차 중복 벌금 방지 | 이미 벌금 존재 | 동일 상태 재처리 | 중복 벌금이 새로 생기지 않는다 | +| B-ATT-07 | 수동 출석 변경 연계 | 관리자에서 상태 수정 | admin attendance 후 DB 확인 | 벌금 생성/갱신/면제 상태가 논리적으로 일치한다 | + +--- + +## 4. 벌금 알림 / 납부 확인 + +| ID | 동작 | 사전조건 | 실행 | 기대결과 | +|---|---|---|---|---| +| B-FINE-01 | 벌금 DM 발송 | 벌금 생성 대상, DM 수신 가능 계정 | 벌금 생성 플로우 실행 | 유저 DM으로 벌금 금액/사유/안내가 발송된다 | +| B-FINE-02 | 리마인드 발송 | unpaid 벌금 존재 | `pnpm --filter @blog-study/bot test-fine-reminder` 또는 스케줄 실행 | 미납 사용자에게 리마인드 DM이 발송된다 | +| B-FINE-03 | pending confirmation 설정 | 납부 확인 버튼 포함 DM 발송 | DM 발송 직후 DB 확인 | `pendingConfirmation` 이 true로 설정된다 | +| B-FINE-04 | DM 버튼으로 납부 처리 | 테스트 계정으로 DM 수신 | `납부 확인` 버튼 클릭 | 벌금 상태가 paid 로 바뀌고 pending confirmation 이 해제된다 | +| B-FINE-05 | 잘못된 확인 버튼 방어 | 만료/잘못된 fineId 사용 | 임의 interaction 시도 | 잘못된 요청은 거부되고 다른 벌금에 영향이 없다 | +| B-FINE-06 | 이미 처리된 벌금 재확인 방어 | paid 또는 waived 벌금 존재 | 동일 fine 재확인 시도 | 중복 처리되지 않고 안전하게 종료된다 | +| B-FINE-07 | DM 실패 처리 | DM 차단 사용자 존재 | 알림 또는 리마인드 실행 | 실패 로그는 남지만 전체 작업은 중단되지 않는다 | + +--- + +## 5. 활동 점수 + +| ID | 동작 | 사전조건 | 실행 | 기대결과 | +|---|---|---|---|---| +| B-SCORE-01 | 일반 메시지 점수 | active 멤버가 일반 채널에서 메시지 작성 | 테스트 메시지 전송 | `DISCORD_MESSAGE` 점수가 적립된다 | +| B-SCORE-02 | 스레드 메시지 점수 | active 멤버가 스레드에 메시지 작성 | 테스트 메시지 전송 | `DISCORD_THREAD` 점수가 적립된다 | +| B-SCORE-03 | 리액션 점수 | active 멤버가 메시지에 리액션 | 테스트 리액션 추가 | `DISCORD_REACTION` 점수가 적립된다 | +| B-SCORE-04 | 비회원/매핑 실패 방어 | discordId 미매핑 사용자 | 메시지/리액션 발생 | 전체 프로세스가 죽지 않고 해당 사용자만 무시된다 | +| B-SCORE-05 | 점수 누적 일관성 | 동일 사용자가 여러 액션 수행 | 메시지/스레드/리액션 반복 | 랭킹/점수 요약과 논리적으로 일치하는 누적값이 기록된다 | + +--- + +## 6. 랭킹 / 리포트 알림 + +| ID | 동작 | 사전조건 | 실행 | 기대결과 | +|---|---|---|---|---| +| B-RANK-01 | 주간 랭킹 생성 | 점수 데이터 존재 | `pnpm --filter @blog-study/bot test-weekly-ranking` 또는 스케줄 실행 | 주간 랭킹 메시지가 정상 생성된다 | +| B-RANK-02 | 라운드 리포트 생성 | 라운드/출석 데이터 존재 | `pnpm --filter @blog-study/bot test-round-report` 또는 스케줄 실행 | 제출/지각/결석/MVP 정보가 포함된 리포트가 생성된다 | +| B-RANK-03 | 랭킹 수치 정합성 | 충분한 테스트 데이터 | 결과 메시지와 DB 비교 | 순위, 점수, 활동 수치가 DB 기준과 일치한다 | +| B-RANK-04 | 데이터 없음 처리 | 대상 데이터 없음 | 리포트/랭킹 실행 | 비정상 종료 없이 빈 상태 또는 안내 메시지로 처리된다 | + +--- + +## 7. 큐레이션 크롤링 + +| ID | 동작 | 사전조건 | 실행 | 기대결과 | +|---|---|---|---|---| +| B-CUR-01 | 소스 전체 크롤링 | 활성 큐레이션 소스 존재 | `pnpm --filter @blog-study/bot test-curation` 또는 스케줄 실행 | 각 소스를 순회하며 아이템을 수집한다 | +| B-CUR-02 | relevance score 계산 | 키워드 데이터 존재 | 크롤링 실행 | 아이템별 relevance score 가 계산된다 | +| B-CUR-03 | 중복 아이템 방지 | 기존 수집 항목 존재 | 크롤링 재실행 | 동일 아이템이 중복 저장되지 않는다 | +| B-CUR-04 | 일부 소스 실패 허용 | 오류 나는 소스 포함 | 크롤링 실행 | 실패 소스만 기록되고 나머지 소스는 계속 처리된다 | +| B-CUR-05 | Discord 요약 전송 | Discord client 연결 | 크롤링 실행 | 신규 수집 결과가 지정 채널/메시지 포맷으로 전달된다 | + +--- + +## 8. 알림 / 메시지 전달 + +| ID | 동작 | 사전조건 | 실행 | 기대결과 | +|---|---|---|---|---| +| B-NOTI-01 | Discord 메시지 전송 테스트 | 테스트 채널 접근 가능 | `pnpm --filter @blog-study/bot test-discord-message` | 지정 채널에 메시지가 도착한다 | +| B-NOTI-02 | 사용자 식별 실패 방어 | 잘못된 discordId | DM 발송 함수 실행 | 예외는 로그로 남고 프로세스 전체는 유지된다 | +| B-NOTI-03 | 메시지 포맷 안정성 | 한글/링크/금액 포함 데이터 | 알림 발송 | 줄바꿈, 링크, 버튼, embed 필드가 깨지지 않는다 | + +--- + +## 9. 운영 스크립트 + +| ID | 동작 | 사전조건 | 실행 | 기대결과 | +|---|---|---|---|---| +| B-OPS-01 | 테스트 데이터 시드 | 테스트 DB | `pnpm --filter @blog-study/bot seed-test-data` | QA에 필요한 데이터가 생성된다 | +| B-OPS-02 | 키워드 시드 | 키워드 테이블 준비 | `pnpm --filter @blog-study/bot seed-keywords` | 기본 키워드 데이터가 입력된다 | +| B-OPS-03 | relevance 재계산 | 큐레이션 아이템 존재 | `pnpm --filter @blog-study/bot recalculate-relevance` | relevance score 가 재계산된다 | +| B-OPS-04 | 수동 RSS 수집 | 유효 RSS 데이터 | `pnpm --filter @blog-study/bot rss-collect` | 수집 결과가 기록되고 글 반영이 정상 수행된다 | + +--- + +## 10. 회귀 체크 묶음 + +배포 전 최소 확인 세트: + +| 묶음 | 포함 항목 | +|---|---| +| 수집 핵심 | `B-RSS-01`, `B-RSS-03`, `B-RSS-04`, `B-RSS-05` | +| 출석/벌금 핵심 | `B-ATT-03`, `B-ATT-04`, `B-FINE-01`, `B-FINE-04` | +| 알림 핵심 | `B-NOTI-01`, `B-RANK-01`, `B-RANK-02`, `B-CUR-05` | +| 점수 핵심 | `B-SCORE-01`, `B-SCORE-02`, `B-SCORE-03`, `B-SCORE-05` | + +--- + +## 11. 버그 기록 예시 + +| 항목 | 내용 | +|---|---| +| ID | B-FINE-04 | +| 환경 | 테스트 디스코드 서버, 테스트 계정 | +| 재현 절차 | 벌금 DM 수신 -> 납부 확인 버튼 클릭 | +| 실제 결과 | 버튼 응답 후 DB 상태 변화 없음 | +| 기대 결과 | 벌금 상태가 paid 로 갱신되고 pending confirmation 이 해제되어야 함 | +| 로그 | interaction 처리 로그 또는 stack trace 첨부 | + diff --git a/docs/26-03-11-web-accessibility-audit.md b/docs/26-03-11-web-accessibility-audit.md new file mode 100644 index 0000000..7dfb284 --- /dev/null +++ b/docs/26-03-11-web-accessibility-audit.md @@ -0,0 +1,434 @@ +# Web Interface Accessibility Audit + +**Audit Date:** 2026-03-11 +**Framework:** Next.js 16, React 19, Tailwind CSS v4, shadcn/ui +**Guidelines:** Vercel Web Interface Guidelines, WCAG 2.1 AA + +--- + +## Executive Summary + +| Scope | P0 (Critical) | P1 (Important) | P2 (Minor) | Total | +|-------|---------------|----------------|------------|-------| +| Landing Page | 9 | 9 | 7 | 25 | +| Admin Pages | 8 | 24 | 15 | 47 | +| User Pages | 12 | 23 | 12 | 47 | +| **Total** | **29** | **56** | **34** | **119** | + +**Overall Status:** ⚠️ Needs significant accessibility improvements + +--- + +## Priority Issues (Top 10) + +### 1. Keyboard Navigation Not Supported (P0) +**Files Affected:** +- `app/(user)/board/page.tsx:129` - Table rows use onClick without keyboard handlers +- `app/(admin)/admin/members/page.tsx:224` - Status filter tabs lack arrow key navigation + +**Fix:** +```tsx +// Add keyboard handlers to clickable table rows + router.push(`/board/${post.id}`)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + router.push(`/board/${post.id}`); + } + }} + className="cursor-pointer" +> +``` + +```tsx +// Add arrow key navigation to tab groups +role="tablist" +aria-orientation="horizontal" +onKeyDown={(e) => { + if (e.key === 'ArrowRight') { + // Move to next tab + } else if (e.key === 'ArrowLeft') { + // Move to previous tab + } +}} +``` + +--- + +### 2. Focus States Missing (P0) +**Files Affected:** +- `components/landing/landing-client.tsx:92,139,336` - All CTA buttons lack `:focus-visible` +- Footer links missing focus indicators + +**Fix:** +```css +/* app/globals.css */ +.glow-button:focus-visible { + outline: 2px solid white; + outline-offset: 2px; + box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.3); +} +``` + +--- + +### 3. Form Errors Not Announced (P0) +**Files Affected:** +- `app/(user)/board/write/page.tsx:229` - Error message lacks `aria-live` +- `app/(user)/profile/page.tsx:427` - Withdraw error not announced +- `components/board/comment-form.tsx:103` - Comment errors not live regions + +**Fix:** +```tsx +{error && ( +
+ {error} +
+)} +``` + +```tsx +// Associate error with input + +{errors.title && ( +

+ {errors.title} +

+)} +``` + +--- + +### 4. Motion Preferences Ignored (P0) +**Files Affected:** +- `components/landing/motion.tsx:40-190` - FadeUp, StaggerContainer, DrawLine ignore `prefers-reduced-motion` +- `app/globals.css:321` - Marquee animation lacks override + +**Fix:** +```tsx +// motion.tsx +const prefersReducedMotion = usePrefersReducedMotion(); + +export function FadeUp({ children, delay = 0 }: MotionProps) { + if (prefersReducedMotion) { + return <>{children}; + } + + return ( + + {children} + + ); +} +``` + +```css +/* globals.css */ +@media (prefers-reduced-motion: reduce) { + .marquee { + animation: none; + } +} +``` + +--- + +### 5. Color Contrast Failures (P0) +**Files Affected:** +- `components/landing/landing-client.tsx:351` - Footer `text-zinc-600` (3.9:1, fails WCAG AA) +- `landing-client.tsx:147,165` - Secondary text `text-zinc-500` (5.2:1, fails WCAG AA) + +**Fix:** +```tsx +// Replace text-zinc-600 with text-zinc-400 or lighter + + +// Secondary text +

// Was text-zinc-500 + Description text +

+``` + +--- + +### 6. Icon-Only Buttons Lack Labels (P0) +**Files Affected:** +- `app/(admin)/admin/members/page.tsx:320` - Edit/Delete buttons +- `components/board/tiptap-editor.tsx:137` - Toolbar buttons + +**Fix:** +```tsx + +``` + +--- + +### 7. Focus Trap Not Implemented (P0) +**Files Affected:** +- `app/(admin)/admin/members/member-form-dialog.tsx:171` - Custom dialog lacks focus trap + +**Fix:** +```tsx +// Use shadcn/ui Dialog component instead of custom div + + + {/* Dialog handles focus trap automatically */} + + +``` + +--- + +### 8. window.confirm() Usage (P1) +**Files Affected:** +- `app/(admin)/admin/curation/page.tsx:187` +- `app/(admin)/admin/curation/items/page.tsx:103` + +**Fix:** +```tsx +// Replace with AlertDialog + !open && setDeleteTarget(null)}> + + + 소스 삭제 + + "{source.name}" 소스를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + 취소 + 삭제 + + + +``` + +--- + +### 9. Table Headers Missing Scope (P1) +**Files Affected:** +- `app/(admin)/admin/members/page.tsx:348` +- `app/(admin)/admin/attendance/page.tsx:367` +- All admin tables + +**Fix:** +```tsx + + + 이름 + 상태 + 파트 + + +``` + +--- + +### 10. External Links Lack Screen Reader Text (P1) +**Files Affected:** +- `app/(user)/posts/page.tsx:297` +- `app/(user)/dashboard/page.tsx:206` +- All external links + +**Fix:** +```tsx + + {post.title} + + (새 탭에서 열기) + +``` + +--- + +## Detailed Findings by Scope + +### Landing Page (25 issues) + +**P0 Issues:** +1. Missing `:focus-visible` on all CTA buttons (landing-client.tsx:92,139,336) +2. Marquee animation lacks `prefers-reduced-motion` override (globals.css:321) +3. FadeUp, StaggerContainer, DrawLine ignore reduced motion (motion.tsx:40-190) +4. Footer text `text-zinc-600` fails WCAG AA (landing-client.tsx:351) +5. Avatar images need `aria-hidden="true"` (landing-client.tsx:309) + +**P1 Issues:** +1. Secondary text `text-zinc-500` insufficient contrast (landing-client.tsx:147,165) +2. Missing skip-to-content link (landing-client.tsx:84-96) +3. Badge uses generic div without semantic role (landing-client.tsx:112-115) +4. Step circles not keyboard navigable (landing-client.tsx:249-288) + +**P2 Issues:** +1. Not using Next.js Image component (landing-client.tsx:86,306) +2. Hardcoded background color (landing-client.tsx:382) +3. Decorative divs need `aria-hidden` (landing-client.tsx:105,107,325) + +--- + +### Admin Pages (47 issues) + +**P0 Issues:** +1. Status filter buttons lack keyboard navigation (members/page.tsx:224) +2. Inline status buttons lack focus management (attendance/page.tsx:432) +3. Custom dropdown lacks proper ARIA (scores/page.tsx:174) +4. Date inputs lack label associations (rounds/page.tsx:273) +5. Crawl progress lacks ARIA live regions (curation/page.tsx:214) +6. Custom dialog lacks focus trap (members/member-form-dialog.tsx:171) + +**P1 Issues:** +1. Icon-only buttons lack aria-label (members/page.tsx:320,397) +2. Table headers missing scope (all tables) +3. Form errors not associated with inputs (member-form-dialog.tsx:185) +4. Search inputs use placeholder as label (all pages) +5. Loading skeletons not hidden from screen readers (scores/page.tsx:787) + +**P2 Issues:** +1. Using div instead of semantic elements (layout.tsx:80) +2. Legend section lacks landmark (attendance/page.tsx:264) +3. Modal content changes not announced (curation/crawl-modal.tsx:61) + +--- + +### User Pages (47 issues) + +**P0 Issues:** +1. Table rows onClick lack keyboard handlers (board/page.tsx:129) +2. Form validation errors lack aria-live (board/write/page.tsx:229) +3. Select placeholder not accessible (board/write/page.tsx:134) +4. Form validation lacks aria-describedby (profile/onboarding/page.tsx:224) +5. Social link chips have keyboard issues (members/page.tsx:172) + +**P1 Issues:** +1. Pagination lacks aria-label (board/page.tsx:508) +2. External links missing screen reader text (board/[id]/page.tsx:207) +3. Card click contains nested interactive elements (members/page.tsx:135) +4. Sort tabs lack proper role (ranking/page.tsx:527) +5. Active nav lacks aria-current (layout/bottom-nav.tsx:61) + +**P2 Issues:** +1. Board controls lack landmark (board/page.tsx:404) +2. Cards lack individual headings (dashboard/page.tsx:144) +3. Tabular data uses div grid (profile/page.tsx:227) + +--- + +## Fix Roadmap + +### Phase 1: P0 Critical (Week 1) +- [ ] Add `:focus-visible` styles to all interactive elements +- [ ] Implement `prefers-reduced-motion` for all animations +- [ ] Add `aria-live="assertive"` to all form error messages +- [ ] Fix footer and secondary text contrast +- [ ] Add keyboard handlers to table rows +- [ ] Add `aria-label` to icon-only buttons +- [ ] Implement focus trap in custom dialog + +### Phase 2: P1 Important (Week 2-3) +- [ ] Replace all `window.confirm()` with AlertDialog +- [ ] Add `scope="col"` to all table headers +- [ ] Add screen reader text to external links +- [ ] Add `aria-current="page"` to active navigation +- [ ] Associate errors with inputs using `aria-describedby` +- [ ] Add proper labels to search inputs +- [ ] Implement arrow key navigation for tabs + +### Phase 3: P2 Improvements (Ongoing) +- [ ] Review and improve landmark regions +- [ ] Verify heading hierarchy across all pages +- [ ] Add skip-to-content links +- [ ] Enhance loading state announcements +- [ ] Add decorative `aria-hidden` attributes + +--- + +## Positive Findings + +### What's Working Well + +**Landing Page:** +- ✅ Semantic HTML structure (header, main, section, footer) +- ✅ External links have `rel="noopener noreferrer"` +- ✅ CountUp implements `prefers-reduced-motion` +- ✅ Framer Motion animations with proper easing +- ✅ Proper heading hierarchy (h1 → h2 → h3) + +**Admin Pages:** +- ✅ shadcn/ui accessible components (Dialog, Table) +- ✅ MemberSelector with excellent ARIA implementation +- ✅ Consistent status badge colors +- ✅ Korean locale support +- ✅ Toast notifications via sonner + +**User Pages:** +- ✅ `focus-visible:ring-*` classes +- ✅ Semantic HTML (nav, header, main) +- ✅ `aria-pressed` on toggle buttons +- ✅ Skeleton loading states +- ✅ Responsive design patterns + +--- + +## Testing Recommendations + +1. **Keyboard Navigation Test** + - Navigate entire site using Tab/Enter/Escape only + - Verify focus is always visible + - Test all interactive elements + +2. **Screen Reader Test** + - NVDA (Windows) or VoiceOver (Mac) + - Verify all errors are announced + - Check navigation and headings + +3. **Color Contrast Test** + - WebAIM Contrast Checker + - Verify all text meets WCAG AA (4.5:1) + +4. **Motion Sensitivity Test** + - Enable "Reduce motion" in OS settings + - Verify animations are disabled + +5. **Focus Visible Test** + - Tab through all elements + - Ensure focus indicator is high contrast + +--- + +## Resources + +- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/) +- [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) +- [WebAIM Accessibility Checklist](https://webaim.org/standards/wcag/checklist) +- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines) + +--- + +## Related Documents + +- `docs/26-03-06-ui-design-system.md` - UI 디자인 시스템 스펙 +- `docs/26-03-06-patterns.md` - API 패턴 & 코드 규칙 +- `docs/plans/26-03-08-landing-page-redesign.md` - 랜딩 페이지 구현 플랜 diff --git a/docs/26-03-11-web-qa-sheet.md b/docs/26-03-11-web-qa-sheet.md new file mode 100644 index 0000000..4bd7cb8 --- /dev/null +++ b/docs/26-03-11-web-qa-sheet.md @@ -0,0 +1,203 @@ +# Web QA Sheet + +**작성일:** 2026-03-11 +**대상:** `packages/web` +**목적:** 화면별이 아니라 실제 사용자 동작 기준으로 빠르게 수동 QA를 수행하기 위한 체크시트 + +--- + +## 사용 방법 + +- 각 항목은 `사전조건 -> 절차 -> 기대결과` 순서로 확인한다. +- 결과 기록은 아래 상태값 중 하나를 사용한다. + - `PASS`: 기대결과와 동일 + - `FAIL`: 재현 가능한 문제 확인 + - `N/A`: 현재 데이터나 권한상 확인 불가 +- 버그를 적을 때는 `화면 / 재현 절차 / 실제 결과 / 기대 결과 / 콘솔 에러`를 함께 남긴다. + +### 기록 템플릿 + +| ID | 결과 | 비고 | +|---|---|---| +| U-AUTH-01 | PASS | | + +--- + +## 1. 공통 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| C-01 | 앱 최초 진입 | 미로그인 | `/` 진입 | 랜딩 페이지가 깨지지 않고 로드된다 | +| C-02 | 로딩 상태 노출 | API 응답 지연 유도 가능 | 주요 목록 페이지 진입 | 스켈레톤 또는 로딩 상태가 먼저 보이고, 이후 실제 데이터로 전환된다 | +| C-03 | 에러 바운더리 동작 | API 실패 또는 강제 에러 | 주요 페이지 진입 | 페이지 전체가 무한 로딩되지 않고 에러 상태 UI가 표시된다 | +| C-04 | 헤더 내비게이션 | 로그인 상태 | 사용자/관리자 주요 페이지 이동 | 헤더가 정상 유지되고 현재 맥락과 충돌하지 않는다 | +| C-05 | 모바일 레이아웃 | 모바일 뷰포트 | 주요 페이지 스크롤 및 탭 전환 | 가로 스크롤이 불필요하게 생기지 않는다 | +| C-06 | Pull to refresh | 모바일, 새로고침 대상 페이지 | 최상단에서 아래로 당김 | 새로고침 인디케이터와 콘텐츠 위치가 자연스럽고 중첩 스크롤과 충돌하지 않는다 | +| C-07 | 새로고침 비활성 조건 | 모바일, 내부 스크롤 존재 | 목록 중간 지점에서 당김 | 페이지가 오작동하지 않고 일반 스크롤만 수행된다 | +| C-08 | 공지 배너 열기/닫기 | 공지 존재 | 배너 토글 및 닫기 | 높이 변화 후 레이아웃이 깨지지 않고 다음 콘텐츠가 정상 배치된다 | + +--- + +## 2. 인증 / 온보딩 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| U-AUTH-01 | 로그인 진입 | 미로그인 | 로그인 페이지 진입 후 OAuth 시작 | 인증 플로우가 시작되고 콜백 후 적절한 페이지로 이동한다 | +| U-AUTH-02 | 온보딩 제출 | 신규 사용자 | 관심사/프로필 입력 후 제출 | 제출 성공 후 대기 또는 메인 흐름으로 이동한다 | +| U-AUTH-03 | 필수값 검증 | 신규 사용자 | 온보딩 필수값 비우고 제출 | 필드 검증이 표시되고 잘못된 상태로 진행되지 않는다 | +| U-AUTH-04 | 승인 대기 화면 | pending 사용자 | 로그인 후 진입 | 승인 대기 안내가 정상 표시된다 | +| U-AUTH-05 | 비활성 사용자 화면 | inactive 사용자 | 로그인 후 진입 | 비활성 상태 안내가 정상 표시된다 | + +--- + +## 3. 사용자 영역 + +### 3-1. 대시보드 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| U-DASH-01 | 대시보드 로드 | 로그인 | `/dashboard` 진입 | 현재 라운드, 제출 현황, 요약 카드가 정상 표시된다 | +| U-DASH-02 | 기간 표시 반응형 | 모바일/데스크톱 | 동일 페이지 확인 | 모바일에서는 줄바꿈, 데스크톱에서는 한 줄로 자연스럽게 보인다 | +| U-DASH-03 | 데이터 없음 처리 | 관련 데이터 없음 | 대시보드 진입 | 0건/빈 상태가 레이아웃 붕괴 없이 표시된다 | + +### 3-2. 큐레이션 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| U-CUR-01 | 큐레이션 목록 로드 | 로그인 | `/curation` 진입 | 카드/리스트가 오류 없이 표시된다 | +| U-CUR-02 | 카테고리 필터 | 데이터 존재 | 추천/전체/컨퍼런스/아티클 전환 | URL과 목록 결과가 함께 바뀐다 | +| U-CUR-03 | 태그 필터 | 데이터 존재 | 태그 선택/해제/초기화 | 선택 상태와 목록 필터가 동기화된다 | +| U-CUR-04 | 검색 | 데이터 존재 | 검색어 입력/삭제 | 입력값에 맞게 목록이 줄어들고 초기화가 가능하다 | +| U-CUR-05 | 외부 링크 이동 | 유효 URL 포함 데이터 | 카드 클릭 | 새 탭으로 안전하게 열린다 | +| U-CUR-06 | 이미지 실패 fallback | 썸네일 깨진 데이터 | 목록 확인 | gradient fallback과 카테고리 이모지가 노출된다 | +| U-CUR-07 | 무한 스크롤 | 다음 페이지 존재 | 하단까지 스크롤 | 중복 로딩 없이 다음 목록이 append 된다 | + +### 3-3. 랭킹 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| U-RANK-01 | 랭킹 로드 | 로그인 | `/ranking` 진입 | 포디움, 목록, 정렬 탭이 정상 렌더링된다 | +| U-RANK-02 | 정렬 기준 전환 | 데이터 존재 | 총점/포스트 수/활동 점수 전환 | 순위 및 수치가 기준에 맞게 변경된다 | +| U-RANK-03 | 출석 히트맵 표시 | attendance history 존재 | 목록 확인 | 점 상태가 표시되고 알 수 없는 상태값이 있어도 페이지가 죽지 않는다 | +| U-RANK-04 | 현재 사용자 강조 | 현재 사용자 포함 | 목록 확인 | 현재 사용자 행/카드가 시각적으로 구분된다 | + +### 3-4. 게시판 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| U-BOARD-01 | 게시글 목록 로드 | 게시글 존재 | `/board` 진입 | 목록, 카테고리 탭, 글쓰기 버튼이 정상 노출된다 | +| U-BOARD-02 | 카테고리 전환 | 여러 카테고리 데이터 | 탭 전환 | 선택 탭과 목록 데이터가 일치한다 | +| U-BOARD-03 | 모바일 탭 정렬 | 모바일 뷰포트 | 게시판 진입 | 탭이 비정상 줄바꿈 없이 사용 가능하다 | +| U-BOARD-04 | 글 상세 진입 | 게시글 존재 | 목록에서 게시글 선택 | 상세 내용, 작성자, 메타정보가 정상 표시된다 | +| U-BOARD-05 | 글 작성 | 작성 권한 | 제목/본문 입력 후 저장 | 저장 성공 후 상세 또는 목록으로 이동한다 | +| U-BOARD-06 | 글 수정 | 본인 글 존재 | 수정 후 저장 | 변경 내용이 반영된다 | +| U-BOARD-07 | 댓글 작성 | 게시글 존재 | 댓글 입력 후 저장 | 새 댓글이 즉시 반영된다 | +| U-BOARD-08 | 대댓글/비밀댓글 | 관련 기능 활성 | 각 기능 사용 | UI 상태와 실제 저장 결과가 일치한다 | + +### 3-5. 프로필 / 멤버 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| U-PRO-01 | 프로필 조회 | 로그인 | `/profile` 진입 | 기본 정보, 통계, 링크가 정상 표시된다 | +| U-PRO-02 | 프로필 수정 | 로그인 | 정보 수정 후 저장 | 수정 결과가 재조회 시 유지된다 | +| U-PRO-03 | 관심사 수정 | 로그인 | 태그 선택/해제 후 저장 | 선택값이 정상 저장된다 | +| U-PRO-04 | 회원 탈퇴 | 테스트 계정 | 탈퇴 진행 | 경고/확인 절차 후 상태가 올바르게 반영된다 | +| U-MEM-01 | 멤버 목록 | 로그인 | `/members` 진입 | 목록, 필터, 멤버 카드/행이 정상 표시된다 | +| U-MEM-02 | 멤버 상세 | 멤버 존재 | 특정 멤버 선택 | 프로필/통계/링크가 깨지지 않는다 | + +--- + +## 4. 관리자 영역 + +### 4-1. 관리자 대시보드 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| A-DASH-01 | 관리자 대시보드 로드 | 관리자 로그인 | `/admin` 진입 | 주요 요약 수치와 카드가 정상 표시된다 | +| A-DASH-02 | 벌금/출석 요약 수치 | 데이터 존재 | 카드 수치 확인 | 하위 관리 화면과 숫자가 논리적으로 일치한다 | + +### 4-2. 멤버 관리 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| A-MEM-01 | 멤버 목록 로드 | 관리자 | `/admin/members` 진입 | 활성/대기 멤버 목록이 정상 표시된다 | +| A-MEM-02 | 멤버 생성 | 관리자 | 신규 멤버 생성 | 목록에 즉시 반영된다 | +| A-MEM-03 | 멤버 수정 | 관리자 | 파트/닉네임 등 수정 | 저장 후 목록과 상세 정보가 갱신된다 | +| A-MEM-04 | 대기 멤버 승인 | pending 멤버 존재 | 승인 처리 | 상태가 활성으로 변경된다 | +| A-MEM-05 | 멤버 삭제 | 삭제 대상 존재 | 삭제 진행 | 확인 절차 후 목록에서 제거된다 | + +### 4-3. 라운드 / 출석 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| A-ROUND-01 | 라운드 목록 | 관리자 | `/admin/rounds` 진입 | 회차, 일정, 통계가 정상 표시된다 | +| A-ROUND-02 | 라운드 생성 | 관리자 | 새 라운드 생성 | 목록에 추가되고 기간 정보가 일관된다 | +| A-ROUND-03 | 라운드 수정 | 관리자 | 날짜 수정 후 저장 | 저장 결과가 반영된다 | +| A-ATT-01 | 출석표 로드 | 관리자 | `/admin/attendance` 진입 | 멤버 x 회차 매트릭스가 깨지지 않는다 | +| A-ATT-02 | 출석 상태 변경 | 출석 레코드 존재 | 제출/지각/결석/대기 변경 | 셀 상태와 통계가 함께 갱신된다 | +| A-ATT-03 | 벌금 연동 | 지각/결석 처리 | 출석 상태 변경 후 벌금 탭 확인 | 벌금 생성/갱신/면제 연동이 맞게 반영된다 | + +### 4-4. 벌금 관리 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| A-FINE-01 | 벌금 목록 로드 | 벌금 데이터 존재 | `/admin/fines` 진입 | 목록, 요약 카드, 멤버별 미납 현황이 정상 표시된다 | +| A-FINE-02 | 상태 필터 | 벌금 데이터 존재 | 전체/미납/납부/면제 클릭 | 카드 선택 상태와 목록 결과가 일치한다 | +| A-FINE-03 | 검색 | 벌금 데이터 존재 | 멤버명/디스코드명/파트 검색 | 필터 결과가 정상 반영되고 null 데이터가 있어도 오류가 없다 | +| A-FINE-04 | 납부 처리 | 미납 벌금 존재 | `납부` 클릭 | 상태가 납부로 변경되고 `paidAt`이 표시된다 | +| A-FINE-05 | 면제 처리 | 미납 벌금 존재 | `면제` 클릭 후 확인 | 상태가 면제로 변경되고 미납 합계에서 제외된다 | +| A-FINE-06 | 상태값 매핑 | 벌금 상태 데이터 존재 | 목록과 배지 확인 | 내부 상태값이 raw string으로 노출되지 않고 한글 라벨로 표시된다 | +| A-FINE-07 | 회차/멤버 정보 누락 | orphan 또는 누락 데이터 | 목록/검색 확인 | 페이지가 깨지지 않고 fallback 문구가 노출된다 | + +### 4-5. 점수 관리 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| A-SCORE-01 | 점수 페이지 로드 | 관리자 | `/admin/scores` 진입 | 점수 목록과 요약이 정상 표시된다 | +| A-SCORE-02 | 점수 부여 | 관리자 | 수동 점수 추가 | 멤버 점수와 요약이 갱신된다 | +| A-SCORE-03 | 점수 요약 조회 | 데이터 존재 | 요약 영역 확인 | 멤버별/유형별 수치가 비정상 음수나 중복 없이 보인다 | + +### 4-6. 큐레이션 관리 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| A-CUR-01 | 소스 목록 로드 | 관리자 | `/admin/curation` 진입 | 소스 목록이 정상 표시된다 | +| A-CUR-02 | 소스 생성 | 관리자 | 이름/URL/카테고리 입력 후 저장 | 목록에 즉시 반영된다 | +| A-CUR-03 | 소스 수정 | 기존 소스 존재 | 수정 후 저장 | 수정값이 반영된다 | +| A-CUR-04 | 소스 활성/비활성 | 기존 소스 존재 | 활성 상태 전환 | 상태가 즉시 반영된다 | +| A-CUR-05 | 수동 크롤링 | 크롤링 가능한 소스 존재 | 크롤링 실행 | 진행 상태와 결과 메시지가 정상 표시된다 | +| A-CUR-06 | 아이템 목록 | 수집 데이터 존재 | `/admin/curation/items` 진입 | 아이템 목록과 필터가 정상 동작한다 | +| A-CUR-07 | 아이템 삭제/변경 | 대상 아이템 존재 | 액션 수행 | 반영 결과가 목록에 즉시 나타난다 | + +### 4-7. 설정 + +| ID | 동작 | 사전조건 | 절차 | 기대결과 | +|---|---|---|---|---| +| A-SET-01 | 설정 로드 | 관리자 | `/admin/settings` 진입 | 설정값이 정상 표시된다 | +| A-SET-02 | 설정 저장 | 관리자 | 값 수정 후 저장 | 재조회 시 저장값이 유지된다 | + +--- + +## 5. 회귀 체크 묶음 + +배포 직전에는 아래 최소 세트를 우선 확인한다. + +| 묶음 | 포함 항목 | +|---|---| +| 사용자 핵심 | `U-DASH-01`, `U-CUR-02`, `U-CUR-07`, `U-RANK-03`, `U-BOARD-05`, `U-PRO-02` | +| 관리자 핵심 | `A-DASH-01`, `A-MEM-04`, `A-ATT-02`, `A-ATT-03`, `A-FINE-02`, `A-FINE-04`, `A-CUR-05` | +| 모바일 핵심 | `C-05`, `C-06`, `U-DASH-02`, `U-BOARD-03`, `A-FINE-01` | + +--- + +## 6. 버그 기록 예시 + +| 항목 | 내용 | +|---|---| +| ID | A-FINE-04 | +| 환경 | Chrome, 모바일 Safari | +| 재현 절차 | 벌금 탭 진입 -> 미납 카드 클릭 -> 특정 벌금 납부 버튼 클릭 | +| 실제 결과 | 버튼 클릭 후 토스트 실패, 상태 변화 없음 | +| 기대 결과 | 납부 상태로 변경되고 납부일이 표시되어야 함 | +| 콘솔/네트워크 | PATCH `/api/admin/fines/:id` 400 | + diff --git a/packages/web/next.config.js b/packages/web/next.config.js index d6878cf..a6145df 100644 --- a/packages/web/next.config.js +++ b/packages/web/next.config.js @@ -41,7 +41,7 @@ const nextConfig = { "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval'", "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net", - "img-src 'self' https://cdn.discordapp.com https://*.supabase.co https://api.dicebear.com data: blob:", + "img-src 'self' https: data: blob:", "font-src 'self' https://cdn.jsdelivr.net", "connect-src 'self' https://*.supabase.co", "frame-ancestors 'none'", diff --git a/packages/web/package.json b/packages/web/package.json index cc7f252..7ddab91 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -48,7 +48,8 @@ "react-dom": "19.2.4", "sonner": "^2.0.7", "tailwind-merge": "^2.3.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2" }, "devDependencies": { "@tailwindcss/postcss": "^4.2.1", diff --git a/packages/web/src/app/(admin)/admin/fines/page.tsx b/packages/web/src/app/(admin)/admin/fines/page.tsx index 27aa3d0..8f109dd 100644 --- a/packages/web/src/app/(admin)/admin/fines/page.tsx +++ b/packages/web/src/app/(admin)/admin/fines/page.tsx @@ -37,10 +37,10 @@ interface Fine { status: string; createdAt: string; paidAt: string | null; - memberName: string; - memberDiscordUsername: string; - memberPart: string; - roundNumber: number; + memberName: string | null; + memberDiscordUsername: string | null; + memberPart: string | null; + roundNumber: number | null; } interface FineSummary { @@ -73,9 +73,9 @@ interface StatusConfigItem { } const statusConfig: Record = { - unpaid: { label: '미납', variant: 'destructive' }, - paid: { label: '납부', variant: 'success' }, - waived: { label: '면제', variant: 'secondary' }, + PENDING: { label: '미납', variant: 'destructive' }, + PAID: { label: '납부', variant: 'success' }, + WAIVED: { label: '면제', variant: 'secondary' }, }; const typeConfig: Record = { @@ -83,6 +83,25 @@ const typeConfig: Record = { absent: { label: '결석', color: 'text-destructive' }, }; +const STATUS_FILTERS = { + all: 'all', + pending: 'PENDING', + paid: 'PAID', + waived: 'WAIVED', +} as const; + +function getMemberDisplayName(fine: Fine) { + return fine.memberName || fine.memberDiscordUsername || '알 수 없는 멤버'; +} + +function getMemberPartLabel(fine: Fine) { + return fine.memberPart || '-'; +} + +function getRoundLabel(roundNumber: number | null) { + return roundNumber ? `${roundNumber}회차` : '회차 정보 없음'; +} + export default function AdminFinesPage() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -95,6 +114,7 @@ export default function AdminFinesPage() { const fetchFines = useCallback(async () => { try { setLoading(true); + setError(null); const response = await fetch('/api/admin/fines'); if (!response.ok) { throw new Error('Failed to fetch fines data'); @@ -119,7 +139,7 @@ export default function AdminFinesPage() { const response = await fetch(`/api/admin/fines/${fineId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: 'paid' }), + body: JSON.stringify({ status: STATUS_FILTERS.paid }), }); if (!response.ok) { @@ -143,7 +163,7 @@ export default function AdminFinesPage() { const response = await fetch(`/api/admin/fines/${fineId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: 'waived' }), + body: JSON.stringify({ status: STATUS_FILTERS.waived }), }); if (!response.ok) { @@ -173,10 +193,13 @@ export default function AdminFinesPage() { // Search filter if (searchQuery) { const query = searchQuery.toLowerCase(); + const memberName = fine.memberName?.toLowerCase() || ''; + const discordUsername = fine.memberDiscordUsername?.toLowerCase() || ''; + const memberPart = fine.memberPart?.toLowerCase() || ''; return ( - fine.memberName.toLowerCase().includes(query) || - fine.memberDiscordUsername.toLowerCase().includes(query) || - fine.memberPart.toLowerCase().includes(query) + memberName.includes(query) || + discordUsername.includes(query) || + memberPart.includes(query) ); } return true; @@ -209,8 +232,8 @@ export default function AdminFinesPage() { setStatusFilter('unpaid')} + className={`cursor-pointer transition-colors ${statusFilter === STATUS_FILTERS.pending ? 'border-primary' : ''}`} + onClick={() => setStatusFilter(STATUS_FILTERS.pending)} > 미납 @@ -224,8 +247,8 @@ export default function AdminFinesPage() { setStatusFilter('paid')} + className={`cursor-pointer transition-colors ${statusFilter === STATUS_FILTERS.paid ? 'border-primary' : ''}`} + onClick={() => setStatusFilter(STATUS_FILTERS.paid)} > 납부 @@ -239,8 +262,8 @@ export default function AdminFinesPage() { setStatusFilter('waived')} + className={`cursor-pointer transition-colors ${statusFilter === STATUS_FILTERS.waived ? 'border-primary' : ''}`} + onClick={() => setStatusFilter(STATUS_FILTERS.waived)} > 면제 @@ -325,8 +348,8 @@ export default function AdminFinesPage() {
-

{fine.memberName}

-

{fine.memberPart}

+

{getMemberDisplayName(fine)}

+

{getMemberPartLabel(fine)}

- {fine.roundNumber}회차 + {getRoundLabel(fine.roundNumber)} · {typeConfig[fine.type]?.label || fine.type} @@ -348,7 +371,7 @@ export default function AdminFinesPage() { · {new Date(fine.createdAt).toLocaleDateString('ko-KR')}
- {fine.status === 'unpaid' && ( + {fine.status === STATUS_FILTERS.pending && (
+ +
+ + + +
-
-
- + + + 큐레이션 필터 + + 카테고리와 태그를 선택해서 원하는 콘텐츠만 볼 수 있어요. + + + +
+
+

카테고리

+
+ {FILTERS.map(({ value, label, emoji }) => ( + + ))} +
+
+ +
+
+

태그

+ {selectedTagCount > 0 && ( + + )} +
+ +
-
-
+ + + + + + + +
{/* ── Content area ── */} -
+
{showInitialSkeletons ? ( <>

콘텐츠를 불러오는 중입니다.

diff --git a/packages/web/src/app/(user)/dashboard/page.tsx b/packages/web/src/app/(user)/dashboard/page.tsx index 27f5015..78d49c4 100644 --- a/packages/web/src/app/(user)/dashboard/page.tsx +++ b/packages/web/src/app/(user)/dashboard/page.tsx @@ -111,9 +111,12 @@ export default function DashboardPage() {

기간

-

+

{data.currentRound.startDate} ~ {data.currentRound.endDate}

+

+ {data.currentRound.startDate}
~ {data.currentRound.endDate} +

마감까지

diff --git a/packages/web/src/app/(user)/members/[id]/page.tsx b/packages/web/src/app/(user)/members/[id]/page.tsx index 6db7d81..2e87504 100644 --- a/packages/web/src/app/(user)/members/[id]/page.tsx +++ b/packages/web/src/app/(user)/members/[id]/page.tsx @@ -62,7 +62,7 @@ export default function MemberProfilePage() { return; } const result = await response.json(); - setData(result); + setData(result.data); } catch (err) { setError('프로필 정보를 불러오는데 실패했습니다.'); console.error(err); diff --git a/packages/web/src/app/(user)/members/page.tsx b/packages/web/src/app/(user)/members/page.tsx index 35a2a90..7083258 100644 --- a/packages/web/src/app/(user)/members/page.tsx +++ b/packages/web/src/app/(user)/members/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { UsersRound, ExternalLink, Github, Linkedin, Instagram } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; @@ -35,6 +35,7 @@ interface MembersData { } export default function MembersPage() { + const router = useRouter(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -48,7 +49,7 @@ export default function MembersPage() { throw new Error('Failed to fetch members'); } const result = await response.json(); - setData(result); + setData(result.data); } catch (err) { setError('스터디원 목록을 불러오는데 실패했습니다.'); console.error(err); @@ -132,71 +133,70 @@ export default function MembersPage() { ].filter((link) => link.url); return ( - - - - {/* Top: Avatar + Name + Part */} -
- - - - {member.nickname.slice(0, 2).toUpperCase()} - - -
-

- {member.nickname} + router.push(`/members/${member.id}`)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + router.push(`/members/${member.id}`); + } + }} + className="h-full cursor-pointer border-border/60 shadow-none transition-colors hover:border-border hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2" + > + + {/* Top: Avatar + Name + Part */} +

+ + + + {member.nickname.slice(0, 2).toUpperCase()} + + +
+

{member.nickname}

+
+

+ @{member.discordUsername.replace(/#0$/, '')}

-
-

- @{member.discordUsername.replace(/#0$/, '')} -

- -
+
+
- {/* Bio */} - {member.bio && ( -

- {member.bio} -

- )} + {/* Bio */} + {member.bio && ( +

+ {member.bio} +

+ )} - {/* Social link chips */} - {socialLinks.length > 0 && ( -
- {socialLinks.map((link) => { - const Icon = link.icon; - return ( - { - e.preventDefault(); - e.stopPropagation(); - window.open(link.url!, '_blank', 'noopener,noreferrer'); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - window.open(link.url!, '_blank', 'noopener,noreferrer'); - } - }} - > - - {link.label} - - ); - })} -
- )} - - - + {/* Social link chips */} + {socialLinks.length > 0 && ( + + )} + + ); })}
diff --git a/packages/web/src/app/(user)/ranking/page.tsx b/packages/web/src/app/(user)/ranking/page.tsx index 21c21c8..a75944e 100644 --- a/packages/web/src/app/(user)/ranking/page.tsx +++ b/packages/web/src/app/(user)/ranking/page.tsx @@ -35,7 +35,7 @@ type AttendanceStatus = 'submitted' | 'late' | 'absent' | 'pending'; interface AttendanceRecord { roundNumber: number; - status: AttendanceStatus; + status: string; } interface DiscordScore { @@ -122,6 +122,19 @@ const ATTENDANCE_DOT_CONFIG: Record {history.map((record) => { - const config = ATTENDANCE_DOT_CONFIG[record.status]; + const config = getAttendanceDotConfig(record.status); return ( -
{children}
+
+ {children} +
); } @@ -81,14 +86,14 @@ export function MainLayout({ }; return ( -
+
본문으로 바로가기
{!isAdmin && } -
+
{showSidebar && ( void; +} + +export function NoticeBanner({ onHeightChange }: NoticeBannerProps) { const [notice, setNotice] = useState(null); const [bannerState, setBannerState] = useState('open'); const [loaded, setLoaded] = useState(false); + const bannerRef = useRef(null); useEffect(() => { fetch('/api/notice-banner') @@ -56,6 +61,25 @@ export function NoticeBanner() { .catch(() => setLoaded(true)); }, []); + useEffect(() => { + if (!loaded || !notice || bannerState === 'closed') { + onHeightChange?.(0); + return; + } + + const element = bannerRef.current; + if (!element) return; + + const updateHeight = () => onHeightChange?.(element.offsetHeight); + + updateHeight(); + + const observer = new ResizeObserver(updateHeight); + observer.observe(element); + + return () => observer.disconnect(); + }, [bannerState, loaded, notice, onHeightChange]); + if (!loaded || !notice || bannerState === 'closed') return null; const handleToggle = () => { @@ -78,6 +102,7 @@ export function NoticeBanner() { return (
{/* Content wrapper that translates down */} -
+
{children}
diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index 14906f1..02d23a2 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -129,9 +129,6 @@ function SidebarContent({ }: SidebarContentProps) { return (
- {/* Spacer to match header height */} -
- {/* ── Primary navigation ───────────────────────────────────────── */}