diff --git a/src/app/(service)/(my)/layout.tsx b/src/app/(service)/(my)/layout.tsx index 062a26cf..2c5c2b20 100644 --- a/src/app/(service)/(my)/layout.tsx +++ b/src/app/(service)/(my)/layout.tsx @@ -1,5 +1,4 @@ import { Metadata } from 'next'; -import GlobalToast from '@/components/ui/global-toast'; import Sidebar from '@/widgets/my-page/sidebar'; export const metadata: Metadata = { @@ -14,7 +13,6 @@ export default function MyLayout({ }>) { return (
-
{children}
diff --git a/src/app/(service)/group-study/layout.tsx b/src/app/(service)/group-study/layout.tsx index f38c40d2..f0aeb75f 100644 --- a/src/app/(service)/group-study/layout.tsx +++ b/src/app/(service)/group-study/layout.tsx @@ -1,14 +1,7 @@ -import GlobalToast from '@/components/ui/global-toast'; - export default function GroupStudyLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - return ( - <> - - {children} - - ); + return <>{children}; } diff --git a/src/app/(service)/home/page.tsx b/src/app/(service)/home/page.tsx index 94efe87f..41451263 100644 --- a/src/app/(service)/home/page.tsx +++ b/src/app/(service)/home/page.tsx @@ -1,6 +1,5 @@ import { Metadata } from 'next'; import StartStudyButton from '@/components/home/start-study-button'; -import GlobalToast from '@/components/ui/global-toast'; import { generateMetadata as generateSEOMetadata } from '@/utils/seo'; import Banner from '@/widgets/home/banner'; import FeedbackLink from '@/widgets/home/feedback-link'; @@ -25,7 +24,6 @@ export default async function Home({ return (
- diff --git a/src/app/(service)/inquiry/[questionId]/page.tsx b/src/app/(service)/inquiry/[questionId]/page.tsx new file mode 100644 index 00000000..e8c6234f --- /dev/null +++ b/src/app/(service)/inquiry/[questionId]/page.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { ArrowLeft, Eye } from 'lucide-react'; +import Image from 'next/image'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { use } from 'react'; +import InquiryStatusBadge from '@/components/ui/badge/inquiry-status-badge'; +import MoreMenu from '@/components/ui/dropdown/more-menu'; +import { CATEGORY_LABEL } from '@/features/study/group/model/question.schema'; +import { useGetQuestion } from '@/hooks/queries/question-api'; +import { useToastStore } from '@/stores/use-toast-store'; +import { formatDateTimeDot } from '@/utils/time'; + +export default function InquiryDetailPage({ + params, +}: { + params: Promise<{ questionId: string }>; +}) { + const { questionId: questionIdStr } = use(params); + const router = useRouter(); + const searchParams = useSearchParams(); + const groupStudyIdStr = searchParams.get('groupStudyId'); + const groupStudyId = groupStudyIdStr ? Number(groupStudyIdStr) : 0; + const studyType = searchParams.get('studyType') ?? 'group'; + const questionId = Number(questionIdStr); + const showToast = useToastStore((state) => state.showToast); + + const { data, isLoading, isError } = useGetQuestion({ + groupStudyId, + questionId, + }); + + const handleBack = () => { + router.push(`/inquiry?groupStudyId=${groupStudyId}&studyType=${studyType}`); + }; + + const moreMenuOptions = [ + { + label: '수정하기', + value: 'edit', + onMenuClick: () => showToast('준비 중인 기능입니다.', 'info'), + }, + { + label: '삭제하기', + value: 'delete', + onMenuClick: () => showToast('준비 중인 기능입니다.', 'info'), + }, + ]; + + if (!groupStudyId) { + return ( +
+
+ 잘못된 접근입니다. 스터디 문의 목록에서 다시 접근해주세요. +
+
+ ); + } + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (isError || (!isLoading && !data)) { + return ( +
+
+ 문의를 불러오는 중 오류가 발생했습니다. 다시 시도해주세요. +
+
+ ); + } + + return ( +
+
+ +
+ + {data && ( +
+ {/* 문의 헤더 */} +
+
+
+ {data.category && ( + + {CATEGORY_LABEL[data.category] ?? data.category} + + )} +

+ {data.title} +

+
+ +
+ +
+
+ 작성자 + {data.authorNickname} +
+
+ + {data.viewCount} +
+
+ 작성일 + + {formatDateTimeDot(data.createdAt)} + +
+
+ +
+
+
+ + {/* 구분선 */} +
+
+
+ + {/* 문의 내용 */} +
+

+ {data.content} +

+ {data.questionImage?.resizedImages?.[0]?.resizedImageUrl && ( + 문의 이미지 + )} +
+ + {/* 구분선 */} +
+
+
+ + {/* 답변 섹션 */} +
+

답변

+ {data.answer ? ( +
+
+ {data.answererNickname} + {formatDateTimeDot(data.answeredAt ?? '')} +
+

+ {data.answer} +

+
+ ) : ( +
+

+ 아직 답변이 등록되지 않았습니다. +

+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/app/(service)/inquiry/page.tsx b/src/app/(service)/inquiry/page.tsx new file mode 100644 index 00000000..759f2bda --- /dev/null +++ b/src/app/(service)/inquiry/page.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import InquiryListTable from '@/components/lists/inquiry-list-table'; +import QuestionModal from '@/components/modals/question-modal'; +import Button from '@/components/ui/button'; +import { useGetQuestions } from '@/hooks/queries/question-api'; + +const PAGE_SIZE = 15; + +export default function InquiryPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const groupStudyIdStr = searchParams.get('groupStudyId'); + const groupStudyId = groupStudyIdStr ? Number(groupStudyIdStr) : null; + const studyType = (searchParams.get('studyType') ?? 'group') as + | 'group' + | 'premium'; + const isPremium = studyType === 'premium'; + + const [page, setPage] = useState(1); + const [isModalOpen, setIsModalOpen] = useState(false); + + useEffect(() => { + if (!groupStudyId) { + router.replace('/group-study'); + } + }, [groupStudyId, router]); + + const handleItemClick = (questionId: number) => { + router.push( + `/inquiry/${questionId}?groupStudyId=${groupStudyId}&studyType=${studyType}`, + ); + }; + + const { data, isLoading } = useGetQuestions({ + groupStudyId: groupStudyId ?? 0, + page, + pageSize: PAGE_SIZE, + }); + + if (!groupStudyId) return null; + + const items = data?.content ?? []; + const totalPages = data?.totalPages ?? 1; + const totalElements = data?.totalElements ?? 0; + + return ( +
+ {/* 헤더 */} +
+
+

+ 문의 게시판{' '} + + {totalElements}개 + +

+

+ 스터디 관련 문의사항을 남겨주세요 +

+

+ 비공개 문의는 작성자, {isPremium ? '멘토' : '리더'}, 관리자만 확인할 + 수 있어요. +

+
+ +
+ + {/* 표 */} + handleItemClick(item.questionId)} + /> + + {/* 문의하기 모달 */} + +
+ ); +} diff --git a/src/app/(service)/layout.tsx b/src/app/(service)/layout.tsx index fc5d3bd8..c88f707c 100644 --- a/src/app/(service)/layout.tsx +++ b/src/app/(service)/layout.tsx @@ -7,6 +7,8 @@ import localFont from 'next/font/local'; import React from 'react'; import ClarityInit from '@/components/analytics/clarity-init'; import PageViewTracker from '@/components/analytics/page-view-tracker'; +import FloatingInquiryButton from '@/components/ui/floating-inquiry-button'; +import GlobalToast from '@/components/ui/global-toast'; import MainProvider from '@/providers'; import { getServerCookie } from '@/utils/server-cookie'; import Header from '@/widgets/home/header'; @@ -40,11 +42,13 @@ export default async function ServiceLayout({ {GTM_ID && } +
{children}
+
diff --git a/src/app/(service)/premium-study/[id]/page.tsx b/src/app/(service)/premium-study/[id]/page.tsx index 2432e305..ef02cc46 100644 --- a/src/app/(service)/premium-study/[id]/page.tsx +++ b/src/app/(service)/premium-study/[id]/page.tsx @@ -95,7 +95,7 @@ export default async function Page({ if (!isLeader && memberId) { // 내가 리더가 아닐 경우에만 내 신청 상태 정보 미리 가져오기 await queryClient.prefetchQuery({ - queryKey: ['groupStudyMyStatus', Number(id)], + queryKey: ['groupStudyMemberStatus', Number(id)], queryFn: () => getGroupStudyMyStatusInServer({ groupStudyId: Number(id) }), }); diff --git a/src/app/(service)/premium-study/layout.tsx b/src/app/(service)/premium-study/layout.tsx index 9afd9a60..e3c30c0b 100644 --- a/src/app/(service)/premium-study/layout.tsx +++ b/src/app/(service)/premium-study/layout.tsx @@ -1,14 +1,7 @@ -import GlobalToast from '@/components/ui/global-toast'; - export default function PremiumStudyLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - return ( - <> - - {children} - - ); + return <>{children}; } diff --git a/src/components/card/study-card.tsx b/src/components/card/study-card.tsx index 84dad595..8ca11cb8 100644 --- a/src/components/card/study-card.tsx +++ b/src/components/card/study-card.tsx @@ -5,12 +5,17 @@ import Image from 'next/image'; import Link from 'next/link'; import { GroupStudyListItemDto } from '@/api/openapi'; import Badge from '@/components/ui/badge'; +import StudyCardCountdownBadge from '@/components/ui/study-card-countdown-badge'; -import { StudyType } from '../../features/study/group/api/group-study-types'; import { + ExperienceLevel, + StudyType, +} from '@/features/study/group/api/group-study-types'; +import { + EXPERIENCE_LEVEL_LABELS, REGULAR_MEETING_LABELS, STUDY_TYPE_LABELS, -} from '../../features/study/group/const/group-study-const'; +} from '@/features/study/group/const/group-study-const'; type BadgeColor = | 'default' @@ -70,15 +75,31 @@ export default function StudyCard({ study, href, onClick }: StudyCardProps) { ZERO ONE IT
)} + +
+ +
{/* 컨텐츠 영역 */}
{/* 뱃지 */} -
+
{studyType ? STUDY_TYPE_LABELS[studyType] : '스터디'} + {study.basicInfo?.experienceLevels?.map((level) => ( + + {EXPERIENCE_LEVEL_LABELS[level as ExperienceLevel]} + + ))}
{/* 제목 */} @@ -91,6 +112,38 @@ export default function StudyCard({ study, href, onClick }: StudyCardProps) { {study.simpleDetailInfo?.summary}

+ {/* 활성 배지 (RECRUITING 일 때만) */} + {study.basicInfo?.status === 'RECRUITING' && + (() => { + const remaining = + (study.basicInfo?.maxMembersCount ?? 0) - + (study.basicInfo?.approvedCount ?? 0); + if (remaining <= 0) + return ( +
+ + 🔥 모집 마감 + +
+ ); + if (remaining <= 3) + return ( +
+ + 🔥 마지막 {remaining}자리! + +
+ ); + + return ( +
+ + 🔥 마감까지 {remaining}명 + +
+ ); + })()} + {/* 하단 정보 */}
diff --git a/src/components/filtering/study-filter.tsx b/src/components/filtering/study-filter.tsx index 7777f944..a3c0fd7c 100644 --- a/src/components/filtering/study-filter.tsx +++ b/src/components/filtering/study-filter.tsx @@ -15,6 +15,7 @@ export interface StudyFilterValues { type: string[]; targetRoles: string[]; method: string[]; + experienceLevels: string[]; recruiting: boolean; } @@ -47,6 +48,15 @@ const METHOD_OPTIONS = [ { value: 'HYBRID', label: '병행' }, ] as const; +// 경험 레벨 옵션 +const EXPERIENCE_LEVEL_OPTIONS = [ + { value: 'BEGINNER', label: '입문자' }, + { value: 'JOB_SEEKER', label: '취준생' }, + { value: 'JUNIOR', label: '주니어' }, + { value: 'MIDDLE', label: '미들' }, + { value: 'SENIOR', label: '시니어' }, +] as const; + interface FilterDropdownProps { label: string; options: ReadonlyArray<{ value: string; label: string }>; @@ -161,6 +171,13 @@ export default function StudyFilter({ values, onChange }: StudyFilterProps) { [values, onChange], ); + const handleExperienceLevelsChange = useCallback( + (experienceLevels: string[]) => { + onChange({ ...values, experienceLevels }); + }, + [values, onChange], + ); + const handleRecruitingChange = useCallback( (pressed: boolean) => { onChange({ ...values, recruiting: pressed }); @@ -173,6 +190,7 @@ export default function StudyFilter({ values, onChange }: StudyFilterProps) { type: [], targetRoles: [], method: [], + experienceLevels: [], recruiting: true, // 기본값: 모집 중만 보기 }); }, [onChange]); @@ -182,6 +200,7 @@ export default function StudyFilter({ values, onChange }: StudyFilterProps) { values.type.length > 0 || values.targetRoles.length > 0 || values.method.length > 0 || + values.experienceLevels.length > 0 || !values.recruiting; // recruiting이 false면 필터 적용 중 return ( @@ -207,6 +226,13 @@ export default function StudyFilter({ values, onChange }: StudyFilterProps) { onChange={handleMethodChange} /> + + void; + onItemClick: (item: QuestionListItemResponse) => void; +} + +export default function InquiryListTable({ + items, + totalElements, + totalPages, + page, + isLoading, + onPageChange, + onItemClick, +}: InquiryListTableProps) { + const [hoveredId, setHoveredId] = useState(null); + const showToast = useToastStore((state) => state.showToast); + + return ( + <> +
+ + + + + + + + + + + + + + {isLoading ? ( + + + + ) : items.length === 0 ? ( + + + + ) : ( + items.map((item, index) => { + const displayNumber = + totalElements - (page - 1) * PAGE_SIZE - index; + const isHovered = hoveredId === item.questionId; + + return ( + { + if (!item.accessible) { + showToast( + '작성자만 확인할 수 있는 문의입니다.', + 'error', + ); + + return; + } + onItemClick(item); + }} + onMouseEnter={() => setHoveredId(item.questionId)} + onMouseLeave={() => setHoveredId(null)} + > + + + + + + + + + ); + }) + )} + +
+ 번호 + + 분류 + + 제목 + + 작성자 + + 작성일시 + + 조회수 + + 상태 +
+ 로딩 중... +
+ 등록된 문의가 없습니다. +
+ {displayNumber} + + {item.category + ? (CATEGORY_LABEL[item.category] ?? item.category) + : '-'} + + {item.accessible ? ( + + {item.title} + + ) : ( + + + 비공개 문의입니다 + + )} + + {item.accessible ? item.authorNickname : '***'} + + {formatDateDot(item.createdAt)} + + + + {item.viewCount} + + + +
+
+ + {totalPages > 1 && ( + + )} + + ); +} diff --git a/src/components/modals/inquiry-modal.tsx b/src/components/modals/question-modal.tsx similarity index 50% rename from src/components/modals/inquiry-modal.tsx rename to src/components/modals/question-modal.tsx index be8f84bf..93ddd2ec 100644 --- a/src/components/modals/inquiry-modal.tsx +++ b/src/components/modals/question-modal.tsx @@ -2,46 +2,59 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { XIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import Button from '@/components/ui/button'; +import ImageUploadInput from '@/components/ui/image-upload-input'; import { BaseInput, TextAreaInput } from '@/components/ui/input'; import { Modal } from '@/components/ui/modal'; import { - INQUIRY_CONTENT_MAX_LENGTH, - inquirySchema, - InquiryCategory, - InquiryFormValues, - INQUIRY_TITLE_MAX_LENGTH, -} from '@/features/study/group/model/inquiry.schema'; -import { useCreateInquiry } from '@/hooks/queries/inquiry-api'; + QUESTION_CONTENT_MAX_LENGTH, + questionSchema, + QuestionCategory, + QuestionFormValues, + QUESTION_TITLE_MAX_LENGTH, +} from '@/features/study/group/model/question.schema'; +import { useCreateQuestion } from '@/hooks/queries/question-api'; import { useScrollToNextField } from '@/hooks/use-scroll-to-next-field'; import { useToastStore } from '@/stores/use-toast-store'; import { SingleDropdown } from '../ui/dropdown'; import FormField from '../ui/form/form-field'; -const INQUIRY_CATEGORY_OPTIONS = [ - { value: InquiryCategory.CURRICULUM, label: '커리큘럼' }, - { value: InquiryCategory.DIFFICULTY, label: '난이도' }, - { value: InquiryCategory.HW_AMOUNT, label: '과제량' }, - { value: InquiryCategory.ETC, label: '기타' }, +const QUESTION_CATEGORY_OPTIONS = [ + { value: QuestionCategory.PAYMENT, label: '결제' }, + { value: QuestionCategory.STUDY_COMMON, label: '스터디 일반' }, + { value: QuestionCategory.LEADER, label: '리더' }, + { value: QuestionCategory.BUG, label: '버그' }, + { value: QuestionCategory.CONCERN, label: '고민' }, ] as const; -interface InquiryModalProps { +interface QuestionModalProps { open: boolean; onOpenChange: (open: boolean) => void; studyId: number; + studyType?: 'group' | 'premium'; + onAfterSubmit?: () => void; } -export default function InquiryModal({ +export default function QuestionModal({ open, onOpenChange, studyId, -}: InquiryModalProps) { + studyType, + onAfterSubmit, +}: QuestionModalProps) { + const router = useRouter(); const showToast = useToastStore((state) => state.showToast); - const { mutate: createInquiry, isPending } = useCreateInquiry(); + const { mutate: createQuestion, isPending } = useCreateQuestion(); + const [imageFile, setImageFile] = useState(undefined); + const [imagePreview, setImagePreview] = useState( + undefined, + ); - const form = useForm({ - resolver: zodResolver(inquirySchema), + const form = useForm({ + resolver: zodResolver(questionSchema), defaultValues: { title: '', content: '', @@ -52,21 +65,70 @@ export default function InquiryModal({ const { handleSubmit, reset } = form; const scrollToNext = useScrollToNextField(); - const onSubmit = (data: InquiryFormValues) => { - createInquiry( + const handleChangeImage = (file: File | undefined) => { + if (file) { + setImageFile(file); + setImagePreview(URL.createObjectURL(file)); + } else { + if (imagePreview) URL.revokeObjectURL(imagePreview); + setImageFile(undefined); + setImagePreview(undefined); + } + }; + + const uploadImage = async (uploadUrl: string, file: File) => { + const res = await fetch(uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type }, + }); + + if (!res.ok) { + throw new Error(`이미지 업로드 실패 (status: ${res.status})`); + } + }; + + const resetImageState = () => { + if (imagePreview) URL.revokeObjectURL(imagePreview); + setImageFile(undefined); + setImagePreview(undefined); + }; + + const onSubmit = (data: QuestionFormValues) => { + const imageExtension = imageFile ? imageFile.type.split('/')[1] : undefined; + + createQuestion( { groupStudyId: studyId, request: { title: data.title, content: data.content, category: data.category, + imageExtension, }, }, { - onSuccess: () => { + onSuccess: async (result) => { + if (imageFile && result.imageUploadUrl) { + try { + await uploadImage(result.imageUploadUrl, imageFile); + } catch (error) { + showToast('이미지 업로드 오류', 'error'); + + return; + } + } showToast('문의가 성공적으로 제출되었습니다.', 'success'); reset(); + resetImageState(); onOpenChange(false); + if (onAfterSubmit) { + onAfterSubmit(); + } else { + router.push( + `/inquiry?groupStudyId=${studyId}${studyType ? `&studyType=${studyType}` : ''}`, + ); + } }, onError: (error) => { showToast('문의 제출에 실패했습니다. 다시 시도해주세요.', 'error'); @@ -79,6 +141,7 @@ export default function InquiryModal({ const handleOpenChange = (isOpen: boolean) => { if (!isOpen) { reset(); + resetImageState(); } onOpenChange(isOpen); }; @@ -87,7 +150,7 @@ export default function InquiryModal({ - + 스터디 문의하기 @@ -97,9 +160,12 @@ export default function InquiryModal({ -
+ - + name="category" label="문의 종류" direction="vertical" @@ -108,11 +174,11 @@ export default function InquiryModal({ onAfterChange={() => scrollToNext('category')} > - + name="title" label="제목" direction="vertical" @@ -121,12 +187,12 @@ export default function InquiryModal({ onAfterBlurFilled={() => scrollToNext('title')} > - + name="content" label="내용" direction="vertical" @@ -135,10 +201,22 @@ export default function InquiryModal({ > +
+ + 이미지 첨부 + + (선택) + + + +

{studyDetail?.detailInfo.summary} @@ -220,7 +208,11 @@ export default function StudyDetailPage({ tab.value === 'intro' || isLeader || isMember, + (tab) => + tab.value === 'intro' || + tab.value === 'inquiry' || + isLeader || + isMember, )} activeTab={active} onChange={(value: StudyTabValue) => { @@ -255,6 +247,13 @@ export default function StudyDetailPage({ myApplicationStatus={myApplicationStatus} /> )} + {active === 'inquiry' && ( + + )}

); } diff --git a/src/components/pages/premium-study-detail-page.tsx b/src/components/pages/premium-study-detail-page.tsx index 79d76b64..9da74ae4 100644 --- a/src/components/pages/premium-study-detail-page.tsx +++ b/src/components/pages/premium-study-detail-page.tsx @@ -22,6 +22,7 @@ import { import ConfirmDeleteModal from '../../features/study/group/ui/confirm-delete-modal'; import GroupStudyFormModal from '../../features/study/group/ui/group-study-form-modal'; import GroupStudyMemberList from '../lists/study-member-list'; +import InquirySection from '../section/inquiry-section'; import MissionSection from '../section/mission-section'; import PremiumStudyInfoSection from '../section/premium-study-info-section'; @@ -41,7 +42,6 @@ export default function PremiumStudyDetailPage({ const searchParams = useSearchParams(); const setLeaderInfo = useLeaderStore((state) => state.setLeaderInfo); const showToast = useToastStore((state) => state.showToast); - const activeTab = (searchParams.get('tab') as StudyTabValue) || 'intro'; const { @@ -243,6 +243,13 @@ export default function PremiumStudyDetailPage({ myApplicationStatus={myApplicationStatus} /> )} + {activeTab === 'inquiry' && ( + + )}
); } diff --git a/src/components/section/curriculum-summary-section.tsx b/src/components/section/curriculum-summary-section.tsx new file mode 100644 index 00000000..af228410 --- /dev/null +++ b/src/components/section/curriculum-summary-section.tsx @@ -0,0 +1,50 @@ +import { ExternalLink } from 'lucide-react'; +import { usePathname } from 'next/navigation'; + +import { CurriculumSummaryItem } from '@/features/study/group/api/group-study-types'; + +interface CurriculumSummarySectionProps { + curriculumSummary: CurriculumSummaryItem[]; +} + +export default function CurriculumSummarySection({ + curriculumSummary, +}: CurriculumSummarySectionProps) { + const pathname = usePathname(); + if (!curriculumSummary?.length) return null; + + const weekCount = Math.max(...curriculumSummary.map((item) => item.weekNum)); + + const handleClickCurriculum = (id: number) => { + window.open(`${pathname}?tab=mission&missionId=${id}`, '_blank'); + }; + + return ( +
+
+

커리큘럼 요약

+ {weekCount}주 +
+ +
+ {curriculumSummary.map((item) => ( +
+ + {item.weekNum} + + + {item.title} + + handleClickCurriculum(item.missionId)} + className="h-[18px] w-[18px] shrink-0 cursor-pointer text-[#A4A7AE]" + /> +
+ ))} +
+
+ ); +} diff --git a/src/components/section/group-study-info-section.tsx b/src/components/section/group-study-info-section.tsx index b5a2dc7f..62b2ab24 100644 --- a/src/components/section/group-study-info-section.tsx +++ b/src/components/section/group-study-info-section.tsx @@ -4,11 +4,14 @@ import Image from 'next/image'; import { useParams, useRouter } from 'next/navigation'; import { useMemo } from 'react'; import { GroupStudyFullResponseDto } from '@/api/openapi'; +import CurriculumSummarySection from '@/components/section/curriculum-summary-section'; 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 StudyActiveTicker from '@/components/ui/study-active-ticker'; import UserProfileModal from '@/entities/user/ui/user-profile-modal'; +import { CurriculumSummaryItem } from '@/features/study/group/api/group-study-types'; import { useApplicantsByStatusQuery } from '@/features/study/group/application/model/use-applicant-qeury'; import SummaryStudyInfo from '../summary/study-info-summary'; @@ -31,7 +34,6 @@ export default function StudyInfoSection({ groupStudyId, status: 'APPROVED', }); - const applicants = useMemo( () => approvedApplicants?.pages[0]?.content ?? [], [approvedApplicants?.pages], @@ -124,7 +126,7 @@ export default function StudyInfoSection({
참가자 목록 - {`${applicants?.length ?? 0}명`} + {`${approvedApplicants?.pages[0]?.totalElements ?? 0}명`}
{isLeader && (
- +
+ + + +
); } diff --git a/src/components/section/inquiry-section.tsx b/src/components/section/inquiry-section.tsx new file mode 100644 index 00000000..0af4adb0 --- /dev/null +++ b/src/components/section/inquiry-section.tsx @@ -0,0 +1,306 @@ +'use client'; + +import { ArrowLeft } from 'lucide-react'; +import Image from 'next/image'; +import { useState } from 'react'; +import InquiryListTable from '@/components/lists/inquiry-list-table'; +import QuestionModal from '@/components/modals/question-modal'; +import InquiryStatusBadge from '@/components/ui/badge/inquiry-status-badge'; +import Button from '@/components/ui/button'; +import MoreMenu from '@/components/ui/dropdown/more-menu'; +import { CATEGORY_LABEL } from '@/features/study/group/model/question.schema'; +import { useGetQuestion, useGetQuestions } from '@/hooks/queries/question-api'; +import { useToastStore } from '@/stores/use-toast-store'; +import { formatDateTimeDot } from '@/utils/time'; + +const PAGE_SIZE = 15; + +interface InquirySectionProps { + groupStudyId: number; + isPremium?: boolean; + isLeader?: boolean; + isAdmin?: boolean; +} + +export default function InquirySection({ + groupStudyId, + isPremium = false, + isLeader = false, + isAdmin = false, +}: InquirySectionProps) { + const [selectedQuestionId, setSelectedQuestionId] = useState< + number | undefined + >(undefined); + const [isModalOpen, setIsModalOpen] = useState(false); + const [page, setPage] = useState(1); + + return ( +
+ {selectedQuestionId === undefined ? ( + setIsModalOpen(true)} + /> + ) : ( + setSelectedQuestionId(undefined)} + isPremium={isPremium} + isLeader={isLeader} + isAdmin={isAdmin} + /> + )} + + setIsModalOpen(false)} + /> +
+ ); +} + +interface ListViewProps { + groupStudyId: number; + isPremium: boolean; + page: number; + onPageChange: (page: number) => void; + onSelectQuestion: (id: number) => void; + onOpenModal: () => void; +} + +function ListView({ + groupStudyId, + isPremium, + page, + onPageChange, + onSelectQuestion, + onOpenModal, +}: ListViewProps) { + const { data, isLoading } = useGetQuestions({ + groupStudyId, + page, + pageSize: PAGE_SIZE, + }); + + const items = data?.content ?? []; + const totalPages = data?.totalPages ?? 1; + const totalElements = data?.totalElements ?? 0; + + return ( + <> + {/* 헤더 */} +
+
+

+ 문의 게시판{' '} + + {totalElements}개 + +

+

+ 스터디 관련 문의사항을 남겨주세요 +

+

+ 비공개 문의는 작성자, {isPremium ? '멘토' : '리더'}, 관리자만 확인할 + 수 있어요. +

+
+
+ + {/* 표 */} + onSelectQuestion(item.questionId)} + /> + + ); +} + +interface DetailViewProps { + groupStudyId: number; + questionId: number; + onBack: () => void; + isPremium?: boolean; + isLeader?: boolean; + isAdmin?: boolean; +} + +function DetailView({ + groupStudyId, + questionId, + onBack, + isPremium = false, + isLeader = false, + isAdmin = false, +}: DetailViewProps) { + const showToast = useToastStore((state) => state.showToast); + const { data, isLoading } = useGetQuestion({ groupStudyId, questionId }); + + const moreMenuOptions = [ + { + label: '수정하기', + value: 'edit', + onMenuClick: () => showToast('준비 중인 기능입니다.', 'info'), + }, + { + label: '삭제하기', + value: 'delete', + onMenuClick: () => showToast('준비 중인 기능입니다.', 'info'), + }, + ]; + + if (isLoading) { + return ( +
로딩 중...
+ ); + } + + return ( + <> +
+ +
+ + {data && ( +
+ {/* 카드 1: 질문 */} +
+
+ {data.category && ( + + {CATEGORY_LABEL[data.category] ?? data.category} + + )} + +
+ +

+ {data.title} +

+ +
+
+ + 작성자 + + + {data.authorNickname} + +
+
+ + 작성일 + + + {formatDateTimeDot(data.createdAt)} + +
+
+ + 조회수 + + + {data.viewCount} + +
+
+ 상태 + +
+
+ +

+ {data.content} +

+ {data.questionImage?.resizedImages?.[0]?.resizedImageUrl && ( + 문의 이미지 + )} +
+ + {/* 카드 2: 답변 */} + {data.answer ? ( +
+
+

+ {isAdmin ? '운영자' : isPremium ? '멘토' : '리더'}의 답변 +

+ + showToast('준비 중인 기능입니다.', 'info'), + }, + { + label: '삭제하기', + value: 'delete', + onMenuClick: () => + showToast('준비 중인 기능입니다.', 'info'), + }, + ]} + iconSize={20} + /> +
+ +
+
+ + 작성자 + + + {data.answererNickname} + +
+
+ + 작성일 + + + {formatDateTimeDot(data.answeredAt ?? '')} + +
+
+ +

+ {data.answer} +

+
+ ) : ( +
+

+ 아직 답변이 등록되지 않았습니다. +

+
+ )} +
+ )} + + ); +} diff --git a/src/components/summary/study-info-summary.tsx b/src/components/summary/study-info-summary.tsx index b150bb7a..21527511 100644 --- a/src/components/summary/study-info-summary.tsx +++ b/src/components/summary/study-info-summary.tsx @@ -8,9 +8,9 @@ import { useState } from 'react'; import Button from '@/components/ui/button'; import { GroupStudyFullResponse } from '@/features/study/group/api/group-study-types'; import ApplyGroupStudyModal from '@/features/study/group/ui/apply-group-study-modal'; +import { useAuth } from '@/hooks/common/use-auth'; import { useGetGroupStudyMyStatus } from '@/hooks/queries/group-study-member-api'; import { useToastStore } from '@/stores/use-toast-store'; -import { useUserStore } from '@/stores/useUserStore'; import { EXPERIENCE_LEVEL_LABELS, REGULAR_MEETING_LABELS, @@ -28,7 +28,7 @@ export default function SummaryStudyInfo({ data }: SummaryStudyInfoProps) { const router = useRouter(); const queryClient = useQueryClient(); const [isExpanded, setIsExpanded] = useState(false); - const memberId = useUserStore((state) => state.memberId); + const { isAuthenticated, data: authData } = useAuth(); const showToast = useToastStore((state) => state.showToast); const { basicInfo, detailInfo, interviewPost } = data; @@ -51,8 +51,8 @@ export default function SummaryStudyInfo({ data }: SummaryStudyInfoProps) { const { title } = detailInfo ?? {}; const { interviewPost: questions } = interviewPost ?? {}; - const isLoggedIn = !!memberId; - const isLeader = leader?.memberId === memberId; + const isLoggedIn = isAuthenticated; + const isLeader = leader?.memberId === authData?.memberId; const { data: myApplicationStatus } = useGetGroupStudyMyStatus({ groupStudyId, diff --git a/src/components/ui/avatar-stack.tsx b/src/components/ui/avatar-stack.tsx index 7698973f..f5be4e1d 100644 --- a/src/components/ui/avatar-stack.tsx +++ b/src/components/ui/avatar-stack.tsx @@ -1,7 +1,7 @@ 'use client'; -import { X } from 'lucide-react'; -import { useState, useCallback, useRef, useEffect } from 'react'; +import { Crown, X } from 'lucide-react'; +import { useCallback, useState } from 'react'; import UserAvatar from '@/components/ui/avatar'; import UserProfileModal from '@/entities/user/ui/user-profile-modal'; import { cn } from './(shadcn)/lib/utils'; @@ -25,24 +25,12 @@ export default function AvatarStack({ 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 popoverRef = useCallback((node: HTMLDivElement | null) => { + if (node) { + node.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }, []); // 리더를 맨 앞으로, 나머지는 가입순(원본 순서) 유지 const sorted = [...members].sort((a, b) => { @@ -56,10 +44,6 @@ export default function AvatarStack({ const overflow = sorted.slice(maxVisible); const hasOverflow = overflow.length > 0; - const handleOverflowToggle = useCallback(() => { - setShowOverflow((prev) => !prev); - }, []); - return (
@@ -72,17 +56,19 @@ export default function AvatarStack({ {/* +n명이 열공 중 */} {hasOverflow && ( -
+
setShowOverflow(true)}> {showOverflow && ( -
+
참가자 목록 @@ -92,10 +78,10 @@ export default function AvatarStack({ onClick={() => setShowOverflow(false)} className="text-text-subtle hover:text-text-default" > - +
-
    +
      {overflow.map((member) => (
    • {/* 왕관 아이콘 (리더) */} {member.isLeader && ( - - 👑 + + )} @@ -173,7 +162,7 @@ function AvatarItem({ {/* 닉네임 툴팁 */} {hovered && ( -
      +
      {member.nickname || '익명'}
      )} diff --git a/src/components/ui/badge/inquiry-status-badge.tsx b/src/components/ui/badge/inquiry-status-badge.tsx new file mode 100644 index 00000000..6fbf732b --- /dev/null +++ b/src/components/ui/badge/inquiry-status-badge.tsx @@ -0,0 +1,24 @@ +import Badge from '@/components/ui/badge'; +import type { QuestionListItemResponse } from '@/features/study/group/api/question-api'; + +type InquiryStatus = QuestionListItemResponse['status']; + +interface InquiryStatusBadgeProps { + status: InquiryStatus; +} + +const STATUS_CONFIG: Record< + InquiryStatus, + { color: 'gray' | 'green'; label: string } +> = { + ACCEPTED: { color: 'gray', label: '접수' }, + ANSWER_COMPLETED: { color: 'green', label: '답변 완료' }, +}; + +export default function InquiryStatusBadge({ + status, +}: InquiryStatusBadgeProps) { + const config = STATUS_CONFIG[status] ?? STATUS_CONFIG.ACCEPTED; + + return {config.label}; +} diff --git a/src/components/ui/dropdown/more-menu.tsx b/src/components/ui/dropdown/more-menu.tsx index f288114e..d99faa25 100644 --- a/src/components/ui/dropdown/more-menu.tsx +++ b/src/components/ui/dropdown/more-menu.tsx @@ -7,7 +7,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/(shadcn)/ui/dropdown-menu'; -interface MoreMenuOption { +interface MoreMenuProps { options: { label: string; value: string; @@ -16,7 +16,7 @@ interface MoreMenuOption { iconSize: number; } -export default function MoreMenu({ options, iconSize }: MoreMenuOption) { +export default function MoreMenu({ options, iconSize }: MoreMenuProps) { return ( diff --git a/src/components/ui/floating-inquiry-button.tsx b/src/components/ui/floating-inquiry-button.tsx new file mode 100644 index 00000000..cffbefd2 --- /dev/null +++ b/src/components/ui/floating-inquiry-button.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { MessageCircle } from 'lucide-react'; +import { usePathname } from 'next/navigation'; +import { useState } from 'react'; + +import QuestionModal from '../modals/question-modal'; + +const STUDY_DETAIL_PATTERN = /^\/(group-study|premium-study)\/(\d+)/; + +export default function FloatingInquiryButton() { + const pathname = usePathname(); + const [open, setOpen] = useState(false); + + const match = pathname.match(STUDY_DETAIL_PATTERN); + if (!match) return null; + + const studyType = match[1] === 'group-study' ? 'group' : ('premium' as const); + const studyId = Number(match[2]); + + return ( + <> + + + + ); +} diff --git a/src/components/ui/image-upload-input.tsx b/src/components/ui/image-upload-input.tsx new file mode 100644 index 00000000..234e9cd7 --- /dev/null +++ b/src/components/ui/image-upload-input.tsx @@ -0,0 +1,125 @@ +'use client'; + +import Image from 'next/image'; +import { useState, DragEvent, ChangeEvent, useRef } from 'react'; +import Button from '@/components/ui/button'; + +const inputStyles = { + base: 'rounded-100 flex w-full flex-col items-center justify-center border-2 p-500', + dragging: 'border-border-brand bg-fill-brand-subtle-hover', + notDragging: 'border-gray-300 border-dashed', +}; + +export default function ImageUploadInput({ + image, + onChangeImage, +}: { + image?: string; + onChangeImage: (file: File | undefined) => void; +}) { + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const handleOpenFileDialog = () => { + fileInputRef.current?.click(); + }; + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + setIsDragging(true); + } + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file && file.type.startsWith('image/')) { + onChangeImage(file); + } + }; + + const handleFileChange = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (file && file.type.startsWith('image/')) { + onChangeImage(file); + } + }; + + const handleRemove = () => { + onChangeImage(undefined); + }; + + return ( +
      + {!image ? ( +
      +
      + 파일 업로드 + + 드래그하여 파일 업로드 + +
      + + +
      + ) : ( +
      + preview + +
      + )} +
      + ); +} diff --git a/src/components/ui/study-active-ticker.tsx b/src/components/ui/study-active-ticker.tsx new file mode 100644 index 00000000..608c5059 --- /dev/null +++ b/src/components/ui/study-active-ticker.tsx @@ -0,0 +1,96 @@ +'use client'; + +import dayjs from 'dayjs'; +import { useEffect, useState } from 'react'; + +import { useNow } from '@/hooks/use-now'; +import { getCountdownState } from '@/lib/countdown'; + +interface StudyActiveTickerProps { + approvedCount: number; + maxMembersCount: number; + startDate: string; // YYYY-MM-DD + viewCount?: number; +} + +export default function StudyActiveTicker({ + approvedCount, + maxMembersCount, + startDate, + viewCount = 0, +}: StudyActiveTickerProps) { + const remaining = Math.max(0, maxMembersCount - approvedCount); + + const messages = [ + remaining > 3 + ? `🔥 마감까지 ${remaining}석` + : remaining > 0 + ? `🔥 마지막 ${remaining}자리` + : '🔥 모집 마감', + `지금 ${viewCount}명이 이 스터디를 보고 있어요.`, + `${approvedCount}명이 가입했고 현재 ${remaining}자리 남았어요.`, + ]; + + const [currentIndex, setCurrentIndex] = useState(0); + const [visible, setVisible] = useState(true); + + useEffect(() => { + const interval = setInterval(() => { + setVisible(false); + setTimeout(() => { + setCurrentIndex((prev) => (prev + 1) % messages.length); + setVisible(true); + }, 300); + }, 3000); + + return () => clearInterval(interval); + }, [messages.length]); + + const now = useNow(); + + const start = dayjs(startDate); + const diffMs = start.diff(now); + const state = getCountdownState(diffMs); + + const countdown = + state?.urgent === true + ? { text: state.label, color: state.textColorClass, pulse: state.pulse } + : null; + + const isPulse = remaining > 0 && remaining <= 3; + + return ( +
      + {/* 전광판 섹션 */} +
      + + 전광판 + + + {messages[currentIndex]} + +
      + + {/* 마감 카운트다운 섹션 */} + {countdown && ( + <> +
      +
      + + 모집 마감까지 + + + {countdown.text} + +
      + + )} +
      + ); +} diff --git a/src/components/ui/study-card-countdown-badge.tsx b/src/components/ui/study-card-countdown-badge.tsx new file mode 100644 index 00000000..da69bbf3 --- /dev/null +++ b/src/components/ui/study-card-countdown-badge.tsx @@ -0,0 +1,52 @@ +'use client'; + +import dayjs from 'dayjs'; + +import { useNow } from '@/hooks/use-now'; +import { getCountdownState } from '@/lib/countdown'; + +interface Props { + startDate?: string; + status?: string; + remaining?: number; +} + +export default function StudyCardCountdownBadge({ + startDate, + status, + remaining, +}: Props) { + const now = useNow(); + + if (status !== 'RECRUITING') return null; + + if (remaining !== undefined && remaining <= 0) { + return ( + + 모집 마감 + + ); + } + + if (!startDate) return null; + + const start = dayjs(startDate); + const diffMs = start.diff(now); + const state = getCountdownState(diffMs); + + if (!state || !state.urgent) { + return ( + + 모집 중 + + ); + } + + return ( + + 마감까지 {state.label} + + ); +} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 9eaba48b..719aecc5 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -12,7 +12,7 @@ interface ToastProps { isVisible: boolean; onClose: () => void; duration?: number; - variant?: 'success' | 'error'; + variant?: 'success' | 'error' | 'info'; } export default function Toast({ @@ -52,13 +52,19 @@ export default function Toast({ const isSuccess = variant === 'success'; + const isError = variant === 'error'; + const toast = (
      { - const res = await axiosInstance.get<{ - statusCode: number; - content: NicknameCheckResponse; - message: string; - }>('/nicknames/check', { - params: { nickname }, - }); - - return res.data.content; -} diff --git a/src/features/auth/model/use-nickname-check.ts b/src/features/auth/model/use-nickname-check.ts index d780cdb1..475ec30d 100644 --- a/src/features/auth/model/use-nickname-check.ts +++ b/src/features/auth/model/use-nickname-check.ts @@ -1,5 +1,8 @@ import { useQuery } from '@tanstack/react-query'; -import { checkNicknameAvailability } from '../api/nickname-check'; +import { createApiInstance } from '@/api/client/open-api-instance'; +import { MemberProfileApi } from '@/api/openapi'; + +const memberProfileApi = createApiInstance(MemberProfileApi); /** * 닉네임 중복 체크 Query @@ -8,7 +11,12 @@ import { checkNicknameAvailability } from '../api/nickname-check'; export const useNicknameCheckQuery = (nickname: string, enabled: boolean) => { return useQuery({ queryKey: ['nicknameCheck', nickname], - queryFn: () => checkNicknameAvailability(nickname), + queryFn: async () => { + const { data } = + await memberProfileApi.checkNicknameAvailability(nickname); + + return data.content; + }, enabled: enabled && nickname.length >= 2 && nickname.length <= 10, staleTime: 0, // 항상 최신 데이터 확인 retry: false, // 중복 체크는 실패해도 재시도하지 않음 diff --git a/src/features/my-page/api/update-user-profile.ts b/src/features/my-page/api/update-user-profile.ts deleted file mode 100644 index ed78f804..00000000 --- a/src/features/my-page/api/update-user-profile.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { axiosInstance } from '@/api/client/axios'; -import type { - AvailableStudyTimeResponse, - CareerResponse, - JobResponse, - StudyFormatTypeResponse, - StudyDashboardResponse, - StudySubjectResponse, - TechStackResponse, - UpdateUserProfileInfoRequest, - UpdateUserProfileInfoResponse, - UpdateUserProfileRequest, - UpdateUserProfileResponse, -} from '@/features/my-page/api/types'; - -export const updateUserProfile = async ( - memberId: number, - body: UpdateUserProfileRequest, -): Promise => { - const res = await axiosInstance.patch(`/members/${memberId}/profile`, body); - - return res.data.content; -}; - -export const updateUserProfileInfo = async ( - memberId: number, - body: UpdateUserProfileInfoRequest, -): Promise => { - const res = await axiosInstance.patch( - `/members/${memberId}/profile/info`, - body, - ); - - return res.data.content; -}; - -export const getAvailableStudyTimes = async (): Promise< - AvailableStudyTimeResponse[] -> => { - const res = await axiosInstance.get('/available-study-times'); - - return res.data.content; -}; - -export const getStudySubjects = async (): Promise => { - const res = await axiosInstance.get('/study-subjects'); - - return res.data.content; -}; - -export const getTechStacks = async (): Promise => { - const res = await axiosInstance.get('/tech-stacks'); - - return res.data.content; -}; - -export const getStudyDashboard = async (): Promise => { - const res = await axiosInstance.get('/study/dashboard'); - - return res.data.content; -}; - -/** - * 회원가입시 받는 정보들 (SPRINT2 프로필개선) - **/ -export const getJobs = async (): Promise => { - const res = await axiosInstance.get('/jobs'); - - return res.data.content; -}; - -export const getCareers = async (): Promise => { - const res = await axiosInstance.get('/careers'); - - return res.data.content; -}; - -export const getStudyFormatTypes = async (): Promise< - StudyFormatTypeResponse[] -> => { - const res = await axiosInstance.get('/study-format-types'); - - return res.data.content; -}; diff --git a/src/features/my-page/model/use-update-user-profile-mutation.ts b/src/features/my-page/model/use-update-user-profile-mutation.ts index 04508738..f2c70dae 100644 --- a/src/features/my-page/model/use-update-user-profile-mutation.ts +++ b/src/features/my-page/model/use-update-user-profile-mutation.ts @@ -1,28 +1,35 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; -import { +import { axiosInstanceV2 } from '@/api/client/axiosV2'; +import type { + AvailableStudyTimeResponse, + CareerResponse, + JobResponse, + StudyFormatTypeResponse, + StudyDashboardResponse, + StudySubjectResponse, + TechStackResponse, UpdateUserProfileInfoRequest, + UpdateUserProfileInfoResponse, UpdateUserProfileRequest, + UpdateUserProfileResponse, } from '../api/types'; -import { - getAvailableStudyTimes, - getStudySubjects, - getTechStacks, - getCareers, - getJobs, - getStudyDashboard, - getStudyFormatTypes, - updateUserProfile, - updateUserProfileInfo, -} from '../api/update-user-profile'; export const useUpdateUserProfileMutation = (memberId: number) => { const router = useRouter(); return useMutation({ mutationKey: ['updateUserProfile', memberId], - mutationFn: (formData: UpdateUserProfileRequest) => - updateUserProfile(memberId, formData), + mutationFn: async ( + formData: UpdateUserProfileRequest, + ): Promise => { + const res = await axiosInstanceV2.patch( + `/api/v1/members/${memberId}/profile`, + formData, + ); + + return res.data.content; + }, onSuccess: () => { router.refresh(); @@ -34,8 +41,16 @@ export const useUpdateUserProfileInfoMutation = (memberId: number) => { const router = useRouter(); return useMutation({ - mutationFn: (formData: UpdateUserProfileInfoRequest) => - updateUserProfileInfo(memberId, formData), + mutationFn: async ( + formData: UpdateUserProfileInfoRequest, + ): Promise => { + const res = await axiosInstanceV2.patch( + `/api/v1/members/${memberId}/profile/info`, + formData, + ); + + return res.data.content; + }, onSuccess: () => { router.refresh(); @@ -46,28 +61,44 @@ export const useUpdateUserProfileInfoMutation = (memberId: number) => { export const useAvailableStudyTimesQuery = () => { return useQuery({ queryKey: ['availableStudyTimes'], - queryFn: getAvailableStudyTimes, + queryFn: async (): Promise => { + const res = await axiosInstanceV2.get('/api/v1/available-study-times'); + + return res.data.content; + }, }); }; export const useStudySubjectsQuery = () => { return useQuery({ queryKey: ['studySubjects'], - queryFn: getStudySubjects, + queryFn: async (): Promise => { + const res = await axiosInstanceV2.get('/api/v1/study-subjects'); + + return res.data.content; + }, }); }; export const useTechStacksQuery = () => { return useQuery({ queryKey: ['techStacks'], - queryFn: getTechStacks, + queryFn: async (): Promise => { + const res = await axiosInstanceV2.get('/api/v1/tech-stacks'); + + return res.data.content; + }, }); }; export const useStudyDashboardQuery = () => { return useQuery({ queryKey: ['studyDashboard'], - queryFn: () => getStudyDashboard(), + queryFn: async (): Promise => { + const res = await axiosInstanceV2.get('/api/v1/study/dashboard'); + + return res.data.content; + }, staleTime: 60 * 1000, }); }; @@ -78,20 +109,32 @@ export const useStudyDashboardQuery = () => { export const useJobsQuery = () => { return useQuery({ queryKey: ['jobs'], - queryFn: getJobs, + queryFn: async (): Promise => { + const res = await axiosInstanceV2.get('/api/v1/jobs'); + + return res.data.content; + }, }); }; export const useCareersQuery = () => { return useQuery({ queryKey: ['careers'], - queryFn: getCareers, + queryFn: async (): Promise => { + const res = await axiosInstanceV2.get('/api/v1/careers'); + + return res.data.content; + }, }); }; export const useStudyFormatTypesQuery = () => { return useQuery({ queryKey: ['studyFormatTypes'], - queryFn: getStudyFormatTypes, + queryFn: async (): Promise => { + const res = await axiosInstanceV2.get('/api/v1/study-format-types'); + + return res.data.content; + }, }); }; diff --git a/src/features/my-page/ui/my-study-info-card.tsx b/src/features/my-page/ui/my-study-info-card.tsx index 6ccd6258..1a6f2c6b 100644 --- a/src/features/my-page/ui/my-study-info-card.tsx +++ b/src/features/my-page/ui/my-study-info-card.tsx @@ -35,12 +35,12 @@ export default function MyStudyInfoCard({ {`${studyId}`} {status === 'COMPLETED' && ( -
      +
      )}
      diff --git a/src/features/phone-verification/api/phone-auth.ts b/src/features/phone-verification/api/phone-auth.ts deleted file mode 100644 index ba4b6b07..00000000 --- a/src/features/phone-verification/api/phone-auth.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { axiosInstance } from '@/api/client/axios'; -import type { - SendPhoneVerificationCodeRequest, - SendPhoneVerificationCodeResponse, - VerifyPhoneCodeRequest, - VerifyPhoneCodeResponse, -} from './types'; - -/** - * SMS 인증번호 발송 - */ -export async function sendPhoneVerificationCode( - data: SendPhoneVerificationCodeRequest, -): Promise { - const res = await axiosInstance.post<{ - statusCode: number; - timestamp: string; - content: SendPhoneVerificationCodeResponse; - message: string; - }>('/auth/phone/send', data); - - return res.data.content; -} - -/** - * SMS 인증번호 검증 - */ -export async function verifyPhoneCode( - data: VerifyPhoneCodeRequest, -): Promise { - const res = await axiosInstance.post<{ - statusCode: number; - timestamp: string; - content: VerifyPhoneCodeResponse; - message: string; - }>('/auth/phone/verify', data); - - return res.data.content; -} diff --git a/src/features/phone-verification/model/use-phone-auth-mutation.ts b/src/features/phone-verification/model/use-phone-auth-mutation.ts index a3b87aae..84f5434f 100644 --- a/src/features/phone-verification/model/use-phone-auth-mutation.ts +++ b/src/features/phone-verification/model/use-phone-auth-mutation.ts @@ -1,24 +1,27 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { getCookie } from '@/api/client/cookie'; +import { createApiInstance } from '@/api/client/open-api-instance'; +import { PhoneAuthApi } from '@/api/openapi'; // mutation 내부에서는 store 직접 사용 허용 (API 성공 시 즉시 업데이트 목적) import { usePhoneVerificationStore } from './store'; -import { sendPhoneVerificationCode, verifyPhoneCode } from '../api/phone-auth'; import type { SendPhoneVerificationCodeRequest, VerifyPhoneCodeRequest, } from '../api/types'; +const phoneAuthApi = createApiInstance(PhoneAuthApi); + /** * SMS 인증번호 발송 Mutation */ export const useSendPhoneVerificationCodeMutation = () => { - return useMutation< - { success: boolean; message: string }, - Error, - SendPhoneVerificationCodeRequest - >({ - mutationFn: sendPhoneVerificationCode, + return useMutation({ + mutationFn: async (data: SendPhoneVerificationCodeRequest) => { + const { data: res } = await phoneAuthApi.sendVerificationCode(data); + + return res.content; + }, }); }; @@ -31,14 +34,14 @@ export const useVerifyPhoneCodeMutation = (memberId?: number) => { const router = useRouter(); const { setVerified } = usePhoneVerificationStore(); - return useMutation< - { success: boolean; message: string }, - Error, - VerifyPhoneCodeRequest - >({ - mutationFn: verifyPhoneCode, + return useMutation({ + mutationFn: async (data: VerifyPhoneCodeRequest) => { + const { data: res } = await phoneAuthApi.verifyCode(data); + + return res.content; + }, onSuccess: async (data, variables) => { - if (data.success) { + if (data?.success) { const currentMemberId = memberId ?? Number(getCookie('memberId')); // 인증 상태 저장 diff --git a/src/features/study/group/api/create-inquiry.ts b/src/features/study/group/api/create-inquiry.ts deleted file mode 100644 index 0419c458..00000000 --- a/src/features/study/group/api/create-inquiry.ts +++ /dev/null @@ -1,34 +0,0 @@ -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/api/group-study-types.ts b/src/features/study/group/api/group-study-types.ts index 58d64fb7..f0580317 100644 --- a/src/features/study/group/api/group-study-types.ts +++ b/src/features/study/group/api/group-study-types.ts @@ -261,6 +261,12 @@ export interface GroupStudyDetailResponse { interviewPost: InterviewPostDetail; // InterviewPost 확장 } +export interface CurriculumSummaryItem { + missionId: number; + title: string; + weekNum: number; +} + // OpenAPI에서 자동 생성된 DTO 타입 (API 응답용) export type { GroupStudyFullResponseDto as GroupStudyFullResponse } from '@/api/openapi'; diff --git a/src/features/study/group/api/question-api.ts b/src/features/study/group/api/question-api.ts new file mode 100644 index 00000000..a4b25871 --- /dev/null +++ b/src/features/study/group/api/question-api.ts @@ -0,0 +1,133 @@ +import { axiosInstance } from '@/api/client/axios'; +import type { ResizedImage } from './group-study-types'; +import { QuestionCategory } from '../model/question.schema'; + +export interface ImageDto { + imageId: string; + resizedImages: ResizedImage[]; +} + +export interface CreateQuestionRequest { + title: string; + content: string; + category?: QuestionCategory; + imageExtension?: string; +} + +export interface CreateQuestionResponse { + statusCode: number; + timestamp: string; + content: { + generatedQuestionId: number; + imageUploadUrl?: string; + }; + message: string; +} + +export interface QuestionListItemResponse { + questionId: number; + accessible: boolean; + title: string; + content: string; + category?: QuestionCategory; + status: 'ACCEPTED' | 'ANSWER_COMPLETED'; + authorId: number; + authorNickname: string; + authorProfileImage?: ImageDto; + viewCount: number; + createdAt: string; +} + +export interface GetQuestionsResponse { + statusCode: number; + timestamp: string; + content: { + totalElements: number; + totalPages: number; + size: number; + content: QuestionListItemResponse[]; + page: number; + hasNext: boolean; + hasPrevious: boolean; + }; + message: string; +} + +export interface QuestionDetailResponse { + questionId: number; + title: string; + content: string; + category?: QuestionCategory; + authorId: number; + authorNickname: string; + authorProfileImage?: ImageDto; + viewCount: number; + questionImage?: ImageDto; + status: 'ACCEPTED' | 'ANSWER_COMPLETED'; + createdAt: string; + answer?: string; + answererId?: number; + answererNickname?: string; + answeredProfileImage?: ImageDto; + answeredAt?: string; +} + +export interface GetQuestionResponse { + statusCode: number; + timestamp: string; + content: QuestionDetailResponse; + message: string; +} + +// 문의 작성 +export const createQuestion = async ( + groupStudyId: number, + request: CreateQuestionRequest, +) => { + const { data } = await axiosInstance.post( + `/group-studies/${groupStudyId}/questions`, + request, + ); + + return data; +}; + +// 문의 목록 조회 +export const getQuestions = async ( + groupStudyId: number, + page: number, + pageSize: number, +) => { + const { data } = await axiosInstance.get( + `/group-studies/${groupStudyId}/questions`, + { params: { page, 'page-size': pageSize } }, + ); + + return data; +}; + +// 문의 단건 조회 +export const getQuestion = async (groupStudyId: number, questionId: number) => { + const { data } = await axiosInstance.get( + `/group-studies/${groupStudyId}/questions/${questionId}`, + ); + + return data; +}; + +export interface CreateAnswerRequest { + answer: string; +} + +export const createAnswer = async ( + groupStudyId: number, + questionId: number, + request: CreateAnswerRequest, +) => { + const { data } = await axiosInstance.post( + `/group-studies/${groupStudyId}/questions/${questionId}/answer`, + request, + ); + + return data; +}; diff --git a/src/features/study/group/model/inquiry.schema.ts b/src/features/study/group/model/inquiry.schema.ts deleted file mode 100644 index e79358ad..00000000 --- a/src/features/study/group/model/inquiry.schema.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/model/question.schema.ts b/src/features/study/group/model/question.schema.ts new file mode 100644 index 00000000..bb57fffc --- /dev/null +++ b/src/features/study/group/model/question.schema.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +export enum QuestionCategory { + PAYMENT = 'PAYMENT', + STUDY_COMMON = 'STUDY_COMMON', + LEADER = 'LEADER', + BUG = 'BUG', + CONCERN = 'CONCERN', +} + +export const CATEGORY_LABEL: Record = { + PAYMENT: '결제', + STUDY_COMMON: '스터디 일반', + LEADER: '리더', + BUG: '버그', + CONCERN: '고민', +}; + +export const QUESTION_TITLE_MIN_LENGTH = 2; +export const QUESTION_CONTENT_MIN_LENGTH = 20; +export const QUESTION_TITLE_MAX_LENGTH = 255; +export const QUESTION_CONTENT_MAX_LENGTH = 3000; + +export const questionSchema = z.object({ + title: z + .string() + .min( + QUESTION_TITLE_MIN_LENGTH, + `제목은 ${QUESTION_TITLE_MIN_LENGTH}자 이상 입력해주세요.`, + ) + .max( + QUESTION_TITLE_MAX_LENGTH, + `제목은 ${QUESTION_TITLE_MAX_LENGTH}자 이하로 입력해주세요.`, + ), + content: z + .string() + .min( + QUESTION_CONTENT_MIN_LENGTH, + `내용은 ${QUESTION_CONTENT_MIN_LENGTH}자 이상 입력해주세요.`, + ) + .max( + QUESTION_CONTENT_MAX_LENGTH, + `내용은 ${QUESTION_CONTENT_MAX_LENGTH}자 이하로 입력해주세요.`, + ), + category: z.nativeEnum(QuestionCategory, { + message: '카테고리를 선택해주세요.', + }), + imageExtension: z.string().optional(), +}); + +export type QuestionFormValues = z.infer; diff --git a/src/features/study/group/ui/group-study-form-modal.tsx b/src/features/study/group/ui/group-study-form-modal.tsx index b485fe7e..bd5a366d 100644 --- a/src/features/study/group/ui/group-study-form-modal.tsx +++ b/src/features/study/group/ui/group-study-form-modal.tsx @@ -11,6 +11,7 @@ import { Modal } from '@/components/ui/modal'; import { usePhoneVerificationStatus } from '@/features/phone-verification/model/use-phone-verification-status'; import PhoneVerificationModal from '@/features/phone-verification/ui/phone-verification-modal'; import { useAuthReady } from '@/hooks/common/use-auth'; +import { useToastStore } from '@/stores/use-toast-store'; import GroupStudyForm from './group-study-form'; import { @@ -45,6 +46,7 @@ export default function GroupStudyFormModal({ onOpenChange: onControlledOpen, classification = 'GROUP_STUDY', }: GroupStudyModalProps) { + const showToast = useToastStore((state) => state.showToast); const router = useRouter(); const qc = useQueryClient(); const [open, setOpen] = useState(false); @@ -80,7 +82,10 @@ export default function GroupStudyFormModal({ return; } if (mode === 'create' && isOpen && isVerificationError) { - alert('인증 상태를 확인할 수 없습니다. 잠시 후 다시 시도해주세요.'); + showToast( + '인증 상태를 확인할 수 없습니다. 잠시 후 다시 시도해주세요.', + 'error', + ); return; } @@ -180,10 +185,13 @@ export default function GroupStudyFormModal({ } await invalidateGroupStudyQueries(); - alert('그룹 스터디 개설이 완료되었습니다!'); + showToast('그룹 스터디 개설이 완료되었습니다.', 'success'); } catch (err) { console.error('[handleCreate] 그룹 스터디 개설 실패:', err); - alert('그룹 스터디 개설 중 오류가 발생했습니다. 다시 시도해 주세요.'); + showToast( + '그룹 스터디 개설 중 오류가 발생했습니다. 다시 시도해 주세요.', + 'error', + ); } finally { setOpen(false); } @@ -206,9 +214,12 @@ export default function GroupStudyFormModal({ } await refetchGroupStudyInfo(); - alert('그룹 스터디 수정이 완료되었습니다!'); + showToast('그룹 스터디 수정이 완료되었습니다.', 'success'); } catch (err) { - alert('그룹 스터디 수정 중 오류가 발생했습니다. 다시 시도해 주세요.'); + showToast( + '그룹 스터디 수정 중 오류가 발생했습니다. 다시 시도해 주세요.', + 'error', + ); } finally { if (onControlledOpen) onControlledOpen(); } diff --git a/src/features/study/group/ui/group-study-thumbnail-input.tsx b/src/features/study/group/ui/group-study-thumbnail-input.tsx index 7cba219e..4294dd85 100644 --- a/src/features/study/group/ui/group-study-thumbnail-input.tsx +++ b/src/features/study/group/ui/group-study-thumbnail-input.tsx @@ -1,132 +1 @@ -'use client'; - -import Image from 'next/image'; -import { useState, DragEvent, ChangeEvent, useRef } from 'react'; -import Button from '@/components/ui/button'; - -const inputStyles = { - base: 'rounded-100 flex w-full flex-col items-center justify-center rounded-lg border-2 p-500', - dragging: 'border-border-brand bg-fill-brand-subtle-hover', - notDragging: 'border-gray-300 border-gray-300 border-dashed', -}; - -export default function GroupStudyThumbnailInput({ - image, - onChangeImage, -}: { - image?: string; - onChangeImage: (file: File | null) => void; -}) { - const fileInputRef = useRef(null); - - const handleOpenFileDialog = () => { - fileInputRef.current?.click(); - }; - - const [isDragging, setIsDragging] = useState(false); - - // 영역 안에 드래그 들어왔을 때 - const handleDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(true); - }; - // 영역 밖으로 드래그 나갈 때 - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - }; - // 영역 안에서 드래그 중일 때 - const handleDragOver = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - - if (e.dataTransfer.files) { - setIsDragging(true); - } - }; - // 영역 안에서 drop 했을 때 - const handleDrop = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - - const file = e.dataTransfer.files[0]; // 1장만 허용 - - if (file && file.type.startsWith('image/')) { - onChangeImage(file); - } - }; - - // 파일 업로드 버튼으로 파일 선택했을 때 preview 설정 - const handleFileChange = (e: ChangeEvent) => { - const file = e.target.files?.[0]; - - if (file && file.type.startsWith('image/')) { - onChangeImage(file); - } - }; - - const handleRemove = () => { - onChangeImage(undefined); - }; - - return ( -
      - {!image ? ( -
      -
      - 파일 업로드 - - 드래그하여 파일 업로드 - -
      - - -
      - ) : ( -
      - preview - -
      - )} -
      - ); -} +export { default } from '@/components/ui/image-upload-input'; diff --git a/src/features/study/group/ui/step/step2-group.tsx b/src/features/study/group/ui/step/step2-group.tsx index 4e19cda0..5746c2e8 100644 --- a/src/features/study/group/ui/step/step2-group.tsx +++ b/src/features/study/group/ui/step/step2-group.tsx @@ -3,12 +3,12 @@ import { useEffect, useState } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import FormField from '@/components/ui/form/form-field'; +import ImageUploadInput from '@/components/ui/image-upload-input'; import { BaseInput, TextAreaInput } from '@/components/ui/input'; import { useScrollToNextField } from '@/hooks/use-scroll-to-next-field'; import { THUMBNAIL_EXTENSION } from '../../const/group-study-const'; import { GroupStudyFormValues } from '../../model/group-study-form.schema'; -import GroupStudyThumbnailInput from '../group-study-thumbnail-input'; export default function Step2OpenGroupStudy() { const { setValue, getValues } = useFormContext(); @@ -74,10 +74,7 @@ export default function Step2OpenGroupStudy() { required scrollable > - + diff --git a/src/hooks/common/use-study-list-filter.ts b/src/hooks/common/use-study-list-filter.ts index a0d8509f..323181e6 100644 --- a/src/hooks/common/use-study-list-filter.ts +++ b/src/hooks/common/use-study-list-filter.ts @@ -24,14 +24,18 @@ export function useStudyListFilter({ type: [], targetRoles: [], method: [], + experienceLevels: [], recruiting: true, }); const [currentPage, setCurrentPage] = useState(1); + const isClientFiltered = + !!searchQuery || filterValues.experienceLevels.length > 0; + const { data, isLoading } = useGetStudies({ classification, - page: searchQuery ? 1 : currentPage, - pageSize: searchQuery ? 10000 : PAGE_SIZE, + page: isClientFiltered ? 1 : currentPage, + pageSize: isClientFiltered ? 10000 : PAGE_SIZE, type: filterValues.type.length > 0 ? (filterValues.type as GetGroupStudiesTypeEnum[]) @@ -63,28 +67,38 @@ export function useStudyListFilter({ setCurrentPage(1); }, []); - // 클라이언트 사이드 검색 필터링 (스터디명만 검색) 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 + let result = allStudies; + + if (filterValues.experienceLevels.length > 0) { + result = result.filter((study) => + study.basicInfo?.experienceLevels?.some((level) => + filterValues.experienceLevels.includes(level), + ), + ); + } + + if (searchQuery) { + const lowerQuery = searchQuery.toLowerCase(); + result = result.filter((study) => + study.simpleDetailInfo?.title?.toLowerCase().includes(lowerQuery), + ); + } + + return result; + }, [allStudies, filterValues.experienceLevels, searchQuery]); + + const totalPages = isClientFiltered ? Math.ceil(filteredStudies.length / PAGE_SIZE) || 1 : (data?.totalPages ?? 1); const displayStudies = useMemo(() => { - if (!searchQuery) return filteredStudies; + if (!isClientFiltered) return filteredStudies; const startIndex = (currentPage - 1) * PAGE_SIZE; return filteredStudies.slice(startIndex, startIndex + PAGE_SIZE); - }, [filteredStudies, searchQuery, currentPage]); + }, [filteredStudies, isClientFiltered, currentPage]); return { searchQuery, diff --git a/src/hooks/queries/inquiry-api.ts b/src/hooks/queries/inquiry-api.ts deleted file mode 100644 index 2b781bbc..00000000 --- a/src/hooks/queries/inquiry-api.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; -import { - createInquiry, - CreateInquiryRequest, -} from '@/features/study/group/api/create-inquiry'; - -export const useCreateInquiry = () => { - return useMutation({ - mutationFn: async ({ - groupStudyId, - request, - }: { - groupStudyId: number; - request: CreateInquiryRequest; - }) => { - const data = await createInquiry(groupStudyId, request); - - return data.content; - }, - }); -}; diff --git a/src/hooks/queries/question-api.ts b/src/hooks/queries/question-api.ts new file mode 100644 index 00000000..138fa007 --- /dev/null +++ b/src/hooks/queries/question-api.ts @@ -0,0 +1,97 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + createAnswer, + createQuestion, + CreateQuestionRequest, + getQuestion, + getQuestions, +} from '@/features/study/group/api/question-api'; + +export const useCreateQuestion = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + groupStudyId, + request, + }: { + groupStudyId: number; + request: CreateQuestionRequest; + }) => { + const data = await createQuestion(groupStudyId, request); + + return data.content; + }, + onSuccess: async (_, variables) => { + await queryClient.invalidateQueries({ + queryKey: ['questions', variables.groupStudyId], + }); + }, + }); +}; + +export const useGetQuestions = ({ + groupStudyId, + page = 1, + pageSize = 15, +}: { + groupStudyId: number; + page?: number; + pageSize?: number; +}) => { + return useQuery({ + queryKey: ['questions', groupStudyId, page, pageSize], + queryFn: async () => { + const data = await getQuestions(groupStudyId, page, pageSize); + + return data.content; + }, + enabled: !!groupStudyId, + staleTime: 60 * 1000, + }); +}; + +export const useGetQuestion = ({ + groupStudyId, + questionId, +}: { + groupStudyId: number; + questionId: number; +}) => { + return useQuery({ + queryKey: ['question', groupStudyId, questionId], + queryFn: async () => { + const data = await getQuestion(groupStudyId, questionId); + + return data.content; + }, + enabled: !!groupStudyId && !!questionId, + staleTime: 60 * 1000, + }); +}; + +export const useCreateAnswer = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + groupStudyId, + questionId, + content, + }: { + groupStudyId: number; + questionId: number; + content: string; + }) => { + return createAnswer(groupStudyId, questionId, { answer: content }); + }, + onSuccess: async (_, variables) => { + await queryClient.invalidateQueries({ + queryKey: ['question', variables.groupStudyId, variables.questionId], + }); + await queryClient.invalidateQueries({ + queryKey: ['questions', variables.groupStudyId], + }); + }, + }); +}; diff --git a/src/hooks/use-now.ts b/src/hooks/use-now.ts new file mode 100644 index 00000000..82a6288b --- /dev/null +++ b/src/hooks/use-now.ts @@ -0,0 +1,39 @@ +'use client'; + +import dayjs, { Dayjs } from 'dayjs'; +import { useEffect, useState } from 'react'; + +// 모듈 레벨 싱글턴 — 구독 컴포넌트 수와 무관하게 setInterval은 1개만 실행 +const listeners = new Set<(now: Dayjs) => void>(); +let currentNow = dayjs(); +let intervalId: ReturnType | null = null; + +function tick() { + currentNow = dayjs(); + listeners.forEach((fn) => fn(currentNow)); +} + +/** + * 1초마다 갱신되는 현재 시각을 반환합니다. + * 여러 컴포넌트에서 호출해도 setInterval은 전역 1개만 실행됩니다. + */ +export function useNow(): Dayjs { + const [now, setNow] = useState(() => currentNow); + + useEffect(() => { + listeners.add(setNow); + if (!intervalId) { + intervalId = setInterval(tick, 1000); + } + + return () => { + listeners.delete(setNow); + if (listeners.size === 0 && intervalId) { + clearInterval(intervalId); + intervalId = null; + } + }; + }, []); + + return now; +} diff --git a/src/lib/countdown.ts b/src/lib/countdown.ts new file mode 100644 index 00000000..292732c4 --- /dev/null +++ b/src/lib/countdown.ts @@ -0,0 +1,70 @@ +export const URGENT_DAYS_THRESHOLD = 3; + +export const COUNTDOWN_STAGE_CONFIG = [ + { + minDays: 3, + maxDays: 3, + label: 'D-3', + bgClass: 'bg-green-500', + textColorClass: 'text-green-600', + pulse: false, + }, + { + minDays: 2, + maxDays: 2, + label: 'D-2', + bgClass: 'bg-orange-500', + textColorClass: 'text-orange-500', + pulse: false, + }, + { + minDays: 1, + maxDays: 1, + label: 'D-1', + bgClass: 'bg-red-500', + textColorClass: 'text-red-500', + pulse: false, + }, +] as const; + +interface UrgentStageResult { + urgent: true; + label: string; + bgClass: string; + textColorClass: string; + pulse: boolean; + isHourly: boolean; +} + +type CountdownState = { urgent: false } | UrgentStageResult | null; + +export function getCountdownState(diffMs: number): CountdownState { + if (diffMs <= 0) return null; + + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays > URGENT_DAYS_THRESHOLD) return { urgent: false }; + + const stage = COUNTDOWN_STAGE_CONFIG.find( + (s) => s.minDays <= diffDays && diffDays <= s.maxDays, + ); + + if (stage) { + return { urgent: true, ...stage, isHourly: false }; + } + + const hh = String(Math.floor(diffMs / (1000 * 60 * 60))).padStart(2, '0'); + const mm = String( + Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)), + ).padStart(2, '0'); + const ss = String(Math.floor((diffMs % (1000 * 60)) / 1000)).padStart(2, '0'); + + return { + urgent: true, + label: `${hh}:${mm}:${ss}`, + bgClass: 'bg-red-500', + textColorClass: 'text-red-500', + pulse: true, + isHourly: true, + }; +} diff --git a/src/stores/use-toast-store.ts b/src/stores/use-toast-store.ts index ee6b6c6d..03a227f5 100644 --- a/src/stores/use-toast-store.ts +++ b/src/stores/use-toast-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -type ToastVariant = 'success' | 'error'; +type ToastVariant = 'success' | 'error' | 'info'; interface ToastState { message: string; diff --git a/src/utils/time.ts b/src/utils/time.ts index 243aa968..bed83433 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -6,9 +6,11 @@ import { differenceInMinutes, format, getDay, + isValid, parseISO, startOfWeek, } from 'date-fns'; +import { ko } from 'date-fns/locale'; // todo: formatToKST로 통일하도록 리팩토링 필요 (getKoreaDate와 혼용 중) /** @@ -164,3 +166,19 @@ export const createDisabledDateMatcherForMission = ( return false; }; }; + +export function formatDateDot(dateString: string): string { + if (!dateString) return '-'; + const parsed = parseISO(dateString); + if (!isValid(parsed)) return '-'; + + return format(parsed, 'yyyy.MM.dd', { locale: ko }); +} + +export function formatDateTimeDot(dateString: string): string { + if (!dateString) return '-'; + const parsed = parseISO(dateString); + if (!isValid(parsed)) return '-'; + + return format(parsed, 'yyyy.MM.dd HH:mm', { locale: ko }); +}