From 6ef15911afbc6fd8a73a828bb83fb7756a0135e1 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:50:45 +0900 Subject: [PATCH 01/36] =?UTF-8?q?fix=20:=20=EC=B0=B8=EA=B0=80=EC=9E=90=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/section/group-study-info-section.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/section/group-study-info-section.tsx b/src/components/section/group-study-info-section.tsx index b5a2dc7f..d09452c1 100644 --- a/src/components/section/group-study-info-section.tsx +++ b/src/components/section/group-study-info-section.tsx @@ -31,7 +31,6 @@ export default function StudyInfoSection({ groupStudyId, status: 'APPROVED', }); - const applicants = useMemo( () => approvedApplicants?.pages[0]?.content ?? [], [approvedApplicants?.pages], @@ -124,7 +123,7 @@ export default function StudyInfoSection({
참가자 목록 - {`${applicants?.length ?? 0}명`} + {`${approvedApplicants?.pages[0]?.totalElements ?? 0}명`}
{isLeader && ( + {(isLeader || myApplicationStatus?.status === 'APPROVED') && ( + + )}

{studyDetail?.detailInfo.summary} From e5c8e2f736a2e00668a2f2b957dc2d424602063e Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:39:20 +0900 Subject: [PATCH 07/36] =?UTF-8?q?feat=20:=20=EC=8A=A4=ED=84=B0=EB=94=94=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20?= =?UTF-8?q?=EC=BB=A4=EB=A6=AC=ED=81=98=EB=9F=BC=20=EC=9A=94=EC=95=BD=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../section/curriculum-summary-section.tsx | 50 +++++++++++++++++++ .../section/group-study-info-section.tsx | 15 +++++- .../study/group/api/group-study-types.ts | 6 +++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 src/components/section/curriculum-summary-section.tsx 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 d09452c1..8efc1e3e 100644 --- a/src/components/section/group-study-info-section.tsx +++ b/src/components/section/group-study-info-section.tsx @@ -4,11 +4,13 @@ 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 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'; @@ -144,7 +146,18 @@ export default function StudyInfoSection({ - +
+ + +
); } 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'; From dec628dad6d45f2996f3cd08759bac30e74f2680 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:58:56 +0900 Subject: [PATCH 08/36] =?UTF-8?q?feat=20:=20=EC=B5=9C=EC=86=8C=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/study/group/model/question.schema.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/features/study/group/model/question.schema.ts b/src/features/study/group/model/question.schema.ts index 814dbd11..ce2d4124 100644 --- a/src/features/study/group/model/question.schema.ts +++ b/src/features/study/group/model/question.schema.ts @@ -8,20 +8,28 @@ export enum QuestionCategory { CONCERN = '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(1, '제목을 입력해주세요.') + .min( + QUESTION_TITLE_MIN_LENGTH, + `제목은 ${QUESTION_TITLE_MIN_LENGTH}자 이상 입력해주세요.`, + ) .max( QUESTION_TITLE_MAX_LENGTH, `제목은 ${QUESTION_TITLE_MAX_LENGTH}자 이하로 입력해주세요.`, ), content: z .string() - .min(1, '내용을 입력해주세요.') + .min( + QUESTION_CONTENT_MIN_LENGTH, + `내용은 ${QUESTION_CONTENT_MIN_LENGTH}자 이상 입력해주세요.`, + ) .max( QUESTION_CONTENT_MAX_LENGTH, `내용은 ${QUESTION_CONTENT_MAX_LENGTH}자 이하로 입력해주세요.`, From 9846154b3fb8da139b92cf9becb5e8835fbffdaf Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:02:57 +0900 Subject: [PATCH 09/36] =?UTF-8?q?fix=20:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20studyType=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/modals/question-modal.tsx | 7 +++++++ src/components/pages/group-study-detail-page.tsx | 5 +++-- src/components/pages/premium-study-detail-page.tsx | 8 ++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/modals/question-modal.tsx b/src/components/modals/question-modal.tsx index 303ea8d4..dcc677fb 100644 --- a/src/components/modals/question-modal.tsx +++ b/src/components/modals/question-modal.tsx @@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { XIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; import { FormProvider, useForm } from 'react-hook-form'; import Button from '@/components/ui/button'; import { BaseInput, TextAreaInput } from '@/components/ui/input'; @@ -31,13 +32,16 @@ interface QuestionModalProps { open: boolean; onOpenChange: (open: boolean) => void; studyId: number; + studyType?: 'group' | 'premium'; } export default function QuestionModal({ open, onOpenChange, studyId, + studyType, }: QuestionModalProps) { + const router = useRouter(); const showToast = useToastStore((state) => state.showToast); const { mutate: createQuestion, isPending } = useCreateQuestion(); @@ -68,6 +72,9 @@ export default function QuestionModal({ showToast('문의가 성공적으로 제출되었습니다.', 'success'); reset(); onOpenChange(false); + router.push( + `/inquiry?groupStudyId=${studyId}${studyType ? `&studyType=${studyType}` : ''}`, + ); }, onError: (error) => { showToast('문의 제출에 실패했습니다. 다시 시도해주세요.', 'error'); diff --git a/src/components/pages/group-study-detail-page.tsx b/src/components/pages/group-study-detail-page.tsx index 2a437036..56d32d06 100644 --- a/src/components/pages/group-study-detail-page.tsx +++ b/src/components/pages/group-study-detail-page.tsx @@ -3,7 +3,7 @@ import { sendGTMEvent } from '@next/third-parties/google'; import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; -import QueestionModal from '@/components/modals/question-modal'; +import QuestionModal from '@/components/modals/question-modal'; import Button from '@/components/ui/button'; import MoreMenu from '@/components/ui/dropdown/more-menu'; import Tabs from '@/components/ui/tabs'; @@ -161,10 +161,11 @@ export default function StudyDetailPage({ groupStudyId={groupStudyId} onOpenChange={() => setShowStudyFormModal(!showStudyFormModal)} /> -
diff --git a/src/components/pages/premium-study-detail-page.tsx b/src/components/pages/premium-study-detail-page.tsx index 79d76b64..a9521d3c 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 QuestionModal from '../modals/question-modal'; import MissionSection from '../section/mission-section'; import PremiumStudyInfoSection from '../section/premium-study-info-section'; @@ -41,6 +42,7 @@ export default function PremiumStudyDetailPage({ const searchParams = useSearchParams(); const setLeaderInfo = useLeaderStore((state) => state.setLeaderInfo); const showToast = useToastStore((state) => state.showToast); + const [showInquiryModal, setShowInquiryModal] = useState(false); const activeTab = (searchParams.get('tab') as StudyTabValue) || 'intro'; @@ -161,6 +163,12 @@ export default function PremiumStudyDetailPage({ classification="PREMIUM_STUDY" onOpenChange={() => setShowStudyFormModal(!showStudyFormModal)} /> +
From 6f23805fe3f7eb15557fe3969faf874ade681f0e Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:03:46 +0900 Subject: [PATCH 10/36] =?UTF-8?q?fix=20:=20null=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/study/group/api/question-api.ts | 30 +++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/features/study/group/api/question-api.ts b/src/features/study/group/api/question-api.ts index 472d209b..e524cf3a 100644 --- a/src/features/study/group/api/question-api.ts +++ b/src/features/study/group/api/question-api.ts @@ -19,12 +19,12 @@ export interface CreateQuestionResponse { export interface QuestionListItemResponse { questionId: number; accessible: boolean; - title: string | null; - content: string | null; - category: QuestionCategory | null; - status: 'ACCEPTED' | 'ANSWER_COMPLETED' | null; - authorNickname: string | null; - createdAt: string | null; + title: string; + content: string; + category: QuestionCategory; + status: 'ACCEPTED' | 'ANSWER_COMPLETED'; + authorNickname: string; + createdAt: string; } export interface GetQuestionsResponse { @@ -40,10 +40,21 @@ export interface GetQuestionsResponse { message: string; } +export interface QuestionDetailResponse { + questionId: number; + title: string; + content: string; + category: QuestionCategory; + authorId: number; + authorNickname: string; + status: 'ACCEPTED' | 'ANSWER_COMPLETED'; + createdAt: string; +} + export interface GetQuestionResponse { statusCode: number; timestamp: string; - content: QuestionListItemResponse; + content: QuestionDetailResponse; message: string; } @@ -80,10 +91,7 @@ export const getQuestions = async ( }; // 문의 단건 조회 -export const getQuestion = async ( - groupStudyId: number, - questionId: number, -) => { +export const getQuestion = async (groupStudyId: number, questionId: number) => { const { data } = await axiosInstance.get( `/group-studies/${groupStudyId}/questions/${questionId}`, ); From fc3c72c24cf3a4c55b490fc8d9898e7304cc7f53 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:04:05 +0900 Subject: [PATCH 11/36] =?UTF-8?q?feat=20:=20=EB=AC=B8=EC=9D=98=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D,=20=EB=AC=B8=EC=9D=98=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(service)/inquiry/[questionId]/page.tsx | 124 +++++++++++ src/app/(service)/inquiry/page.tsx | 199 ++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 src/app/(service)/inquiry/[questionId]/page.tsx create mode 100644 src/app/(service)/inquiry/page.tsx diff --git a/src/app/(service)/inquiry/[questionId]/page.tsx b/src/app/(service)/inquiry/[questionId]/page.tsx new file mode 100644 index 00000000..dc11ff9a --- /dev/null +++ b/src/app/(service)/inquiry/[questionId]/page.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { use } from 'react'; +import Badge from '@/components/ui/badge'; +import Button from '@/components/ui/button'; +import { useGetQuestion } from '@/hooks/queries/question-api'; + +const CATEGORY_LABEL: Record = { + PAYMENT: '결제', + STUDY_COMMON: '스터디 일반', + LEADER: '리더', + BUG: '버그', + CONCERN: '고민', +}; + +function formatDateTime(dateString: string): string { + if (!dateString) return '-'; + const date = new Date(dateString); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hour = String(date.getHours()).padStart(2, '0'); + const minute = String(date.getMinutes()).padStart(2, '0'); + + return `${year}.${month}.${day} ${hour}:${minute}`; +} + +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 { data, isLoading } = useGetQuestion({ groupStudyId, questionId }); + + const handleBack = () => { + router.push(`/inquiry?groupStudyId=${groupStudyId}&studyType=${studyType}`); + }; + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+
+ +
+ + {data && ( +
+ {/* 문의 헤더 */} +
+ {data.category && ( + + {CATEGORY_LABEL[data.category] ?? data.category} + + )} + +

+ {data.title} +

+ +
+
+ 작성자 + {data.authorNickname} +
+
+ 작성일 + + {formatDateTime(data.createdAt)} + +
+
+ 문의 상태 + {data.status === 'ANSWER_COMPLETED' ? ( + + 답변 완료 + + ) : ( + + 접수 + + )} +
+
+
+ + {/* 구분선 */} +
+
+
+ + {/* 문의 내용 */} +
+

+ {data.content} +

+
+
+ )} +
+ ); +} diff --git a/src/app/(service)/inquiry/page.tsx b/src/app/(service)/inquiry/page.tsx new file mode 100644 index 00000000..06c14fe8 --- /dev/null +++ b/src/app/(service)/inquiry/page.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { LockIcon } from 'lucide-react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import QuestionModal from '@/components/modals/question-modal'; +import Badge from '@/components/ui/badge'; +import Button from '@/components/ui/button'; +import Pagination from '@/components/ui/pagination'; +import { useGetQuestions } from '@/hooks/queries/question-api'; +import { useToastStore } from '@/stores/use-toast-store'; + +const CATEGORY_LABEL: Record = { + PAYMENT: '결제', + STUDY_COMMON: '스터디 일반', + LEADER: '리더', + BUG: '버그', + CONCERN: '고민', +}; + +function formatDate(dateString: string): string { + if (!dateString) return '-'; + const date = new Date(dateString); + + return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`; +} + +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); + const showToast = useToastStore((state) => state.showToast); + + useEffect(() => { + if (!groupStudyId) { + router.replace('/group-study'); + } + }, [groupStudyId, router]); + + const { data, isLoading } = useGetQuestions({ + groupStudyId: groupStudyId ?? 0, + page, + pageSize: 15, + }); + + if (!groupStudyId) return null; + + const items = data?.content ?? []; + const totalPages = data?.totalPages ?? 1; + const totalElements = data?.totalElements ?? 0; + + return ( +
+ {/* 헤더 */} +
+
+

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

+

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

+

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

+
+ +
+ + {/* 표 */} +
+ + + + + + + + + + + + + {isLoading ? ( + + + + ) : items.length === 0 ? ( + + + + ) : ( + items.map((item) => ( + { + if (!item.accessible) { + showToast('작성자만 확인할 수 있는 문의입니다.', 'error'); + + return; + } + router.push( + `/inquiry/${item.questionId}?groupStudyId=${groupStudyId}&studyType=${studyType}`, + ); + }} + > + + + + + + + + )) + )} + +
+ 번호 + + 분류 + + 제목 + + 작성자 + + 작성일시 + + 상태 +
+ 로딩 중... +
+ 등록된 문의가 없습니다. +
+ {item.questionId} + + {item.category + ? (CATEGORY_LABEL[item.category] ?? item.category) + : '-'} + + {item.accessible ? ( + item.title + ) : ( + + + 비공개 문의입니다 + + )} + + {item.accessible ? item.authorNickname : '***'} + + {formatDate(item.createdAt)} + + {item.status === 'ANSWER_COMPLETED' ? ( + + 답변 완료 + + ) : ( + + 접수 + + )} +
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( + + )} + + {/* 문의하기 모달 */} + +
+ ); +} From 5e753389d72c5da7dcb506ef5a33d456526e6340 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:39:22 +0900 Subject: [PATCH 12/36] =?UTF-8?q?fix=20:=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=9C=A8=EB=8A=94=20=ED=83=80=EC=9D=B4=EB=B0=8D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EC=8A=A4=ED=84=B0=EB=94=94=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=EC=9E=90=20=EC=8A=B9=EC=9D=B8=20=ED=9B=84=20=EB=92=A4?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=80=EA=B8=B0=EB=A5=BC=20=EB=88=8C=EB=9F=AC?= =?UTF-8?q?=EC=95=BC=20=ED=86=A0=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EB=9C=A8?= =?UTF-8?q?=EA=B3=A0=20=EC=9E=88=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EB=B0=9C?= =?UTF-8?q?=EA=B2=AC=20-=20=EC=8A=B9=EC=9D=B8=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=ED=9B=84=20=EB=B0=94=EB=A1=9C=20=ED=86=A0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EB=9C=A8=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(service)/(my)/layout.tsx | 2 -- src/app/(service)/group-study/layout.tsx | 9 +-------- src/app/(service)/home/page.tsx | 2 -- src/app/(service)/layout.tsx | 2 ++ src/app/(service)/premium-study/layout.tsx | 9 +-------- 5 files changed, 4 insertions(+), 20 deletions(-) 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)/layout.tsx b/src/app/(service)/layout.tsx index fc5d3bd8..e5043597 100644 --- a/src/app/(service)/layout.tsx +++ b/src/app/(service)/layout.tsx @@ -7,6 +7,7 @@ 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 GlobalToast from '@/components/ui/global-toast'; import MainProvider from '@/providers'; import { getServerCookie } from '@/utils/server-cookie'; import Header from '@/widgets/home/header'; @@ -40,6 +41,7 @@ export default async function ServiceLayout({ {GTM_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}; } From 55cb27bdaf2088ed51e3b5c33a5859c66ca492d7 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:47:38 +0900 Subject: [PATCH 13/36] =?UTF-8?q?feat=20:=20=EB=AC=B8=EC=9D=98=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EB=B0=8F=20=EB=AC=B8=EC=9D=98=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=97=90=20=EC=A1=B0=ED=9A=8C=20=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(service)/inquiry/[questionId]/page.tsx | 24 +++++++++++++------ src/app/(service)/inquiry/page.tsx | 14 +++++++---- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/app/(service)/inquiry/[questionId]/page.tsx b/src/app/(service)/inquiry/[questionId]/page.tsx index dc11ff9a..8527bbb8 100644 --- a/src/app/(service)/inquiry/[questionId]/page.tsx +++ b/src/app/(service)/inquiry/[questionId]/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import Image from 'next/image'; import { useRouter, useSearchParams } from 'next/navigation'; import { use } from 'react'; import Badge from '@/components/ui/badge'; @@ -69,7 +70,6 @@ export default function InquiryDetailPage({ {CATEGORY_LABEL[data.category] ?? data.category} @@ -94,15 +94,15 @@ export default function InquiryDetailPage({
문의 상태 {data.status === 'ANSWER_COMPLETED' ? ( - - 답변 완료 - + 답변 완료 ) : ( - - 접수 - + 접수 )}
+
+ 조회수 + {data.viewCount} +
@@ -116,6 +116,16 @@ export default function InquiryDetailPage({

{data.content}

+ {data.questionImage?.resizedImages?.[0]?.resizedImageUrl && ( + 문의 이미지 + )}
)} diff --git a/src/app/(service)/inquiry/page.tsx b/src/app/(service)/inquiry/page.tsx index 06c14fe8..3df3ca64 100644 --- a/src/app/(service)/inquiry/page.tsx +++ b/src/app/(service)/inquiry/page.tsx @@ -101,6 +101,9 @@ export default function InquiryPage() { 작성일시 + + 조회수 + 상태 @@ -109,13 +112,13 @@ export default function InquiryPage() { {isLoading ? ( - + 로딩 중... ) : items.length === 0 ? ( - + 등록된 문의가 없습니다. @@ -159,13 +162,16 @@ export default function InquiryPage() { {formatDate(item.createdAt)} + + {item.viewCount} + {item.status === 'ANSWER_COMPLETED' ? ( - + 답변 완료 ) : ( - + 접수 )} From 1d1f46c902f05de7891cfed457a9b3e96949a4da Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:53:03 +0900 Subject: [PATCH 14/36] =?UTF-8?q?feat=20:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=B2=A8=EB=B6=80=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=95=B4=EB=8B=B9=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=9E=AC=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/modals/question-modal.tsx | 64 ++++++++- src/components/ui/image-upload-input.tsx | 125 ++++++++++++++++ .../group/ui/group-study-thumbnail-input.tsx | 133 +----------------- .../study/group/ui/step/step2-group.tsx | 4 +- 4 files changed, 189 insertions(+), 137 deletions(-) create mode 100644 src/components/ui/image-upload-input.tsx diff --git a/src/components/modals/question-modal.tsx b/src/components/modals/question-modal.tsx index dcc677fb..a296f5b3 100644 --- a/src/components/modals/question-modal.tsx +++ b/src/components/modals/question-modal.tsx @@ -3,8 +3,10 @@ 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 { @@ -44,6 +46,10 @@ export default function QuestionModal({ const router = useRouter(); const showToast = useToastStore((state) => state.showToast); const { mutate: createQuestion, isPending } = useCreateQuestion(); + const [imageFile, setImageFile] = useState(undefined); + const [imagePreview, setImagePreview] = useState( + undefined, + ); const form = useForm({ resolver: zodResolver(questionSchema), @@ -57,6 +63,34 @@ export default function QuestionModal({ const { handleSubmit, reset } = form; const scrollToNext = useScrollToNextField(); + 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 formData = new FormData(); + formData.append('file', file); + + const res = await fetch(uploadUrl, { method: 'PUT', body: formData }); + + if (!res.ok) { + throw new Error(`이미지 업로드 실패 (status: ${res.status})`); + } + }; + + const resetImageState = () => { + if (imagePreview) URL.revokeObjectURL(imagePreview); + setImageFile(undefined); + setImagePreview(undefined); + }; + const onSubmit = (data: QuestionFormValues) => { createQuestion( { @@ -68,9 +102,17 @@ export default function QuestionModal({ }, }, { - onSuccess: () => { + onSuccess: async (result) => { + if (imageFile && result.imageUploadUrl) { + try { + await uploadImage(result.imageUploadUrl, imageFile); + } catch (error) { + console.error('이미지 업로드 오류:', error); + } + } showToast('문의가 성공적으로 제출되었습니다.', 'success'); reset(); + resetImageState(); onOpenChange(false); router.push( `/inquiry?groupStudyId=${studyId}${studyType ? `&studyType=${studyType}` : ''}`, @@ -87,6 +129,7 @@ export default function QuestionModal({ const handleOpenChange = (isOpen: boolean) => { if (!isOpen) { reset(); + resetImageState(); } onOpenChange(isOpen); }; @@ -95,7 +138,7 @@ export default function QuestionModal({ - + 스터디 문의하기 @@ -105,7 +148,10 @@ export default function QuestionModal({ - + name="category" @@ -147,6 +193,18 @@ export default function QuestionModal({ className="font-designer-16m text-text-default h-auto min-h-[150px]" /> +
+ + 이미지 첨부 + + (선택) + + + +
+
+ ) : ( +
+ preview + +
+ )} +
+ ); +} 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..46979a7e 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,7 +74,7 @@ export default function Step2OpenGroupStudy() { required scrollable > - From caf83896605b5d9f60c1b936630176e41d6ba59e Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:10:41 +0900 Subject: [PATCH 15/36] =?UTF-8?q?fix=20:=20alert=EB=A5=BC=20toast=EB=A1=9C?= =?UTF-8?q?=20=EB=8C=80=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/group/ui/group-study-form-modal.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) 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..8f51957e 100644 --- a/src/features/study/group/ui/group-study-form-modal.tsx +++ b/src/features/study/group/ui/group-study-form-modal.tsx @@ -25,6 +25,7 @@ import { toUpdateRequest, } from '../model/group-study-form.schema'; import { useGroupStudyDetailQuery } from '../model/use-study-query'; +import { useToastStore } from '@/stores/use-toast-store'; export type { StudyClassification }; @@ -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(); } From 15b9b47e59bdd7d200b1a7eb1ed824d13d12b061 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:58:17 +0900 Subject: [PATCH 16/36] =?UTF-8?q?feat=20:=20=EC=8A=A4=ED=84=B0=EB=94=94=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EB=B1=83=EC=A7=80=20=EB=B0=8F=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=A0=84=EA=B4=91=ED=8C=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - countdown.ts: D-3/D-2/D-1/HH:MM:SS 단계별 카운트다운 유틸리티 추가 - useNow: 싱글턴 setInterval로 1초마다 현재 시각 갱신하는 훅 추가 - StudyCardCountdownBadge: 스터디 카드 이미지 영역에 모집 마감 D-day 뱃지 표시 - StudyActiveTicker: 스터디 상세 페이지에 모집 현황 전광판 및 마감 카운트다운 추가 - study-card: 카운트다운 뱃지 연결 및 남은 자리 모집 현황 UI 추가 - group-study-info-section: StudyActiveTicker 연결 Co-Authored-By: Claude Sonnet 4.6 --- src/components/card/study-card.tsx | 57 ++++++++++- .../section/group-study-info-section.tsx | 6 ++ src/components/ui/study-active-ticker.tsx | 96 +++++++++++++++++++ .../ui/study-card-countdown-badge.tsx | 52 ++++++++++ src/hooks/use-now.ts | 39 ++++++++ src/lib/countdown.ts | 77 +++++++++++++++ 6 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 src/components/ui/study-active-ticker.tsx create mode 100644 src/components/ui/study-card-countdown-badge.tsx create mode 100644 src/hooks/use-now.ts create mode 100644 src/lib/countdown.ts diff --git a/src/components/card/study-card.tsx b/src/components/card/study-card.tsx index 84dad595..c5feaeb3 100644 --- a/src/components/card/study-card.tsx +++ b/src/components/card/study-card.tsx @@ -5,9 +5,14 @@ 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'; @@ -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/section/group-study-info-section.tsx b/src/components/section/group-study-info-section.tsx index 8efc1e3e..62b2ab24 100644 --- a/src/components/section/group-study-info-section.tsx +++ b/src/components/section/group-study-info-section.tsx @@ -9,6 +9,7 @@ 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'; @@ -147,6 +148,11 @@ export default function StudyInfoSection({
+ 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/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..09150315 --- /dev/null +++ b/src/lib/countdown.ts @@ -0,0 +1,77 @@ +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; + +/** + * diffMs(남은 밀리초)를 받아 카운트다운 표시 상태를 반환합니다. + * - null: 이미 지남 (diffMs <= 0) + * - { urgent: false }: 임박하지 않음 (D-4 이상) + * - { urgent: true, ... }: D-3 / D-2 / D-1 / HH:MM:SS + */ +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 }; + } + + // diffDays === 0: HH:MM:SS 카운트다운 + 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, + }; +} From 51f81a3693d5429fad300911759d70ee34c3662f Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:58:22 +0900 Subject: [PATCH 17/36] =?UTF-8?q?feat=20:=20=EC=8A=A4=ED=84=B0=EB=94=94=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EA=B2=BD=ED=97=98=20=EB=A0=88=EB=B2=A8=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - study-filter: 스터디 대상(입문자/취준생/주니어/미들/시니어) 필터 드롭다운 추가 - use-study-list-filter: 경험 레벨 클라이언트 사이드 필터링 로직 추가 - 경험 레벨 필터 적용 시 전체 데이터를 가져온 후 클라이언트에서 필터링 (isClientFiltered 플래그) - 검색과 경험 레벨 필터를 함께 지원하도록 filteredStudies 로직 개선 Co-Authored-By: Claude Sonnet 4.6 --- src/components/filtering/study-filter.tsx | 26 ++++++++++++++ src/hooks/common/use-study-list-filter.ts | 44 +++++++++++++++-------- 2 files changed, 55 insertions(+), 15 deletions(-) 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} /> + + 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, From f309c38481221071693fb3f7472dad5177748f52 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:58:29 +0900 Subject: [PATCH 18/36] =?UTF-8?q?feat=20:=20=EB=AC=B8=EC=9D=98=20API=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=ED=99=95=EC=9E=A5=20(=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80,=20=EC=A1=B0=ED=9A=8C=EC=88=98,=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImageDto, ResizedImage 타입 추가 - QuestionListItemResponse: viewCount, authorProfileImage, authorId 필드 추가 - QuestionDetailResponse: viewCount, authorProfileImage, questionImage 필드 추가 - GetQuestionsResponse: page/hasNext/hasPrevious 페이지네이션 필드 추가 (number → page 변경) - category 필드 옵셔널 처리 Co-Authored-By: Claude Sonnet 4.6 --- src/features/study/group/api/question-api.ts | 30 ++++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/features/study/group/api/question-api.ts b/src/features/study/group/api/question-api.ts index e524cf3a..7ee74f5d 100644 --- a/src/features/study/group/api/question-api.ts +++ b/src/features/study/group/api/question-api.ts @@ -1,6 +1,21 @@ import { axiosInstance } from '@/api/client/axios'; import { QuestionCategory } from '../model/question.schema'; +export interface ResizedImage { + resizedImageId: number; + resizedImageUrl: string; + imageSizeType: { + imageTypeName: string; + width: number; + height: number; + }; +} + +export interface ImageDto { + imageId: string; + resizedImages: ResizedImage[]; +} + export interface CreateQuestionRequest { title: string; content: string; @@ -12,6 +27,7 @@ export interface CreateQuestionResponse { timestamp: string; content: { generatedQuestionId: number; + imageUploadUrl?: string; }; message: string; } @@ -21,9 +37,12 @@ export interface QuestionListItemResponse { accessible: boolean; title: string; content: string; - category: QuestionCategory; + category?: QuestionCategory; status: 'ACCEPTED' | 'ANSWER_COMPLETED'; + authorId: number; authorNickname: string; + authorProfileImage?: ImageDto; + viewCount: number; createdAt: string; } @@ -35,7 +54,9 @@ export interface GetQuestionsResponse { totalPages: number; size: number; content: QuestionListItemResponse[]; - number: number; + page: number; + hasNext: boolean; + hasPrevious: boolean; }; message: string; } @@ -44,9 +65,12 @@ export interface QuestionDetailResponse { questionId: number; title: string; content: string; - category: QuestionCategory; + category?: QuestionCategory; authorId: number; authorNickname: string; + authorProfileImage?: ImageDto; + viewCount: number; + questionImage?: ImageDto; status: 'ACCEPTED' | 'ANSWER_COMPLETED'; createdAt: string; } From b011867d9095da60927551d71663535b4c5e6179 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:58:34 +0900 Subject: [PATCH 19/36] =?UTF-8?q?style=20:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inquiry/page: Badge 컴포넌트 인라인 포맷팅 - group-study-form-modal: import 순서 정렬 - step2-group: ImageUploadInput props 인라인 포맷팅 Co-Authored-By: Claude Sonnet 4.6 --- src/app/(service)/inquiry/page.tsx | 8 ++------ src/features/study/group/ui/group-study-form-modal.tsx | 2 +- src/features/study/group/ui/step/step2-group.tsx | 5 +---- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/app/(service)/inquiry/page.tsx b/src/app/(service)/inquiry/page.tsx index 3df3ca64..da4080c1 100644 --- a/src/app/(service)/inquiry/page.tsx +++ b/src/app/(service)/inquiry/page.tsx @@ -167,13 +167,9 @@ export default function InquiryPage() { {item.status === 'ANSWER_COMPLETED' ? ( - - 답변 완료 - + 답변 완료 ) : ( - - 접수 - + 접수 )} 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 8f51957e..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 { @@ -25,7 +26,6 @@ import { toUpdateRequest, } from '../model/group-study-form.schema'; import { useGroupStudyDetailQuery } from '../model/use-study-query'; -import { useToastStore } from '@/stores/use-toast-store'; export type { StudyClassification }; diff --git a/src/features/study/group/ui/step/step2-group.tsx b/src/features/study/group/ui/step/step2-group.tsx index 46979a7e..5746c2e8 100644 --- a/src/features/study/group/ui/step/step2-group.tsx +++ b/src/features/study/group/ui/step/step2-group.tsx @@ -74,10 +74,7 @@ export default function Step2OpenGroupStudy() { required scrollable > - + From bb513e6acb690af4af7de3502ee8971e659fab82 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Sat, 21 Feb 2026 00:54:59 +0900 Subject: [PATCH 20/36] =?UTF-8?q?fix=20:=20=ED=8C=9D=EC=98=A4=EB=B2=84=20U?= =?UTF-8?q?I=20=EC=88=98=EC=A0=95=20-=20=EB=82=A8=EC=9D=80=20=EC=9D=B8?= =?UTF-8?q?=EC=9B=90=EB=A7=8C=20=EB=B3=B4=EC=9D=B4=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=ED=8C=9D=EC=98=A4=EB=B2=84=EA=B0=80?= =?UTF-8?q?=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=EC=97=90=20=EC=9D=98?= =?UTF-8?q?=ED=95=B4=20=EC=9E=98=EB=A6=AC=EB=8A=94=20=EB=B6=80=EB=B6=84?= =?UTF-8?q?=EC=9D=84=20ref=EB=A1=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/avatar-stack.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/components/ui/avatar-stack.tsx b/src/components/ui/avatar-stack.tsx index 1b3b9ce6..f5be4e1d 100644 --- a/src/components/ui/avatar-stack.tsx +++ b/src/components/ui/avatar-stack.tsx @@ -1,7 +1,7 @@ 'use client'; import { Crown, X } from 'lucide-react'; -import { useState } from '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'; @@ -26,6 +26,12 @@ export default function AvatarStack({ }: AvatarStackProps) { const [showOverflow, setShowOverflow] = useState(false); + const popoverRef = useCallback((node: HTMLDivElement | null) => { + if (node) { + node.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }, []); + // 리더를 맨 앞으로, 나머지는 가입순(원본 순서) 유지 const sorted = [...members].sort((a, b) => { if (a.isLeader && !b.isLeader) return -1; @@ -59,7 +65,10 @@ export default function AvatarStack({ {showOverflow && ( -
+
참가자 목록 @@ -69,10 +78,10 @@ export default function AvatarStack({ onClick={() => setShowOverflow(false)} className="text-text-subtle hover:text-text-default" > - +
-
    +
      {overflow.map((member) => (
    • +
      {member.nickname || '익명'}
      )} From dc1186b915e2af730bbb3feede3198ef98451dd8 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Sun, 22 Feb 2026 12:59:04 +0900 Subject: [PATCH 21/36] =?UTF-8?q?delete=20:=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/countdown.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/lib/countdown.ts b/src/lib/countdown.ts index 09150315..daa8890a 100644 --- a/src/lib/countdown.ts +++ b/src/lib/countdown.ts @@ -38,12 +38,6 @@ interface UrgentStageResult { type CountdownState = { urgent: false } | UrgentStageResult; -/** - * diffMs(남은 밀리초)를 받아 카운트다운 표시 상태를 반환합니다. - * - null: 이미 지남 (diffMs <= 0) - * - { urgent: false }: 임박하지 않음 (D-4 이상) - * - { urgent: true, ... }: D-3 / D-2 / D-1 / HH:MM:SS - */ export function getCountdownState(diffMs: number): CountdownState { if (diffMs <= 0) return null; @@ -59,7 +53,6 @@ export function getCountdownState(diffMs: number): CountdownState { return { urgent: true, ...stage, isHourly: false }; } - // diffDays === 0: HH:MM:SS 카운트다운 const hh = String(Math.floor(diffMs / (1000 * 60 * 60))).padStart(2, '0'); const mm = String( Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)), From d15a71877226e17f58b48fedd991f51a343fd9c1 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:18:16 +0900 Subject: [PATCH 22/36] =?UTF-8?q?fix=20:=20axios=20->=20axiosV2=EB=A1=9C?= =?UTF-8?q?=20=EB=B6=80=EB=B6=84=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/auth/api/nickname-check.ts | 23 ----- src/features/auth/model/use-nickname-check.ts | 12 ++- .../my-page/api/update-user-profile.ts | 84 ----------------- .../model/use-update-user-profile-mutation.ts | 89 ++++++++++++++----- .../phone-verification/api/phone-auth.ts | 39 -------- .../model/use-phone-auth-mutation.ts | 31 ++++--- 6 files changed, 93 insertions(+), 185 deletions(-) delete mode 100644 src/features/auth/api/nickname-check.ts delete mode 100644 src/features/my-page/api/update-user-profile.ts delete mode 100644 src/features/phone-verification/api/phone-auth.ts diff --git a/src/features/auth/api/nickname-check.ts b/src/features/auth/api/nickname-check.ts deleted file mode 100644 index be3ef535..00000000 --- a/src/features/auth/api/nickname-check.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { axiosInstance } from '@/api/client/axios'; - -export interface NicknameCheckResponse { - nickname: string; - available: boolean; -} - -/** - * 닉네임 중복 체크 API - */ -export async function checkNicknameAvailability( - nickname: string, -): Promise { - 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/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')); // 인증 상태 저장 From 8121a259a83f09a1b9c748aced9e82799e0aab86 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:51:14 +0900 Subject: [PATCH 23/36] =?UTF-8?q?feat:=20=EB=AC=B8=EC=9D=98=20=EB=8B=B5?= =?UTF-8?q?=EB=B3=80=20API=20=ED=83=80=EC=9E=85=20=ED=99=95=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=8F=20useCreateAnswer=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QuestionDetailResponse에 answer(string), answererId, answererNickname, answeredAt 플랫 필드 추가 - CreateQuestionRequest에 imageExtension 필드 추가 - createAnswer API 함수 추가 - question.schema에 imageExtension 옵셔널 필드 추가 - useCreateAnswer 뮤테이션 훅 추가 (onSuccess 캐시 무효화 포함) - useCreateQuestion에 onSuccess invalidateQueries 추가 Co-Authored-By: Claude Sonnet 4.6 --- src/features/study/group/api/question-api.ts | 23 ++++++++++++ .../study/group/model/question.schema.ts | 1 + src/hooks/queries/question-api.ts | 36 ++++++++++++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/features/study/group/api/question-api.ts b/src/features/study/group/api/question-api.ts index 7ee74f5d..fa44f463 100644 --- a/src/features/study/group/api/question-api.ts +++ b/src/features/study/group/api/question-api.ts @@ -20,6 +20,7 @@ export interface CreateQuestionRequest { title: string; content: string; category?: QuestionCategory; + imageExtension?: string; } export interface CreateQuestionResponse { @@ -73,6 +74,11 @@ export interface QuestionDetailResponse { questionImage?: ImageDto; status: 'ACCEPTED' | 'ANSWER_COMPLETED'; createdAt: string; + answer?: string; + answererId?: number; + answererNickname?: string; + answeredProfileImage?: ImageDto; + answeredAt?: string; } export interface GetQuestionResponse { @@ -122,3 +128,20 @@ export const getQuestion = async (groupStudyId: number, questionId: number) => { 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/question.schema.ts b/src/features/study/group/model/question.schema.ts index ce2d4124..45ec2cb4 100644 --- a/src/features/study/group/model/question.schema.ts +++ b/src/features/study/group/model/question.schema.ts @@ -37,6 +37,7 @@ export const questionSchema = z.object({ category: z.nativeEnum(QuestionCategory, { message: '카테고리를 선택해주세요.', }), + imageExtension: z.string().optional(), }); export type QuestionFormValues = z.infer; diff --git a/src/hooks/queries/question-api.ts b/src/hooks/queries/question-api.ts index 91d7e2fd..fbc90c59 100644 --- a/src/hooks/queries/question-api.ts +++ b/src/hooks/queries/question-api.ts @@ -1,5 +1,6 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { + createAnswer, createQuestion, CreateQuestionRequest, getQuestion, @@ -7,6 +8,8 @@ import { } from '@/features/study/group/api/question-api'; export const useCreateQuestion = () => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: async ({ groupStudyId, @@ -19,6 +22,11 @@ export const useCreateQuestion = () => { return data.content; }, + onSuccess: async (_, variables) => { + await queryClient.invalidateQueries({ + queryKey: ['questions', variables.groupStudyId], + }); + }, }); }; @@ -59,3 +67,29 @@ export const useGetQuestion = ({ enabled: !!groupStudyId && !!questionId, }); }; + +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], + }); + }, + }); +}; From 9a698b5526f774211d0c912e2300bf1ad85c6b72 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:51:21 +0900 Subject: [PATCH 24/36] =?UTF-8?q?feat:=20InquiryStatusBadge=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=AC=B8=EC=9D=98=20=ED=83=AD=20=EC=84=B9=EC=85=98=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InquiryStatusBadge 컴포넌트 신규 추가 (ACCEPTED: gray, ANSWER_COMPLETED: green) - InquirySection 컴포넌트 신규 추가 (그룹 스터디 탭 내 문의 기능 통합) - QuestionModal에 onAfterSubmit 콜백 추가 (제출 후 동작 커스터마이즈 지원) - QuestionModal에서 imageExtension을 API 요청에 전달하도록 수정 Co-Authored-By: Claude Sonnet 4.6 --- src/components/modals/question-modal.tsx | 15 +- src/components/section/inquiry-section.tsx | 530 ++++++++++++++++++ .../ui/badge/inquiry-status-badge.tsx | 23 + 3 files changed, 565 insertions(+), 3 deletions(-) create mode 100644 src/components/section/inquiry-section.tsx create mode 100644 src/components/ui/badge/inquiry-status-badge.tsx diff --git a/src/components/modals/question-modal.tsx b/src/components/modals/question-modal.tsx index a296f5b3..3eef4c49 100644 --- a/src/components/modals/question-modal.tsx +++ b/src/components/modals/question-modal.tsx @@ -35,6 +35,7 @@ interface QuestionModalProps { onOpenChange: (open: boolean) => void; studyId: number; studyType?: 'group' | 'premium'; + onAfterSubmit?: () => void; } export default function QuestionModal({ @@ -42,6 +43,7 @@ export default function QuestionModal({ onOpenChange, studyId, studyType, + onAfterSubmit, }: QuestionModalProps) { const router = useRouter(); const showToast = useToastStore((state) => state.showToast); @@ -92,6 +94,8 @@ export default function QuestionModal({ }; const onSubmit = (data: QuestionFormValues) => { + const imageExtension = imageFile ? imageFile.type.split('/')[1] : undefined; + createQuestion( { groupStudyId: studyId, @@ -99,6 +103,7 @@ export default function QuestionModal({ title: data.title, content: data.content, category: data.category, + imageExtension, }, }, { @@ -114,9 +119,13 @@ export default function QuestionModal({ reset(); resetImageState(); onOpenChange(false); - router.push( - `/inquiry?groupStudyId=${studyId}${studyType ? `&studyType=${studyType}` : ''}`, - ); + if (onAfterSubmit) { + onAfterSubmit(); + } else { + router.push( + `/inquiry?groupStudyId=${studyId}${studyType ? `&studyType=${studyType}` : ''}`, + ); + } }, onError: (error) => { showToast('문의 제출에 실패했습니다. 다시 시도해주세요.', 'error'); diff --git a/src/components/section/inquiry-section.tsx b/src/components/section/inquiry-section.tsx new file mode 100644 index 00000000..0ef3a70d --- /dev/null +++ b/src/components/section/inquiry-section.tsx @@ -0,0 +1,530 @@ +'use client'; + +import { format } from 'date-fns'; +import { ko } from 'date-fns/locale'; +import { ArrowLeft, Eye, LockIcon, XIcon } from 'lucide-react'; +import Image from 'next/image'; +import { useState } from 'react'; +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 { Modal } from '@/components/ui/modal'; +import Pagination from '@/components/ui/pagination'; +import { + useCreateAnswer, + useGetQuestion, + useGetQuestions, +} from '@/hooks/queries/question-api'; +import { useToastStore } from '@/stores/use-toast-store'; + +const ANSWER_CONTENT_MAX_LENGTH = 2000; + +const CATEGORY_LABEL: Record = { + PAYMENT: '결제', + STUDY_COMMON: '스터디 일반', + LEADER: '리더', + BUG: '버그', + CONCERN: '고민', +}; + +function formatDate(dateString: string): string { + if (!dateString) return '-'; + + return format(new Date(dateString), 'yyyy.MM.dd', { locale: ko }); +} + +function formatDateTime(dateString: string): string { + if (!dateString) return '-'; + + return format(new Date(dateString), 'yyyy.MM.dd HH:mm', { locale: ko }); +} + +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 showToast = useToastStore((state) => state.showToast); + const [selectedQuestionId, setSelectedQuestionId] = useState< + number | undefined + >(undefined); + const [isModalOpen, setIsModalOpen] = useState(false); + const [page, setPage] = useState(1); + const [hoveredId, setHoveredId] = useState(undefined); + + return ( +
      + {selectedQuestionId === undefined ? ( + setIsModalOpen(true)} + showToast={showToast} + /> + ) : ( + setSelectedQuestionId(undefined)} + showToast={showToast} + isLeader={isLeader} + isAdmin={isAdmin} + /> + )} + + setIsModalOpen(false)} + /> +
      + ); +} + +interface ListViewProps { + groupStudyId: number; + isPremium: boolean; + page: number; + hoveredId: number | undefined; + onPageChange: (page: number) => void; + onSelectQuestion: (id: number) => void; + onHoverChange: (id: number | undefined) => void; + onOpenModal: () => void; + showToast: (message: string, type?: 'success' | 'error') => void; +} + +function ListView({ + groupStudyId, + isPremium, + page, + hoveredId, + onPageChange, + onSelectQuestion, + onHoverChange, + onOpenModal, + showToast, +}: 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 ? '멘토' : '리더'}, 관리자만 확인할 + 수 있어요. +

      +
      + +
      + + {/* 표 */} +
      + + + + + + + + + + + + + + {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; + } + onSelectQuestion(item.questionId); + }} + onMouseEnter={() => onHoverChange(item.questionId)} + onMouseLeave={() => onHoverChange(undefined)} + > + + + + + + + + + ); + }) + )} + +
      + 번호 + + 분류 + + 제목 + + 작성자 + + 작성일시 + + 조회수 + + 상태 +
      + 로딩 중... +
      + 등록된 문의가 없습니다. +
      + {displayNumber} + + {item.category + ? (CATEGORY_LABEL[item.category] ?? item.category) + : '-'} + + {item.accessible ? ( + + {item.title} + + ) : ( + + + 비공개 문의입니다 + + )} + + {item.accessible ? item.authorNickname : '***'} + + {formatDate(item.createdAt)} + + + + {item.viewCount} + + + +
      +
      + + {/* 페이지네이션 */} + {totalPages > 1 && ( + + )} + + ); +} + +interface DetailViewProps { + groupStudyId: number; + questionId: number; + onBack: () => void; + showToast: (message: string, type?: 'success' | 'error') => void; + isLeader?: boolean; + isAdmin?: boolean; +} + +function DetailView({ + groupStudyId, + questionId, + onBack, + showToast, + isLeader = false, + isAdmin = false, +}: DetailViewProps) { + const { data, isLoading } = useGetQuestion({ groupStudyId, questionId }); + const [showAnswerModal, setShowAnswerModal] = useState(false); + const [answerContent, setAnswerContent] = useState(''); + const { mutate: submitAnswer, isPending } = useCreateAnswer(); + + console.log({ answerContent }); + + const moreMenuOptions = [ + { + label: '수정하기', + value: 'edit', + onMenuClick: () => showToast('준비 중인 기능입니다.', 'error'), + }, + { + label: '삭제하기', + value: 'delete', + onMenuClick: () => showToast('준비 중인 기능입니다.', 'error'), + }, + ]; + + const handleAnswerSubmit = () => { + submitAnswer( + { groupStudyId, questionId, content: answerContent }, + { + onSuccess: () => { + showToast('답변이 등록되었습니다.', 'success'); + setAnswerContent(''); + setShowAnswerModal(false); + }, + onError: () => { + showToast('답변 등록에 실패했습니다.', 'error'); + }, + }, + ); + }; + + if (isLoading) { + return ( +
      로딩 중...
      + ); + } + + return ( + <> +
      + +
      + + {data && ( +
      + {/* 카드 1: 질문 */} +
      +
      +
      +
      + {data.category && ( + + {CATEGORY_LABEL[data.category] ?? data.category} + + )} +

      + {data.title} +

      +
      + +
      + +
      +
      + 작성자 + + {data.authorNickname} + +
      +
      + + {data.viewCount} +
      +
      + 작성일 + + {formatDateTime(data.createdAt)} + +
      +
      + +
      +
      +
      + +
      +
      +
      + +
      +

      + {data.content} +

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

      + 리더 답변 +

      +
      +
      +
      + 작성자 + + {data.answererNickname} + +
      +
      + 작성일 + + {formatDateTime(data.answeredAt ?? '')} + +
      +
      +
      + +
      +
      +
      + +
      +

      + {data.answer} +

      +
      + + ) : ( +
      +

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

      + {(isLeader || isAdmin) && ( + + )} +
      + )} +
      +
      + )} + + {/* 답변 작성 모달 */} + + + + + + + 답변 작성 + + + + + + +
      + +