diff --git a/app/(service)/(nav)/study/[id]/page.tsx b/app/(service)/(nav)/study/[id]/page.tsx index 38c8f779..c20dcc26 100644 --- a/app/(service)/(nav)/study/[id]/page.tsx +++ b/app/(service)/(nav)/study/[id]/page.tsx @@ -1,24 +1,7 @@ -import { - HydrationBoundary, - dehydrate, - QueryClient, -} from '@tanstack/react-query'; - -import { prefetchGroupStudyDetail } from '@/features/study/group/model/use-study-query'; - import StudyDetailPage from '@/features/study/group/ui/group-study-detail-page'; export default async function Page({ params }: { params: { id: string } }) { const id = Number(params.id); - const qc = new QueryClient(); - - // 서버에서 미리 패치 (API 호출이 서버에서 실행됨) - await prefetchGroupStudyDetail(qc, id); - - return ( - - - - ); + return ; } diff --git a/src/features/my-page/ui/applicant-list.tsx b/src/features/my-page/ui/applicant-list.tsx index d1465f3a..7a1c8cbe 100644 --- a/src/features/my-page/ui/applicant-list.tsx +++ b/src/features/my-page/ui/applicant-list.tsx @@ -1,7 +1,7 @@ 'use client'; import React from 'react'; -import { useApplicantsByStatusQuery } from '@/features/application/model/use-applicant-qeury'; +import { useApplicantsByStatusQuery } from '@/features/study/group/application/model/use-applicant-qeury'; import ProfileCard from './profile-card'; export default function ApplicantList() { diff --git a/src/features/my-page/ui/profile-card.tsx b/src/features/my-page/ui/profile-card.tsx index 71591a7d..5b78ae3a 100644 --- a/src/features/my-page/ui/profile-card.tsx +++ b/src/features/my-page/ui/profile-card.tsx @@ -1,10 +1,10 @@ import dayjs from 'dayjs'; import Image from 'next/image'; import React from 'react'; +import { GroupStudyApply } from '@/features/study/group/application/api/type'; import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets'; import { cn } from '@/shared/shadcn/lib/utils'; import Button from '@/shared/ui/button'; -import { GroupStudyApply } from '@/features/application/api/type'; export default function ProfileCard(props: { data: GroupStudyApply }) { const { data: applicant } = props; diff --git a/src/features/study/group/api/get-gruoup-study-detail.ts b/src/features/study/group/api/get-gruoup-study-detail.ts index bbb28646..b9cc9e60 100644 --- a/src/features/study/group/api/get-gruoup-study-detail.ts +++ b/src/features/study/group/api/get-gruoup-study-detail.ts @@ -10,8 +10,6 @@ export const getGroupStudyDetail = async ( ): Promise => { const { groupStudyId } = params; - console.log('groupStudyId', groupStudyId); - try { const { data } = await axiosInstance.get(`/group-studies/${groupStudyId}`); @@ -19,8 +17,9 @@ export const getGroupStudyDetail = async ( throw new Error('Failed to fetch group study list'); } - console.log('data', data); - return data.content; - } catch (err) {} + } catch (err) { + console.error('Error in getGroupStudyDetail:', err); + throw err; + } }; diff --git a/src/features/application/api/get-applicants-by-status.ts b/src/features/study/group/application/api/get-applicants-by-status.ts similarity index 77% rename from src/features/application/api/get-applicants-by-status.ts rename to src/features/study/group/application/api/get-applicants-by-status.ts index 262daa22..d5df7660 100644 --- a/src/features/application/api/get-applicants-by-status.ts +++ b/src/features/study/group/application/api/get-applicants-by-status.ts @@ -1,13 +1,13 @@ import { axiosInstance } from '@/shared/tanstack-query/axios'; import { - getApplicantsByStatusRequest, - getApplicantsByStatusResponse, + GetApplicantsByStatusRequest, + GetApplicantsByStatusResponse, } from './type'; // 상태별 스터디 신청자 조회 export const getApplicantsByStatus = async ( - params: getApplicantsByStatusRequest, -): Promise => { + params: GetApplicantsByStatusRequest, +): Promise => { const { page, size, status, groupStudyId } = params; const { data } = await axiosInstance.get( diff --git a/src/features/application/api/type.ts b/src/features/study/group/application/api/type.ts similarity index 91% rename from src/features/application/api/type.ts rename to src/features/study/group/application/api/type.ts index 544ca109..e7756e81 100644 --- a/src/features/application/api/type.ts +++ b/src/features/study/group/application/api/type.ts @@ -54,14 +54,14 @@ export interface ImageSizeType { } // api DTO -export interface getApplicantsByStatusRequest { +export interface GetApplicantsByStatusRequest { groupStudyId: number; page: number; size: number; status: ApplyStatus; } -export interface getApplicantsByStatusResponse { +export interface GetApplicantsByStatusResponse { content: GroupStudyApply[]; page: number; size: number; @@ -71,7 +71,7 @@ export interface getApplicantsByStatusResponse { hasPrevious: boolean; } -export interface updateApplicantByStatusRequest { +export interface UpdateApplicantByStatusRequest { applyId: number; groupStudyId: number; status?: ApplyStatus; diff --git a/src/features/application/api/update-applicant-by-status.ts b/src/features/study/group/application/api/update-applicant-by-status.ts similarity index 83% rename from src/features/application/api/update-applicant-by-status.ts rename to src/features/study/group/application/api/update-applicant-by-status.ts index cd5c85a6..e92237d0 100644 --- a/src/features/application/api/update-applicant-by-status.ts +++ b/src/features/study/group/application/api/update-applicant-by-status.ts @@ -1,9 +1,9 @@ import { axiosInstance } from '@/shared/tanstack-query/axios'; -import { updateApplicantByStatusRequest } from './type'; +import { UpdateApplicantByStatusRequest } from './type'; // 스터디 신청자 상태 변경 export const updateApplicantByStatus = async ( - params: updateApplicantByStatusRequest, + params: UpdateApplicantByStatusRequest, ) => { const { status, groupStudyId, applyId } = params; diff --git a/src/features/application/model/use-applicant-qeury.ts b/src/features/study/group/application/model/use-applicant-qeury.ts similarity index 100% rename from src/features/application/model/use-applicant-qeury.ts rename to src/features/study/group/application/model/use-applicant-qeury.ts diff --git a/src/features/study/group/model/use-study-query.ts b/src/features/study/group/model/use-study-query.ts index 70384396..f2e1ff31 100644 --- a/src/features/study/group/model/use-study-query.ts +++ b/src/features/study/group/model/use-study-query.ts @@ -1,24 +1,7 @@ -import { QueryClient, useQuery } from '@tanstack/react-query'; -import axios from 'axios'; +import { useQuery } from '@tanstack/react-query'; import { getGroupStudyDetail } from '../api/get-gruoup-study-detail'; // study-detail -export const getGroupStudyDetailQueryKey = (id: number) => - ['groupStudyDetail', id] as const; - -export const fetchGroupStudyDetail = async (id: number) => { - const { data } = await axios.get(`/api/v1/group-studies/${id}`); - - return data; -}; - -export const prefetchGroupStudyDetail = (qc: QueryClient, id: number) => - qc.prefetchQuery({ - queryKey: getGroupStudyDetailQueryKey(id), - queryFn: () => fetchGroupStudyDetail(id), - staleTime: 1000 * 60 * 5, - }); - export const useGroupStudyDetailQuery = (groupStudyId: number) => { return useQuery({ queryKey: ['groupStudyDetail', groupStudyId], diff --git a/src/features/study/group/ui/group-study-detail-page.tsx b/src/features/study/group/ui/group-study-detail-page.tsx index 00d13a7d..7dd22a4b 100644 --- a/src/features/study/group/ui/group-study-detail-page.tsx +++ b/src/features/study/group/ui/group-study-detail-page.tsx @@ -1,118 +1,32 @@ 'use client'; -import dayjs from 'dayjs'; -import { - Calendar, - Clock, - File, - Folder, - Globe, - HandCoins, - MapPin, - SignpostBig, - UserCheck, - Users, -} from 'lucide-react'; -import Image from 'next/image'; -import React from 'react'; -import UserProfileModal from '@/entities/user/ui/user-profile-modal'; -import { useApplicantsByStatusQuery } from '@/features/application/model/use-applicant-qeury'; -import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets'; -import { cn } from '@/shared/shadcn/lib/utils'; -import UserAvatar from '@/shared/ui/avatar'; -import Button from '@/shared/ui/button'; -import InfoCard from '@/widgets/study/group/ui/group-detail/InfoCard'; +import { useState } from 'react'; -import { BasicInfoDetail } from '../api/group-study-types'; -import { - EXPERIENCE_LEVEL_LABELS, - REGULAR_MEETING_LABELS, - ROLE_LABELS, - STUDY_STATUS_LABELS, - STUDY_TYPE_LABELS, -} from '../const/group-study-const'; +import MoreMenu from '@/shared/ui/dropdown/more-menu'; +import Tabs from '@/shared/ui/tabs'; +import StudyInfoSection from './study-info-section'; import { useGroupStudyDetailQuery } from '../model/use-study-query'; +type ActiveTab = 'intro' | 'members' | 'channel'; + export default function StudyDetailPage({ id: groupStudyId }: { id: number }) { + const [active, setActive] = useState('intro'); + + const tabs = [ + { label: '스터디 소개', value: 'intro' }, + { label: '참가자', value: 'members' }, + { label: '채널', value: 'channel' }, + ]; + const { data: studyDetail, isLoading } = useGroupStudyDetailQuery(groupStudyId); - const { data: applicants } = useApplicantsByStatusQuery({ - groupStudyId, - status: 'APPROVED', - }); - if (isLoading) return; - const basicInfoItems = (basicInfo: BasicInfoDetail) => [ - { - label: '유형', - value: STUDY_TYPE_LABELS[basicInfo.type], - icon: , - }, - { - label: '주제', - value: basicInfo.targetRoles - .map((role) => { - return ROLE_LABELS[role]; - }) - .join(', '), - icon: , - }, - { - label: '경력', - value: - basicInfo.experienceLevels - .map((level) => { - return EXPERIENCE_LEVEL_LABELS[level]; - }) - .join(', ') || '무관', - icon: , - }, - { - label: '진행 방식', - value: `${basicInfo.location}`, - icon: , - }, - { - label: '진행 기간', - value: REGULAR_MEETING_LABELS[basicInfo.regularMeeting], - icon: , - }, - { - label: '정기모임', - value: REGULAR_MEETING_LABELS[basicInfo.regularMeeting], - icon: , - }, - { - label: '모집인원', - value: `${basicInfo.maxMembersCount}`, - icon: , - }, - { - label: '시작일자', - value: dayjs(basicInfo.createdAt).format('YYYY.MM.DD'), - icon: , - }, - { - label: '참가비', - value: - basicInfo.price === 0 - ? '무료' - : `${basicInfo.price.toLocaleString()}원`, - icon: , - }, - { - label: '상태', - value: `${STUDY_STATUS_LABELS[basicInfo.status]}`, - icon: , - }, - ]; - return (
-
-
+
+

{studyDetail?.detailInfo.title}

@@ -120,104 +34,25 @@ export default function StudyDetailPage({ id: groupStudyId }: { id: number }) { {studyDetail?.detailInfo.summary}

-
- - -
+ {} }, + { label: '삭제하기', value: 'remove', onMenuClick: () => {} }, + ]} + />
-
-
- 썸네일 -
-
-

스터디 소개

-
- {studyDetail?.detailInfo.description} -
-
-
-

기본 정보

-
프로필박스
-
- {basicInfoItems(studyDetail?.basicInfo).map((item, index) => { - return ( - - ); - })} -
-
-
-
- 실시간 신청자 목록 - {`${studyDetail.basicInfo.approvedCount}명`} -
- - {applicants?.pages.map((page, pageIndex) => ( - - {page.content.map((applicant) => { - const temperPreset = getSincerityPresetByLevelName( - applicant.applicantInfo.sincerityTemp.levelName as string, - ); - return ( -
- -
-
-
- {applicant.applicantInfo.memberName} -
- - {`${applicant.applicantInfo.sincerityTemp.temperature}`}{' '} - ℃ - -
-
- - 프로필 -
- } - /> -
- ); - })} - - ))} -
-
-
-
-
스터디정보
-
- - -
-
-
+ {/** 탭리스트 */} + setActive(value)} + /> + {active === 'intro' && ( + + )} + {active === 'members' &&
참가자 목록
} + {active === 'channel' &&
채널 내용
}
); } diff --git a/src/features/study/group/ui/group-study-list.tsx b/src/features/study/group/ui/group-study-list.tsx index b107deb5..9c9f4c7c 100644 --- a/src/features/study/group/ui/group-study-list.tsx +++ b/src/features/study/group/ui/group-study-list.tsx @@ -1,11 +1,11 @@ 'use client'; import { useInfiniteQuery } from '@tanstack/react-query'; import Image from 'next/image'; +import { useRouter } from 'next/navigation'; import React, { Fragment } from 'react'; import Badge from '@/shared/ui/badge'; import { getGroupStudyList } from '../api/get-group-study-list'; -import { useRouter } from 'next/navigation'; import { BasicInfoDetail } from '../api/group-study-types'; import { EXPERIENCE_LEVEL_LABELS, @@ -16,7 +16,7 @@ import { export default function GroupStudyList() { const router = useRouter(); - const { data, fetchNextPage } = useInfiniteQuery({ + const { data } = useInfiniteQuery({ queryKey: ['groupStudies'], queryFn: async ({ pageParam }) => { const response = await getGroupStudyList({ diff --git a/src/features/study/group/ui/study-info-section.tsx b/src/features/study/group/ui/study-info-section.tsx new file mode 100644 index 00000000..904ef85a --- /dev/null +++ b/src/features/study/group/ui/study-info-section.tsx @@ -0,0 +1,257 @@ +import dayjs from 'dayjs'; +import { + Calendar, + Clock, + File, + Folder, + Globe, + HandCoins, + MapPin, + SignpostBig, + UserCheck, + Users, +} from 'lucide-react'; +import Image from 'next/image'; +import React from 'react'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; +import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets'; +import { cn } from '@/shared/shadcn/lib/utils'; +import UserAvatar from '@/shared/ui/avatar'; +import InfoCard from '@/widgets/study/group/ui/group-detail/Info-card'; +import SummaryStudyInfo from './summary-study-info'; + +import { + BasicInfoDetail, + GroupStudyDetailResponse, +} from '../api/group-study-types'; + +import { useApplicantsByStatusQuery } from '../application/model/use-applicant-qeury'; +import { + EXPERIENCE_LEVEL_LABELS, + REGULAR_MEETING_LABELS, + ROLE_LABELS, + STUDY_METHOD_LABELS, + STUDY_STATUS_LABELS, + STUDY_TYPE_LABELS, +} from '../const/group-study-const'; + +interface StudyInfoSectionProps { + study: GroupStudyDetailResponse; + groupStudyId: number; +} + +export default function StudyInfoSection({ + study: studyDetail, + groupStudyId, +}: StudyInfoSectionProps) { + const { data: applicants } = useApplicantsByStatusQuery({ + groupStudyId, + status: 'APPROVED', + }); + + const basicInfoItems = (basicInfo: BasicInfoDetail) => { + const getDurationText = (startDate: string, endDate: string): string => { + const start = new Date(startDate); + const end = new Date(endDate); + + const diffTime = end.getTime() - start.getTime(); + if (diffTime < 0) return '기간이 잘못되었습니다.'; + + const diffDays = diffTime / (1000 * 60 * 60 * 24); + const diffWeeks = diffDays / 7; + const diffMonths = diffDays / 30; // 대략적인 월 계산 (평균 30일) + + return diffMonths < 1 + ? `약 ${Math.round(diffWeeks)}주` + : `약 ${Math.round(diffMonths)}개월`; + }; + + return [ + { + label: '유형', + value: STUDY_TYPE_LABELS[basicInfo.type], + icon: , + }, + { + label: '주제', + value: basicInfo.targetRoles + .map((role) => { + return ROLE_LABELS[role]; + }) + .join(', '), + icon: , + }, + { + label: '경력', + value: + basicInfo.experienceLevels + .map((level) => { + return EXPERIENCE_LEVEL_LABELS[level]; + }) + .join(', ') || '무관', + icon: , + }, + { + label: '진행 방식', + value: `${STUDY_METHOD_LABELS[basicInfo.method]}, ${basicInfo.location}`, + icon: , + }, + { + label: '진행 기간', + value: getDurationText(basicInfo.startDate, basicInfo.endDate), + icon: , + }, + { + label: '정기모임', + value: REGULAR_MEETING_LABELS[basicInfo.regularMeeting], + icon: , + }, + { + label: '모집인원', + value: `${basicInfo.maxMembersCount}명`, + icon: , + }, + { + label: '시작일자', + value: dayjs(basicInfo.createdAt).format('YYYY.MM.DD'), + icon: , + }, + { + label: '참가비', + value: + basicInfo.price === 0 + ? '무료' + : `${basicInfo.price.toLocaleString()}원`, + icon: , + }, + { + label: '상태', + value: `${STUDY_STATUS_LABELS[basicInfo.status]}`, + icon: , + }, + ]; + }; + + const summaryBasicInfoItems = (basicInfo: BasicInfoDetail) => { + return [ + { + label: '주제', + value: basicInfo.targetRoles + .map((role) => { + return ROLE_LABELS[role]; + }) + .join(', '), + icon: , + }, + { + label: '정기모임', + value: `${REGULAR_MEETING_LABELS[basicInfo.regularMeeting]}, ${basicInfo.location}`, + icon: , + }, + { + label: '경력', + value: + basicInfo.experienceLevels + .map((level) => { + return EXPERIENCE_LEVEL_LABELS[level]; + }) + .join(', ') || '무관', + icon: , + }, + { + label: '모집인원', + value: `${basicInfo.maxMembersCount}명`, + icon: , + }, + ]; + }; + + return ( +
+
+ 썸네일 +
+
+

스터디 소개

+
+ {studyDetail?.detailInfo.description} +
+
+
+

기본 정보

+ {/*
프로필박스
*/} +
+ {basicInfoItems(studyDetail?.basicInfo).map((item) => { + return ( + + ); + })} +
+
+
+
+ 실시간 신청자 목록 + {`${studyDetail.basicInfo.approvedCount}명`} +
+ {applicants?.pages.map((applicant, i) => ( + + {applicant.content.map((data) => { + const temperPreset = getSincerityPresetByLevelName( + data.applicantInfo.sincerityTemp.levelName as string, + ); + + return ( +
+ +
+
+
+ {data.applicantInfo.memberName} +
+ + {`${data.applicantInfo.sincerityTemp.temperature}`}℃ + +
+
+ + 프로필 +
+ } + /> +
+ ); + })} + + ))} +
+
+
+ +
+ ); +} diff --git a/src/features/study/group/ui/summary-study-info.tsx b/src/features/study/group/ui/summary-study-info.tsx new file mode 100644 index 00000000..9a15c85e --- /dev/null +++ b/src/features/study/group/ui/summary-study-info.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import Button from '@/shared/ui/button'; + +interface SummaryStudyInfoProps { + data: { + label: string; + value: string; + icon: React.ReactNode; + }[]; + title: string; +} + +export default function SummaryStudyInfo({ + data, + title, +}: SummaryStudyInfoProps) { + return ( +
+

{title}

+
+
+ {data.map((item) => ( +
+
{item.icon}
+ + {item.value} + +
+ ))} +
+ +
+ + +
+
+ ); +} diff --git a/src/shared/ui/dropdown/more-menu.tsx b/src/shared/ui/dropdown/more-menu.tsx new file mode 100644 index 00000000..ab05e544 --- /dev/null +++ b/src/shared/ui/dropdown/more-menu.tsx @@ -0,0 +1,39 @@ +import { Ellipsis } from 'lucide-react'; +import React from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/shared/shadcn/ui/dropdown-menu'; + +interface MoreMenuOption { + options: { + label: string; + value: string; + onMenuClick: () => void; + }[]; +} + +export default function MoreMenu({ options }: MoreMenuOption) { + return ( + + + + + + {options.map((option) => ( + + + {option.label} + + + ))} + + + ); +} diff --git a/src/shared/ui/tabs/index.tsx b/src/shared/ui/tabs/index.tsx new file mode 100644 index 00000000..485a745c --- /dev/null +++ b/src/shared/ui/tabs/index.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { cn } from '@/shared/shadcn/lib/utils'; + +interface TabItem { + label: string; + value: string; +} + +interface SectionTabsProps { + /** 탭 목록 */ + tabs: TabItem[]; + /** 선택된 탭 */ + activeTab: string; + /** 탭 클릭 시 호출 */ + onChange: (value: string) => void; + /** 추가 className */ + className?: string; +} + +export default function Tabs({ + tabs, + activeTab, + onChange, + className, +}: SectionTabsProps) { + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ); +} diff --git a/src/widgets/home/header.tsx b/src/widgets/home/header.tsx index 699b0d52..40eac450 100644 --- a/src/widgets/home/header.tsx +++ b/src/widgets/home/header.tsx @@ -51,8 +51,8 @@ export default async function Header() { {/* 1차 MVP에선 사용하지 않아 제외 */} {/* 알림 기능을 구현하지 못해 주석 처리 */} diff --git a/src/widgets/study/group/ui/group-detail/InfoCard.tsx b/src/widgets/study/group/ui/group-detail/info-card.tsx similarity index 74% rename from src/widgets/study/group/ui/group-detail/InfoCard.tsx rename to src/widgets/study/group/ui/group-detail/info-card.tsx index bf26482a..8e46e693 100644 --- a/src/widgets/study/group/ui/group-detail/InfoCard.tsx +++ b/src/widgets/study/group/ui/group-detail/info-card.tsx @@ -9,12 +9,12 @@ interface Props { export default function InfoCard({ icon, title, value }: Props) { return ( -
+
{title} {value}
-
{icon}
+
{icon}
); }