diff --git a/src/components/contents/homework-detail-content.tsx b/src/components/contents/homework-detail-content.tsx index c4eb6a88..258dc613 100644 --- a/src/components/contents/homework-detail-content.tsx +++ b/src/components/contents/homework-detail-content.tsx @@ -15,7 +15,7 @@ import { useDeletePeerReview, useUpdatePeerReview, } from '@/hooks/queries/peer-review-api'; -import { useIsLeader } from '@/stores/useLeaderStore'; + import { useUserStore } from '@/stores/useUserStore'; import DeleteHomeworkModal from '../modals/delete-homework-modal'; import EditHomeworkModal from '../modals/edit-homework-modal'; @@ -152,11 +152,8 @@ function PeerReviewSection({ peerReviews, isMyHomework, }: PeerReviewSectionProps) { - const currentUserId = useUserStore((state) => state.memberId); - const isMissionCreator = useIsLeader(currentUserId); - - // 자기 과제가 아니고, 미션 생성자(리더)가 아닌 경우에만 리뷰 작성 가능 - const canWriteReview = !isMyHomework && !isMissionCreator; + // 자기 과제가 아닌 경우에만 리뷰 작성 가능 (리더도 허용) + const canWriteReview = !isMyHomework; const [reviewText, setReviewText] = useState(''); const { mutate: createPeerReview, isPending } = useCreatePeerReview(); diff --git a/src/components/filtering/study-filter.tsx b/src/components/filtering/study-filter.tsx index ab07ee47..7777f944 100644 --- a/src/components/filtering/study-filter.tsx +++ b/src/components/filtering/study-filter.tsx @@ -75,19 +75,26 @@ function FilterDropdown({ const hasSelection = selected.length > 0; + const selectedLabels = selected + .map((v) => options.find((option) => option.value === v)?.label) + .filter(Boolean) + .join(', '); + + const displayLabel = hasSelection ? `${label}: ${selectedLabels}` : label; + return ( + + + + + + + + ); +} diff --git a/src/components/modals/submit-homework-modal.tsx b/src/components/modals/submit-homework-modal.tsx index 92b0f290..22473d3b 100644 --- a/src/components/modals/submit-homework-modal.tsx +++ b/src/components/modals/submit-homework-modal.tsx @@ -1,6 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useState } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { z } from 'zod'; import Button from '@/components/ui/button'; import FormField from '@/components/ui/form/form-field'; @@ -28,37 +28,50 @@ export default function SubmitHomeworkModal({ onSuccess, }: SubmitHomeworkModalProps) { const [open, setOpen] = useState(false); + const methods = useForm({ + resolver: zodResolver(SubmitHomeworkFormSchema), + mode: 'onChange', + defaultValues: { + textContent: '', + attachmentLink: '', + }, + }); return ( - - - - - - - - - - - 과제 제출하기 - - setOpen(false)} /> - - - setOpen(false)} - onSuccess={onSuccess} - /> - - - + + + + + + + + + + + + 과제 제출하기 + + setOpen(false)} /> + + + setOpen(false)} + onSuccess={() => { + methods.reset(); + onSuccess?.(); + }} + /> + + + + ); } @@ -73,15 +86,7 @@ function SubmitHomeworkForm({ onClose, onSuccess, }: SubmitHomeworkFormProps) { - const methods = useForm({ - resolver: zodResolver(SubmitHomeworkFormSchema), - mode: 'onChange', - defaultValues: { - textContent: '', - attachmentLink: '', - }, - }); - + const methods = useFormContext(); const { handleSubmit, formState } = methods; const { mutate: submitHomework } = useSubmitHomework(); @@ -110,7 +115,7 @@ function SubmitHomeworkForm({ }; return ( - + <>
- + ); } diff --git a/src/components/pages/group-study-detail-page.tsx b/src/components/pages/group-study-detail-page.tsx index f4b1b9a6..7572fc04 100644 --- a/src/components/pages/group-study-detail-page.tsx +++ b/src/components/pages/group-study-detail-page.tsx @@ -3,6 +3,8 @@ import { sendGTMEvent } from '@next/third-parties/google'; import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; +import InquiryModal from '@/components/modals/inquiry-modal'; +import Button from '@/components/ui/button'; import MoreMenu from '@/components/ui/dropdown/more-menu'; import Tabs from '@/components/ui/tabs'; import { STUDY_DETAIL_TABS, StudyTabValue } from '@/config/constants'; @@ -59,6 +61,7 @@ export default function StudyDetailPage({ const [showModal, setShowModal] = useState(false); const [action, setAction] = useState(null); const [showStudyFormModal, setShowStudyFormModal] = useState(false); + const [showInquiryModal, setShowInquiryModal] = useState(false); const { data: myApplicationStatus } = useGetGroupStudyMyStatus({ groupStudyId, @@ -158,12 +161,25 @@ export default function StudyDetailPage({ groupStudyId={groupStudyId} onOpenChange={() => setShowStudyFormModal(!showStudyFormModal)} /> +
-

+

{studyDetail?.detailInfo.title} -

+ +

{studyDetail?.detailInfo.summary}

@@ -220,7 +236,9 @@ export default function StudyDetailPage({ }); }} /> - {active === 'intro' && } + {active === 'intro' && ( + + )} {active === 'members' && ( import('@/widgets/home/banner'), { ssr: false, }); -const PAGE_SIZE = 15; - export default function GroupStudyListPage() { const { isAuthReady } = useAuthReady(); - const router = useRouter(); - const searchParams = useSearchParams(); - - // 로컬 검색 상태 - 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) ?? []; - // URL에 recruiting 파라미터가 없으면 기본값 true (모집 중만 보기) - // 파라미터가 있으면 그 값 사용 (명시적 사용자 선택) - const recruitingParam = searchParams.get('recruiting'); - const recruiting = - recruitingParam === null ? true : recruitingParam === 'true'; - - return { type, targetRoles, method, recruiting }; - }, [searchParams]); - - const currentPage = Number(searchParams.get('page')) || 1; - - // 검색어가 있으면 전체 데이터를 가져오고, 없으면 페이지네이션된 데이터를 가져옴 - const { data, isLoading } = useGetStudies({ + const { + searchQuery, + filterValues, + currentPage, + totalPages, + displayStudies, + isLoading, + handleFilterChange, + handlePageChange, + handleSearch, + } = useStudyListFilter({ classification: 'GROUP_STUDY', - page: searchQuery ? 1 : currentPage, - pageSize: searchQuery ? 10000 : PAGE_SIZE, - type: - filterValues.type.length > 0 - ? (filterValues.type as GetGroupStudiesTypeEnum[]) - : undefined, - targetRoles: - filterValues.targetRoles.length > 0 - ? (filterValues.targetRoles as GetGroupStudiesTargetRolesEnum[]) - : undefined, - method: - filterValues.method.length > 0 - ? (filterValues.method as GetGroupStudiesMethodEnum[]) - : undefined, - // 기본값: true (모집 중만), false면 전체 조회 - recruiting: filterValues.recruiting ? true : undefined, }); - const allStudies = useMemo(() => data?.content ?? [], [data?.content]); - - // URL 파라미터 업데이트 함수 - const updateSearchParams = useCallback( - (updates: Record) => { - const params = new URLSearchParams(searchParams.toString()); - - Object.entries(updates).forEach(([key, value]) => { - if (value === undefined || value === '') { - params.delete(key); - } else { - // recruiting의 경우 'false'도 명시적으로 저장 (기본값이 true이므로) - // 다른 필터는 'false'일 때 파라미터 제거 - if (key === 'recruiting' || value !== 'false') { - params.set(key, value); - } else { - params.delete(key); - } - } - }); - - // 필터나 검색이 변경되면 페이지를 1로 리셋 - if (!updates.page) { - params.delete('page'); - } - - const queryString = params.toString(); - router.push(queryString ? `?${queryString}` : '/group-study'); - }, - [router, searchParams], - ); - - // 필터 변경 핸들러 - // recruiting 토글: true면 'true' 저장, false면 'false' 명시적으로 저장 - // (기본값이 true이므로 false일 때도 명시적으로 저장해야 토글이 정상 작동) - const handleFilterChange = useCallback( - (values: StudyFilterValues) => { - updateSearchParams({ - type: values.type.length > 0 ? values.type.join(',') : undefined, - targetRoles: - values.targetRoles.length > 0 - ? values.targetRoles.join(',') - : undefined, - method: values.method.length > 0 ? values.method.join(',') : undefined, - // recruiting: true면 'true', false면 'false' 명시적으로 저장 - // (기본값이 true이므로 false일 때도 URL에 저장해야 토글 해제 가능) - recruiting: values.recruiting ? 'true' : 'false', - }); - }, - [updateSearchParams], - ); - - // 검색 핸들러 (로컬 상태만 업데이트) - const handleSearch = useCallback((query: string) => { - setSearchQuery(query); - }, []); - - // 클라이언트 사이드 검색 필터링 (스터디명만 검색) - const filteredStudies = useMemo(() => { - if (!searchQuery) return allStudies; - - const lowerQuery = searchQuery.toLowerCase(); - - return allStudies.filter((study) => - study.simpleDetailInfo?.title?.toLowerCase().includes(lowerQuery), - ); - }, [allStudies, searchQuery]); - - // 검색어가 있을 때는 클라이언트에서 페이지네이션, 없으면 서버 페이지네이션 사용 - const totalPages = searchQuery - ? Math.ceil(filteredStudies.length / PAGE_SIZE) || 1 - : (data?.totalPages ?? 1); - - const displayStudies = useMemo(() => { - if (!searchQuery) return filteredStudies; - - const startIndex = (currentPage - 1) * PAGE_SIZE; - - return filteredStudies.slice(startIndex, startIndex + PAGE_SIZE); - }, [filteredStudies, searchQuery, currentPage]); - if (isLoading) { return ( @@ -215,6 +91,7 @@ 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 a6a90312..0b10eff0 100644 --- a/src/components/pages/premium-study-list-page.tsx +++ b/src/components/pages/premium-study-list-page.tsx @@ -2,16 +2,7 @@ import { Plus } from 'lucide-react'; import dynamic from 'next/dynamic'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useCallback, useMemo, useState } from 'react'; -import type { - GetGroupStudiesTypeEnum, - GetGroupStudiesTargetRolesEnum, - GetGroupStudiesMethodEnum, -} from '@/api/openapi/api/group-study-management-api'; -import StudyFilter, { - StudyFilterValues, -} from '@/components/filtering/study-filter'; +import StudyFilter from '@/components/filtering/study-filter'; import StudySearch from '@/components/filtering/study-search'; import PageContainer from '@/components/layout/page-container'; import PremiumStudyList from '@/components/premium/premium-study-list'; @@ -19,7 +10,7 @@ import PremiumStudyPagination from '@/components/premium/premium-study-paginatio import Button from '@/components/ui/button'; import GroupStudyFormModal from '@/features/study/group/ui/group-study-form-modal'; import { useAuthReady } from '@/hooks/common/use-auth'; -import { useGetStudies } from '@/hooks/queries/study-query'; +import { useStudyListFilter } from '@/hooks/common/use-study-list-filter'; import MyParticipatingStudiesSection from '../section/my-participating-studies-section'; // Carousel이 클라이언트 전용이므로 dynamic import로 로드 @@ -27,135 +18,23 @@ const Banner = dynamic(() => import('@/widgets/home/banner'), { ssr: false, }); -const PAGE_SIZE = 15; - export default function PremiumStudyListPage() { const { isAuthReady } = useAuthReady(); - const router = useRouter(); - const searchParams = useSearchParams(); - - // 로컬 검색 상태 - const [searchQuery, setSearchQuery] = useState(''); - - // 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) ?? []; - // URL에 recruiting 파라미터가 없으면 기본값 true (모집 중만 보기) - const recruitingParam = searchParams.get('recruiting'); - const recruiting = - recruitingParam === null ? true : recruitingParam === 'true'; - - return { type, targetRoles, method, recruiting }; - }, [searchParams]); - - const currentPage = Number(searchParams.get('page')) || 1; - - // 검색어가 있으면 전체 데이터를 가져오고, 없으면 페이지네이션된 데이터를 가져옴 - const { data, isLoading } = useGetStudies({ + const { + searchQuery, + filterValues, + currentPage, + totalPages, + displayStudies, + isLoading, + handleFilterChange, + handlePageChange, + handleSearch, + } = useStudyListFilter({ classification: 'PREMIUM_STUDY', - page: searchQuery ? 1 : currentPage, - pageSize: searchQuery ? 10000 : PAGE_SIZE, - type: - filterValues.type.length > 0 - ? (filterValues.type as GetGroupStudiesTypeEnum[]) - : undefined, - targetRoles: - filterValues.targetRoles.length > 0 - ? (filterValues.targetRoles as GetGroupStudiesTargetRolesEnum[]) - : undefined, - method: - filterValues.method.length > 0 - ? (filterValues.method as GetGroupStudiesMethodEnum[]) - : undefined, - // 기본값: true (모집 중만), false면 전체 조회 - recruiting: filterValues.recruiting ? true : undefined, }); - const allStudies = useMemo(() => data?.content ?? [], [data?.content]); - - // URL 파라미터 업데이트 함수 - const updateSearchParams = useCallback( - (updates: Record) => { - const params = new URLSearchParams(searchParams.toString()); - - Object.entries(updates).forEach(([key, value]) => { - if (value === undefined || value === '') { - params.delete(key); - } else { - // recruiting의 경우 'false'도 명시적으로 저장 (기본값이 true이므로) - // 다른 필터는 'false'일 때 파라미터 제거 - if (key === 'recruiting' || value !== 'false') { - params.set(key, value); - } else { - params.delete(key); - } - } - }); - - // 필터나 검색이 변경되면 페이지를 1로 리셋 - if (!updates.page) { - params.delete('page'); - } - - const queryString = params.toString(); - router.push(queryString ? `?${queryString}` : '/premium-study'); - }, - [router, searchParams], - ); - - // 필터 변경 핸들러 - // recruiting 토글: true면 'true' 저장, false면 'false' 명시적으로 저장 - // (기본값이 true이므로 false일 때도 명시적으로 저장해야 토글이 정상 작동) - const handleFilterChange = useCallback( - (values: StudyFilterValues) => { - updateSearchParams({ - type: values.type.length > 0 ? values.type.join(',') : undefined, - targetRoles: - values.targetRoles.length > 0 - ? values.targetRoles.join(',') - : undefined, - method: values.method.length > 0 ? values.method.join(',') : undefined, - // recruiting: true면 'true', false면 'false' 명시적으로 저장 - // (기본값이 true이므로 false일 때도 URL에 저장해야 토글 해제 가능) - recruiting: values.recruiting ? 'true' : 'false', - }); - }, - [updateSearchParams], - ); - - // 검색 핸들러 (로컬 상태만 업데이트) - const handleSearch = useCallback((query: string) => { - setSearchQuery(query); - }, []); - - // 클라이언트 사이드 검색 필터링 (스터디명만 검색) - const filteredStudies = useMemo(() => { - if (!searchQuery) return allStudies; - - const lowerQuery = searchQuery.toLowerCase(); - - return allStudies.filter((study) => - study.simpleDetailInfo?.title?.toLowerCase().includes(lowerQuery), - ); - }, [allStudies, searchQuery]); - - // 검색어가 있을 때는 클라이언트에서 페이지네이션, 없으면 서버 페이지네이션 사용 - const totalPages = searchQuery - ? Math.ceil(filteredStudies.length / PAGE_SIZE) || 1 - : (data?.totalPages ?? 1); - - const displayStudies = useMemo(() => { - if (!searchQuery) return filteredStudies; - - const startIndex = (currentPage - 1) * PAGE_SIZE; - - return filteredStudies.slice(startIndex, startIndex + PAGE_SIZE); - }, [filteredStudies, searchQuery, currentPage]); - if (isLoading) { return ( @@ -212,6 +91,7 @@ export default function PremiumStudyListPage() { )}
diff --git a/src/components/premium/premium-study-pagination.tsx b/src/components/premium/premium-study-pagination.tsx index 51851adb..f8e2b7d2 100644 --- a/src/components/premium/premium-study-pagination.tsx +++ b/src/components/premium/premium-study-pagination.tsx @@ -1,31 +1,23 @@ 'use client'; -import { useRouter, useSearchParams } from 'next/navigation'; import Pagination from '@/components/ui/pagination'; interface PremiumStudyPaginationProps { currentPage: number; totalPages: number; + onPageChange: (page: number) => void; } export default function PremiumStudyPagination({ currentPage, totalPages, + onPageChange, }: PremiumStudyPaginationProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - - const handleChangePage = (page: number) => { - const params = new URLSearchParams(searchParams.toString()); - params.set('page', String(page)); - router.push(`/premium-study?${params.toString()}`); - }; - return ( ); diff --git a/src/components/section/group-study-info-section.tsx b/src/components/section/group-study-info-section.tsx index 221c5cb4..b5a2dc7f 100644 --- a/src/components/section/group-study-info-section.tsx +++ b/src/components/section/group-study-info-section.tsx @@ -1,34 +1,29 @@ 'use client'; -import { sendGTMEvent } from '@next/third-parties/google'; import Image from 'next/image'; import { useParams, useRouter } from 'next/navigation'; +import { useMemo } from 'react'; import { GroupStudyFullResponseDto } from '@/api/openapi'; -import { cn } from '@/components/ui/(shadcn)/lib/utils'; import UserAvatar from '@/components/ui/avatar'; +import AvatarStack from '@/components/ui/avatar-stack'; +import type { AvatarStackMember } from '@/components/ui/avatar-stack'; import Button from '@/components/ui/button'; -import { getSincerityPresetByLevelName } from '@/config/sincerity-temp-presets'; import UserProfileModal from '@/entities/user/ui/user-profile-modal'; import { useApplicantsByStatusQuery } from '@/features/study/group/application/model/use-applicant-qeury'; -import { useAuthReady } from '@/hooks/common/use-auth'; -import { useIsLeader } from '@/stores/useLeaderStore'; -import { useUserStore } from '@/stores/useUserStore'; -import { hashValue } from '@/utils/hash'; import SummaryStudyInfo from '../summary/study-info-summary'; interface StudyInfoSectionProps { study: GroupStudyFullResponseDto; + isLeader: boolean; } export default function StudyInfoSection({ study: studyDetail, + isLeader, }: StudyInfoSectionProps) { const router = useRouter(); const params = useParams(); - const { memberId: authMemberId, isAuthReady } = useAuthReady(); - const memberId = useUserStore((state) => state.memberId); - const isLeader = useIsLeader(memberId); const groupStudyId = Number(params.id); @@ -36,11 +31,40 @@ export default function StudyInfoSection({ groupStudyId, status: 'APPROVED', }); - const applicants = approvedApplicants?.pages[0]?.content; + + const applicants = useMemo( + () => approvedApplicants?.pages[0]?.content ?? [], + [approvedApplicants?.pages], + ); + + const avatarMembers = useMemo(() => { + if (!applicants.length) return []; + + const leader = applicants.find((applicant) => applicant.role === 'LEADER'); + const participants = applicants.filter( + (applicant) => applicant.role !== 'LEADER', + ); + + const sortedParticipants = [...participants].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + const sortedApplicants = leader + ? [leader, ...sortedParticipants] + : sortedParticipants; + + return sortedApplicants.map((data) => ({ + memberId: data.applicantInfo.memberId, + nickname: data.applicantInfo.memberNickname || '익명', + profileImageUrl: + data.applicantInfo.profileImage?.resizedImages[0]?.resizedImageUrl ?? + '', + isLeader: data.role === 'LEADER', + })); + }, [applicants]); return ( - // todo: 스터디 공지 모달 추가 - //
@@ -99,8 +123,8 @@ export default function StudyInfoSection({
- 실시간 신청자 목록 - {`${applicants?.length}명`} + 참가자 목록 + {`${applicants?.length ?? 0}명`}
{isLeader && (
+
diff --git a/src/components/section/premium-study-info-section.tsx b/src/components/section/premium-study-info-section.tsx index 21967fc9..62bf80e0 100644 --- a/src/components/section/premium-study-info-section.tsx +++ b/src/components/section/premium-study-info-section.tsx @@ -1,17 +1,15 @@ 'use client'; -import { sendGTMEvent } from '@next/third-parties/google'; import Image from 'next/image'; import { useParams, useRouter } from 'next/navigation'; -import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import { useMemo } from 'react'; import UserAvatar from '@/components/ui/avatar'; +import AvatarStack from '@/components/ui/avatar-stack'; +import type { AvatarStackMember } from '@/components/ui/avatar-stack'; import Button from '@/components/ui/button'; -import { getSincerityPresetByLevelName } from '@/config/sincerity-temp-presets'; import UserProfileModal from '@/entities/user/ui/user-profile-modal'; -import { useAuthReady } from '@/hooks/common/use-auth'; import { useIsLeader } from '@/stores/useLeaderStore'; import { useUserStore } from '@/stores/useUserStore'; -import { hashValue } from '@/utils/hash'; import { GroupStudyFullResponse } from '../../features/study/group/api/group-study-types'; @@ -33,7 +31,6 @@ export default function PremiumStudyInfoSection({ }: PremiumStudyInfoSectionProps) { const router = useRouter(); const params = useParams(); - const { memberId: authMemberId, isAuthReady } = useAuthReady(); const memberId = useUserStore((state) => state.memberId); const isLeader = useIsLeader(memberId); @@ -46,6 +43,22 @@ export default function PremiumStudyInfoSection({ const applicantsList = getApplicantsList(approvedApplicants?.pages); + const avatarMembers = useMemo(() => { + return [...applicantsList] + .sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ) + .map((data) => ({ + memberId: data.applicantInfo.memberId, + nickname: data.applicantInfo.memberNickname || '익명', + profileImageUrl: + data.applicantInfo.profileImage?.resizedImages[0]?.resizedImageUrl ?? + '', + isLeader: data.role === 'LEADER', + })); + }, [applicantsList]); + return (
@@ -79,7 +92,7 @@ export default function PremiumStudyInfoSection({ {studyDetail.basicInfo.leader.memberNickname}
- 스터디 리더 + 스터디 멘토 {studyDetail.basicInfo.leader.simpleIntroduction} @@ -105,7 +118,7 @@ export default function PremiumStudyInfoSection({
- 실시간 신청자 목록 + 멘티 목록 {`${applicantsList.length}명`}
{isLeader && ( @@ -120,70 +133,10 @@ export default function PremiumStudyInfoSection({ )}
-
- {applicantsList.map((data) => { - const temperPreset = getSincerityPresetByLevelName( - data.applicantInfo.sincerityTemp.levelName as string, - ); - - return ( -
- -
-
-
- {data.applicantInfo.memberNickname !== '' - ? data.applicantInfo.memberNickname - : '익명'} -
- - {`${data.applicantInfo.sincerityTemp.temperature}`}℃ - -
-
- { - sendGTMEvent({ - event: 'premium_study_member_profile_click', - dl_timestamp: new Date().toISOString(), - ...(isAuthReady && - authMemberId && { - dl_member_id: hashValue(String(authMemberId)), - }), - dl_target_member_id: String( - data.applicantInfo.memberId, - ), - dl_group_study_id: String(groupStudyId), - }); - }} - > - 프로필 -
- } - /> -
- ); - })} -
+
diff --git a/src/components/summary/study-info-summary.tsx b/src/components/summary/study-info-summary.tsx index 711509cd..b150bb7a 100644 --- a/src/components/summary/study-info-summary.tsx +++ b/src/components/summary/study-info-summary.tsx @@ -20,11 +20,11 @@ import { STUDY_TYPE_LABELS, } from '../../features/study/group/const/group-study-const'; -interface Props { +interface SummaryStudyInfoProps { data: GroupStudyFullResponse; } -export default function SummaryStudyInfo({ data }: Props) { +export default function SummaryStudyInfo({ data }: SummaryStudyInfoProps) { const router = useRouter(); const queryClient = useQueryClient(); const [isExpanded, setIsExpanded] = useState(false); @@ -102,7 +102,7 @@ export default function SummaryStudyInfo({ data }: Props) { value: STUDY_STATUS_LABELS[groupStudyStatus], }, { - label: '현직자 참여 여부', + label: '스터디 대상', value: experienceLevels .map((level) => EXPERIENCE_LEVEL_LABELS[level]) diff --git a/src/components/ui/avatar-stack.tsx b/src/components/ui/avatar-stack.tsx new file mode 100644 index 00000000..7698973f --- /dev/null +++ b/src/components/ui/avatar-stack.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { X } from 'lucide-react'; +import { useState, useCallback, useRef, useEffect } from 'react'; +import UserAvatar from '@/components/ui/avatar'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; +import { cn } from './(shadcn)/lib/utils'; + +export interface AvatarStackMember { + memberId: number; + nickname: string; + profileImageUrl: string; + isLeader: boolean; +} + +interface AvatarStackProps { + members: AvatarStackMember[]; + maxVisible?: number; + guideText?: string; +} + +export default function AvatarStack({ + members, + maxVisible = 5, + guideText = '프로필을 클릭하여 스터디원들의 정보를 확인해보세요.', +}: AvatarStackProps) { + const [showOverflow, setShowOverflow] = useState(false); + const overflowRef = useRef(null); + + useEffect(() => { + if (!showOverflow) return; + + const handleClickOutside = (e: MouseEvent) => { + if ( + overflowRef.current && + !overflowRef.current.contains(e.target as Node) + ) { + setShowOverflow(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [showOverflow]); + + // 리더를 맨 앞으로, 나머지는 가입순(원본 순서) 유지 + const sorted = [...members].sort((a, b) => { + if (a.isLeader && !b.isLeader) return -1; + if (!a.isLeader && b.isLeader) return 1; + + return 0; + }); + + const visible = sorted.slice(0, maxVisible); + const overflow = sorted.slice(maxVisible); + const hasOverflow = overflow.length > 0; + + const handleOverflowToggle = useCallback(() => { + setShowOverflow((prev) => !prev); + }, []); + + return ( +
+
+ {/* 아바타 겹침 영역 */} +
+ {visible.map((member, index) => ( + + ))} +
+ + {/* +n명이 열공 중 */} + {hasOverflow && ( +
+ + + {showOverflow && ( +
+
+ + 참가자 목록 + + +
+
    + {overflow.map((member) => ( +
  • + + + + {member.nickname || '익명'} + + + } + /> +
  • + ))} +
+
+ )} +
+ )} +
+ + {/* 안내 문구 */} +

+ {members.length ? guideText : '아직 스터디원이 없습니다.'} +

+
+ ); +} + +function AvatarItem({ + member, + index, +}: { + member: AvatarStackMember; + index: number; +}) { + const [hovered, setHovered] = useState(false); + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* 왕관 아이콘 (리더) */} + {member.isLeader && ( + + 👑 + + )} + +
+ +
+ + {/* 닉네임 툴팁 */} + {hovered && ( +
+ {member.nickname || '익명'} +
+ )} +
+ } + /> + ); +} diff --git a/src/components/ui/form/field-control.tsx b/src/components/ui/form/field-control.tsx index d2853387..fa612621 100644 --- a/src/components/ui/form/field-control.tsx +++ b/src/components/ui/form/field-control.tsx @@ -33,6 +33,10 @@ export interface FieldControlProps< describedById?: string; children: React.ReactElement>; controlId?: string; + /** 필드 값이 변경된 직후 호출되는 콜백. 자동 스크롤 등 부가 동작에 사용 */ + onAfterChange?: (value: unknown) => void; + /** blur 시 필드 값이 채워져 있으면 호출되는 콜백. 텍스트 입력 등에서 자동 스크롤에 사용 */ + onAfterBlurFilled?: () => void; } export function FieldControl< @@ -45,6 +49,8 @@ export function FieldControl< describedById, controlId, children, + onAfterChange, + onAfterBlurFilled, }: FieldControlProps) { const { control } = useFormContext(); @@ -65,19 +71,39 @@ export function FieldControl< !Array.isArray(children) ) { const child = children; - const nextOnChange: ChangeHandler = + const coreOnChange: ChangeHandler = child.props.onChange ?? ((arg: V | React.ChangeEvent) => { if (isReactChangeEvent(arg)) field.onChange(arg); else field.onChange(arg); }); + const nextOnChange: ChangeHandler = onAfterChange + ? (((arg: V | React.ChangeEvent) => { + if (isReactChangeEvent(arg)) { + (coreOnChange as EventChange)(arg); + onAfterChange((arg.target as HTMLInputElement).value); + } else { + (coreOnChange as ValueChange)(arg); + onAfterChange(arg); + } + }) as ChangeHandler) + : coreOnChange; + + const baseOnBlur: () => void = child.props.onBlur ?? field.onBlur; + const nextOnBlur = onAfterBlurFilled + ? () => { + baseOnBlur(); + if (field.value) onAfterBlurFilled(); + } + : baseOnBlur; + injected = cloneElement(child, { id: child.props.id ?? controlId, name: child.props.name ?? field.name, value: (child.props.value ?? field.value) as V, onChange: nextOnChange, - onBlur: child.props.onBlur ?? field.onBlur, + onBlur: nextOnBlur, 'aria-invalid': child.props['aria-invalid'] ?? (fieldState.invalid || undefined), 'aria-describedby': diff --git a/src/components/ui/form/form-field.tsx b/src/components/ui/form/form-field.tsx index 8f9dcebf..c81d86f8 100644 --- a/src/components/ui/form/form-field.tsx +++ b/src/components/ui/form/form-field.tsx @@ -49,6 +49,13 @@ export interface FormFieldProps< showCounterRight?: boolean; counterMax?: number; + /** true로 설정하면 data-scroll-field 속성을 wrapper div에 추가 (자동 스크롤 위치 마킹) */ + scrollable?: boolean; + /** 필드 값이 변경된 직후 호출되는 콜백. 자동 스크롤 등 부가 동작에 사용 */ + onAfterChange?: (value: unknown) => void; + /** blur 시 필드 값이 채워져 있으면 호출되는 콜백. 텍스트 입력 등에서 자동 스크롤에 사용 */ + onAfterBlurFilled?: () => void; + children: React.ReactElement>; } @@ -69,6 +76,9 @@ export default function FormField< children, showCounterRight = false, counterMax, + scrollable, + onAfterChange, + onAfterBlurFilled, }: FormFieldProps) { const { control, formState } = useFormContext(); const autoId = useId(); @@ -89,6 +99,7 @@ export default function FormField< return (
{children} diff --git a/src/features/my-page/ui/applicant-page.tsx b/src/features/my-page/ui/applicant-page.tsx index 6e1220f4..f177600d 100644 --- a/src/features/my-page/ui/applicant-page.tsx +++ b/src/features/my-page/ui/applicant-page.tsx @@ -63,19 +63,26 @@ export default function ApplicantPage(props: ApplicantListProps) {
{data?.pages.map((page, pageIndex) => ( - {page.content.map((applicant) => ( - - handleApprove( - Number(props.studyId), - applicant.applyId, - status, - ) - } - /> - ))} + {page.content + .slice() + .sort( + (a, b) => + new Date(a.createdAt).getTime() - + new Date(b.createdAt).getTime(), + ) + .map((applicant) => ( + + handleApprove( + Number(props.studyId), + applicant.applyId, + status, + ) + } + /> + ))} ))}
diff --git a/src/features/study/group/api/create-inquiry.ts b/src/features/study/group/api/create-inquiry.ts new file mode 100644 index 00000000..0419c458 --- /dev/null +++ b/src/features/study/group/api/create-inquiry.ts @@ -0,0 +1,34 @@ +import { axiosInstance } from '@/api/client/axios'; + +export interface CreateInquiryRequest { + title: string; + content: string; + category?: 'CURRICULUM' | 'DIFFICULTY' | 'HW_AMOUNT' | 'SCHEDULE' | 'ETC'; +} + +export interface CreateInquiryResponse { + statusCode: number; + timestamp: string; + content: { + generatedQuestionId: number; + }; + message: string; +} + +// 문의 작성 +export const createInquiry = async ( + groupStudyId: number, + request: CreateInquiryRequest, +) => { + try { + const { data } = await axiosInstance.post( + `/group-studies/${groupStudyId}/questions`, + request, + ); + + return data; + } catch (error) { + console.error('문의 작성 실패:', error); + throw error; + } +}; diff --git a/src/features/study/group/model/inquiry.schema.ts b/src/features/study/group/model/inquiry.schema.ts new file mode 100644 index 00000000..e79358ad --- /dev/null +++ b/src/features/study/group/model/inquiry.schema.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +export enum InquiryCategory { + CURRICULUM = 'CURRICULUM', + DIFFICULTY = 'DIFFICULTY', + HW_AMOUNT = 'HW_AMOUNT', + ETC = 'ETC', +} + +export const INQUIRY_TITLE_MAX_LENGTH = 50; +export const INQUIRY_CONTENT_MAX_LENGTH = 500; + +export const inquirySchema = z.object({ + title: z + .string() + .min(1, '제목을 입력해주세요.') + .max( + INQUIRY_TITLE_MAX_LENGTH, + `제목은 ${INQUIRY_TITLE_MAX_LENGTH}자 이하로 입력해주세요.`, + ), + content: z + .string() + .min(1, '내용을 입력해주세요.') + .max( + INQUIRY_CONTENT_MAX_LENGTH, + `내용은 ${INQUIRY_CONTENT_MAX_LENGTH}자 이하로 입력해주세요.`, + ), + category: z.nativeEnum(InquiryCategory, { + message: '카테고리를 선택해주세요.', + }), +}); + +export type InquiryFormValues = z.infer; diff --git a/src/features/study/group/ui/group-notice-modal.tsx b/src/features/study/group/ui/group-notice-modal.tsx index 0597f93e..fab3a3f7 100644 --- a/src/features/study/group/ui/group-notice-modal.tsx +++ b/src/features/study/group/ui/group-notice-modal.tsx @@ -132,11 +132,11 @@ function GroupStudyNoticeForm({ label="스터디 공지, 규칙 등을 안내해주세요." direction="vertical" showCounterRight - counterMax={100} + counterMax={500} > diff --git a/src/features/study/group/ui/group-study-pagination.tsx b/src/features/study/group/ui/group-study-pagination.tsx index e519f0f9..154796b0 100644 --- a/src/features/study/group/ui/group-study-pagination.tsx +++ b/src/features/study/group/ui/group-study-pagination.tsx @@ -1,31 +1,23 @@ 'use client'; -import { useRouter, useSearchParams } from 'next/navigation'; import Pagination from '@/components/ui/pagination'; interface GroupStudyPaginationProps { currentPage: number; totalPages: number; + onPageChange: (page: number) => void; } export default function GroupStudyPagination({ currentPage, totalPages, + onPageChange, }: GroupStudyPaginationProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - - const handleChangePage = (page: number) => { - const params = new URLSearchParams(searchParams.toString()); - params.set('page', String(page)); - router.push(`/group-study?${params.toString()}`); - }; - return ( ); diff --git a/src/features/study/group/ui/step/step1-group.tsx b/src/features/study/group/ui/step/step1-group.tsx index f25a7f25..99948ada 100644 --- a/src/features/study/group/ui/step/step1-group.tsx +++ b/src/features/study/group/ui/step/step1-group.tsx @@ -1,7 +1,6 @@ 'use client'; import { addDays } from 'date-fns'; -import { useEffect } from 'react'; import { Controller, useController, @@ -14,6 +13,10 @@ import FormField from '@/components/ui/form/form-field'; import { BaseInput } from '@/components/ui/input'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio'; import { GroupItems } from '@/components/ui/toggle'; +import { + useScrollToNextField, + SCROLL_FIELD_ATTR, +} from '@/hooks/use-scroll-to-next-field'; import { formatKoreaYMD, getKoreaDate } from '@/utils/time'; import { TargetRole } from '../../api/group-study-types'; import { @@ -59,6 +62,8 @@ export default function Step1OpenGroupStudy() { control, }); + const scrollToNext = useScrollToNextField(); + const filteredStudyTypes = classification === 'GROUP_STUDY' ? STUDY_TYPES.filter((type) => type !== 'MENTORING') @@ -74,11 +79,15 @@ export default function Step1OpenGroupStudy() { direction="vertical" size="medium" required + scrollable > { + typeField.onChange(v); + scrollToNext('type'); + }} > {filteredStudyTypes.map((type) => (
@@ -100,6 +109,7 @@ export default function Step1OpenGroupStudy() { direction="vertical" size="medium" required + scrollable > @@ -110,6 +120,8 @@ export default function Step1OpenGroupStudy() { direction="vertical" size="medium" required + scrollable + onAfterChange={() => scrollToNext('maxMembersCount')} > @@ -120,10 +132,14 @@ export default function Step1OpenGroupStudy() { direction="vertical" size="medium" required + scrollable > -
+