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 (
-
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 && (
+
{/* 댓글 작성 폼 */}
- {isActive && (
+ {isAuthenticated && isActive && (
<>
{!hasVoted ? (