diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..d373cdc2 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,89 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "ko-KR" +early_access: false + +reviews: + profile: "chill" + request_changes_workflow: false + high_level_summary: true + high_level_summary_placeholder: "@coderabbitai summary" + poem: true + review_status: true + review_details: false + + # 자동 리뷰 설정 + auto_review: + enabled: true + # drafts: false = 드래프트 PR은 리뷰하지 않음 + # drafts: true = 드래프트 PR도 리뷰함 (모든 브랜치에 적용되는 게 아니라 드래프트 PR 포함 여부) + drafts: true + auto_incremental_review: true + base_branches: + - develop + - main + + # 경로 필터 (필요시 조정) + path_filters: [] + + # 도구 설정 + tools: + # ESLint: JavaScript/TypeScript 린터 (프로젝트에서 사용 중) + eslint: + enabled: true + # Biome: 빠른 포맷터/린터 (프로젝트에서 사용 안 함 - 비활성화 권장) + biome: + enabled: false + # Markdownlint: Markdown 파일 린터 + markdownlint: + enabled: true + # GitHub Checks: GitHub의 체크 상태 표시 + github-checks: + enabled: true + # Gitleaks: 시크릿(API 키, 비밀번호 등) 유출 검사 + gitleaks: + enabled: true + # OSV Scanner: 오픈소스 취약점 스캐너 + osvScanner: + enabled: true + +chat: + auto_reply: true + art: true + +# 지식 베이스 설정 +knowledge_base: + opt_out: false + web_search: + enabled: true + code_guidelines: + enabled: true + # 코드 가이드라인 파일 패턴 (실제 존재하는 파일만 포함) + filePatterns: + - "**/CLAUDE.md" # ✅ 존재함 (루트에 있음) + # - "**/.cursorrules" # ❌ 존재하지 않음 + # - ".github/copilot-instructions.md" # ❌ 존재하지 않음 + # - ".github/instructions/*.instructions.md" # ❌ 존재하지 않음 + pull_requests: + scope: "auto" + issues: + scope: "auto" + +# 코드 생성 설정 +code_generation: + docstrings: + language: "ko-KR" + path_instructions: [] + unit_tests: + path_instructions: [] + +# 이슈 풍부화 설정 +issue_enrichment: + planning: + enabled: true + auto_planning: + enabled: true + labels: [] + labeling: + auto_apply_labels: false + labeling_instructions: [] + diff --git a/.prettierignore b/.prettierignore index 555e66ac..cb737a04 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,4 +4,7 @@ build/ # OpenAPI auto-generated files src/api/openapi -**/*.json \ No newline at end of file +**/*.json + +# CodeRabbit config +.coderabbit.yaml \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index fa0942f5..51ef58b3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,7 @@ import type { NextConfig } from 'next'; +import type { RemotePattern } from 'next/dist/shared/lib/image-config'; + +const isProd = process.env.NODE_ENV === 'production'; const nextConfig: NextConfig = { // output: 'standalone', @@ -31,7 +34,16 @@ const nextConfig: NextConfig = { hostname: 'www.zeroone.it.kr', pathname: '/**', }, - // CMS 개발 환경에서 사용하는 이미지 도메인 허용 설정 + ...(isProd + ? ([] as RemotePattern[]) + : ([ + { + protocol: 'http', + hostname: 'localhost', + port: '8080', + pathname: '/**', + }, + ] as RemotePattern[])), { protocol: 'http', hostname: 'localhost', diff --git a/src/app/(service)/home/home-content.tsx b/src/app/(service)/home/home-content.tsx index b20f2849..47f639eb 100644 --- a/src/app/(service)/home/home-content.tsx +++ b/src/app/(service)/home/home-content.tsx @@ -5,18 +5,24 @@ import CommunityTab from '@/features/study/one-to-one/balance-game/ui/community- import HallOfFameTab from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab'; import StudyHistoryTab from '@/features/study/one-to-one/history/ui/study-history-tab'; import StudyTab from '@/features/study/one-to-one/schedule/ui/home-study-tab'; +import { getServerCookie } from '@/utils/server-cookie'; +import { isNumeric } from '@/utils/validation'; interface HomeContentProps { activeTab: string; } -export default function HomeContent({ activeTab }: HomeContentProps) { +export default async function HomeContent({ activeTab }: HomeContentProps) { + const isHistoryTab = activeTab === 'history'; + const memberIdStr = isHistoryTab ? await getServerCookie('memberId') : null; + const isLoggedIn = !!memberIdStr && isNumeric(memberIdStr); + const renderTabContent = () => { switch (activeTab) { case 'study': return ; case 'history': - return ; + return isLoggedIn ? : ; case 'ranking': return ; case 'archive': diff --git a/src/app/(service)/layout.tsx b/src/app/(service)/layout.tsx index 41b1a551..d99c0c6e 100644 --- a/src/app/(service)/layout.tsx +++ b/src/app/(service)/layout.tsx @@ -39,12 +39,12 @@ export default function ServiceLayout({ return ( {GTM_ID && } - + -
+
-
{children}
+
{children}
diff --git a/src/components/card/voting-card.tsx b/src/components/card/voting-card.tsx index 81b2b836..365e2eb1 100644 --- a/src/components/card/voting-card.tsx +++ b/src/components/card/voting-card.tsx @@ -90,7 +90,7 @@ export default function VotingCard({ voting, onClick }: VotingCardProps) {

)} - {/* 간단한 투표 결과 미리보기 (투표했을 때만) */} + {/* 간단한 투표 결과 미리보기 */} {hasVoted && (
diff --git a/src/components/filtering/study-filter.tsx b/src/components/filtering/study-filter.tsx index 031dae3b..41cbdda6 100644 --- a/src/components/filtering/study-filter.tsx +++ b/src/components/filtering/study-filter.tsx @@ -167,15 +167,16 @@ export default function StudyFilter({ values, onChange }: StudyFilterProps) { type: [], targetRoles: [], method: [], - recruiting: false, + recruiting: true, // 기본값: 모집 중만 보기 }); }, [onChange]); + // recruiting은 기본값이므로 필터로 간주하지 않음 const hasAnyFilter = values.type.length > 0 || values.targetRoles.length > 0 || values.method.length > 0 || - values.recruiting; + !values.recruiting; // recruiting이 false면 필터 적용 중 return (
diff --git a/src/components/home/tab-navigation.tsx b/src/components/home/tab-navigation.tsx index 13a09619..5f791d13 100644 --- a/src/components/home/tab-navigation.tsx +++ b/src/components/home/tab-navigation.tsx @@ -8,7 +8,9 @@ import { History, } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; +import { getCookie } from '@/api/client/cookie'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import { useAuth } from '@/hooks/common/use-auth'; interface TabNavigationProps { activeTab: string; @@ -50,6 +52,12 @@ const TABS = [ export default function TabNavigation({ activeTab }: TabNavigationProps) { const router = useRouter(); const searchParams = useSearchParams(); + const { isAuthenticated } = useAuth(); + const hasMemberId = !!getCookie('memberId'); + const canViewHistory = isAuthenticated && hasMemberId; + const visibleTabs = canViewHistory + ? TABS + : TABS.filter((tab) => tab.id !== 'history'); const handleTabChange = (tabId: string) => { const params = new URLSearchParams(searchParams.toString()); @@ -64,7 +72,7 @@ export default function TabNavigation({ activeTab }: TabNavigationProps) {
+ {/* 내가 참여중인 스터디 섹션 */} + + {/* 헤더 */}

diff --git a/src/components/pages/premium-study-list-page.tsx b/src/components/pages/premium-study-list-page.tsx index 0d9ed711..a4cb5676 100644 --- a/src/components/pages/premium-study-list-page.tsx +++ b/src/components/pages/premium-study-list-page.tsx @@ -20,6 +20,7 @@ import Button from '@/components/ui/button'; import GroupStudyFormModal from '@/features/study/group/ui/group-study-form-modal'; import { useAuth } from '@/hooks/common/use-auth'; import { useGetStudies } from '@/hooks/queries/study-query'; +import MyParticipatingStudiesSection from '../section/my-participating-studies-section'; // Carousel이 클라이언트 전용이므로 dynamic import로 로드 const Banner = dynamic(() => import('@/widgets/home/banner'), { @@ -37,13 +38,16 @@ export default function PremiumStudyListPage() { // 로컬 검색 상태 const [searchQuery, setSearchQuery] = useState(''); - // URL에서 필터 값 읽기 + // URL에서 필터 값 읽기 (기본값: recruiting = true, 모집 중만 보기) const filterValues = useMemo(() => { const type = searchParams.get('type')?.split(',').filter(Boolean) ?? []; const targetRoles = searchParams.get('targetRoles')?.split(',').filter(Boolean) ?? []; const method = searchParams.get('method')?.split(',').filter(Boolean) ?? []; - const recruiting = searchParams.get('recruiting') === 'true'; + // URL에 recruiting 파라미터가 없으면 기본값 true (모집 중만 보기) + const recruitingParam = searchParams.get('recruiting'); + const recruiting = + recruitingParam === null ? true : recruitingParam === 'true'; return { type, targetRoles, method, recruiting }; }, [searchParams]); @@ -67,7 +71,8 @@ export default function PremiumStudyListPage() { filterValues.method.length > 0 ? (filterValues.method as GetGroupStudiesMethodEnum[]) : undefined, - recruiting: filterValues.recruiting || undefined, + // 기본값: true (모집 중만), false면 전체 조회 + recruiting: filterValues.recruiting ? true : undefined, }); const allStudies = useMemo(() => data?.content ?? [], [data?.content]); @@ -78,10 +83,16 @@ export default function PremiumStudyListPage() { const params = new URLSearchParams(searchParams.toString()); Object.entries(updates).forEach(([key, value]) => { - if (value === undefined || value === '' || value === 'false') { + if (value === undefined || value === '') { params.delete(key); } else { - params.set(key, value); + // recruiting의 경우 'false'도 명시적으로 저장 (기본값이 true이므로) + // 다른 필터는 'false'일 때 파라미터 제거 + if (key === 'recruiting' || value !== 'false') { + params.set(key, value); + } else { + params.delete(key); + } } }); @@ -97,6 +108,8 @@ export default function PremiumStudyListPage() { ); // 필터 변경 핸들러 + // recruiting 토글: true면 'true' 저장, false면 'false' 명시적으로 저장 + // (기본값이 true이므로 false일 때도 명시적으로 저장해야 토글이 정상 작동) const handleFilterChange = useCallback( (values: StudyFilterValues) => { updateSearchParams({ @@ -106,7 +119,9 @@ export default function PremiumStudyListPage() { ? values.targetRoles.join(',') : undefined, method: values.method.length > 0 ? values.method.join(',') : undefined, - recruiting: values.recruiting ? 'true' : undefined, + // recruiting: true면 'true', false면 'false' 명시적으로 저장 + // (기본값이 true이므로 false일 때도 URL에 저장해야 토글 해제 가능) + recruiting: values.recruiting ? 'true' : 'false', }); }, [updateSearchParams], @@ -158,6 +173,9 @@ export default function PremiumStudyListPage() {

+ {/* 내가 참여중인 스터디 섹션 */} + + {/* 헤더 */}

diff --git a/src/components/section/my-participating-studies-section.tsx b/src/components/section/my-participating-studies-section.tsx new file mode 100644 index 00000000..0f204fa0 --- /dev/null +++ b/src/components/section/my-participating-studies-section.tsx @@ -0,0 +1,198 @@ +'use client'; + +import Link from 'next/link'; +import Image from 'next/image'; +import { useMemo } from 'react'; +import { useAuth } from '@/hooks/common/use-auth'; +import { useMemberStudyListQuery } from '@/features/study/group/model/use-member-study-list-query'; +import { useGetStudies } from '@/hooks/queries/study-query'; +import StudyCard from '@/components/card/study-card'; +import { sendGTMEvent } from '@next/third-parties/google'; +import { hashValue } from '@/utils/hash'; + +interface MyParticipatingStudiesSectionProps { + classification: 'GROUP_STUDY' | 'PREMIUM_STUDY'; +} + +export default function MyParticipatingStudiesSection({ + classification, +}: MyParticipatingStudiesSectionProps) { + const { data: authData } = useAuth(); + const memberId = authData?.memberId; + + /** + * TODO : PREMIUM_STUDY 에서도 동작확인 필요 (BE 미구현) + */ + + /** + * TODO: 성능 최적화 필요 + * + * '참여중인 스터디 목록' 과 '전체 스터디 목록' 의 데이터타입이 맞지 않아, + * 현재 구현은 '참여중인 스터디 목록' ID set 을 만들고, 모든 '전체 스터디 목록'을 가져온 후 클라이언트에서 필터링하는 방식. + * + * 개선 방안: + * 1. 백엔드 API 개선: 스터디 ID 리스트를 받아서 해당 스터디만 반환하는 API + * GET /api/v1/group-studies/by-ids?ids=1,2,3&classification=GROUP_STUDY + * + * 2. 내 스터디 API 개선: getMemberStudyList에서 카드에 필요한 정보를 모두 반환 + * + */ + + // 내가 참여중인 스터디 ID 목록만 가져오기 + // React Hooks 규칙: hooks는 항상 같은 순서로 호출되어야 하므로 early return 전에 호출 + // memberId가 없으면 enabled: false로 설정하여 실제 API 호출은 하지 않음 + const { data: myStudiesData } = useMemberStudyListQuery({ + memberId: memberId ?? 0, + studyType: classification, + studyStatus: 'NOT_COMPLETED', // 진행 중과 모집 중 모두 포함 + inProgressPage: 1, + inProgressPageSize: 100, // 충분히 많이 가져오기 + completedPage: 1, + completedPageSize: 1, + }); + + // 일반 스터디 목록 가져오기 (카드에 필요한 완전한 정보를 위해) + const { data: allStudiesData, isLoading } = useGetStudies({ + classification, + page: 1, + pageSize: 100, // 충분히 많이 가져와서 필터링 + recruiting: undefined, // 모든 상태 포함 (진행 중, 모집 중 모두) + }); + + // 내가 참여중인 스터디 ID Set 생성 (IN_PROGRESS, RECRUITING) + const participatingStudyIds = useMemo(() => { + if (!myStudiesData?.notCompleted?.content) return new Set(); + + const filtered = myStudiesData.notCompleted.content.filter( + (study) => + study.status === 'IN_PROGRESS' || study.status === 'RECRUITING', + ); + + // GROUP_STUDY인 경우 type 필드로 추가 필터링 + const classificationFiltered = + classification === 'GROUP_STUDY' + ? filtered.filter((study) => study.type === 'GROUP_STUDY') + : filtered; // PREMIUM_STUDY는 type 필드에 없을 수 있으므로 일단 모두 포함 + + return new Set(classificationFiltered.map((study) => study.studyId)); + }, [myStudiesData?.notCompleted?.content, classification]); + + // 내가 참여중인 스터디만 필터링 (최대 3개) + const participatingStudies = useMemo(() => { + if (!allStudiesData?.content || participatingStudyIds.size === 0) { + return []; + } + + // 내가 참여중인 스터디 ID에 해당하는 스터디만 필터링 + const filtered = allStudiesData.content.filter((study) => + participatingStudyIds.has(study.basicInfo?.groupStudyId ?? 0), + ); + + return filtered.slice(0, 3); // 최대 3개만 표시 + }, [allStudiesData?.content, participatingStudyIds]); + + // 비회원은 표시하지 않음 (hooks 호출 후 early return) + if (!memberId) { + return null; + } + + // 로딩 중 + if (isLoading) { + return ( +
+
+

+ 내가 참여중인 스터디 +

+
+
+ 로딩 중... +
+
+ ); + } + + // 스터디가 없는 경우 빈 상태 표시 + if (participatingStudies.length === 0 && !isLoading) { + return ( +
+
+

+ 나의 소중한 스터디 +

+
+
+ 참여중인 스터디가 없습니다. +
+ + 참여하는 스터디가 없습니다. + + + 원하는 주제로 스터디에 참여해 성장 파티를 시작해보세요. + +
+
+
+ ); + } + + // 전체보기 링크 표시 여부 (3개 이상이거나 더 많은 스터디가 있을 경우) + const hasMoreStudies = + participatingStudyIds.size > participatingStudies.length; + + const handleStudyClick = (studyId: number, title: string) => { + sendGTMEvent({ + event: + classification === 'GROUP_STUDY' + ? 'group_study_detail_view' + : 'premium_study_detail_view', + dl_timestamp: new Date().toISOString(), + dl_member_id: hashValue(String(memberId)), + dl_study_id: String(studyId), + dl_study_title: title, + }); + }; + + return ( +
+
+

+ 나의 소중한 스터디 +

+ {hasMoreStudies && ( + + 전체보기 + + )} +
+ +
+ {participatingStudies.map((study) => { + const studyId = study.basicInfo?.groupStudyId ?? 0; + const title = study.simpleDetailInfo?.title ?? ''; + + return ( + handleStudyClick(studyId, title)} + /> + ); + })} +
+
+ ); +} diff --git a/src/components/study-history/study-history-row.tsx b/src/components/study-history/study-history-row.tsx index 851ccef4..77186f2c 100644 --- a/src/components/study-history/study-history-row.tsx +++ b/src/components/study-history/study-history-row.tsx @@ -10,11 +10,13 @@ import { CheckCircle2, } from 'lucide-react'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; -import { ProfileAvatar } from '@/components/ui/profile-avatar'; +import UserAvatar from '@/components/ui/avatar'; import UserProfileModal from '@/entities/user/ui/user-profile-modal'; import { StudyHistoryItem } from '@/types/study-history'; export const StudyHistoryRow = ({ item }: { item: StudyHistoryItem }) => { + const partner = item.partner; + return (
{/* 날짜 */} @@ -33,25 +35,37 @@ export const StudyHistoryRow = ({ item }: { item: StudyHistoryItem }) => { {/* 상대방 */}
- e.stopPropagation()} // 행 클릭 이벤트(링크 이동) 방지 - > - - - {item.partner.name} - -
- } - /> + {partner ? ( + e.stopPropagation()} // 행 클릭 이벤트(링크 이동) 방지 + > + + + {partner.name} + +
+ } + /> + ) : ( +
+ + 상대방 정보 없음 +
+ )}

{/* 역할 */} diff --git a/src/components/ui/avatar/index.tsx b/src/components/ui/avatar/index.tsx index f1192c46..caedac59 100644 --- a/src/components/ui/avatar/index.tsx +++ b/src/components/ui/avatar/index.tsx @@ -33,6 +33,8 @@ export default function Avatar({ image.toUpperCase() !== 'LOCAL' && (image.startsWith('http://') || image.startsWith('https://') || + image.startsWith('blob:') || + image.startsWith('data:') || image.startsWith('/')); const showImage = isValidImage && !isError; diff --git a/src/components/ui/profile-avatar.tsx b/src/components/ui/profile-avatar.tsx deleted file mode 100644 index aff38286..00000000 --- a/src/components/ui/profile-avatar.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import React, { useMemo, useState } from 'react'; -import { cn } from '@/components/ui/(shadcn)/lib/utils'; - -interface ProfileAvatarProps { - src?: string | null; - alt: string; - size?: 'sm' | 'md' | 'lg' | 'xl'; - className?: string; - name?: string; -} - -export const ProfileAvatar = ({ - src, - alt, - size = 'md', - className = '', - name, -}: ProfileAvatarProps) => { - const px = { sm: 32, md: 48, lg: 80, xl: 120 }[size]; // 기존 명예의 전당 사이즈와 최대한 비슷하게 매핑 (sm:32px는 기존 row에 맞춤) - const effectiveAlt = alt || name || 'profile'; - - // src 정리(공백/이상한 상대경로 방지) - const normalizedSrc = useMemo(() => { - if (!src || typeof src !== 'string') return null; - const s = src.trim(); - if (!s) return null; - // 유효하지 않은 값 필터링 (LOCAL, null 등) - if (s.toUpperCase() === 'LOCAL' || s === 'null' || s === 'undefined') - return null; - // LOCAL/로 시작하는 경우 처리 (예: LOCAL/https:/picsum.photos/202) - if (s.toUpperCase().startsWith('LOCAL/')) { - const afterLocal = s.substring(6); // 'LOCAL/'.length = 6 - // LOCAL/ 뒤에 실제 URL이 있는 경우 - if ( - afterLocal.startsWith('http://') || - afterLocal.startsWith('https://') - ) { - return afterLocal; - } - - // LOCAL/ 뒤에 유효하지 않은 값인 경우 - return null; - } - if ( - s.startsWith('http://') || - s.startsWith('https://') || - s.startsWith('/') - ) - return s; - - return `/${s}`; - }, [src]); - - const [broken, setBroken] = useState(false); - const finalSrc = - !broken && normalizedSrc ? normalizedSrc : '/profile-default.svg'; - - return ( - {effectiveAlt} setBroken(true)} - /> - ); -}; diff --git a/src/components/voting/voting-detail-view.tsx b/src/components/voting/voting-detail-view.tsx index 6b19ba55..7740ee84 100644 --- a/src/components/voting/voting-detail-view.tsx +++ b/src/components/voting/voting-detail-view.tsx @@ -8,6 +8,7 @@ import { MoreVertical, Edit, Trash2, + Lock, } from 'lucide-react'; import React, { useState, useEffect } from 'react'; import CommentForm from '@/components/discussion/comment-form'; @@ -20,6 +21,7 @@ import DailyStatsChart from '@/components/voting/daily-stats-chart'; import VoteResultsChart from '@/components/voting/vote-results-chart'; import VoteTimer from '@/components/voting/vote-timer'; import UserProfileModal from '@/entities/user/ui/user-profile-modal'; +import LoginModal from '@/features/auth/ui/login-modal'; import { useVoteBalanceGameMutation, useCancelVoteBalanceGameMutation, @@ -33,6 +35,7 @@ import { useBalanceGameDetailQuery, useBalanceGameCommentsQuery, } from '@/features/study/one-to-one/balance-game/model/use-balance-game-query'; +import { useAuth } from '@/hooks/common/use-auth'; import { useUserStore } from '@/stores/useUserStore'; import { BalanceGameComment } from '@/types/balance-game'; import { @@ -61,6 +64,7 @@ export default function VotingDetailView({ // User Info const memberId = useUserStore((state) => state.memberId); + const { isAuthenticated } = useAuth(); // Queries const { @@ -109,6 +113,9 @@ export default function VotingDetailView({ }); }, [commentsData]); + const commentTotalCount = + commentsData?.pages?.[0]?.totalElements ?? comments.length; + // isActive는 백엔드가 내려줄 수도 있고(권장), 없으면 endsAt 기준으로 프론트에서 계산 // VoteTimer도 endsAt으로 "종료"를 판단하므로, 두 로직이 어긋나지 않게 맞춘다. const isActiveByEndsAt = React.useMemo(() => { @@ -254,7 +261,7 @@ export default function VotingDetailView({ isActive, }); - const showVoteOptions = !hasVoted && isActive; + const showVoteOptions = isAuthenticated && !hasVoted && isActive; return (
@@ -512,31 +519,50 @@ export default function VotingDetailView({ )}
- +
+
+ +
+ {!isAuthenticated && ( +
+ + + 회원가입 후 결과 보기 + + } + /> +
+ )} +
)}
{/* 일별 통계 (투표 후에만 표시) */} - {hasVoted && voting.dailyStats && voting.dailyStats.length > 0 && ( -
- -
- )} + {isAuthenticated && + hasVoted && + voting.dailyStats && + voting.dailyStats.length > 0 && ( +
+ +
+ )} {/* 댓글 섹션 */}
- 댓글 {voting.commentCount || 0} + 댓글 {commentTotalCount}
{/* 댓글 목록 (항상 표시) */} @@ -564,7 +590,7 @@ export default function VotingDetailView({
{/* 댓글 작성 폼 */} - {isActive && ( + {isAuthenticated && isActive && ( <> {!hasVoted ? (
diff --git a/src/entities/user/ui/user-phone-number-copy-modal.tsx b/src/entities/user/ui/user-phone-number-copy-modal.tsx index 058672cd..948920f4 100644 --- a/src/entities/user/ui/user-phone-number-copy-modal.tsx +++ b/src/entities/user/ui/user-phone-number-copy-modal.tsx @@ -5,9 +5,11 @@ import { Modal } from '@/components/ui/modal'; export default function UserPhoneNumberCopyModal({ trigger, phoneNumber, + realName, }: { trigger: React.ReactNode; phoneNumber: string; + realName?: string; }) { const handleCopy = async () => { try { @@ -27,27 +29,43 @@ export default function UserPhoneNumberCopyModal({ - 전화하기 + 연락하기 - -
- - {phoneNumber} - + +
+
+
+ {realName || '상대방'} +
+
+ 스터디 파트너 +
+
+
+ +
+
+ + 전화번호 + + + {phoneNumber} + +
- +
개인정보 보호를 위해 통화 시 주의해 주세요. - +
diff --git a/src/features/auth/model/types.ts b/src/features/auth/model/types.ts index f891d496..747847d2 100644 --- a/src/features/auth/model/types.ts +++ b/src/features/auth/model/types.ts @@ -2,8 +2,8 @@ export interface SignUpResponse { content: { generatedMemberId: string; - accessToken: string; - refreshToken: string; + accessToken?: string; + refreshToken?: string; }; status: number; message: string; diff --git a/src/features/study/group/api/group-study-types.ts b/src/features/study/group/api/group-study-types.ts index 3f8c9d9a..58d64fb7 100644 --- a/src/features/study/group/api/group-study-types.ts +++ b/src/features/study/group/api/group-study-types.ts @@ -332,7 +332,7 @@ export interface GroupStudyMyStatusResponse { // 회원의 스터디 리스트 조회 API 타입 export interface MemberStudyListRequest { memberId: number; - studyType?: 'BOTH' | 'GROUP_STUDY' | 'ONE_ON_ONE_STUDY'; + studyType?: 'BOTH' | 'GROUP_STUDY' | 'ONE_ON_ONE_STUDY' | 'PREMIUM_STUDY'; studyStatus?: 'BOTH' | 'NOT_COMPLETED' | 'COMPLETED'; inProgressPage?: number; inProgressPageSize?: number; @@ -355,7 +355,7 @@ export interface MemberStudyItem { endTime: string; studyRole: 'PARTICIPANT' | 'LEADER'; status: 'RECRUITING' | 'IN_PROGRESS' | 'COMPLETED'; - type: 'GROUP_STUDY' | 'ONE_ON_ONE_STUDY'; + type: 'GROUP_STUDY' | 'ONE_ON_ONE_STUDY' | 'PREMIUM_STUDY'; } export interface MemberStudyListResponse { diff --git a/src/features/study/group/channel/api/get-comments.ts b/src/features/study/group/channel/api/get-comments.ts index a05f6193..fc554df5 100644 --- a/src/features/study/group/channel/api/get-comments.ts +++ b/src/features/study/group/channel/api/get-comments.ts @@ -1,9 +1,9 @@ import { axiosInstance } from '@/api/client/axios'; -import { GetCommentsRequest, GetCommentsResponse } from './types'; +import { GetCommentsRequest, PaginatedCommentsResponse } from './types'; export const getComments = async ( param: GetCommentsRequest, -): Promise => { +): Promise => { const { groupStudyId, threadId } = param; try { @@ -12,12 +12,12 @@ export const getComments = async ( ); if (data.statusCode !== 200) { - throw new Error('Failed to fetch post'); + throw new Error('Failed to fetch comments'); } - return data.content.content; + return data.content; } catch (err) { - console.error('Error fetching post:', err); + console.error('Error fetching comments:', err); throw err; } }; diff --git a/src/features/study/group/channel/api/get-threads.ts b/src/features/study/group/channel/api/get-threads.ts index 5297a279..8c7b3c09 100644 --- a/src/features/study/group/channel/api/get-threads.ts +++ b/src/features/study/group/channel/api/get-threads.ts @@ -1,23 +1,24 @@ import { axiosInstance } from '@/api/client/axios'; -import { GetThreadsRequest, GetThreadsResponse } from './types'; +import { GetThreadsRequest, PaginatedThreadsResponse } from './types'; export const getThreads = async ( param: GetThreadsRequest, -): Promise => { - const { groupStudyId } = param; +): Promise => { + const { groupStudyId, page = 0, size = 10 } = param; try { const { data } = await axiosInstance.get( `group-studies/${groupStudyId}/threads`, + { params: { page, size } }, ); if (data.statusCode !== 200) { - throw new Error('Failed to fetch post'); + throw new Error('Failed to fetch threads'); } - return data.content.content; + return data.content; } catch (err) { - console.error('Error fetching post:', err); + console.error('Error fetching threads:', err); throw err; } }; diff --git a/src/features/study/group/channel/api/types.ts b/src/features/study/group/channel/api/types.ts index 50358b63..f9cabce0 100644 --- a/src/features/study/group/channel/api/types.ts +++ b/src/features/study/group/channel/api/types.ts @@ -28,7 +28,16 @@ export interface DeleteCommentRequest extends GetCommentsRequest { commentId: number; } -export type GetThreadsRequest = GroupStudyIdParam; +export interface GetThreadsRequest extends GroupStudyIdParam { + page?: number; + size?: number; +} + +export interface PaginatedThreadsResponse { + content: GetThreadsResponse[]; + totalElements: number; + totalPages: number; +} export interface PostThreadRequest extends GroupStudyIdParam { content: string; @@ -76,3 +85,9 @@ export interface GetPostResponse { export interface GetCommentsResponse extends GetThreadsResponse { commentId: number; } + +export interface PaginatedCommentsResponse { + content: GetCommentsResponse[]; + totalElements: number; + totalPages: number; +} diff --git a/src/features/study/group/channel/model/use-channel-query.ts b/src/features/study/group/channel/model/use-channel-query.ts index e7599094..3ff07fae 100644 --- a/src/features/study/group/channel/model/use-channel-query.ts +++ b/src/features/study/group/channel/model/use-channel-query.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query'; import { deleteComment } from '../api/delete-comment'; import { deleteThread } from '../api/delete-thread'; import { getComments } from '../api/get-comments'; @@ -31,11 +31,22 @@ export const usePostQuery = (groupStudyId: number) => { }; // thread -export const useThreadsQuery = (groupStudyId: number) => { +interface UseThreadsQueryParams { + groupStudyId: number; + page?: number; + size?: number; +} + +export const useThreadsQuery = ({ + groupStudyId, + page = 1, + size = 10, +}: UseThreadsQueryParams) => { return useQuery({ - queryKey: ['get-threads', groupStudyId], - queryFn: () => getThreads({ groupStudyId }), - enabled: !!groupStudyId, // id가 존재할 때만 실행 + queryKey: ['get-threads', groupStudyId, page, size], + queryFn: () => getThreads({ groupStudyId, page: page - 1, size }), + enabled: !!groupStudyId, + placeholderData: keepPreviousData, }); }; diff --git a/src/features/study/group/channel/ui/comment-section.tsx b/src/features/study/group/channel/ui/comment-section.tsx index e9b2f070..9b1aa11c 100644 --- a/src/features/study/group/channel/ui/comment-section.tsx +++ b/src/features/study/group/channel/ui/comment-section.tsx @@ -1,27 +1,54 @@ +import { useQueries } from '@tanstack/react-query'; import { MessageCircle } from 'lucide-react'; import { useState } from 'react'; +import Pagination from '@/components/ui/pagination'; import Comment from './comment'; import CommentInput from './comment-input'; import SubComments from './sub-comments'; import { ThreadReaction } from './thread-reaction'; +import { getComments } from '../api/get-comments'; import { usePostThreadMutation, useThreadsQuery, } from '../model/use-channel-query'; +const COMMENTS_PAGE_SIZE = 10; + interface CommentProps { groupStudyId: number; } export default function CommentSection({ groupStudyId }: CommentProps) { + const [page, setPage] = useState(1); const { data, isLoading, + isError, refetch: threadRefetch, - } = useThreadsQuery(groupStudyId); + } = useThreadsQuery({ groupStudyId, page, size: COMMENTS_PAGE_SIZE }); const [threadText, setThreadText] = useState(''); - const [openThreadId, setOpenThreadId] = useState(null); // 👈 변경 + const [openThreadId, setOpenThreadId] = useState(null); + + // 모든 스레드의 답글을 병렬로 조회 + const commentQueries = useQueries({ + queries: (data?.content ?? []).map((thread) => ({ + queryKey: ['comments', groupStudyId, thread.threadId], + queryFn: () => getComments({ groupStudyId, threadId: thread.threadId }), + enabled: !!data, + })), + }); + + // 모든 답글 쿼리 로딩 완료 여부 + const allCommentsLoaded = commentQueries.every((q) => !q.isLoading); + + // 총 댓글 수 (메인 댓글 + 답글) + const totalReplyCount = commentQueries.reduce( + (sum, q) => sum + (q.data?.totalElements ?? 0), + 0, + ); + console.log(data.content, 'data'); + const totalCommentCount = (data?.totalElements ?? 0) + totalReplyCount; const { mutate: createThread } = usePostThreadMutation(); @@ -38,7 +65,26 @@ export default function CommentSection({ groupStudyId }: CommentProps) { ); }; - if (isLoading) return null; // 👈 안전 반환 + // threads 로딩 중이거나 답글 로딩 중이면 대기 + if (isLoading || !allCommentsLoaded) return null; + + if (isError) { + return ( +
+ + 댓글을 불러오는 중 오류가 발생했습니다. + + +
+ ); + } + + const hasComments = (data?.content?.length ?? 0) > 0; return (
@@ -46,12 +92,20 @@ export default function CommentSection({ groupStudyId }: CommentProps) { 댓글 - {data?.length ?? 0}개 + {totalCommentCount}개
- {data?.map((comment) => { + {!hasComments && ( +
+ + 아직 댓글이 없습니다. 첫 번째 댓글을 남겨보세요! + +
+ )} + + {data?.content?.map((comment) => { const isOpen = openThreadId === comment.threadId; const toggle = () => setOpenThreadId((prev) => @@ -89,8 +143,8 @@ export default function CommentSection({ groupStudyId }: CommentProps) {
@@ -104,6 +158,15 @@ export default function CommentSection({ groupStudyId }: CommentProps) { onConfirm={() => handleThreadSubmit(groupStudyId, threadText)} onCancel={() => setThreadText('')} /> + + {(data?.totalPages ?? 0) > 1 && ( + + )}
); diff --git a/src/features/study/group/channel/ui/sub-comments.tsx b/src/features/study/group/channel/ui/sub-comments.tsx index b3cb7b59..6e56a7f8 100644 --- a/src/features/study/group/channel/ui/sub-comments.tsx +++ b/src/features/study/group/channel/ui/sub-comments.tsx @@ -7,6 +7,7 @@ import { useCommentsQuery, usePostCommentMutation, } from '../model/use-channel-query'; +import Button from '@/components/ui/button'; interface SubCommentsProps { threadId: number; @@ -24,9 +25,12 @@ export default function SubComments({ const { data, isLoading, + isError, refetch: commentsRefetch, } = useCommentsQuery(groupStudyId, threadId); + console.log(data, 'subcomment'); + const [commentText, setCommentText] = useState(''); const { mutate: createComment } = usePostCommentMutation(); @@ -48,9 +52,27 @@ export default function SubComments({ if (isLoading) return null; + if (isError) { + return ( +
+ + 답글을 불러오지 못했습니다. + + +
+ ); + } + return (
- {data.map((subComment) => ( + {data?.content?.map((subComment) => (
0, // memberId가 유효할 때만 쿼리 실행 }); }; diff --git a/src/features/study/interview/api/interview-types.ts b/src/features/study/interview/api/interview-types.ts index 7556e251..b2b759eb 100644 --- a/src/features/study/interview/api/interview-types.ts +++ b/src/features/study/interview/api/interview-types.ts @@ -9,9 +9,11 @@ export interface DailyStudyDetail { dailyStudyId: number; interviewerId: number; interviewerName: string; + interviewerRealName?: string; interviewerImage: string; intervieweeId: number; intervieweeName: string; + intervieweeRealName?: string; intervieweeImage: string; partnerTel: string; studySpaceId: number; diff --git a/src/features/study/one-to-one/archive/model/use-archive-actions.ts b/src/features/study/one-to-one/archive/model/use-archive-actions.ts index badc8382..d8d581b3 100644 --- a/src/features/study/one-to-one/archive/model/use-archive-actions.ts +++ b/src/features/study/one-to-one/archive/model/use-archive-actions.ts @@ -2,41 +2,47 @@ import { useCallback } from 'react'; import { useToggleArchiveBookmarkMutation } from '@/features/study/one-to-one/archive/model/use-bookmark-mutation'; import { useToggleArchiveLikeMutation } from '@/features/study/one-to-one/archive/model/use-like-mutation'; import { useRecordArchiveViewMutation } from '@/features/study/one-to-one/archive/model/use-view-mutation'; +import { useAuth } from '@/hooks/common/use-auth'; interface ArchiveViewTarget { id: number; link: string; } export const useArchiveActions = () => { + const { isAuthenticated } = useAuth(); const { mutate: toggleBookmark } = useToggleArchiveBookmarkMutation(); const { mutate: toggleLike } = useToggleArchiveLikeMutation(); const { mutate: recordView } = useRecordArchiveViewMutation(); const handleToggleBookmark = useCallback( (id: number) => { + if (!isAuthenticated) return; toggleBookmark(id); }, - [toggleBookmark], + [isAuthenticated, toggleBookmark], ); const handleToggleLike = useCallback( (id: number) => { + if (!isAuthenticated) return; toggleLike(id); }, - [toggleLike], + [isAuthenticated, toggleLike], ); const openAndRecordView = useCallback( (target: ArchiveViewTarget) => { window.open(target.link, '_blank'); + if (!isAuthenticated) return; recordView(target.id); }, - [recordView], + [isAuthenticated, recordView], ); return { toggleBookmark: handleToggleBookmark, toggleLike: handleToggleLike, openAndRecordView, + isAuthenticated, }; }; diff --git a/src/features/study/one-to-one/archive/ui/archive-filters.tsx b/src/features/study/one-to-one/archive/ui/archive-filters.tsx index bd82ddb4..658e922e 100644 --- a/src/features/study/one-to-one/archive/ui/archive-filters.tsx +++ b/src/features/study/one-to-one/archive/ui/archive-filters.tsx @@ -14,6 +14,7 @@ interface ArchiveFiltersProps { onSearchChange: (value: string) => void; showBookmarkedOnly: boolean; onToggleBookmarkedOnly: () => void; + isAuthenticated: boolean; } export default function ArchiveFilters({ @@ -25,6 +26,7 @@ export default function ArchiveFilters({ onSearchChange, showBookmarkedOnly, onToggleBookmarkedOnly, + isAuthenticated, }: ArchiveFiltersProps) { const sortLabel = ARCHIVE_SORT_OPTIONS.find((option) => option.value === librarySort) @@ -36,11 +38,13 @@ export default function ArchiveFilters({
} /> diff --git a/src/features/study/one-to-one/ui/one-on-one-page.tsx b/src/features/study/one-to-one/ui/one-on-one-page.tsx index 83f6cf78..44ac6eb1 100644 --- a/src/features/study/one-to-one/ui/one-on-one-page.tsx +++ b/src/features/study/one-to-one/ui/one-on-one-page.tsx @@ -26,7 +26,7 @@ import Image from 'next/image'; import Link from 'next/link'; import React, { useState, useEffect } from 'react'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; -import { ProfileAvatar } from '@/components/ui/profile-avatar'; +import UserAvatar from '@/components/ui/avatar'; import UserProfileModal from '@/entities/user/ui/user-profile-modal'; // ---------------------------------------------------------------------- @@ -283,10 +283,10 @@ const TopRankerCard = ({ ranker }: { ranker: Ranker }) => {
)} -
-
diff --git a/src/types/hall-of-fame.ts b/src/types/hall-of-fame.ts index 207ee707..4b543ca1 100644 --- a/src/types/hall-of-fame.ts +++ b/src/types/hall-of-fame.ts @@ -12,11 +12,13 @@ export interface Job { description?: string; // 설명 (예: "게임 개발자") } +import type { ProfileImage } from '@/entities/user/api/types'; + export interface Ranker { rank: number; userId: number; nickname: string; - profileImage: string | null; + profileImage: ProfileImage | null; score: number; sincerity: number | null; major: string; @@ -34,7 +36,7 @@ export interface Rankings { export interface MVPTeamMember { userId: number; nickname: string; - profileImage: string | null; + profileImage: ProfileImage | null; } export interface SharedLink { diff --git a/src/types/study-history.ts b/src/types/study-history.ts index fcae31f5..6dd07878 100644 --- a/src/types/study-history.ts +++ b/src/types/study-history.ts @@ -20,7 +20,7 @@ export interface StudyHistoryContent { memberId: number; nickname: string; profileImageUrl: string | null; - }; + } | null; } export interface PageableResponse { @@ -70,5 +70,5 @@ export interface StudyHistoryItem { id: number; name: string; profileImage: string | null; - }; + } | null; } diff --git a/src/widgets/home/header.tsx b/src/widgets/home/header.tsx index ad8dc964..1089e05e 100644 --- a/src/widgets/home/header.tsx +++ b/src/widgets/home/header.tsx @@ -28,8 +28,8 @@ export default async function Header() { : undefined; return ( -
-
+
+
Logo