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/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/pages/group-study-list-page.tsx b/src/components/pages/group-study-list-page.tsx index 58c8d477..e9117aab 100644 --- a/src/components/pages/group-study-list-page.tsx +++ b/src/components/pages/group-study-list-page.tsx @@ -20,6 +20,7 @@ import { useGetStudies } from '@/hooks/queries/study-query'; import GroupStudyFormModal from '../../features/study/group/ui/group-study-form-modal'; import GroupStudyPagination from '../../features/study/group/ui/group-study-pagination'; import GroupStudyList from '../lists/group-study-list'; +import MyParticipatingStudiesSection from '../section/my-participating-studies-section'; // Carousel이 클라이언트 전용이므로 dynamic import로 로드 const Banner = dynamic(() => import('@/widgets/home/banner'), { @@ -38,12 +39,18 @@ export default function GroupStudyListPage() { const [searchQuery, setSearchQuery] = useState(''); // URL에서 필터 값 읽기 + // 기본값: recruiting = true (모집 중만 보기) + // 사용자가 토글을 조작하면 URL에 명시적으로 저장됨 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 +74,8 @@ export default function GroupStudyListPage() { 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 +86,16 @@ export default function GroupStudyListPage() { 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 +111,8 @@ export default function GroupStudyListPage() { ); // 필터 변경 핸들러 + // recruiting 토글: true면 'true' 저장, false면 'false' 명시적으로 저장 + // (기본값이 true이므로 false일 때도 명시적으로 저장해야 토글이 정상 작동) const handleFilterChange = useCallback( (values: StudyFilterValues) => { updateSearchParams({ @@ -106,7 +122,9 @@ export default function GroupStudyListPage() { ? 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 +176,9 @@ export default function GroupStudyListPage() {
+ {/* 내가 참여중인 스터디 섹션 */} + + {/* 헤더 */}

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/features/study/group/api/group-study-types.ts b/src/features/study/group/api/group-study-types.ts index 696da154..753e1e66 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/model/use-member-study-list-query.ts b/src/features/study/group/model/use-member-study-list-query.ts index ff6e1c22..dfcb0c27 100644 --- a/src/features/study/group/model/use-member-study-list-query.ts +++ b/src/features/study/group/model/use-member-study-list-query.ts @@ -33,5 +33,6 @@ export const useMemberStudyListQuery = ({ completedPage, completedPageSize, }), + enabled: memberId > 0, // memberId가 유효할 때만 쿼리 실행 }); };