diff --git a/frontend/.claude/commands/commit.md b/frontend/.claude/commands/commit.md new file mode 100644 index 000000000..3e883cf48 --- /dev/null +++ b/frontend/.claude/commands/commit.md @@ -0,0 +1,152 @@ +--- +description: 세션 작업 기록 + 기능 문서화 + 변경 내용 커밋 +allowed-tools: Bash(mkdir *), Bash(ls *), Bash(date *), Bash(git status), Bash(git diff *), Bash(git log *), Bash(git add *), Bash(git commit *), Read, Write, Edit, Glob, Grep +--- + +# 작업 지시 + +현재 세션의 작업 내용을 기록하고, 기능별 문서를 자동 생성한 뒤, 변경 파일을 커밋합니다. + +--- + +## Phase 1: 세션 기록 + +먼저 세션 작업 내용을 dailyNote에 기록합니다. + +1. `dailyNote/` 폴더가 없으면 생성 +2. 오늘 날짜 파일 확인 (형식: `YYYY-MM-DD.md`) +3. 파일이 없으면 새로 생성, 있으면 기존 내용 뒤에 `---` 구분선 추가 후 이어서 작성 + +**기록할 내용:** + +- 세션에서 논의한 내용 +- 고민한 흔적과 의사결정 과정 (Decision Log) +- 트러블슈팅 경험 +- 새로 배운 기술이나 발견한 것들 + +**기록 형식 예시:** + +```markdown +## [14:30] 세션 요약 + +### 논의 내용 + +- React Query의 stale time 설정에 대해 고민 + +### Decision Log + +- useEffect 의존성 배열에서 함수 참조 문제 해결을 위해 useCallback 적용 결정 + +### 트러블슈팅 + +- MSW 핸들러에서 응답 지연 시 스토리북 렌더링 이슈 발견 → delay 시간 조정으로 해결 +``` + +--- + +## Phase 2: 기능 문서화 (선택적) + +세션에서 새로운 기능을 구현하거나 중요한 변경이 있었다면 문서화합니다. + +### 기능 매핑 테이블 + +| 기능 | 문서 경로 | 키워드 | +| ------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------- | +| Main Page | `docs/features/main/` | MainPage, ClubCard, Filter, Banner, CategoryButton, SearchBox, Popup | +| Club Detail | `docs/features/club-detail/` | ClubDetailPage, ClubFeed, ClubProfileCard, ShareButton, ApplyButton, ClubScheduleCalendar | +| Admin - Info | `docs/features/admin/info/` | ClubInfoEditTab, MakeTags, SelectTags | +| Admin - Intro | `docs/features/admin/intro/` | ClubIntroEditTab, AwardEditor, FAQEditor | +| Admin - Photo | `docs/features/admin/photo/` | PhotoEditTab, ImagePreview, ClubLogoEditor, ClubCoverEditor | +| Admin - Recruit | `docs/features/admin/recruit/` | RecruitEditTab, DateTimeRangePicker, MarkdownEditor | +| Admin - Application | `docs/features/admin/application/` | ApplicationEditTab, QuestionBuilder, ApplicationListTab | +| Admin - Applicants | `docs/features/admin/applicants/` | ApplicantsTab, ApplicantDetailPage, ApplicantsListTab | +| Admin - Calendar | `docs/features/admin/calendar/` | CalendarSyncTab, calendarOAuth | +| Admin - Account | `docs/features/admin/account/` | AccountEditTab, LoginTab, PrivateRoute | +| Application Form | `docs/features/application-form/` | ApplicationFormPage, QuestionAnswerer, QuestionContainer | +| Auth | `docs/features/auth/` | auth, secureFetch, refreshAccessToken, JWT | +| API Layer | `docs/features/api/` | apis, apiHelpers, handleResponse, withErrorHandling | +| Hooks | `docs/features/hooks/` | useClub, useApplication, useApplicants, React Query | +| Store | `docs/features/store/` | Zustand, useCategoryStore, useSearchStore | +| Components | `docs/features/components/` | 공용 컴포넌트 | +| Utils | `docs/features/utils/` | debounce, validateSocialLink, formatRelativeDateTime, recruitmentDateParser | +| Experiments | `docs/features/experiments/` | A/B test, useExperiment, Mixpanel | +| Festival | `docs/features/festival/` | FestivalPage, PerformanceCard, TimelineRow, BoothMapSection | +| Introduce | `docs/features/introduce/` | IntroducePage, FeatureSection | + +어려우면 사용자에게 물어봅니다. 매핑에 없는 기능이면 새 폴더를 만듭니다. + +**문서 형식:** + +```markdown +# [제목 — 세션 핵심 주제] + +[본문 — 세션에서 논의/작업한 내용을 정리] + +## 관련 코드 + +- `[세션에서 다룬 파일 경로]` — [간단한 설명] +``` + +**원칙:** + +- 억지로 내용을 늘리지 않는다 +- 대화체 → 문서체로 변환 +- 제목, 본문: 한국어 / 코드, 경로, 기술 용어: 영어 + +**결과 출력:** + +```text +## 문서화 완료 +- **경로**: `docs/features/[기능]/[파일명]` +- **내용**: [1줄 요약] +``` + +--- + +## Phase 3: Git Commit + +기록이 완료되면 커밋을 수행합니다. + +1. `git status`로 변경된 파일 확인 +2. `git diff HEAD`로 모든 변경사항 확인 (또는 `git diff`와 `git diff --staged`를 각각 실행) +3. `git log --oneline -5`로 최근 커밋 스타일 참고 +4. 변경 내용을 분석하여 커밋 메시지 작성 +5. 관련 파일만 `git add`로 스테이징 + - `docs/features/` 문서 파일 포함 + - `dailyNote/`는 gitignore 대상이므로 제외 +6. **커밋 전에 변경 내용과 커밋 메시지를 사용자에게 확인 요청** +7. 사용자 승인 후 커밋 실행 + +**커밋 메시지 형식:** + +```text +(): + + +``` + +**타입:** + +- `feat`: 새로운 기능 +- `fix`: 버그 수정 +- `docs`: 문서 변경 +- `style`: 코드 포맷팅 +- `refactor`: 리팩토링 +- `test`: 테스트 추가/수정 +- `chore`: 기타 변경 + +**스코프 예시:** + +- `main`, `club-detail`, `admin`, `application`, `auth`, `api`, `hooks`, `store`, `utils`, `components` + +**주의사항:** + +- Co-Authored-By 라인을 추가하지 않는다 + +--- + +## 참고사항 + +- dailyNote는 `.gitignore`에 포함되어 커밋 대상이 아님 +- 문서화는 중요한 변경이 있을 때만 수행 (매 세션 필수 아님) +- 사소한 변경은 Phase 1만 수행하고 커밋하지 않아도 됨 diff --git a/frontend/.claude/commands/test.md b/frontend/.claude/commands/test.md new file mode 100644 index 000000000..1f437ff25 --- /dev/null +++ b/frontend/.claude/commands/test.md @@ -0,0 +1,220 @@ +--- +description: 테스트 코드 작성 (Jest / RTL / Playwright) +allowed-tools: Bash(npm run test *), Bash(npx jest *), Bash(npx playwright *), Read, Write, Edit, Glob, Grep +--- + +# 테스트 코드 작성 + +대상 파일 또는 기능에 대한 테스트 코드를 작성합니다. + +--- + +## Step 1: 테스트 유형 확인 + +사용자에게 테스트 유형을 확인합니다: + +1. **단위 테스트 (Jest)** - 유틸리티 함수, 훅, 순수 로직 +2. **컴포넌트 테스트 (RTL)** - React 컴포넌트 렌더링 및 인터랙션 +3. **E2E 테스트 (Playwright)** - 사용자 시나리오 기반 통합 테스트 + +--- + +## Step 2: 대상 파일 분석 + +테스트 대상 파일을 읽고 분석합니다: + +- 함수/컴포넌트의 입력과 출력 +- 엣지 케이스 및 예외 상황 +- 의존성 (외부 API, 스토어, 라우터 등) + +--- + +## Step 3: 테스트 작성 + +### 단위 테스트 (Jest) + +**파일 위치**: 대상 파일과 동일 경로에 `*.test.ts` 생성 + +**패턴**: + +```typescript +import { targetFunction } from './targetFile'; + +describe('targetFunction', () => { + beforeEach(() => { + // 테스트 환경 설정 + }); + + afterEach(() => { + // 정리 + }); + + it('정상 케이스를 처리한다', () => { + const result = targetFunction(input); + expect(result).toBe(expected); + }); + + it('엣지 케이스를 처리한다', () => { + // ... + }); + + it('예외 상황에서 적절히 동작한다', () => { + // ... + }); +}); +``` + +**체크리스트**: + +- [ ] 정상 동작 케이스 +- [ ] 경계값 테스트 +- [ ] 예외/에러 케이스 +- [ ] 타이머 사용 시 `jest.useFakeTimers()` +- [ ] 비동기 함수는 `async/await` 사용 + +--- + +### 컴포넌트 테스트 (RTL) + +**파일 위치**: 컴포넌트와 동일 경로에 `*.test.tsx` 생성 + +**패턴**: + +```typescript +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import TargetComponent from './TargetComponent'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, +}); + +const renderWithProviders = (ui: React.ReactElement) => { + return render( + + {ui} + + ); +}; + +describe('TargetComponent', () => { + beforeEach(() => { + queryClient.clear(); + }); + + it('초기 상태가 올바르게 렌더링된다', () => { + renderWithProviders(); + expect(screen.getByText('예상 텍스트')).toBeInTheDocument(); + }); + + it('사용자 인터랙션에 올바르게 반응한다', async () => { + renderWithProviders(); + + fireEvent.click(screen.getByRole('button', { name: '버튼명' })); + + await waitFor(() => { + expect(screen.getByText('변경된 텍스트')).toBeInTheDocument(); + }); + }); + + it('로딩 상태를 표시한다', () => { + // ... + }); + + it('에러 상태를 처리한다', () => { + // ... + }); +}); +``` + +**체크리스트**: + +- [ ] 초기 렌더링 상태 +- [ ] 사용자 인터랙션 (클릭, 입력 등) +- [ ] 로딩/에러 상태 +- [ ] 조건부 렌더링 +- [ ] Props 변경에 따른 업데이트 +- [ ] 필요시 MSW로 API 모킹 + +--- + +### E2E 테스트 (Playwright) + +**파일 위치**: `e2e/` 폴더에 `*.spec.ts` 생성 + +**패턴**: + +```typescript +import { expect, test } from '@playwright/test'; + +test.describe('기능명', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/target-page'); + }); + + test('사용자 시나리오를 완료한다', async ({ page }) => { + // 1. 페이지 로드 확인 + await expect( + page.getByRole('heading', { name: '페이지 제목' }), + ).toBeVisible(); + + // 2. 사용자 액션 + await page.getByRole('button', { name: '버튼명' }).click(); + + // 3. 결과 확인 + await expect(page.getByText('성공 메시지')).toBeVisible(); + }); + + test('에러 케이스를 처리한다', async ({ page }) => { + // ... + }); +}); +``` + +**체크리스트**: + +- [ ] 핵심 사용자 플로우 +- [ ] 폼 제출 및 유효성 검사 +- [ ] 네비게이션 +- [ ] 반응형 (모바일/데스크톱) +- [ ] 에러 처리 및 복구 + +--- + +## Step 4: 테스트 실행 + +```bash +# 단위/컴포넌트 테스트 +npx jest path/to/file.test.ts + +# 전체 테스트 +npm run test + +# E2E 테스트 +npx playwright test e2e/file.spec.ts + +# E2E 테스트 (UI 모드) +npx playwright test --ui +``` + +--- + +## 작성 원칙 + +1. **테스트 설명은 한국어로** - `it('정상적으로 동작한다')` +2. **Given-When-Then 구조** - 준비 → 실행 → 검증 +3. **독립적인 테스트** - 테스트 간 의존성 없음 +4. **의미 있는 테스트명** - 무엇을 테스트하는지 명확히 +5. **과도한 모킹 지양** - 실제 동작에 가깝게 + +--- + +## 참고: 프로젝트 테스트 설정 + +- Jest 설정: `jest.config.js` +- RTL 설정: `@testing-library/react`, `@testing-library/jest-dom` +- MSW 핸들러: `src/mocks/handlers/` +- Playwright 설정: `playwright.config.ts` (없으면 생성 필요) diff --git a/frontend/.claude/commands/tm/auto-implement-tasks.md b/frontend/.claude/commands/tm/auto-implement-tasks.md deleted file mode 100644 index 42d99226f..000000000 --- a/frontend/.claude/commands/tm/auto-implement-tasks.md +++ /dev/null @@ -1 +0,0 @@ -- Assess test coverage needs diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 19e1b19d1..9fb87dbd1 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -95,7 +95,6 @@ npm run generate:sitemap # sitemap.xml 생성 API는 `src/apis/utils/apiHelpers.ts`의 헬퍼 함수를 사용하는 일관된 패턴을 따름: - `handleResponse()` - 응답 파싱, `{ data: {...} }` 형식 자동 언래핑 -- `withErrorHandling()` - API 호출을 에러 로깅으로 래핑 - `secureFetch()` - 인증된 요청, 403 시 토큰 자동 갱신 쿼리 키는 `src/constants/queryKeys.ts`에 중앙 관리. diff --git a/frontend/codecov.yml b/frontend/codecov.yml index dadfe510b..46b886c40 100644 --- a/frontend/codecov.yml +++ b/frontend/codecov.yml @@ -6,7 +6,7 @@ coverage: status: project: default: - enabled: false # 전체 프로젝트 레벨의 status 체크 비활성화 + enabled: false # 전체 프로젝트 레벨의 status 체크 비활성화 patch: default: - enabled: false # PR(patch) 레벨의 status 체크 비활성화 + enabled: false # PR(patch) 레벨의 status 체크 비활성화 diff --git a/frontend/docs/features/admin/calendar/google-calendar-sync.md b/frontend/docs/features/admin/calendar/google-calendar-sync.md new file mode 100644 index 000000000..5d984abaf --- /dev/null +++ b/frontend/docs/features/admin/calendar/google-calendar-sync.md @@ -0,0 +1,29 @@ +# Google 캘린더 연동 + +Google Calendar API를 백엔드를 통해 연동하여 동아리 일정을 동기화하는 기능. + +## 연동 흐름 + +1. 사용자가 "Google 캘린더 연동하기" 버튼 클릭 +2. `startGoogleOAuth()` → state 생성 후 sessionStorage에 저장, 백엔드 OAuth URL로 리다이렉트 +3. Google 인증 완료 후 `/callback/google` 페이지로 콜백 +4. 콜백 페이지에서 코드 교환 후 성공/에러 플래그를 sessionStorage에 저장 +5. `/admin/calendar-sync`로 리다이렉트 +6. Admin 훅(`useGoogleCalendarData`)이 sessionStorage 플래그 확인 +7. 성공 시 캘린더 목록 조회 후 `isGoogleConnected = true` 설정 +8. 동기화할 캘린더 선택 (`selectGoogleCalendar`) + +## UI 상태 + +| 상태 | 표시 내용 | +| ------ | --------------------------------------------------- | +| 미연결 | 안내 메시지 + 연동 버튼 | +| 연결됨 | 성공 메시지 + 캘린더 선택 드롭다운 + 연결 해제 버튼 | + +## 관련 코드 + +- `src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.tsx` — UI 컴포넌트 +- `src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useCalendarSync.ts` — 상태 관리 훅 +- `src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts` — Google 캘린더 데이터 훅 +- `src/pages/CallbackPage/GoogleCallbackPage.tsx` — OAuth 콜백 페이지 +- `src/apis/calendarOAuth.ts` — 백엔드 API 함수 diff --git a/frontend/docs/features/admin/calendar/unified-calendar.md b/frontend/docs/features/admin/calendar/unified-calendar.md new file mode 100644 index 000000000..0f59143b2 --- /dev/null +++ b/frontend/docs/features/admin/calendar/unified-calendar.md @@ -0,0 +1,105 @@ +# 통합 캘린더 — Google과 Notion 이벤트를 하나의 UI에 표시 + +Google Calendar와 Notion 캘린더 페이지를 하나의 통합 캘린더 UI에 동시에 표시하는 기능입니다. 각 소스의 이벤트를 색상으로 구분하고, 독립적으로 표시 여부를 제어할 수 있습니다. + +## 주요 기능 + +- Google Calendar 이벤트와 Notion 페이지를 하나의 캘린더에 통합 표시 +- 출처별 색상 구분 (파란색=Google, 보라색=Notion) +- 각 소스별 독립적인 표시 토글 기능 +- 날짜별 자동 정렬 및 그룹화 + +## 아키텍처 + +### 데이터 흐름 + +```text +Google API → fetchGoogleCalendarEvents() → GoogleCalendarEvent[] + ↓ + convertGoogleEventToUnified() + ↓ + UnifiedCalendarEvent[] ← + ↑ + convertNotionEventToUnified() + ↑ +Notion API → fetchNotionPages() → NotionSearchItem[] → NotionCalendarEvent[] +``` + +### 타입 정의 + +**UnifiedCalendarEvent** (`src/utils/calendarSyncUtils.ts`) + +```typescript +interface UnifiedCalendarEvent { + id: string; // "google-{id}" 또는 "notion-{id}" 형식 + title: string; + start: string; // ISO 8601 형식 + dateKey: string; // YYYY-MM-DD 형식 + end?: string; + url?: string; + source: 'GOOGLE' | 'NOTION'; + description?: string; +} +``` + +### Hooks 계층 + +``` +useCalendarSync (통합) + ├── useGoogleCalendarData (Google 데이터) + │ └── googleCalendarEvents: GoogleCalendarEvent[] + ├── useNotionCalendarData (Notion 데이터) + │ └── notionItems: NotionSearchItem[] + ├── useNotionCalendarUiState (Notion 전용 UI) + │ └── notionCalendarEvents: NotionCalendarEvent[] + └── useUnifiedCalendarUiState (통합 UI) ★ + ├── allUnifiedEvents: UnifiedCalendarEvent[] + ├── visibleUnifiedEvents: UnifiedCalendarEvent[] + └── eventsByDate: Record +``` + +## 관련 코드 + +- `src/types/google.ts` — GoogleCalendarEvent 타입 정의 +- `src/utils/calendarSyncUtils.ts` — UnifiedCalendarEvent 타입 및 변환 함수 +- `src/apis/calendarOAuth.ts` — fetchGoogleCalendarEvents API 함수 +- `src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts` — 구글 캘린더 이벤트 자동 로드 +- `src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useUnifiedCalendarUiState.ts` — 통합 캘린더 UI 상태 관리 +- `src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useCalendarSync.ts` — 통합 데이터 제공 +- `src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.tsx` — 통합 캘린더 UI 렌더링 +- `src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts` — 출처별 색상 스타일 + +## 구현 세부사항 + +### ID 충돌 방지 + +Google과 Notion에서 같은 ID가 올 수 있으므로, prefix를 추가하여 구분합니다: + +- Google 이벤트: `google-{originalId}` +- Notion 이벤트: `notion-{originalId}` + +### 날짜 파싱 + +`parseDateKey()` 함수로 다양한 날짜 형식을 `YYYY-MM-DD` 키로 정규화: + +- 순수 날짜 (`YYYY-MM-DD`): 그대로 반환 +- ISO 8601 datetime: UTC 기준으로 파싱하여 날짜 추출 + +### 자동 이벤트 로드 + +선택된 Google 캘린더가 변경되면 자동으로 이벤트를 로드합니다: + +```typescript +useEffect(() => { + if (selectedCalendarId && isGoogleConnected) { + loadGoogleCalendarEvents(selectedCalendarId); + } +}, [selectedCalendarId, isGoogleConnected]); +``` + +### 출처별 색상 + +styled-components의 `$source` prop으로 출처에 따라 다른 배경색 적용: + +- `GOOGLE`: `#dbeafe` (파란색) +- `NOTION`: `#f3e8ff` (보라색) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 32a6e1aaa..1448b3880 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -6,7 +6,6 @@ import storybook from 'eslint-plugin-storybook'; const config = [ { - files: ['src/**/*.{js,ts,jsx,tsx}'], ignores: [ 'dist/**', 'node_modules/**', @@ -16,6 +15,9 @@ const config = [ 'jest.setup.ts', 'netlify.toml', ], + }, + { + files: ['src/**/*.{js,ts,jsx,tsx}'], languageOptions: { parser, parserOptions: { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d31efb526..4f21bd9e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import MainPage from '@/pages/MainPage/MainPage'; import GlobalStyles from '@/styles/Global.styles'; import { theme } from '@/styles/theme'; import ApplicationFormPage from './pages/ApplicationFormPage/ApplicationFormPage'; +import GoogleCallbackPage from './pages/CallbackPage/GoogleCallbackPage'; import ClubUnionPage from './pages/ClubUnionPage/ClubUnionPage'; import IntroducePage from './pages/IntroducePage/IntroducePage'; import 'swiper/css'; @@ -22,6 +23,8 @@ import { import LegacyClubDetailPage from './pages/ClubDetailPage/LegacyClubDetailPage'; import ErrorTestPage from './pages/ErrorTestPage/ErrorTestPage'; import IntroductionPage from './pages/FestivalPage/IntroductionPage/IntroductionPage'; +import PromotionDetailPage from './pages/PromotionPage/PromotionDetailPage'; +import PromotionListPage from './pages/PromotionPage/PromotionListPage'; const queryClient = new QueryClient({ defaultOptions: { @@ -108,6 +111,10 @@ const App = () => { } /> + } + /> } /> { } /> + + + + } + /> + + + + } + /> {/* 개발 환경에서만 사용 가능한 에러 테스트 페이지 */} {import.meta.env.DEV && ( } /> diff --git a/frontend/src/apis/calendarOAuth.ts b/frontend/src/apis/calendarOAuth.ts new file mode 100644 index 000000000..da63db309 --- /dev/null +++ b/frontend/src/apis/calendarOAuth.ts @@ -0,0 +1,384 @@ +import API_BASE_URL from '@/constants/api'; +import type { + GoogleCalendarEvent, + GoogleCalendarItem, + GoogleCalendarListResponse, + GoogleEventItem, +} from '@/types/google'; +import type { + NotionDatabaseOption, + NotionPagesResponse, + NotionSearchItem, +} from '@/types/notion'; +import { secureFetch } from './auth/secureFetch'; +import { handleResponse } from './utils/apiHelpers'; + +export type { GoogleCalendarItem, GoogleEventItem, GoogleCalendarEvent }; +export type { NotionSearchItem, NotionDatabaseOption, NotionPagesResponse }; + +interface GoogleAuthorizeResponse { + authorizeUrl: string; +} + +interface GoogleTokenResponse { + email: string; +} + +export const fetchGoogleCalendarList = async (accessToken: string) => { + const response = await fetch( + 'https://www.googleapis.com/calendar/v3/users/me/calendarList', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw new Error('Google 캘린더 목록 조회에 실패했습니다.'); + } + + const data = await response.json(); + return (data.items ?? []) as GoogleCalendarItem[]; +}; + +export const fetchGooglePrimaryEvents = async (accessToken: string) => { + const query = new URLSearchParams({ + maxResults: '10', + singleEvents: 'true', + orderBy: 'startTime', + timeMin: new Date().toISOString(), + }); + + const response = await fetch( + `https://www.googleapis.com/calendar/v3/calendars/primary/events?${query.toString()}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw new Error('Google 캘린더 이벤트 조회에 실패했습니다.'); + } + + const data = await response.json(); + return (data.items ?? []) as GoogleEventItem[]; +}; + +interface NotionTokenRequest { + code: string; +} + +interface NotionTokenResponse { + accessToken?: string; + workspaceName?: string; + workspaceId?: string; +} + +interface NotionAuthorizeResponse { + authorizeUrl: string; +} + +interface NotionPagesPayload { + items?: NotionSearchItem[]; + results?: NotionSearchItem[]; + total_results?: number; + totalResults?: number; + database_id?: string; + databaseId?: string; +} + +interface NotionDatabasePayload { + id: string; + object?: string; + title?: Array<{ plain_text?: string }>; +} + +export const fetchNotionAuthorizeUrl = async (state?: string) => { + const params = new URLSearchParams(); + if (state) { + params.set('state', state); + } + + const url = `${API_BASE_URL}/api/integration/notion/oauth/authorize${params.toString() ? `?${params.toString()}` : ''}`; + const response = await secureFetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + const data = await handleResponse( + response, + 'Notion 인가 URL 생성에 실패했습니다.', + ); + + if (!data?.authorizeUrl) { + throw new Error('Notion 인가 URL이 비어있습니다.'); + } + return data.authorizeUrl; +}; + +export const exchangeNotionCode = async ({ code }: NotionTokenRequest) => { + const response = await secureFetch( + `${API_BASE_URL}/api/integration/notion/oauth/token`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + code, + }), + }, + ); + + const data = await handleResponse( + response, + 'Notion 토큰 교환에 실패했습니다.', + ); + return data; +}; + +export const fetchNotionPages = async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/integration/notion/pages`, + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + ); + + const data = await handleResponse( + response, + 'Notion 데이터 조회에 실패했습니다.', + ); + if (Array.isArray(data)) { + return { + items: data, + totalResults: data.length, + } satisfies NotionPagesResponse; + } + + const items = (data?.items ?? data?.results ?? []) as NotionSearchItem[]; + const totalResults = + data?.total_results ?? data?.totalResults ?? items.length; + const databaseId = data?.database_id ?? data?.databaseId; + return { + items, + totalResults, + databaseId, + } satisfies NotionPagesResponse; +}; + +export const fetchNotionDatabasePages = async ({ + databaseId, + dateProperty, +}: { + databaseId: string; + dateProperty?: string; +}) => { + const params = new URLSearchParams(); + if (dateProperty) { + params.set('dateProperty', dateProperty); + } + + const query = params.toString(); + const response = await secureFetch( + `${API_BASE_URL}/api/integration/notion/databases/${databaseId}/pages${query ? `?${query}` : ''}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + ); + + const data = await handleResponse( + response, + 'Notion 데이터베이스 페이지 조회에 실패했습니다.', + ); + + if (Array.isArray(data)) { + return { + items: data, + totalResults: data.length, + databaseId, + } satisfies NotionPagesResponse; + } + + const items = (data?.items ?? data?.results ?? []) as NotionSearchItem[]; + const totalResults = + data?.total_results ?? data?.totalResults ?? items.length; + const resolvedDatabaseId = + data?.database_id ?? data?.databaseId ?? databaseId; + return { + items, + totalResults, + databaseId: resolvedDatabaseId, + } satisfies NotionPagesResponse; +}; + +export const fetchNotionDatabases = async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/integration/notion/databases`, + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + ); + + const data = await handleResponse< + { results?: NotionDatabasePayload[] } | NotionDatabasePayload[] + >(response, 'Notion 데이터베이스 목록 조회에 실패했습니다.'); + + const databases = Array.isArray(data) ? data : (data?.results ?? []); + return databases.map((database) => ({ + id: database.id, + title: + database.title + ?.map((segment) => segment.plain_text ?? '') + .join('') + .trim() || '(이름 없는 데이터베이스)', + })) as NotionDatabaseOption[]; +}; + +export const fetchGoogleAuthorizeUrl = async (state?: string) => { + const params = new URLSearchParams(); + if (state) { + params.set('state', state); + } + + const url = `${API_BASE_URL}/api/integration/google/oauth/authorize${params.toString() ? `?${params.toString()}` : ''}`; + const response = await secureFetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + const data = await handleResponse( + response, + 'Google 인가 URL 생성에 실패했습니다.', + ); + + if (!data?.authorizeUrl) { + throw new Error('Google 인가 URL이 비어있습니다.'); + } + return data.authorizeUrl; +}; + +export const exchangeGoogleCode = async (code: string) => { + const response = await secureFetch( + `${API_BASE_URL}/api/integration/google/oauth/token`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code }), + }, + ); + + const data = await handleResponse( + response, + 'Google 토큰 교환에 실패했습니다.', + ); + return data; +}; + +export const fetchGoogleCalendars = async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/integration/google/calendars`, + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + ); + + const data = await handleResponse( + response, + 'Google 캘린더 목록 조회에 실패했습니다.', + ); + return { + items: data?.items ?? [], + selectedCalendarId: data?.selectedCalendarId, + selectedCalendarName: data?.selectedCalendarName, + }; +}; + +export const selectGoogleCalendar = async ( + calendarId: string, + calendarName: string, +) => { + const encodedId = encodeURIComponent(calendarId); + const response = await secureFetch( + `${API_BASE_URL}/api/integration/google/calendars/${encodedId}/select`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ calendarId, calendarName }), + }, + ); + + await handleResponse(response, 'Google 캘린더 선택에 실패했습니다.'); +}; + +export const disconnectGoogleCalendar = async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/integration/google/connection`, + { + method: 'DELETE', + headers: { + Accept: 'application/json', + }, + }, + ); + + await handleResponse( + response, + 'Google Calendar 연결 해제에 실패했습니다.', + ); +}; + +export const fetchGoogleCalendarEvents = async ( + calendarId: string, + timeMin?: string, + timeMax?: string, +) => { + const params = new URLSearchParams(); + if (timeMin) { + params.set('timeMin', timeMin); + } + if (timeMax) { + params.set('timeMax', timeMax); + } + + const encodedId = encodeURIComponent(calendarId); + const query = params.toString(); + const response = await secureFetch( + `${API_BASE_URL}/api/integration/google/calendars/${encodedId}/events${query ? `?${query}` : ''}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + ); + + const data = await handleResponse( + response, + 'Google 캘린더 이벤트 조회에 실패했습니다.', + ); + return data ?? []; +}; diff --git a/frontend/src/apis/club.ts b/frontend/src/apis/club.ts index 259b708b9..f4085bcf6 100644 --- a/frontend/src/apis/club.ts +++ b/frontend/src/apis/club.ts @@ -1,5 +1,5 @@ import API_BASE_URL from '@/constants/api'; -import { ClubDescription, ClubDetail } from '@/types/club'; +import { ClubCalendarEvent, ClubDescription, ClubDetail } from '@/types/club'; import { secureFetch } from './auth/secureFetch'; import { handleResponse } from './utils/apiHelpers'; @@ -46,6 +46,23 @@ export const getClubList = async ( }; }; +export const getClubCalendarEvents = async ( + clubId: string, +): Promise => { + const response = await fetch( + `${API_BASE_URL}/api/club/${clubId}/calendar-events`, + ); + const data = await handleResponse<{ + calendarEvents?: ClubCalendarEvent[]; + }>(response, '동아리 일정 정보를 불러오는데 실패했습니다.'); + + if (!Array.isArray(data?.calendarEvents)) { + return []; + } + + return data.calendarEvents; +}; + export const updateClubDescription = async ( updatedData: ClubDescription, ): Promise => { diff --git a/frontend/src/apis/promotion.test.ts b/frontend/src/apis/promotion.test.ts index 9d3700b68..3b7bd1ef6 100644 --- a/frontend/src/apis/promotion.test.ts +++ b/frontend/src/apis/promotion.test.ts @@ -12,7 +12,6 @@ jest.mock('@/constants/api', () => ({ const API_BASE_URL = 'http://localhost:3000'; -// Mock secureFetch jest.mock('./auth/secureFetch', () => ({ secureFetch: jest.fn((url: string, options?: RequestInit) => { const token = localStorage.getItem('accessToken'); @@ -36,6 +35,7 @@ describe('promotion API', () => { it('API 응답을 올바르게 파싱하여 반환한다', async () => { const mockArticles: PromotionArticle[] = [ { + id: '1', clubName: '테스트 클럽 1', clubId: 'club1', title: '테스트 홍보글 1', @@ -46,10 +46,11 @@ describe('promotion API', () => { images: ['image1.jpg'], }, { + id: '2', clubName: '테스트 클럽 2', clubId: 'club2', title: '테스트 홍보글 2', - location: null, + location: '부산', eventStartDate: '2024-02-01', eventEndDate: '2024-02-28', description: '설명 2', @@ -65,6 +66,7 @@ describe('promotion API', () => { const result = await getPromotionArticles(); expect(result).toEqual(mockArticles); + expect(fetchMock).toHaveBeenCalledWith(`${API_BASE_URL}/api/promotion`); }); @@ -124,7 +126,7 @@ describe('promotion API', () => { const result = await createPromotionArticle(mockPayload); expect(result).toEqual(mockResponse); - // 올바른 URL과 메서드로 호출되었는지 확인 + expect(fetchMock).toHaveBeenCalledWith( `${API_BASE_URL}/api/promotion`, expect.objectContaining({ @@ -138,7 +140,7 @@ describe('promotion API', () => { const mockPayload: CreatePromotionArticleRequest = { clubId: 'club1', title: '새로운 홍보글', - location: null, + location: '부산', eventStartDate: '2024-03-01', eventEndDate: '2024-03-31', description: '홍보 내용', diff --git a/frontend/src/apis/promotion.ts b/frontend/src/apis/promotion.ts index 2444255d5..c34200477 100644 --- a/frontend/src/apis/promotion.ts +++ b/frontend/src/apis/promotion.ts @@ -1,4 +1,6 @@ import API_BASE_URL from '@/constants/api'; +import { festivalMock } from '@/mocks/data/festivalMock'; +import { sortPromotions } from '@/pages/PromotionPage/utils/sortPromotions'; import { CreatePromotionArticleRequest, PromotionArticle, @@ -13,11 +15,18 @@ export const getPromotionArticles = async (): Promise => { '홍보게시판 목록을 불러오는데 실패했습니다.', ); - if (!data?.articles) { - return []; + const serverArticle = data?.articles ?? []; + + const isTest = + typeof process !== 'undefined' && process.env.NODE_ENV === 'test'; + + if (isTest) { + return serverArticle; } - return data.articles; + const merged = [...festivalMock, ...serverArticle]; + + return sortPromotions(merged); }; export const createPromotionArticle = async ( @@ -30,5 +39,6 @@ export const createPromotionArticle = async ( }, body: JSON.stringify(payload), }); + return handleResponse(response, '홍보게시판 글 추가에 실패했습니다.'); }; diff --git a/frontend/src/assets/images/icons/category_button/index.ts b/frontend/src/assets/images/icons/category_button/index.ts index b49dafdb6..e159fffe2 100644 --- a/frontend/src/assets/images/icons/category_button/index.ts +++ b/frontend/src/assets/images/icons/category_button/index.ts @@ -12,6 +12,7 @@ import iconStudyActive from '@/assets/images/icons/category_button/category_stud import iconStudy from '@/assets/images/icons/category_button/category_study_button_icon.svg'; import iconVolunteerActive from '@/assets/images/icons/category_button/category_volunteer_button_icon_active.svg'; import iconVolunteer from '@/assets/images/icons/category_button/category_volunteer_button_icon.svg'; +import iconRepresentative from '@/assets/images/icons/club_union_representative_icon.svg'; export const inactiveCategoryIcons: Record = { all: iconAll, @@ -21,6 +22,7 @@ export const inactiveCategoryIcons: Record = { study: iconStudy, sport: iconSport, performance: iconPerformance, + representative: iconRepresentative, }; export const activeCategoryIcons: Record = { diff --git a/frontend/src/assets/images/icons/club_union_representative_icon.svg b/frontend/src/assets/images/icons/club_union_representative_icon.svg new file mode 100644 index 000000000..ac06cec8d --- /dev/null +++ b/frontend/src/assets/images/icons/club_union_representative_icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/assets/images/icons/location_icon.svg b/frontend/src/assets/images/icons/location_icon.svg new file mode 100644 index 000000000..3d9551bfa --- /dev/null +++ b/frontend/src/assets/images/icons/location_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/images/icons/more_arraw_icon.svg b/frontend/src/assets/images/icons/more_arraw_icon.svg new file mode 100644 index 000000000..4fb7b3b2d --- /dev/null +++ b/frontend/src/assets/images/icons/more_arraw_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/icons/time_icon.svg b/frontend/src/assets/images/icons/time_icon.svg new file mode 100644 index 000000000..700612e33 --- /dev/null +++ b/frontend/src/assets/images/icons/time_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/pages/MainPage/components/Filter/Filter.stories.tsx b/frontend/src/components/common/Filter/Filter.stories.tsx similarity index 94% rename from frontend/src/pages/MainPage/components/Filter/Filter.stories.tsx rename to frontend/src/components/common/Filter/Filter.stories.tsx index e924d8bc6..582041b69 100644 --- a/frontend/src/pages/MainPage/components/Filter/Filter.stories.tsx +++ b/frontend/src/components/common/Filter/Filter.stories.tsx @@ -10,8 +10,11 @@ const setViewportWidth = (width: number) => { }; const meta = { - title: 'Pages/MainPage/Components/Filter', + title: 'Components/Common/Filter', component: Filter, + args: { + hasNotification: false, + }, parameters: { layout: 'fullscreen', docs: { @@ -44,7 +47,7 @@ export const PromotionTab: Story = { (Story) => { setViewportWidth(375); return ( - + ); diff --git a/frontend/src/pages/MainPage/components/Filter/Filter.styles.ts b/frontend/src/components/common/Filter/Filter.styles.ts similarity index 100% rename from frontend/src/pages/MainPage/components/Filter/Filter.styles.ts rename to frontend/src/components/common/Filter/Filter.styles.ts diff --git a/frontend/src/pages/MainPage/components/Filter/Filter.tsx b/frontend/src/components/common/Filter/Filter.tsx similarity index 82% rename from frontend/src/pages/MainPage/components/Filter/Filter.tsx rename to frontend/src/components/common/Filter/Filter.tsx index 8a4247b53..1ce54b02f 100644 --- a/frontend/src/pages/MainPage/components/Filter/Filter.tsx +++ b/frontend/src/components/common/Filter/Filter.tsx @@ -5,16 +5,16 @@ import useDevice from '@/hooks/useDevice'; import * as Styled from './Filter.styles'; const FILTER_OPTIONS = [ - { label: '동소한', path: '/festival-introduction' }, { label: '동아리', path: '/' }, + { label: '홍보', path: '/promotions' }, ] as const; -const FESTIVAL_PATH = '/festival-introduction'; interface FilterProps { alwaysVisible?: boolean; + hasNotification: boolean; } -const Filter = ({ alwaysVisible = false }: FilterProps) => { +const Filter = ({ alwaysVisible = false, hasNotification }: FilterProps) => { const { isMobile } = useDevice(); const navigate = useNavigate(); const { pathname } = useLocation(); @@ -25,6 +25,7 @@ const Filter = ({ alwaysVisible = false }: FilterProps) => { trackEvent(USER_EVENT.FILTER_OPTION_CLICKED, { path: path, }); + navigate(path); }; @@ -34,6 +35,9 @@ const Filter = ({ alwaysVisible = false }: FilterProps) => { {FILTER_OPTIONS.map((filter) => ( + handleFilterOptionClick(filter.path)} diff --git a/frontend/src/components/common/Footer/Footer.stories.tsx b/frontend/src/components/common/Footer/Footer.stories.tsx index a884f7b54..337b5d1ed 100644 --- a/frontend/src/components/common/Footer/Footer.stories.tsx +++ b/frontend/src/components/common/Footer/Footer.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import { BrowserRouter } from 'react-router-dom'; import type { Meta, StoryObj } from '@storybook/react'; import Footer from './Footer'; @@ -8,7 +8,13 @@ const meta = { parameters: { layout: 'fullscreen', }, - decorators: [(Story) => ], + decorators: [ + (Story) => ( + + + + ), + ], tags: ['autodocs'], } satisfies Meta; diff --git a/frontend/src/components/common/Footer/Footer.styles.ts b/frontend/src/components/common/Footer/Footer.styles.ts index ec21b26ea..fbcc3e646 100644 --- a/frontend/src/components/common/Footer/Footer.styles.ts +++ b/frontend/src/components/common/Footer/Footer.styles.ts @@ -1,5 +1,6 @@ import styled from 'styled-components'; import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; export const FooterContainer = styled.footer` text-align: left; @@ -11,14 +12,24 @@ export const Divider = styled.hr` border-top: 1px solid #c5c5c5; `; -export const FooterContent = styled.div` +export const LeftSection = styled.div` display: flex; flex-direction: column; + gap: 4px; +`; + +export const FooterContent = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-end; padding: 20px 140px 30px 140px; line-height: 1.25rem; color: #818181; - ${media.mobile} { + ${media.tablet} { + flex-direction: column; + align-items: flex-start; + gap: 10px; font-size: 0.625rem; padding: 20px 20px 30px 20px; } @@ -47,3 +58,31 @@ export const EmailText = styled.p` } } `; + +export const AdminButton = styled.button` + background: ${colors.base.white}; + border: 1px solid ${colors.gray[400]}; + border-radius: 6px; + + padding: 6px 12px; + font-size: 0.75rem; + color: ${colors.gray[700]}; + + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: ${colors.gray[100]}; + border-color: ${colors.gray[500]}; + color: ${colors.gray[800]}; + } + + &:active { + background: ${colors.gray[200]}; + } + + ${media.mobile} { + align-self: flex-start; + margin-top: 6px; + } +`; diff --git a/frontend/src/components/common/Footer/Footer.tsx b/frontend/src/components/common/Footer/Footer.tsx index 4e7b6dee1..8f7894542 100644 --- a/frontend/src/components/common/Footer/Footer.tsx +++ b/frontend/src/components/common/Footer/Footer.tsx @@ -1,25 +1,34 @@ +import useHeaderNavigation from '@/hooks/Header/useHeaderNavigation'; import * as Styled from './Footer.styles'; const Footer = () => { + const { handleAdminClick } = useHeaderNavigation(); + return ( <> - - 개인정보 처리방침 - - - Copyright © moodong. All Rights Reserved - - - e-mail:{' '} - pknu.moadong@gmail.com - + + + 개인정보 처리방침 + + + Copyright © moodong. All Rights Reserved + + + e-mail:{' '} + pknu.moadong@gmail.com + + + + + 동아리 운영 페이지 + diff --git a/frontend/src/components/common/Header/Header.styles.ts b/frontend/src/components/common/Header/Header.styles.ts index 8eaac12ab..cb64ec935 100644 --- a/frontend/src/components/common/Header/Header.styles.ts +++ b/frontend/src/components/common/Header/Header.styles.ts @@ -7,6 +7,8 @@ export const Header = styled.header<{ isScrolled: boolean }>` top: 0; left: 0; right: 0; + display: flex; + justify-content: center; width: 100%; padding: 18px 0; background-color: white; @@ -16,12 +18,16 @@ export const Header = styled.header<{ isScrolled: boolean }>` isScrolled ? '0px 2px 12px rgba(0, 0, 0, 0.04)' : 'none'}; transition: box-shadow 0.2s ease-in-out; + ${media.laptop} { + padding: 18px 20px; + } ${media.tablet} { - height: 56px; + height: 76px; padding: 10px 20px; } ${media.mobile} { + height: 56px; padding: 8px 20px; } `; @@ -32,7 +38,6 @@ export const Container = styled.div` justify-content: space-between; width: 100%; max-width: 1180px; - margin: 0 auto; gap: 50px; ${media.tablet} { diff --git a/frontend/src/components/common/Header/Header.tsx b/frontend/src/components/common/Header/Header.tsx index 6e8125683..3c0ad32ac 100644 --- a/frontend/src/components/common/Header/Header.tsx +++ b/frontend/src/components/common/Header/Header.tsx @@ -29,7 +29,7 @@ const Header = ({ showOn, hideOn }: HeaderProps) => { handleHomeClick, handleIntroduceClick, handleClubUnionClick, - handleAdminClick, + handlePromotionClick, } = useHeaderNavigation(); const isAdminPage = location.pathname.startsWith('/admin'); @@ -71,7 +71,11 @@ const Header = ({ showOn, hideOn }: HeaderProps) => { handler: handleClubUnionClick, path: '/club-union', }, - { label: '관리자 페이지', handler: handleAdminClick, path: '/admin' }, + { + label: '홍보•이벤트', + handler: handlePromotionClick, + path: '/promotions', + }, ]; const closeMenu = () => { diff --git a/frontend/src/constants/clubUnionInfo.ts b/frontend/src/constants/clubUnionInfo.ts index 0ac79afab..563e77263 100644 --- a/frontend/src/constants/clubUnionInfo.ts +++ b/frontend/src/constants/clubUnionInfo.ts @@ -6,14 +6,15 @@ export interface ClubUnionMember { role: string; description: string; imageSrc: string; + type: keyof typeof MEMBER_AVATARS; } const MEMBER_AVATARS = { - PRESIDENT: inactiveCategoryIcons.all, - VICE_PRESIDENT: inactiveCategoryIcons.all, - PLANNING: inactiveCategoryIcons.all, - SECRETARY: inactiveCategoryIcons.all, - PROMOTION: inactiveCategoryIcons.all, + PRESIDENT: inactiveCategoryIcons.representative, + VICE_PRESIDENT: inactiveCategoryIcons.representative, + PLANNING: inactiveCategoryIcons.representative, + SECRETARY: inactiveCategoryIcons.representative, + PROMOTION: inactiveCategoryIcons.representative, RELIGION: inactiveCategoryIcons.religion, HOBBY: inactiveCategoryIcons.hobby, STUDY: inactiveCategoryIcons.study, @@ -28,97 +29,114 @@ export const CLUB_UNION_SNS = { } as const; // 개발자 가이드: description 필드는 UI가 깨지지 않도록 글자 수를 제한합니다. -// (권장) 모바일: 50자 이내, 데스크톱: 100자 이내 +// (권장) 데스크톱: 30자 이내 export const CLUB_UNION_MEMBERS: ClubUnionMember[] = [ { id: 1, name: '이주은', role: '회장', - description: '', + description: '모두가 바라는 미래가 이루어질 수 있도록 노력하겠습니다.', imageSrc: MEMBER_AVATARS.PRESIDENT, + type: 'PRESIDENT', }, { id: 2, name: '이정원', role: '부회장', - description: '', + description: '동아리가 한 해동안 잘 운영될 수 있도록 노력하겠습니다.', imageSrc: MEMBER_AVATARS.VICE_PRESIDENT, + type: 'VICE_PRESIDENT', }, { id: 3, name: '최지현', role: '기획국장', - description: '', + description: + '동아리의 바램이 이루어지도록 질 높은 행사를 만들어가겠습니다.', imageSrc: MEMBER_AVATARS.PLANNING, + type: 'PLANNING', }, { id: 4, name: '김민서', role: '사무국장', - description: '', + description: '작은 바람이 모여 우리가 될 수 있도록, 함께 하겠습니다.', imageSrc: MEMBER_AVATARS.SECRETARY, + type: 'SECRETARY', }, { id: 5, name: '최동희', role: '홍보국장', - description: '', + description: + '모두가 바라는 대로, 즐거운 동아리 활동에 앞장서 노력하겠습니다.', imageSrc: MEMBER_AVATARS.PROMOTION, + type: 'PROMOTION', }, { id: 6, name: '전호진', role: '봉사분과장', - description: '', + description: + '나눔은 나눌수록 배가 됩니다. 작은 나눔을 통해 삶의 따스함을 느낍시다.', imageSrc: MEMBER_AVATARS.VOLUNTEER, + type: 'VOLUNTEER', }, { id: 7, name: '신가윤', role: '종교분과장', - description: '', + description: '여러분의 소원이 현실이 되도록, 열심히 하겠습니다.', imageSrc: MEMBER_AVATARS.RELIGION, + type: 'RELIGION', }, { id: 8, name: '정상윤', role: '취미교양분과장', - description: '', + description: '목소리에 귀 기울이며, 바라는 방향으로 함께 나아가겠습니다.', imageSrc: MEMBER_AVATARS.HOBBY, + type: 'HOBBY', }, { id: 9, name: '김은새', role: '학술분과장', - description: '', + description: '기대에 부응할 수 있도록, 책임감 있게 움직이겠습니다.', imageSrc: MEMBER_AVATARS.STUDY, + type: 'STUDY', }, { id: 10, - name: '권민준', - role: '공연1분과장', - description: '', - imageSrc: MEMBER_AVATARS.PERFORMANCE, + name: '김민제', + role: '운동1분과장', + description: '동아리의 연결과 성장을 이끄는 we:sh가 함께하겠습니다.', + imageSrc: MEMBER_AVATARS.SPORT, + type: 'SPORT', }, { id: 11, - name: '곽현우', - role: '공연2분과장', - description: '', - imageSrc: MEMBER_AVATARS.PERFORMANCE, + name: '이상재', + role: '운동2분과장', + description: '여러분이 바라는 대로, 원하는 대로 열심히 하겠습니다.', + imageSrc: MEMBER_AVATARS.SPORT, + type: 'SPORT', }, { id: 12, - name: '김민제', - role: '운동1분과장', - description: '', - imageSrc: MEMBER_AVATARS.SPORT, + name: '권민준', + role: '공연1분과장', + description: + '많은 학우분들이 공연과 다양한 볼거리를 즐길 수 있도록 노력하겠습니다.', + imageSrc: MEMBER_AVATARS.PERFORMANCE, + type: 'PERFORMANCE', }, { id: 13, - name: '이상재', - role: '운동2분과장', - description: '', - imageSrc: MEMBER_AVATARS.SPORT, + name: '곽현우', + role: '공연2분과장', + description: '여러분들의 즐거운 동아리 생활을 위해 열심히 노력하겠습니다.', + imageSrc: MEMBER_AVATARS.PERFORMANCE, + type: 'PERFORMANCE', }, ]; diff --git a/frontend/src/constants/eventName.ts b/frontend/src/constants/eventName.ts index d14f7b485..6e36f2d37 100644 --- a/frontend/src/constants/eventName.ts +++ b/frontend/src/constants/eventName.ts @@ -32,6 +32,7 @@ export const USER_EVENT = { CLUB_CARD_CLICKED: 'ClubCard Clicked', CLUB_INTRO_TAB_CLICKED: 'Club Intro Tab Clicked', CLUB_FEED_TAB_CLICKED: 'Club Feed Tab Clicked', + CLUB_SCHEDULE_TAB_CLICKED: 'Club Schedule Tab Clicked', // 동아리 지원 CLUB_APPLY_BUTTON_CLICKED: 'Club Apply Button Clicked', @@ -59,6 +60,10 @@ export const USER_EVENT = { FESTIVAL_BOOTH_MAP_SLIDE_CHANGED: 'Festival BoothMap Slide Changed', FESTIVAL_PERFORMANCE_CARD_CLICKED: 'Festival PerformanceCard Clicked', FESTIVAL_TAB_DURATION: 'Festival Tab Duration', + + // 홍보 + PROMOTION_BUTTON_CLICKED: 'Promotion Button Clicked', + PROMOTION_CARD_CLICKED: 'Promotion Card Clicked', } as const; export const ADMIN_EVENT = { @@ -113,6 +118,8 @@ export const PAGE_VIEW = { INTRODUCE_PAGE: 'IntroducePage', CLUB_UNION_PAGE: 'ClubUnionPage', FESTIVAL_INTRODUCTION_PAGE: '동소한 페이지', + PROMOTION_LIST_PAGE: '홍보 목록 페이지', + PROMOTION_DETAIL_PAGE: '홍보 상세 페이지', // 관리자 LOGIN_PAGE: '로그인페이지', diff --git a/frontend/src/constants/queryKeys.ts b/frontend/src/constants/queryKeys.ts index 764b0fab9..54b6f231c 100644 --- a/frontend/src/constants/queryKeys.ts +++ b/frontend/src/constants/queryKeys.ts @@ -1,4 +1,10 @@ export const queryKeys = { + googleCalendar: { + all: ['googleCalendar'] as const, + calendars: () => ['googleCalendar', 'calendars'] as const, + events: (calendarId: string, timeMin: string, timeMax: string) => + ['googleCalendar', 'events', calendarId, timeMin, timeMax] as const, + }, applicants: { all: ['clubApplicants'] as const, detail: (applicationFormId: string) => @@ -12,6 +18,8 @@ export const queryKeys = { club: { all: ['clubs'] as const, detail: (clubParam: string) => ['clubDetail', clubParam] as const, + calendarEvents: (clubParam: string) => + ['clubCalendarEvents', clubParam] as const, list: ( keyword: string, recruitmentStatus: string, diff --git a/frontend/src/hooks/Header/useHeaderNavigation.test.ts b/frontend/src/hooks/Header/useHeaderNavigation.test.ts index 676394b63..027dd20a5 100644 --- a/frontend/src/hooks/Header/useHeaderNavigation.test.ts +++ b/frontend/src/hooks/Header/useHeaderNavigation.test.ts @@ -188,6 +188,32 @@ describe('useHeaderNavigation 테스트', () => { }); }); + describe('홍보 게시판 버튼 클릭 테스트', () => { + it('홍보 게시판 버튼 클릭 시 홍보 페이지로 이동한다', () => { + // Given + const { result } = renderHook(() => useHeaderNavigation()); + + // When + result.current.handlePromotionClick(); + + // Then + expect(mockNavigate).toHaveBeenCalledWith('/promotions'); + }); + + it('홍보 게시판 버튼 클릭 시 Mixpanel 이벤트를 전송한다', () => { + // Given + const { result } = renderHook(() => useHeaderNavigation()); + + // When + result.current.handlePromotionClick(); + + // Then + expect(mockTrackEvent).toHaveBeenCalledWith( + USER_EVENT.PROMOTION_BUTTON_CLICKED, + ); + }); + }); + describe('관리자 버튼 클릭 테스트', () => { it('관리자 버튼 클릭 시 관리자 페이지로 이동한다', () => { // Given @@ -223,6 +249,7 @@ describe('useHeaderNavigation 테스트', () => { expect(result.current).toHaveProperty('handleHomeClick'); expect(result.current).toHaveProperty('handleIntroduceClick'); expect(result.current).toHaveProperty('handleClubUnionClick'); + expect(result.current).toHaveProperty('handlePromotionClick'); expect(result.current).toHaveProperty('handleAdminClick'); }); @@ -234,6 +261,7 @@ describe('useHeaderNavigation 테스트', () => { expect(typeof result.current.handleHomeClick).toBe('function'); expect(typeof result.current.handleIntroduceClick).toBe('function'); expect(typeof result.current.handleClubUnionClick).toBe('function'); + expect(typeof result.current.handlePromotionClick).toBe('function'); expect(typeof result.current.handleAdminClick).toBe('function'); }); }); @@ -246,13 +274,15 @@ describe('useHeaderNavigation 테스트', () => { // When result.current.handleHomeClick(); result.current.handleIntroduceClick(); + result.current.handlePromotionClick(); result.current.handleAdminClick(); // Then - expect(mockNavigate).toHaveBeenCalledTimes(3); + expect(mockNavigate).toHaveBeenCalledTimes(4); expect(mockNavigate).toHaveBeenNthCalledWith(1, '/'); expect(mockNavigate).toHaveBeenNthCalledWith(2, '/introduce'); - expect(mockNavigate).toHaveBeenNthCalledWith(3, '/admin'); + expect(mockNavigate).toHaveBeenNthCalledWith(3, '/promotions'); + expect(mockNavigate).toHaveBeenNthCalledWith(4, '/admin'); }); it('모든 네비게이션 액션이 Mixpanel 이벤트를 트리거한다', () => { @@ -263,10 +293,11 @@ describe('useHeaderNavigation 테스트', () => { result.current.handleHomeClick(); result.current.handleIntroduceClick(); result.current.handleClubUnionClick(); + result.current.handlePromotionClick(); result.current.handleAdminClick(); // Then - expect(mockTrackEvent).toHaveBeenCalledTimes(4); + expect(mockTrackEvent).toHaveBeenCalledTimes(5); }); }); }); diff --git a/frontend/src/hooks/Header/useHeaderNavigation.ts b/frontend/src/hooks/Header/useHeaderNavigation.ts index 1a418af5f..3097f4e3a 100644 --- a/frontend/src/hooks/Header/useHeaderNavigation.ts +++ b/frontend/src/hooks/Header/useHeaderNavigation.ts @@ -27,6 +27,11 @@ const useHeaderNavigation = () => { trackEvent(USER_EVENT.CLUB_UNION_BUTTON_CLICKED); }, [navigate, trackEvent]); + const handlePromotionClick = useCallback(() => { + navigate('/promotions'); + trackEvent(USER_EVENT.PROMOTION_BUTTON_CLICKED); + }, [navigate, trackEvent]); + const handleAdminClick = useCallback(() => { navigate('/admin'); trackEvent(USER_EVENT.ADMIN_BUTTON_CLICKED); @@ -36,6 +41,7 @@ const useHeaderNavigation = () => { handleHomeClick, handleIntroduceClick, handleClubUnionClick, + handlePromotionClick, handleAdminClick, }; }; diff --git a/frontend/src/hooks/Mixpanel/useTrackPageView.ts b/frontend/src/hooks/Mixpanel/useTrackPageView.ts index 8e2308ed9..23351fc7c 100644 --- a/frontend/src/hooks/Mixpanel/useTrackPageView.ts +++ b/frontend/src/hooks/Mixpanel/useTrackPageView.ts @@ -6,11 +6,18 @@ const useTrackPageView = ( pageName: string, clubName?: string, skip: boolean = false, + recruitmentStatus?: string, ) => { const location = useLocation(); const isTracked = useRef(false); const startTime = useRef(Date.now()); const clubNameRef = useRef(clubName); + const recruitmentStatusRef = useRef(recruitmentStatus); + + // ref 동기화는 별도 effect에서 처리 (방문 이벤트 중복 방지) + useEffect(() => { + recruitmentStatusRef.current = recruitmentStatus; + }, [recruitmentStatus]); useEffect(() => { clubNameRef.current = clubName; @@ -25,6 +32,7 @@ const useTrackPageView = ( timestamp: startTime.current, referrer: document.referrer || 'direct', clubName: clubNameRef.current, + recruitmentStatus: recruitmentStatusRef.current, }); const trackPageDuration = () => { @@ -37,6 +45,7 @@ const useTrackPageView = ( duration: duration, duration_seconds: Math.round(duration / 1000), clubName: clubNameRef.current, + recruitmentStatus: recruitmentStatusRef.current, }); }; diff --git a/frontend/src/hooks/Queries/useClub.ts b/frontend/src/hooks/Queries/useClub.ts index 5e57e9dd3..976015018 100644 --- a/frontend/src/hooks/Queries/useClub.ts +++ b/frontend/src/hooks/Queries/useClub.ts @@ -5,13 +5,19 @@ import { useQueryClient, } from '@tanstack/react-query'; import { + getClubCalendarEvents, getClubDetail, getClubList, updateClubDescription, updateClubDetail, } from '@/apis/club'; import { queryKeys } from '@/constants/queryKeys'; -import { ClubDescription, ClubDetail, ClubSearchResponse } from '@/types/club'; +import { + ClubCalendarEvent, + ClubDescription, + ClubDetail, + ClubSearchResponse, +} from '@/types/club'; import convertGoogleDriveUrl from '@/utils/convertGoogleDriveUrl'; interface UseGetCardListProps { @@ -38,6 +44,26 @@ export const useGetClubDetail = (clubParam: string) => { }); }; +export const useGetClubCalendarEvents = ( + clubParam: string, + options?: { enabled?: boolean }, +) => { + return useQuery({ + queryKey: queryKeys.club.calendarEvents(clubParam), + queryFn: () => getClubCalendarEvents(clubParam), + staleTime: 5 * 60 * 1000, + enabled: (options?.enabled ?? true) && !!clubParam, + select: (data) => + data.filter( + (event): event is ClubCalendarEvent => + !!event && + typeof event.id === 'string' && + typeof event.title === 'string' && + typeof event.start === 'string', + ), + }); +}; + export const useGetCardList = ({ keyword, recruitmentStatus, diff --git a/frontend/src/hooks/Queries/useGoogleCalendar.ts b/frontend/src/hooks/Queries/useGoogleCalendar.ts new file mode 100644 index 000000000..e1cc703e1 --- /dev/null +++ b/frontend/src/hooks/Queries/useGoogleCalendar.ts @@ -0,0 +1,70 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + disconnectGoogleCalendar, + fetchGoogleCalendarEvents, + fetchGoogleCalendars, + selectGoogleCalendar, +} from '@/apis/calendarOAuth'; +import { queryKeys } from '@/constants/queryKeys'; +import { ApiError } from '@/errors'; +import type { GoogleCalendarListResponse } from '@/types/google'; + +export const useGetGoogleCalendars = () => { + return useQuery({ + queryKey: queryKeys.googleCalendar.calendars(), + queryFn: async () => { + try { + return await fetchGoogleCalendars(); + } catch (error) { + if (error instanceof ApiError && error.errorCode === '960-4') { + return null; + } + throw error; + } + }, + staleTime: 5 * 60 * 1000, + }); +}; + +export const useGetGoogleCalendarEvents = ( + calendarId: string, + timeMin: string, + timeMax: string, +) => { + return useQuery({ + queryKey: queryKeys.googleCalendar.events(calendarId, timeMin, timeMax), + queryFn: () => fetchGoogleCalendarEvents(calendarId, timeMin, timeMax), + staleTime: 5 * 60 * 1000, + enabled: !!calendarId, + }); +}; + +export const useSelectGoogleCalendar = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + calendarId, + calendarName, + }: { + calendarId: string; + calendarName: string; + }) => selectGoogleCalendar(calendarId, calendarName), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.googleCalendar.calendars(), + }); + }, + }); +}; + +export const useDisconnectGoogleCalendar = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: disconnectGoogleCalendar, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.googleCalendar.all, + }); + }, + }); +}; diff --git a/frontend/src/hooks/Queries/usePromotion.ts b/frontend/src/hooks/Queries/usePromotion.ts index b22a3f57d..5a3f325d2 100644 --- a/frontend/src/hooks/Queries/usePromotion.ts +++ b/frontend/src/hooks/Queries/usePromotion.ts @@ -1,3 +1,4 @@ +import { useLocation } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createPromotionArticle, getPromotionArticles } from '@/apis/promotion'; import { queryKeys } from '@/constants/queryKeys'; @@ -7,10 +8,16 @@ import { } from '@/types/promotion'; export const useGetPromotionArticles = () => { + const location = useLocation(); + const isPromotionPage = location.pathname.startsWith('/promotions'); + return useQuery({ queryKey: queryKeys.promotion.list(), queryFn: getPromotionArticles, - staleTime: 60 * 1000, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: true, + refetchInterval: isPromotionPage ? 180000 : 300000, + refetchIntervalInBackground: false, }); }; diff --git a/frontend/src/hooks/Queries/usePromotionNotification.ts b/frontend/src/hooks/Queries/usePromotionNotification.ts new file mode 100644 index 000000000..d672232fa --- /dev/null +++ b/frontend/src/hooks/Queries/usePromotionNotification.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useGetPromotionArticles } from '@/hooks/Queries/usePromotion'; +import { + getLastCheckedTime, + getLatestPromotionTime, + setLastCheckedTime, +} from '@/pages/PromotionPage/utils/promotionNotification'; + +const usePromotionNotification = () => { + const { data } = useGetPromotionArticles(); + const { pathname } = useLocation(); + const [hasNotification, setHasNotification] = useState(false); + + useEffect(() => { + if (!data || data.length === 0) return; + + const latestTime = getLatestPromotionTime(data); + const lastChecked = getLastCheckedTime(); + + if (pathname === '/promotions') { + setLastCheckedTime(latestTime); + setHasNotification(false); + return; + } + + if (lastChecked === null || latestTime > lastChecked) { + setHasNotification(true); + } else { + setHasNotification(false); + } + }, [data, pathname]); + + return hasNotification; +}; + +export default usePromotionNotification; diff --git a/frontend/src/mocks/data/festivalMock.ts b/frontend/src/mocks/data/festivalMock.ts new file mode 100644 index 000000000..46f9a9ea7 --- /dev/null +++ b/frontend/src/mocks/data/festivalMock.ts @@ -0,0 +1,18 @@ +import { PromotionArticle } from '@/types/promotion'; + +export const festivalMock: PromotionArticle[] = [ + { + id: '600000000000000000000003', + clubId: 'festival-1', + clubName: '총동연', + title: '🎉 동아리 소개 한마당', + description: '부경대학교 동아리 소개 한마당이 열립니다.', + location: '부경대학교', + eventStartDate: '2026-03-05T11:00:00', + eventEndDate: '2026-03-05T18:00:00', + images: [ + 'https://github.com/user-attachments/assets/42962967-a5e6-4270-9a43-6fce39b6c306', + ], + isFestival: true, + }, +]; diff --git a/frontend/src/mocks/handlers/promotion.ts b/frontend/src/mocks/handlers/promotion.ts index 066cc40a1..ad41000d5 100644 --- a/frontend/src/mocks/handlers/promotion.ts +++ b/frontend/src/mocks/handlers/promotion.ts @@ -7,38 +7,35 @@ const API_BASE_URL = // Mock 데이터 export const mockPromotionArticles: PromotionArticle[] = [ { - clubName: '모각코 동아리', - clubId: 'club-1', - title: '2024 봄 신입 부원 모집', - location: '서울 강남구', - eventStartDate: '2024-03-01', - eventEndDate: '2024-03-31', - description: '함께 성장하는 개발자 커뮤니티에 참여하세요!', + id: '600000000000000000000001', + clubName: 'WAP', + clubId: '67e54ae51cfd27718dd40bec', + title: '💌✨WAP 최종 전시회 초대장 ✨💌', + location: '부경대학교 동원 장보고관 1층', + eventStartDate: '2026-03-15T13:10:00Z', + eventEndDate: '2026-03-16T13:10:00Z', + description: + 'WAP 최종 전시회에 여러분을 초대합니다! \n\n이번 전시회에서는 WAP 팀이 한 학기 동안 열심히 준비한 프로젝트들을 선보입니다. 다양한 작품과 아이디어가 가득한 이번 전시회에서 여러분의 많은 관심과 참여 부탁드립니다! 🙌\n\n#WAP #최종전시회 #부경대학교', images: [ 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=800', + 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=800', + 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=800', ], }, { - clubName: '디자인 스터디', - clubId: 'club-2', - title: 'UI/UX 디자인 워크샵', - location: null, - eventStartDate: '2024-04-15', - eventEndDate: '2024-04-20', - description: '실무 디자이너와 함께하는 5일간의 집중 워크샵', - images: [], - }, - { - clubName: '창업 동아리', - clubId: 'club-3', - title: '스타트업 데모데이', - location: '부산 해운대구', - eventStartDate: '2024-05-10', - eventEndDate: '2024-05-10', - description: '학생 창업팀의 아이디어를 공유하는 자리', + id: '600000000000000000000002', + clubName: 'WAP', + clubId: '67e54ae51cfd27718dd40bec', + title: 'WAP 최종 전시회 초대장', + location: '부경대학교 동원 장보고관 1층', + eventStartDate: '2026-05-06T06:30:00Z', + eventEndDate: '2026-05-06T07:30:00Z', + description: + 'WAP 최종 전시회에 여러분을 초대합니다! \n\n이번 전시회에서는 WAP 팀이 한 학기 동안 열심히 준비한 프로젝트들을 선보입니다. 다양한 작품과 아이디어가 가득한 이번 전시회에서 여러분의 많은 관심과 참여 부탁드립니다! 🙌\n\n#WAP #최종전시회 #부경대학교', images: [ - 'https://images.unsplash.com/photo-1559136555-9303baea8ebd?w=800', - 'https://images.unsplash.com/photo-2540575467063-178a50c2df87?w=800', + 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=800', + 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=800', + 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=800', ], }, ]; diff --git a/frontend/src/pages/AdminPage/AdminRoutes.tsx b/frontend/src/pages/AdminPage/AdminRoutes.tsx index 8c5c7e687..a4bdb3d1c 100644 --- a/frontend/src/pages/AdminPage/AdminRoutes.tsx +++ b/frontend/src/pages/AdminPage/AdminRoutes.tsx @@ -5,6 +5,7 @@ import ApplicantDetailPage from '@/pages/AdminPage/tabs/ApplicantsTab/ApplicantD import ApplicantsListTab from '@/pages/AdminPage/tabs/ApplicantsTab/ApplicantsListTab/ApplicantsListTab'; import ApplicationEditTab from '@/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab'; import ApplicationListTab from '@/pages/AdminPage/tabs/ApplicationListTab/ApplicationListTab'; +import CalendarSyncTab from '@/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab'; import ClubInfoEditTab from '@/pages/AdminPage/tabs/ClubInfoEditTab/ClubInfoEditTab'; import PhotoEditTab from '@/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab'; import RecruitEditTab from '@/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab'; @@ -18,6 +19,7 @@ export default function AdminRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx b/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx index c6304f5fe..b38e36af3 100644 --- a/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx +++ b/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx @@ -23,6 +23,8 @@ const tabs: TabCategory[] = [ { label: '기본 정보 수정', path: '/admin/club-info' }, { label: '소개 정보 수정', path: '/admin/club-intro' }, { label: '활동 사진 수정', path: '/admin/photo-edit' }, + // TODO: 캘린더 기능 재오픈 시 복구 + // { label: '동아리 일정 관리', path: '/admin/calendar-sync' }, ], }, { diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts new file mode 100644 index 000000000..c03a4d62f --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts @@ -0,0 +1,291 @@ +import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + gap: 24px; +`; + +export const Description = styled.p` + font-size: 0.94rem; + line-height: 1.5; + color: ${colors.gray[600]}; +`; + +export const ConfigGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + + ${media.laptop} { + grid-template-columns: 1fr; + } +`; + +export const Block = styled.div` + border: 1px solid ${colors.gray[300]}; + border-radius: 12px; + padding: 14px; +`; + +export const BlockTitle = styled.h4` + margin: 0 0 10px; + font-size: 1rem; + font-weight: 700; +`; + +export const Buttons = styled.div` + margin-top: 12px; + display: flex; + gap: 10px; + flex-wrap: wrap; +`; + +export const SelectRow = styled.div` + display: flex; + gap: 10px; + align-items: center; + margin-top: 10px; + flex-wrap: wrap; +`; + +export const Select = styled.select` + min-width: 240px; + height: 42px; + border: 1px solid #d1d5db; + border-radius: 10px; + padding: 0 12px; + font-size: 0.9rem; + color: #111827; + background: #ffffff; +`; + +export const TokenText = styled.code` + display: block; + margin-top: 10px; + padding: 10px; + border-radius: 8px; + background: #f8fafc; + font-size: 0.82rem; + color: #1f2937; + word-break: break-all; +`; + +export const StatusText = styled.p` + margin-top: 10px; + font-size: 0.9rem; + font-weight: 600; + color: #0f766e; +`; + +export const ErrorText = styled.p` + margin-top: 8px; + font-size: 0.9rem; + color: #b91c1c; + font-weight: 600; +`; + +export const DataGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + } +`; + +export const DataCard = styled.div` + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 14px; + background: #ffffff; +`; + +export const WideDataCard = styled(DataCard)` + grid-column: 1 / -1; +`; + +export const DataTitle = styled.h4` + margin: 0 0 10px; + font-size: 1rem; + font-weight: 700; +`; + +export const Empty = styled.p` + font-size: 0.9rem; + color: #6b7280; +`; + +export const List = styled.ul` + margin: 0; + padding-left: 18px; + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const ListItem = styled.li` + font-size: 0.92rem; + color: #111827; + line-height: 1.45; +`; + +export const ExternalLink = styled.a` + color: #1d4ed8; +`; + +export const CalendarBoard = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; + +export const TogglePanel = styled.div` + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 10px; + background: #f9fafb; +`; + +export const ToggleHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 8px; +`; + +export const ToggleTitle = styled.h6` + margin: 0; + font-size: 0.9rem; + font-weight: 700; + color: #111827; +`; + +export const ToggleActions = styled.div` + display: flex; + gap: 8px; +`; + +export const ToggleActionButton = styled.button` + border: none; + background: transparent; + color: #1d4ed8; + font-size: 0.82rem; + font-weight: 700; + cursor: pointer; + padding: 0; +`; + +export const ToggleList = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + max-height: 180px; + overflow-y: auto; +`; + +export const ToggleItem = styled.label` + display: flex; + align-items: center; + gap: 8px; + font-size: 0.84rem; + color: #374151; +`; + +export const ToggleCheckbox = styled.input` + width: 14px; + height: 14px; +`; + +export const ToggleText = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const CalendarHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +`; + +export const CalendarMonth = styled.h5` + margin: 0; + font-size: 1rem; + font-weight: 700; + color: #111827; +`; + +export const CalendarWeekRow = styled.div` + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 8px; +`; + +export const CalendarWeekCell = styled.div` + text-align: center; + font-size: 0.82rem; + font-weight: 700; + color: #4b5563; +`; + +export const CalendarGrid = styled.div` + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 8px; +`; + +export const CalendarCell = styled.div<{ $muted: boolean }>` + min-height: 120px; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 8px; + background: ${({ $muted }) => ($muted ? '#f9fafb' : '#ffffff')}; + opacity: ${({ $muted }) => ($muted ? 0.55 : 1)}; + display: flex; + flex-direction: column; + gap: 8px; + + @media (max-width: 768px) { + min-height: 96px; + padding: 6px; + } +`; + +export const CalendarDayNumber = styled.span` + font-size: 0.82rem; + font-weight: 600; + color: #374151; +`; + +export const CalendarEventList = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; + +export const CalendarEvent = styled.div<{ $source?: 'GOOGLE' | 'NOTION' }>` + font-size: 0.8rem; + line-height: 1.35; + color: #111827; + padding: 4px 6px; + border-radius: 6px; + background: ${({ $source }) => { + if ($source === 'GOOGLE') return '#dbeafe'; // 파란색 (구글) + if ($source === 'NOTION') return '#f3e8ff'; // 보라색 (노션) + return '#eff6ff'; // 기본색 + }}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const CalendarTitle = styled.span` + font-size: 0.82rem; + color: #111827; +`; diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.tsx b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.tsx new file mode 100644 index 000000000..a55fe1b55 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.tsx @@ -0,0 +1,363 @@ +import Button from '@/components/common/Button/Button'; +import { + buildDateKeyFromDate, + formatDateOnly, + WEEKDAY_LABELS, +} from '@/utils/calendarSyncUtils'; +import * as Styled from './CalendarSyncTab.styles'; +import { useCalendarSync } from './hooks/useCalendarSync'; + +const CalendarSyncTab = () => { + const { + isGoogleConnected, + isGoogleInitialChecking, + googleCalendars, + selectedGoogleCalendarId, + googleCalendarEvents, + notionItems, + notionTotalResults, + notionDatabaseSourceId, + notionDatabaseOptions, + selectedNotionDatabaseId, + setSelectedNotionDatabaseId, + isNotionDatabaseApplying, + statusMessage, + errorMessage, + isGoogleLoading, + isNotionLoading, + notionWorkspaceName, + notionCalendarEvents, + allUnifiedEvents, + visibleUnifiedEvents, + unifiedEventsByDate, + unifiedCalendarDays, + unifiedCalendarLabel, + unifiedVisibleMonth, + notionEventEnabledMap, + googleEventEnabledMap, + startGoogleOAuth, + selectGoogleCalendar, + disconnectGoogle, + startNotionOAuth, + goToPreviousMonth, + goToNextMonth, + toggleNotionEvent, + toggleGoogleEvent, + setAllNotionEventsEnabled, + setAllGoogleEventsEnabled, + applySelectedNotionDatabase, + } = useCalendarSync(); + + return ( + + + {/* ── Google 캘린더 블록 ── */} + + Google 캘린더 + + {isGoogleInitialChecking ? ( + /* 초기 연결 확인 중 */ + 연결 상태 확인 중… + ) : !isGoogleConnected ? ( + /* 미연결 상태 */ + <> + + Google 계정을 연동하여 캘린더를 가져오세요. + + + + + + ) : ( + /* 연결된 상태: 캘린더 선택 UI */ + <> + + ✅ Google 계정이 연결되었습니다. + + {googleCalendars.length > 0 && ( + <> + + 동기화할 캘린더를 선택하고 적용하세요. + + + selectGoogleCalendar(e.target.value)} + disabled={isGoogleLoading} + > + {googleCalendars.map((calendar) => ( + + ))} + + + + )} + + + + + )} + + + {/* ── Notion 캘린더 블록 ── */} + + Notion 캘린더 + + 연결할 데이터베이스를 선택하고 적용하세요. + + + setSelectedNotionDatabaseId(e.target.value)} + > + {notionDatabaseOptions.length === 0 ? ( + + ) : ( + notionDatabaseOptions.map((database) => ( + + )) + )} + + + + + + + {notionWorkspaceName && ( + + 연결된 워크스페이스: {notionWorkspaceName} + + )} + + + + {statusMessage && {statusMessage}} + {errorMessage && {errorMessage}} + + + {/* ── Google 캘린더 목록 카드 ── */} + + Google 캘린더 목록 + {isGoogleInitialChecking ? ( + 연결 상태 확인 중… + ) : !isGoogleConnected ? ( + + 아직 데이터가 없습니다. Google 캘린더 연동을 먼저 완료해주세요. + + ) : googleCalendars.length === 0 ? ( + 캘린더 목록을 불러오는 중입니다… + ) : ( + + {googleCalendars.map((calendar) => ( + + {calendar.id === selectedGoogleCalendarId ? '✓ ' : ''} + {calendar.summary || '(제목 없음)'} + {calendar.primary ? ' (기본 캘린더)' : ''} + + ))} + + )} + + + {/* ── 통합 캘린더 일정 카드 (Google + Notion) ── */} + + + 통합 캘린더 일정 (Google + Notion) + + + Google 이벤트 {googleCalendarEvents.length}개 / Notion 페이지{' '} + {notionTotalResults}개 / 캘린더 표시 {visibleUnifiedEvents.length}개 + + {notionDatabaseSourceId && ( + + Notion 데이터베이스: {notionDatabaseSourceId} + + )} + {allUnifiedEvents.length === 0 ? ( + + 아직 데이터가 없습니다. Google 캘린더 연동 또는 Notion 캘린더 + 가져오기를 먼저 완료해주세요. + + ) : ( + + {/* 구글 이벤트 토글 */} + {googleCalendarEvents.length > 0 && ( + + + + 🔵 Google 이벤트 선택 + + + setAllGoogleEventsEnabled(true)} + > + 전체 ON + + setAllGoogleEventsEnabled(false)} + > + 전체 OFF + + + + + {googleCalendarEvents.map((event) => ( + + toggleGoogleEvent(event.id)} + /> + + {event.title} ({formatDateOnly(event.start)}) + + + ))} + + + )} + {/* 노션 이벤트 토글 */} + {notionCalendarEvents.length > 0 && ( + + + + 🟣 Notion 페이지 선택 + + + setAllNotionEventsEnabled(true)} + > + 전체 ON + + setAllNotionEventsEnabled(false)} + > + 전체 OFF + + + + + {notionCalendarEvents.map((event) => ( + + toggleNotionEvent(event.id)} + /> + + {event.title} ({formatDateOnly(event.dateKey)}) + + + ))} + + + )} + + + + {unifiedCalendarLabel} + + + + + {WEEKDAY_LABELS.map((label) => ( + + {label} + + ))} + + + {unifiedCalendarDays.map((day) => { + const dateKey = buildDateKeyFromDate(day); + const events = unifiedEventsByDate[dateKey] ?? []; + const isOutsideMonth = + day.getMonth() !== unifiedVisibleMonth.getMonth() || + day.getFullYear() !== unifiedVisibleMonth.getFullYear(); + + return ( + + + {day.getDate()} + + + {events.map((event) => ( + + {event.url ? ( + + {event.title} + + ) : ( + + {event.title} + + )} + + ))} + + + ); + })} + + + )} + + + + ); +}; + +export default CalendarSyncTab; diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useCalendarSync.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useCalendarSync.ts new file mode 100644 index 000000000..d2aafadc4 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useCalendarSync.ts @@ -0,0 +1,94 @@ +import { useCallback, useState } from 'react'; +import { useGoogleCalendarData } from './useGoogleCalendarData'; +import { useNotionCalendarData } from './useNotionCalendarData'; +import { useNotionCalendarUiState } from './useNotionCalendarUiState'; +import { useNotionOAuth } from './useNotionOAuth'; +import { useUnifiedCalendarUiState } from './useUnifiedCalendarUiState'; + +export const useCalendarSync = () => { + const [statusMessage, setStatusMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [notionWorkspaceName, setNotionWorkspaceName] = useState(''); + + const clearError = useCallback(() => setErrorMessage(''), []); + + const googleData = useGoogleCalendarData({ + onError: setErrorMessage, + onStatus: setStatusMessage, + clearError, + }); + + const notionData = useNotionCalendarData({ + onError: setErrorMessage, + onStatus: setStatusMessage, + clearError, + }); + + const notionUi = useNotionCalendarUiState({ + notionItems: notionData.notionItems, + }); + + const unifiedCalendar = useUnifiedCalendarUiState({ + notionCalendarEvents: notionUi.notionCalendarEvents, + googleCalendarEvents: googleData.googleCalendarEvents, + }); + + const notionOAuth = useNotionOAuth({ + loadNotionPages: notionData.loadNotionPages, + onWorkspaceName: setNotionWorkspaceName, + onError: setErrorMessage, + onStatus: setStatusMessage, + clearError, + }); + + return { + isGoogleConnected: googleData.isGoogleConnected, + isGoogleInitialChecking: googleData.isInitialChecking, + googleCalendars: googleData.googleCalendars, + selectedGoogleCalendarId: googleData.selectedCalendarId, + googleCalendarEvents: googleData.googleCalendarEvents, + notionItems: notionData.notionItems, + notionTotalResults: notionData.notionTotalResults, + notionDatabaseSourceId: notionData.notionDatabaseSourceId, + notionDatabaseOptions: notionData.notionDatabaseOptions, + selectedNotionDatabaseId: notionData.selectedNotionDatabaseId, + setSelectedNotionDatabaseId: notionData.setSelectedNotionDatabaseId, + isNotionDatabaseApplying: notionData.isNotionDatabaseApplying, + statusMessage, + errorMessage, + isGoogleLoading: googleData.isGoogleLoading, + isNotionLoading: + notionData.isNotionLoading || + notionOAuth.isNotionOAuthLoading || + notionData.isNotionDatabaseApplying, + notionWorkspaceName, + // 노션 전용 UI (기존 호환성 유지) + notionCalendarEvents: notionUi.notionCalendarEvents, + notionVisibleCalendarEvents: notionUi.notionVisibleCalendarEvents, + notionEventsByDate: notionUi.notionEventsByDate, + notionEventEnabledMap: unifiedCalendar.notionEventEnabledMap, + notionCalendarDays: notionUi.notionCalendarDays, + notionCalendarLabel: notionUi.notionCalendarLabel, + visibleMonth: notionUi.visibleMonth, + // 통합 캘린더 UI (구글 + 노션) + allUnifiedEvents: unifiedCalendar.allUnifiedEvents, + visibleUnifiedEvents: unifiedCalendar.visibleUnifiedEvents, + unifiedEventsByDate: unifiedCalendar.eventsByDate, + unifiedCalendarDays: unifiedCalendar.calendarDays, + unifiedCalendarLabel: unifiedCalendar.calendarLabel, + unifiedVisibleMonth: unifiedCalendar.visibleMonth, + googleEventEnabledMap: unifiedCalendar.googleEventEnabledMap, + // 액션 + startGoogleOAuth: googleData.startGoogleOAuth, + selectGoogleCalendar: googleData.selectCalendar, + disconnectGoogle: googleData.disconnectGoogle, + startNotionOAuth: notionOAuth.startNotionOAuth, + goToPreviousMonth: unifiedCalendar.goToPreviousMonth, + goToNextMonth: unifiedCalendar.goToNextMonth, + toggleNotionEvent: unifiedCalendar.toggleNotionEvent, + toggleGoogleEvent: unifiedCalendar.toggleGoogleEvent, + setAllNotionEventsEnabled: unifiedCalendar.setAllNotionEventsEnabled, + setAllGoogleEventsEnabled: unifiedCalendar.setAllGoogleEventsEnabled, + applySelectedNotionDatabase: notionData.applySelectedNotionDatabase, + }; +}; diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts new file mode 100644 index 000000000..c054af3ce --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { fetchGoogleAuthorizeUrl } from '@/apis/calendarOAuth'; +import { + useDisconnectGoogleCalendar, + useGetGoogleCalendarEvents, + useGetGoogleCalendars, + useSelectGoogleCalendar, +} from '@/hooks/Queries/useGoogleCalendar'; +import { createState } from '@/utils/calendarSyncUtils'; + +const GOOGLE_STATE_KEY = 'admin_calendar_sync_google_state'; +const GOOGLE_OAUTH_SUCCESS_KEY = 'admin_calendar_sync_google_oauth_success'; +const GOOGLE_OAUTH_ERROR_KEY = 'admin_calendar_sync_google_oauth_error'; + +interface UseGoogleCalendarDataParams { + onError: (message: string) => void; + onStatus: (message: string) => void; + clearError: () => void; +} + +export const useGoogleCalendarData = ({ + onError, + onStatus, + clearError, +}: UseGoogleCalendarDataParams) => { + const [selectedCalendarId, setSelectedCalendarId] = useState(''); + const [isOAuthLoading, setIsOAuthLoading] = useState(false); + const hasLoadedOnce = useRef(false); + + const calendarsQuery = useGetGoogleCalendars(); + const selectMutation = useSelectGoogleCalendar(); + const disconnectMutation = useDisconnectGoogleCalendar(); + + const eventTimeRange = useMemo(() => { + const now = new Date(); + return { + timeMin: new Date(now.getFullYear(), now.getMonth() - 3, 1).toISOString(), + timeMax: new Date(now.getFullYear(), now.getMonth() + 4, 1).toISOString(), + }; + }, []); + + const eventsQuery = useGetGoogleCalendarEvents( + selectedCalendarId, + eventTimeRange.timeMin, + eventTimeRange.timeMax, + ); + + const isGoogleConnected = calendarsQuery.data != null; + const googleCalendars = calendarsQuery.data?.items ?? []; + const googleCalendarEvents = eventsQuery.data ?? []; + const isGoogleLoading = + isOAuthLoading || selectMutation.isPending || disconnectMutation.isPending; + + // 서버 데이터 기반 selectedCalendarId 초기화 (서버 선택값 → primary → 첫 번째) + useEffect(() => { + if (!selectedCalendarId && calendarsQuery.data) { + const { items, selectedCalendarId: serverSelected } = calendarsQuery.data; + if (serverSelected) { + setSelectedCalendarId(serverSelected); + } else { + const primary = items.find((cal) => cal.primary); + setSelectedCalendarId(primary?.id ?? items[0]?.id ?? ''); + } + } + }, [calendarsQuery.data, selectedCalendarId]); + + useEffect(() => { + if (!calendarsQuery.isPending) { + hasLoadedOnce.current = true; + } + }, [calendarsQuery.isPending]); + + useEffect(() => { + if (calendarsQuery.error instanceof Error) { + onError(calendarsQuery.error.message); + } + }, [calendarsQuery.error, onError]); + + useEffect(() => { + if (eventsQuery.error instanceof Error) { + onError(eventsQuery.error.message); + } + }, [eventsQuery.error, onError]); + + // OAuth 콜백 처리 + useEffect(() => { + const errorMessage = sessionStorage.getItem(GOOGLE_OAUTH_ERROR_KEY); + if (errorMessage) { + onError(errorMessage); + sessionStorage.removeItem(GOOGLE_OAUTH_ERROR_KEY); + return; + } + + const successFlag = sessionStorage.getItem(GOOGLE_OAUTH_SUCCESS_KEY); + if (successFlag) { + sessionStorage.removeItem(GOOGLE_OAUTH_SUCCESS_KEY); + onStatus('Google OAuth 인증이 완료되었습니다.'); + } + }, [onError, onStatus]); + + const startGoogleOAuth = useCallback(async () => { + setIsOAuthLoading(true); + clearError(); + + try { + const state = createState(); + sessionStorage.setItem(GOOGLE_STATE_KEY, state); + const authorizeUrl = await fetchGoogleAuthorizeUrl(state); + window.location.href = authorizeUrl; + } catch (error) { + if (error instanceof Error) { + onError(error.message); + } + setIsOAuthLoading(false); + } + }, [clearError, onError]); + + const handleSelectCalendar = useCallback( + (calendarId: string) => { + const calendar = googleCalendars.find((cal) => cal.id === calendarId); + if (!calendar) return; + + clearError(); + selectMutation.mutate( + { calendarId, calendarName: calendar.summary || '' }, + { + onSuccess: () => { + setSelectedCalendarId(calendarId); + onStatus('캘린더가 선택되었습니다.'); + }, + onError: (error) => { + if (error instanceof Error) onError(error.message); + }, + }, + ); + }, + [clearError, googleCalendars, onError, onStatus, selectMutation], + ); + + const handleDisconnect = useCallback(() => { + clearError(); + disconnectMutation.mutate(undefined, { + onSuccess: () => { + setSelectedCalendarId(''); + onStatus('Google Calendar 연결이 해제되었습니다.'); + }, + onError: (error) => { + if (error instanceof Error) onError(error.message); + }, + }); + }, [clearError, disconnectMutation, onError, onStatus]); + + return { + isGoogleConnected, + googleCalendars, + selectedCalendarId, + googleCalendarEvents, + isGoogleLoading, + isInitialChecking: calendarsQuery.isPending && !hasLoadedOnce.current, + startGoogleOAuth, + selectCalendar: handleSelectCalendar, + disconnectGoogle: handleDisconnect, + }; +}; diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts new file mode 100644 index 000000000..3c3432e53 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + fetchNotionDatabasePages, + fetchNotionDatabases, + fetchNotionPages, + NotionDatabaseOption, + NotionPagesResponse, + NotionSearchItem, +} from '@/apis/calendarOAuth'; + +interface UseNotionCalendarDataParams { + onError: (message: string) => void; + onStatus: (message: string) => void; + clearError: () => void; +} + +export const useNotionCalendarData = ({ + onError, + onStatus, + clearError, +}: UseNotionCalendarDataParams) => { + const [notionItems, setNotionItems] = useState([]); + const [notionTotalResults, setNotionTotalResults] = useState(0); + const [notionDatabaseSourceId, setNotionDatabaseSourceId] = useState(''); + const [notionDatabaseOptions, setNotionDatabaseOptions] = useState< + NotionDatabaseOption[] + >([]); + const [selectedNotionDatabaseId, setSelectedNotionDatabaseId] = useState(''); + const [isNotionLoading, setIsNotionLoading] = useState(false); + const [isNotionDatabaseApplying, setIsNotionDatabaseApplying] = + useState(false); + + const pagesRequestIdRef = useRef(0); + + const applyPagesResponse = useCallback((response: NotionPagesResponse) => { + setNotionItems(response.items); + setNotionTotalResults(response.totalResults); + const databaseId = response.databaseId ?? ''; + setNotionDatabaseSourceId(databaseId); + if (databaseId) { + setSelectedNotionDatabaseId(databaseId); + } + }, []); + + const loadNotionPages = useCallback(async () => { + const requestId = ++pagesRequestIdRef.current; + setIsNotionLoading(true); + try { + const response = await fetchNotionPages(); + if (requestId !== pagesRequestIdRef.current) { + return null; + } + applyPagesResponse(response); + return response; + } catch (error: unknown) { + const status = + typeof error === 'object' && + error !== null && + 'status' in error && + typeof (error as { status?: unknown }).status === 'number' + ? (error as { status: number }).status + : undefined; + + if (status === 401 || status === 403) { + return null; + } + + if (error instanceof Error) { + onError(error.message); + } + return null; + } finally { + setIsNotionLoading(false); + } + }, [applyPagesResponse, onError]); + + const applySelectedNotionDatabase = useCallback(() => { + if (!selectedNotionDatabaseId) { + onError('먼저 Notion 데이터베이스를 선택해주세요.'); + return; + } + + setIsNotionDatabaseApplying(true); + clearError(); + + const requestId = ++pagesRequestIdRef.current; + fetchNotionDatabasePages({ + databaseId: selectedNotionDatabaseId, + }) + .then((pagesResponse) => { + if (requestId !== pagesRequestIdRef.current) { + return; + } + applyPagesResponse(pagesResponse); + onStatus('선택한 Notion 데이터베이스를 연결했습니다.'); + }) + .catch((error: Error) => { + onError(error.message); + }) + .finally(() => { + setIsNotionDatabaseApplying(false); + }); + }, [ + applyPagesResponse, + clearError, + onError, + onStatus, + selectedNotionDatabaseId, + ]); + + useEffect(() => { + fetchNotionDatabases() + .then((options) => { + setNotionDatabaseOptions(options); + setSelectedNotionDatabaseId( + (previous) => previous || options[0]?.id || '', + ); + }) + .catch(() => { + // OAuth 전 단계에서는 목록 실패가 자연스러울 수 있다. + }); + }, []); + + useEffect(() => { + loadNotionPages(); + }, [loadNotionPages]); + + return { + notionItems, + notionTotalResults, + notionDatabaseSourceId, + notionDatabaseOptions, + selectedNotionDatabaseId, + setSelectedNotionDatabaseId, + isNotionLoading, + isNotionDatabaseApplying, + applyPagesResponse, + loadNotionPages, + applySelectedNotionDatabase, + }; +}; diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarUiState.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarUiState.ts new file mode 100644 index 000000000..5d0aa71a2 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarUiState.ts @@ -0,0 +1,128 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { NotionSearchItem } from '@/apis/calendarOAuth'; +import { + buildMonthCalendarDays, + dateFromKey, + formatMonthLabel, + NotionCalendarEvent, + parseNotionCalendarEvent, +} from '@/utils/calendarSyncUtils'; + +interface UseNotionCalendarUiStateParams { + notionItems: NotionSearchItem[]; +} + +export const useNotionCalendarUiState = ({ + notionItems, +}: UseNotionCalendarUiStateParams) => { + const [visibleMonth, setVisibleMonth] = useState(() => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1); + }); + const [notionEventEnabledMap, setNotionEventEnabledMap] = useState< + Record + >({}); + const didInitVisibleMonthRef = useRef(false); + + const notionCalendarEvents = useMemo( + () => + notionItems + .map(parseNotionCalendarEvent) + .filter((event): event is NotionCalendarEvent => event !== null) + .sort((a, b) => a.dateKey.localeCompare(b.dateKey)), + [notionItems], + ); + const notionVisibleCalendarEvents = useMemo( + () => + notionCalendarEvents.filter( + (event) => notionEventEnabledMap[event.id] !== false, + ), + [notionCalendarEvents, notionEventEnabledMap], + ); + const notionEventsByDate = useMemo( + () => + notionVisibleCalendarEvents.reduce>( + (accumulator, event) => { + if (!accumulator[event.dateKey]) { + accumulator[event.dateKey] = []; + } + accumulator[event.dateKey].push(event); + return accumulator; + }, + {}, + ), + [notionVisibleCalendarEvents], + ); + const notionCalendarDays = useMemo( + () => buildMonthCalendarDays(visibleMonth), + [visibleMonth], + ); + const notionCalendarLabel = useMemo( + () => formatMonthLabel(visibleMonth), + [visibleMonth], + ); + + useEffect(() => { + if (notionCalendarEvents.length === 0 || didInitVisibleMonthRef.current) + return; + + const firstEventDate = dateFromKey(notionCalendarEvents[0].dateKey); + setVisibleMonth( + new Date(firstEventDate.getFullYear(), firstEventDate.getMonth(), 1), + ); + didInitVisibleMonthRef.current = true; + }, [notionCalendarEvents]); + + useEffect(() => { + setNotionEventEnabledMap((previous) => { + const next: Record = {}; + notionCalendarEvents.forEach((event) => { + next[event.id] = previous[event.id] ?? true; + }); + return next; + }); + }, [notionCalendarEvents]); + + const goToPreviousMonth = () => { + setVisibleMonth( + new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() - 1, 1), + ); + }; + + const goToNextMonth = () => { + setVisibleMonth( + new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1), + ); + }; + + const toggleNotionEvent = (id: string) => { + setNotionEventEnabledMap((previous) => ({ + ...previous, + [id]: !(previous[id] ?? true), + })); + }; + + const setAllNotionEventsEnabled = (enabled: boolean) => { + setNotionEventEnabledMap((previous) => { + const next = { ...previous }; + notionCalendarEvents.forEach((event) => { + next[event.id] = enabled; + }); + return next; + }); + }; + + return { + visibleMonth, + notionCalendarEvents, + notionVisibleCalendarEvents, + notionEventsByDate, + notionEventEnabledMap, + notionCalendarDays, + notionCalendarLabel, + goToPreviousMonth, + goToNextMonth, + toggleNotionEvent, + setAllNotionEventsEnabled, + }; +}; diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionOAuth.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionOAuth.ts new file mode 100644 index 000000000..3a1be1172 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionOAuth.ts @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react'; +import { + exchangeNotionCode, + fetchNotionAuthorizeUrl, +} from '@/apis/calendarOAuth'; +import { createState } from '@/utils/calendarSyncUtils'; + +const NOTION_STATE_KEY = 'admin_calendar_sync_notion_state'; + +interface UseNotionOAuthParams { + loadNotionPages: () => Promise; + onWorkspaceName: (name: string) => void; + onError: (message: string) => void; + onStatus: (message: string) => void; + clearError: () => void; +} + +export const useNotionOAuth = ({ + loadNotionPages, + onWorkspaceName, + onError, + onStatus, + clearError, +}: UseNotionOAuthParams) => { + const [isNotionOAuthLoading, setIsNotionOAuthLoading] = useState(false); + + const startNotionOAuth = () => { + const state = createState(); + sessionStorage.setItem(NOTION_STATE_KEY, state); + setIsNotionOAuthLoading(true); + clearError(); + + fetchNotionAuthorizeUrl(state) + .then((authorizeUrl) => { + window.location.href = authorizeUrl; + }) + .catch((error: Error) => { + onError(error.message); + }) + .finally(() => { + setIsNotionOAuthLoading(false); + }); + }; + + useEffect(() => { + const clearOAuthParamsFromUrl = () => { + const cleanUrl = window.location.pathname; + window.history.replaceState({}, document.title, cleanUrl); + }; + + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const state = params.get('state'); + const error = params.get('error'); + const hasOAuthParams = Boolean(code || state || error); + const expectedState = sessionStorage.getItem(NOTION_STATE_KEY); + + if (error) { + onError(`Notion OAuth 실패: ${error}`); + sessionStorage.removeItem(NOTION_STATE_KEY); + clearOAuthParamsFromUrl(); + return; + } + + if (!code || !state || !expectedState || state !== expectedState) { + if (hasOAuthParams) { + onError('Notion OAuth 인증 정보가 올바르지 않습니다.'); + } + sessionStorage.removeItem(NOTION_STATE_KEY); + if (hasOAuthParams) clearOAuthParamsFromUrl(); + return; + } + + setIsNotionOAuthLoading(true); + clearError(); + + exchangeNotionCode({ code }) + .then((tokenResponse) => { + onWorkspaceName(tokenResponse?.workspaceName ?? ''); + return loadNotionPages(); + }) + .then(() => { + onStatus('Notion OAuth 인증이 완료되었습니다.'); + }) + .catch((oauthError: Error) => { + onError(oauthError.message); + }) + .finally(() => { + setIsNotionOAuthLoading(false); + sessionStorage.removeItem(NOTION_STATE_KEY); + clearOAuthParamsFromUrl(); + }); + }, [clearError, loadNotionPages, onError, onStatus, onWorkspaceName]); + + return { + isNotionOAuthLoading, + startNotionOAuth, + }; +}; diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useUnifiedCalendarUiState.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useUnifiedCalendarUiState.ts new file mode 100644 index 000000000..671c705b8 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useUnifiedCalendarUiState.ts @@ -0,0 +1,188 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { GoogleCalendarEvent } from '@/apis/calendarOAuth'; +import { + buildMonthCalendarDays, + convertGoogleEventToUnified, + convertNotionEventToUnified, + dateFromKey, + formatMonthLabel, + NotionCalendarEvent, + UnifiedCalendarEvent, +} from '@/utils/calendarSyncUtils'; + +interface UseUnifiedCalendarUiStateParams { + notionCalendarEvents: NotionCalendarEvent[]; + googleCalendarEvents: GoogleCalendarEvent[]; +} + +export const useUnifiedCalendarUiState = ({ + notionCalendarEvents, + googleCalendarEvents, +}: UseUnifiedCalendarUiStateParams) => { + const [visibleMonth, setVisibleMonth] = useState(() => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1); + }); + const [notionEventEnabledMap, setNotionEventEnabledMap] = useState< + Record + >({}); + const [googleEventEnabledMap, setGoogleEventEnabledMap] = useState< + Record + >({}); + const didInitVisibleMonthRef = useRef(false); + + const unifiedNotionEvents = useMemo( + () => notionCalendarEvents.map(convertNotionEventToUnified), + [notionCalendarEvents], + ); + + const unifiedGoogleEvents = useMemo( + () => + googleCalendarEvents + .map(convertGoogleEventToUnified) + .filter((event): event is UnifiedCalendarEvent => event !== null), + [googleCalendarEvents], + ); + + const allUnifiedEvents = useMemo( + () => + [...unifiedNotionEvents, ...unifiedGoogleEvents].sort((a, b) => + a.dateKey.localeCompare(b.dateKey), + ), + [unifiedNotionEvents, unifiedGoogleEvents], + ); + + const visibleUnifiedEvents = useMemo(() => { + return allUnifiedEvents.filter((event) => { + if (event.source === 'NOTION') { + const notionId = event.id.replace('notion-', ''); + return notionEventEnabledMap[notionId] !== false; + } + if (event.source === 'GOOGLE') { + const googleId = event.id.replace('google-', ''); + return googleEventEnabledMap[googleId] !== false; + } + return true; + }); + }, [allUnifiedEvents, notionEventEnabledMap, googleEventEnabledMap]); + + const eventsByDate = useMemo( + () => + visibleUnifiedEvents.reduce>( + (accumulator, event) => { + if (!accumulator[event.dateKey]) { + accumulator[event.dateKey] = []; + } + accumulator[event.dateKey].push(event); + return accumulator; + }, + {}, + ), + [visibleUnifiedEvents], + ); + + const calendarDays = useMemo( + () => buildMonthCalendarDays(visibleMonth), + [visibleMonth], + ); + + const calendarLabel = useMemo( + () => formatMonthLabel(visibleMonth), + [visibleMonth], + ); + + useEffect(() => { + if (allUnifiedEvents.length === 0 || didInitVisibleMonthRef.current) return; + + const lastEventDate = dateFromKey( + allUnifiedEvents[allUnifiedEvents.length - 1].dateKey, + ); + setVisibleMonth( + new Date(lastEventDate.getFullYear(), lastEventDate.getMonth(), 1), + ); + didInitVisibleMonthRef.current = true; + }, [allUnifiedEvents]); + + useEffect(() => { + setNotionEventEnabledMap((previous) => { + const next: Record = {}; + notionCalendarEvents.forEach((event) => { + next[event.id] = previous[event.id] ?? true; + }); + return next; + }); + }, [notionCalendarEvents]); + + useEffect(() => { + setGoogleEventEnabledMap((previous) => { + const next: Record = {}; + googleCalendarEvents.forEach((event) => { + next[event.id] = previous[event.id] ?? true; + }); + return next; + }); + }, [googleCalendarEvents]); + + const goToPreviousMonth = () => { + setVisibleMonth( + new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() - 1, 1), + ); + }; + + const goToNextMonth = () => { + setVisibleMonth( + new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1), + ); + }; + + const toggleNotionEvent = (id: string) => { + setNotionEventEnabledMap((previous) => ({ + ...previous, + [id]: !(previous[id] ?? true), + })); + }; + + const toggleGoogleEvent = (id: string) => { + setGoogleEventEnabledMap((previous) => ({ + ...previous, + [id]: !(previous[id] ?? true), + })); + }; + + const setAllNotionEventsEnabled = (enabled: boolean) => { + setNotionEventEnabledMap((previous) => { + const next = { ...previous }; + notionCalendarEvents.forEach((event) => { + next[event.id] = enabled; + }); + return next; + }); + }; + + const setAllGoogleEventsEnabled = (enabled: boolean) => { + setGoogleEventEnabledMap((previous) => { + const next = { ...previous }; + googleCalendarEvents.forEach((event) => { + next[event.id] = enabled; + }); + return next; + }); + }; + + return { + visibleMonth, + allUnifiedEvents, + visibleUnifiedEvents, + eventsByDate, + notionEventEnabledMap, + googleEventEnabledMap, + calendarDays, + calendarLabel, + goToPreviousMonth, + goToNextMonth, + toggleNotionEvent, + toggleGoogleEvent, + setAllNotionEventsEnabled, + setAllGoogleEventsEnabled, + }; +}; diff --git a/frontend/src/pages/CallbackPage/GoogleCallbackPage.tsx b/frontend/src/pages/CallbackPage/GoogleCallbackPage.tsx new file mode 100644 index 000000000..67f62be66 --- /dev/null +++ b/frontend/src/pages/CallbackPage/GoogleCallbackPage.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { exchangeGoogleCode } from '@/apis/calendarOAuth'; + +const GOOGLE_STATE_KEY = 'admin_calendar_sync_google_state'; +const GOOGLE_OAUTH_SUCCESS_KEY = 'admin_calendar_sync_google_oauth_success'; +const GOOGLE_OAUTH_ERROR_KEY = 'admin_calendar_sync_google_oauth_error'; + +const GoogleCallbackPage = () => { + const navigate = useNavigate(); + const [status, setStatus] = useState('Google 인증 처리 중...'); + + useEffect(() => { + const handleCallback = async () => { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const state = params.get('state'); + const error = params.get('error'); + const expectedState = sessionStorage.getItem(GOOGLE_STATE_KEY); + + if (error) { + sessionStorage.setItem( + GOOGLE_OAUTH_ERROR_KEY, + `Google OAuth 실패: ${error}`, + ); + sessionStorage.removeItem(GOOGLE_STATE_KEY); + navigate('/admin/calendar-sync', { replace: true }); + return; + } + + if (!code || !state || !expectedState || state !== expectedState) { + sessionStorage.setItem( + GOOGLE_OAUTH_ERROR_KEY, + 'Google OAuth 인증 정보가 올바르지 않습니다.', + ); + sessionStorage.removeItem(GOOGLE_STATE_KEY); + navigate('/admin/calendar-sync', { replace: true }); + return; + } + + try { + setStatus('토큰 교환 중...'); + await exchangeGoogleCode(code); + sessionStorage.setItem(GOOGLE_OAUTH_SUCCESS_KEY, 'true'); + sessionStorage.removeItem(GOOGLE_STATE_KEY); + navigate('/admin/calendar-sync', { replace: true }); + } catch (err) { + const message = + err instanceof Error + ? err.message + : 'Google 인증에 실패했습니다. 다시 시도해주세요.'; + sessionStorage.setItem(GOOGLE_OAUTH_ERROR_KEY, message); + sessionStorage.removeItem(GOOGLE_STATE_KEY); + navigate('/admin/calendar-sync', { replace: true }); + } + }; + + handleCallback(); + }, [navigate]); + + return ( +
+ {status} +
+ ); +}; + +export default GoogleCallbackPage; diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx index 06dd814c7..85d617166 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import Footer from '@/components/common/Footer/Footer'; import Header from '@/components/common/Header/Header'; @@ -6,12 +6,16 @@ import UnderlineTabs from '@/components/common/UnderlineTabs/UnderlineTabs'; import { PAGE_VIEW, USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; -import { useGetClubDetail } from '@/hooks/Queries/useClub'; +import { + useGetClubCalendarEvents, + useGetClubDetail, +} from '@/hooks/Queries/useClub'; import { useScrollTo } from '@/hooks/Scroll/useScrollTo'; import useDevice from '@/hooks/useDevice'; import ClubFeed from '@/pages/ClubDetailPage/components/ClubFeed/ClubFeed'; import ClubIntroContent from '@/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent'; import ClubProfileCard from '@/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard'; +import ClubScheduleCalendar from '@/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar'; import isInAppWebView from '@/utils/isInAppWebView'; import * as Styled from './ClubDetailPage.styles'; import ClubDetailFooter from './components/ClubDetailFooter/ClubDetailFooter'; @@ -20,11 +24,12 @@ import ClubDetailTopBar from './components/ClubDetailTopBar/ClubDetailTopBar'; export const TAB_TYPE = { INTRO: 'intro', PHOTOS: 'photos', + SCHEDULE: 'schedule', } as const; type TabType = (typeof TAB_TYPE)[keyof typeof TAB_TYPE]; -// 소개내용/활동사진 탭 클릭 시 스크롤이 탑바 하단에 정확히 위치하도록 하는 높이 값 +// 탭 클릭 시 스크롤이 탑바 하단에 정확히 위치하도록 하는 높이 값 const TOP_BAR_HEIGHT = 50; const ClubDetailPage = () => { @@ -33,11 +38,6 @@ const ClubDetailPage = () => { const [searchParams, setSearchParams] = useSearchParams(); const tabParam = searchParams.get('tab') as TabType | null; - const activeTab: TabType = - tabParam && Object.values(TAB_TYPE).includes(tabParam) - ? tabParam - : TAB_TYPE.INTRO; - const { clubId, clubName } = useParams<{ clubId: string; clubName: string; @@ -49,7 +49,63 @@ const ClubDetailPage = () => { (clubName ?? clubId) || '', ); - useTrackPageView(PAGE_VIEW.CLUB_DETAIL_PAGE, clubDetail?.name, !clubDetail); + const hasCalendarConnection = clubDetail?.hasCalendarConnection ?? false; + + const activeTab: TabType = useMemo(() => { + if (!tabParam || !Object.values(TAB_TYPE).includes(tabParam)) { + return TAB_TYPE.INTRO; + } + if (tabParam === TAB_TYPE.SCHEDULE && !hasCalendarConnection) { + return TAB_TYPE.INTRO; + } + return tabParam; + }, [tabParam, hasCalendarConnection]); + + useEffect(() => { + if ( + clubDetail && + tabParam === TAB_TYPE.SCHEDULE && + !hasCalendarConnection + ) { + setSearchParams({ tab: TAB_TYPE.INTRO }, { replace: true }); + } + }, [clubDetail, tabParam, hasCalendarConnection, setSearchParams]); + + const { data: calendarEvents = [] } = useGetClubCalendarEvents( + (clubName ?? clubId) || '', + { enabled: hasCalendarConnection && activeTab === TAB_TYPE.SCHEDULE }, + ); + + const tabs = useMemo( + () => + [ + { key: TAB_TYPE.INTRO, label: '소개 내용' }, + { key: TAB_TYPE.PHOTOS, label: '활동사진' }, + hasCalendarConnection + ? { key: TAB_TYPE.SCHEDULE, label: '일정 보기' } + : null, + ].filter(Boolean) as Array<{ key: TabType; label: string }>, + [hasCalendarConnection], + ); + + const topBarTabs = useMemo( + () => + [ + { key: TAB_TYPE.INTRO, label: '소개내용' }, + { key: TAB_TYPE.PHOTOS, label: '활동사진' }, + hasCalendarConnection + ? { key: TAB_TYPE.SCHEDULE, label: '일정 보기' } + : null, + ].filter(Boolean) as Array<{ key: TabType; label: string }>, + [hasCalendarConnection], + ); + + useTrackPageView( + PAGE_VIEW.CLUB_DETAIL_PAGE, + clubDetail?.name, + !clubDetail, + clubDetail?.recruitmentStatus, + ); const contentRef = useRef(null); const { scrollToElement } = useScrollTo(); @@ -64,7 +120,9 @@ const ClubDetailPage = () => { trackEvent( tabKey === TAB_TYPE.INTRO ? USER_EVENT.CLUB_INTRO_TAB_CLICKED - : USER_EVENT.CLUB_FEED_TAB_CLICKED, + : tabKey === TAB_TYPE.PHOTOS + ? USER_EVENT.CLUB_FEED_TAB_CLICKED + : USER_EVENT.CLUB_SCHEDULE_TAB_CLICKED, ); }, [setSearchParams, trackEvent], @@ -85,10 +143,7 @@ const ClubDetailPage = () => { { handleTabClick(tabKey as TabType); @@ -110,10 +165,7 @@ const ClubDetailPage = () => { handleTabClick(tabKey as TabType)} centerOnMobile @@ -134,6 +186,18 @@ const ClubDetailPage = () => { > + {hasCalendarConnection && ( +
+ +
+ )}
diff --git a/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.styles.ts b/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.styles.ts new file mode 100644 index 000000000..9a1d0e7a3 --- /dev/null +++ b/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.styles.ts @@ -0,0 +1,192 @@ +import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; + +export const Container = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; +`; + +export const CalendarCard = styled.section` + border-radius: 20px; + border: 1px solid ${colors.gray[200]}; + background-color: ${colors.base.white}; + padding: 16px 14px; +`; + +export const MonthHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +`; + +export const MonthLabel = styled.h3` + font-size: 30px; + line-height: 1; + font-weight: 700; + color: ${colors.gray[900]}; +`; + +export const MonthMoveButton = styled.button` + width: 28px; + height: 28px; + border: none; + border-radius: 999px; + background-color: ${colors.gray[100]}; + color: ${colors.gray[700]}; + font-size: 16px; + cursor: pointer; +`; + +export const WeekdayGrid = styled.div` + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 6px; + margin-bottom: 8px; +`; + +export const Weekday = styled.span<{ $dayIndex: number }>` + text-align: center; + font-size: 12px; + font-weight: 700; + color: ${({ $dayIndex }) => + $dayIndex === 0 + ? colors.secondary[1].main + : $dayIndex === 6 + ? colors.secondary[4].main + : colors.gray[700]}; +`; + +export const DayGrid = styled.div` + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 6px; +`; + +export const DayCell = styled.button<{ + $isCurrentMonth: boolean; + $isSelected: boolean; +}>` + border: none; + border-radius: 12px; + min-height: 54px; + background-color: ${({ $isSelected }) => + $isSelected ? colors.gray[100] : 'transparent'}; + color: ${({ $isCurrentMonth }) => + $isCurrentMonth ? colors.gray[900] : colors.gray[500]}; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + cursor: pointer; + padding: 6px 0; +`; + +export const DayNumber = styled.span<{ $highlightColor?: string }>` + width: 28px; + height: 28px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 600; + background-color: ${({ $highlightColor }) => + $highlightColor ?? 'transparent'}; + color: ${({ $highlightColor }) => + $highlightColor ? colors.base.white : 'inherit'}; +`; + +export const DotRow = styled.div` + display: flex; + align-items: center; + gap: 4px; + min-height: 6px; +`; + +export const Dot = styled.span<{ $color: string }>` + width: 6px; + height: 6px; + border-radius: 999px; + background-color: ${({ $color }) => $color}; +`; + +export const ScheduleCard = styled.section` + border-radius: 20px; + border: 1px solid ${colors.gray[200]}; + background-color: ${colors.base.white}; + padding: 18px 16px; +`; + +export const SectionTitle = styled.h4` + font-size: 20px; + font-weight: 700; + color: ${colors.gray[900]}; + margin-bottom: 14px; +`; + +export const EventList = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +export const EventItem = styled.article` + border-radius: 12px; + background-color: ${colors.gray[100]}; + padding: 12px; +`; + +export const EventHeader = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +`; + +export const EventTitle = styled.p` + font-size: 17px; + font-weight: 700; + color: ${colors.gray[900]}; + overflow-wrap: anywhere; +`; + +export const EventDescription = styled.p` + font-size: 14px; + line-height: 1.45; + color: ${colors.gray[700]}; +`; + +export const EmptyText = styled.p` + font-size: 15px; + color: ${colors.gray[600]}; +`; + +export const EventLink = styled.a` + font-size: 13px; + font-weight: 600; + color: ${colors.gray[800]}; + text-decoration: underline; + text-underline-offset: 3px; +`; + +export const SelectedDateLabel = styled.p` + font-size: 14px; + color: ${colors.gray[700]}; + margin-bottom: 10px; +`; + +export const MobilePanelHint = styled.p` + display: none; + + ${media.tablet} { + display: block; + font-size: 13px; + color: ${colors.gray[600]}; + margin-bottom: 8px; + } +`; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.tsx b/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.tsx new file mode 100644 index 000000000..a06420539 --- /dev/null +++ b/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.tsx @@ -0,0 +1,307 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { ClubCalendarEvent } from '@/types/club'; +import { + buildDateKeyFromDate, + buildMonthCalendarDays, + dateFromKey, + formatMonthLabel, + parseDateKey, + WEEKDAY_LABELS, +} from '@/utils/calendarSyncUtils'; +import * as Styled from './ClubScheduleCalendar.styles'; + +const EVENT_COLORS = [ + '#FF7DA4', + '#FFD54A', + '#5FD8C0', + '#7094FF', + '#FFA04D', + '#C379F6', + '#7ED957', + '#4FC3F7', +] as const; + +interface CalendarEventItem { + id: string; + title: string; + description?: string; + url?: string; + dateKey: string; + groupKey: string; +} + +interface ClubScheduleCalendarProps { + events: ClubCalendarEvent[]; +} + +const normalizeEventGroupKey = (title: string) => { + const normalized = title + .toLowerCase() + .replace(/[\d]+/g, '') + .replace(/\([^)]*\)/g, '') + .replace(/[^가-힣a-z\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + return normalized || title.trim().toLowerCase(); +}; + +const hashText = (value: string) => { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + return hash; +}; + +const buildSeededPalette = (seed: string) => { + const palette = [...EVENT_COLORS]; + let hash = hashText(seed) || 1; + + for (let index = palette.length - 1; index > 0; index -= 1) { + hash = (hash * 1664525 + 1013904223) >>> 0; + const swapIndex = hash % (index + 1); + [palette[index], palette[swapIndex]] = [palette[swapIndex], palette[index]]; + } + + return palette; +}; + +const formatSelectedDate = (dateKey: string) => { + const date = dateFromKey(dateKey); + return `${date.getMonth() + 1}월 ${date.getDate()}일`; +}; + +const ClubScheduleCalendar = ({ events }: ClubScheduleCalendarProps) => { + const didInitFromEventsRef = useRef(false); + + const parsedEvents = useMemo(() => { + const normalizedEvents = events.flatMap((event) => { + const dateKey = parseDateKey(event.start); + if (!dateKey) return []; + + return { + id: event.id, + title: event.title, + description: event.description, + url: event.url, + dateKey, + groupKey: normalizeEventGroupKey(event.title), + } satisfies CalendarEventItem; + }); + + return normalizedEvents.sort((a, b) => a.dateKey.localeCompare(b.dateKey)); + }, [events]); + + const firstEventMonth = parsedEvents[0] + ? dateFromKey(parsedEvents[0].dateKey) + : new Date(); + const [visibleMonth, setVisibleMonth] = useState( + new Date(firstEventMonth.getFullYear(), firstEventMonth.getMonth(), 1), + ); + + const monthKey = `${visibleMonth.getFullYear()}-${visibleMonth.getMonth() + 1}`; + + const monthEvents = useMemo(() => { + return parsedEvents.filter((event) => { + const date = dateFromKey(event.dateKey); + return ( + date.getFullYear() === visibleMonth.getFullYear() && + date.getMonth() === visibleMonth.getMonth() + ); + }); + }, [parsedEvents, visibleMonth]); + + const colorByGroup = useMemo(() => { + const groups = Array.from( + new Set(monthEvents.map((event) => event.groupKey)), + ); + const palette = buildSeededPalette(monthKey); + return groups.reduce>( + (accumulator, group, index) => { + accumulator[group] = palette[index % palette.length]; + return accumulator; + }, + {}, + ); + }, [monthEvents, monthKey]); + + const eventsByDate = useMemo(() => { + return monthEvents.reduce>( + (accumulator, event) => { + if (!accumulator[event.dateKey]) { + accumulator[event.dateKey] = []; + } + accumulator[event.dateKey].push(event); + return accumulator; + }, + {}, + ); + }, [monthEvents]); + + const [selectedDateKey, setSelectedDateKey] = useState(() => { + if (parsedEvents[0]) return parsedEvents[0].dateKey; + return buildDateKeyFromDate(new Date()); + }); + + useEffect(() => { + if (didInitFromEventsRef.current || parsedEvents.length === 0) return; + const firstDate = dateFromKey(parsedEvents[0].dateKey); + setVisibleMonth(new Date(firstDate.getFullYear(), firstDate.getMonth(), 1)); + setSelectedDateKey(parsedEvents[0].dateKey); + didInitFromEventsRef.current = true; + }, [parsedEvents]); + + const calendarDays = useMemo( + () => buildMonthCalendarDays(visibleMonth), + [visibleMonth], + ); + + const selectedEvents = eventsByDate[selectedDateKey] ?? []; + + const changeMonth = (diff: number) => { + const next = new Date( + visibleMonth.getFullYear(), + visibleMonth.getMonth() + diff, + 1, + ); + setVisibleMonth(next); + + const firstDayKey = buildDateKeyFromDate(next); + const firstEventInMonth = parsedEvents.find((event) => { + const date = dateFromKey(event.dateKey); + return ( + date.getFullYear() === next.getFullYear() && + date.getMonth() === next.getMonth() + ); + }); + setSelectedDateKey(firstEventInMonth?.dateKey ?? firstDayKey); + }; + + if (parsedEvents.length === 0) { + return ( + + 일정 + 등록된 행사 일정이 없습니다. + + ); + } + + return ( + + + + changeMonth(-1)} + > + ◀ + + + {formatMonthLabel(visibleMonth)} + + changeMonth(1)} + > + ▶ + + + + + {WEEKDAY_LABELS.map((day, dayIndex) => ( + + {day} + + ))} + + + + {calendarDays.map((day) => { + const dateKey = buildDateKeyFromDate(day); + const dayEvents = eventsByDate[dateKey] ?? []; + const firstColor = dayEvents[0] + ? colorByGroup[dayEvents[0].groupKey] + : undefined; + const isCurrentMonth = day.getMonth() === visibleMonth.getMonth(); + const uniqueColors = Array.from( + new Set( + dayEvents + .map((event) => colorByGroup[event.groupKey]) + .filter((color): color is string => !!color), + ), + ); + + return ( + setSelectedDateKey(dateKey)} + > + + {day.getDate()} + + + {uniqueColors.slice(0, 3).map((color) => ( + + ))} + + + ); + })} + + + + + 일정 + + {formatSelectedDate(selectedDateKey)} + + + 같은 이름 계열의 이벤트는 같은 색으로 표시됩니다. + + + {selectedEvents.length === 0 ? ( + + 선택한 날짜에 등록된 일정이 없습니다. + + ) : ( + + {selectedEvents.map((event) => { + const eventColor = + colorByGroup[event.groupKey] ?? EVENT_COLORS[0]; + return ( + + + + {event.title} + + {event.description && ( + + {event.description} + + )} + {event.url && ( + + 일정 상세 보기 + + )} + + ); + })} + + )} + + + ); +}; + +export default ClubScheduleCalendar; diff --git a/frontend/src/pages/ClubUnionPage/ClubUnionPage.styles.ts b/frontend/src/pages/ClubUnionPage/ClubUnionPage.styles.ts index 13e67a94b..530faabaf 100644 --- a/frontend/src/pages/ClubUnionPage/ClubUnionPage.styles.ts +++ b/frontend/src/pages/ClubUnionPage/ClubUnionPage.styles.ts @@ -1,5 +1,6 @@ import styled from 'styled-components'; import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; export const Title = styled.h1` font-size: 2.5rem; @@ -86,6 +87,7 @@ export const ProfileGrid = styled.div` export const InfoOverlay = styled.div` position: absolute; + inset: 0; top: 0; left: 0; width: 100%; @@ -105,9 +107,13 @@ export const InfoOverlay = styled.div` `; export const ProfileImage = styled.img` - width: 100%; - height: 100%; - object-fit: cover; + width: 80%; + height: 80%; + object-fit: contain; + position: absolute; + top: 42%; + left: 50%; + transform: translate(-50%, -50%); transition: transform 0.3s ease, filter 0.3s ease; @@ -118,17 +124,21 @@ export const NameBadge = styled.div` bottom: 10%; left: 50%; transform: translateX(-50%); - background-color: rgba(255, 255, 255, 0.8); - color: #333; - padding: 5px 15px; - border-radius: 15px; + background-color: ${colors.base.white}; + color: ${colors.base.black}; + padding: 3px 12px; + border-radius: 30px; font-weight: 600; font-size: 1rem; transition: opacity 0.3s ease; white-space: nowrap; + + ${media.mobile} { + font-size: 0.8rem; + } `; -export const ProfileCardContainer = styled.div` +export const ProfileCardContainer = styled.div<{ $bgColor: string }>` position: relative; width: 180px; height: 180px; @@ -136,6 +146,7 @@ export const ProfileCardContainer = styled.div` overflow: hidden; cursor: pointer; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + background-color: ${({ $bgColor }) => $bgColor}; ${media.laptop} { width: 160px; @@ -154,8 +165,9 @@ export const ProfileCardContainer = styled.div` &:hover { ${ProfileImage} { - transform: scale(1.1); + transform: translate(-50%, -50%) scale(1.4); filter: brightness(0.5); + top: 50%; } ${InfoOverlay} { opacity: 1; @@ -184,6 +196,10 @@ export const Description = styled.p` font-size: 0.9rem; line-height: 1.5; margin: 0; + + ${media.tablet} { + display: none; + } `; export const Contact = styled.p` diff --git a/frontend/src/pages/ClubUnionPage/ClubUnionPage.tsx b/frontend/src/pages/ClubUnionPage/ClubUnionPage.tsx index 6cf769d41..6e8bc3763 100644 --- a/frontend/src/pages/ClubUnionPage/ClubUnionPage.tsx +++ b/frontend/src/pages/ClubUnionPage/ClubUnionPage.tsx @@ -6,8 +6,23 @@ import { PAGE_VIEW, USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; import { PageContainer } from '@/styles/PageContainer.styles'; +import { colors } from '@/styles/theme/colors'; import * as Styled from './ClubUnionPage.styles'; +const MEMBER_COLORS = { + PRESIDENT: colors.accent[1][500], + VICE_PRESIDENT: colors.accent[1][500], + PLANNING: colors.accent[1][500], + SECRETARY: colors.accent[1][500], + PROMOTION: colors.accent[1][500], + VOLUNTEER: colors.secondary[1].back, + RELIGION: colors.secondary[2].back, + HOBBY: colors.secondary[3].back, + STUDY: colors.secondary[4].back, + SPORT: colors.secondary[5].back, + PERFORMANCE: colors.secondary[6].back, +}; + const ClubUnionPage = () => { useTrackPageView(PAGE_VIEW.CLUB_UNION_PAGE); const trackEvent = useMixpanelTrack(); @@ -51,7 +66,10 @@ const ClubUnionPage = () => { {CLUB_UNION_MEMBERS.map((member) => ( - + { <>
- {!isInAppWebView() && } - + { category: searchCategory, division, }); + const hasNotification = usePromotionNotification(); const clubs = data?.clubs || []; const totalCount = data?.totalCount ?? clubs.length; @@ -50,7 +52,7 @@ const MainPage = () => { <>
- + diff --git a/frontend/src/pages/PromotionPage/PromotionDetailPage.styles.ts b/frontend/src/pages/PromotionPage/PromotionDetailPage.styles.ts new file mode 100644 index 000000000..5b51afdfe --- /dev/null +++ b/frontend/src/pages/PromotionPage/PromotionDetailPage.styles.ts @@ -0,0 +1,77 @@ +import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; + +export const DesktopHeader = styled.div` + display: block; + padding-top: 98px; + ${media.tablet} { + display: none; + } +`; + +export const Container = styled.div` + width: 100%; + min-height: 100vh; + background: #fff; +`; + +export const MobileTopBar = styled.div` + display: none; + + ${media.tablet} { + display: block; + position: sticky; + top: 0; + z-index: 100; + background-color: ${colors.base.white}; + } +`; + +export const TitleWrapper = styled.div` + max-width: 1180px; + margin: 0px auto; + padding: 20px auto 0px; + + ${media.tablet} { + margin: 20px auto; + } +`; + +export const ContentWrapper = styled.div` + max-width: 1180px; + width: 100%; + margin: 20px auto 66.49px; + + display: flex; + gap: 50px; + + ${media.laptop} { + padding: 0 20px; + gap: 30px; + } + + ${media.tablet} { + flex-direction: column; + gap: 0px; + padding: 0; + } +`; + +export const LeftSection = styled.div` + width: 420px; + + ${media.tablet} { + width: 100%; + order: 2; + } +`; + +export const RightSection = styled.div` + flex: 1; +`; + +export const Message = styled.p` + padding: 40px 18px; + text-align: center; +`; diff --git a/frontend/src/pages/PromotionPage/PromotionDetailPage.tsx b/frontend/src/pages/PromotionPage/PromotionDetailPage.tsx new file mode 100644 index 000000000..3edde9c70 --- /dev/null +++ b/frontend/src/pages/PromotionPage/PromotionDetailPage.tsx @@ -0,0 +1,82 @@ +import { useLayoutEffect } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; +import Footer from '@/components/common/Footer/Footer'; +import Header from '@/components/common/Header/Header'; +import { PAGE_VIEW } from '@/constants/eventName'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; +import { useGetPromotionArticles } from '@/hooks/Queries/usePromotion'; +import PromotionClubCTA from './components/detail/PromotionClubCTA/PromotionClubCTA'; +import PromotionDetailTopBar from './components/detail/PromotionDetailTopBar/PromotionDetailTopBar'; +import PromotionImageGallery from './components/detail/PromotionImageGallery/PromotionImageGallery'; +import PromotionInfoSection from './components/detail/PromotionInfoSection/PromotionInfoSection'; +import PromotionTitleSection from './components/detail/PromotionTitleSection/PromotionTitleSection'; +import RelatedPromotionSection from './components/detail/RelatedPromotionSection/RelatedPromotionSection'; +import * as Styled from './PromotionDetailPage.styles'; + +const PromotionDetailPage = () => { + useTrackPageView(PAGE_VIEW.PROMOTION_DETAIL_PAGE); + + const { promotionId } = useParams<{ promotionId: string }>(); + const { data, isLoading, isError } = useGetPromotionArticles(); + + const article = data?.find((item) => item.id === promotionId) ?? null; + const showRelatedPromotion = false; // 관련 이벤트 추천 기능은 현재 비활성화 상태 + const { pathname } = useLocation(); + + useLayoutEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + + return ( + <> + +
+ + + + + + + {isLoading && 로딩 중...} + {isError && 오류가 발생했습니다.} + + {!isLoading && !isError && !article && ( + 존재하지 않는 이벤트입니다. + )} + + {!isLoading && !isError && article && ( + <> + + + + + + + + + {/* + TODO: 관련 이벤트 추천 기능 + 현재는 기획 미정으로 비활성화 상태. + showRelatedPromotion 값을 true로 변경하면 활성화됨. + */} + {showRelatedPromotion && ( + + )} + + + + + + + + )} + +