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가 유효할 때만 쿼리 실행
});
};