From 10a395d22eda74c8efbad7abd8ed07c0b44f0a95 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:54:50 +0900 Subject: [PATCH 01/12] =?UTF-8?q?fix:=20HTML=20=EC=9A=94=EC=86=8C=EC=97=90?= =?UTF-8?q?=20overflow-x-hidden=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20Toast=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EC=A0=95=EB=B3=B4=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(admin)/layout.tsx | 2 +- src/app/(landing)/layout.tsx | 2 +- src/app/(service)/insights/ui/blog-detail-page.tsx | 2 +- src/app/(service)/layout.tsx | 4 ++-- src/components/ui/toast.tsx | 6 ++++-- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx index 29dd1c52..cddddc45 100644 --- a/src/app/(admin)/layout.tsx +++ b/src/app/(admin)/layout.tsx @@ -37,7 +37,7 @@ export default async function AdminLayout({ const initialAccessToken = await getServerCookie('accessToken'); return ( - + {GTM_ID && } diff --git a/src/app/(landing)/layout.tsx b/src/app/(landing)/layout.tsx index 296f2063..5aa0f364 100644 --- a/src/app/(landing)/layout.tsx +++ b/src/app/(landing)/layout.tsx @@ -92,7 +92,7 @@ export default async function LandingPageLayout({ -
+
{/** 1400 + 48*2 패딩 양옆 48로 임의적용 */}
diff --git a/src/app/(service)/insights/ui/blog-detail-page.tsx b/src/app/(service)/insights/ui/blog-detail-page.tsx index 3d0434f2..754f0542 100644 --- a/src/app/(service)/insights/ui/blog-detail-page.tsx +++ b/src/app/(service)/insights/ui/blog-detail-page.tsx @@ -445,7 +445,7 @@ export default function BlogDetailPage({ article }: BlogDetailPageProps) {
{/* 커버 이미지 (GNB 바로 아래, 전체 너비) */} {coverUrl && ( -
+
{article.title} + {GTM_ID && } -
+
{children}
diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 719aecc5..9af44b08 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -1,6 +1,6 @@ 'use client'; -import { CheckCircle2, XCircle } from 'lucide-react'; +import { CheckCircle2, Info, XCircle } from 'lucide-react'; import React, { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; @@ -72,8 +72,10 @@ export default function Toast({ > {isSuccess ? ( - ) : ( + ) : isError ? ( + ) : ( + )} {message}
From 1a8c1f7c858c9f12a02fa2c1a8cab581916a46e4 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:56:55 +0900 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20InquirySection=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=97=B4=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=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/components/modals/question-modal.tsx | 18 +++++++++--------- src/components/section/inquiry-section.tsx | 3 --- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/components/modals/question-modal.tsx b/src/components/modals/question-modal.tsx index 93ddd2ec..c79cc6bd 100644 --- a/src/components/modals/question-modal.tsx +++ b/src/components/modals/question-modal.tsx @@ -5,6 +5,7 @@ import { XIcon } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; +import { axiosInstanceForMultipart } from '@/api/client/axios'; import Button from '@/components/ui/button'; import ImageUploadInput from '@/components/ui/image-upload-input'; import { BaseInput, TextAreaInput } from '@/components/ui/input'; @@ -67,6 +68,7 @@ export default function QuestionModal({ const handleChangeImage = (file: File | undefined) => { if (file) { + if (imagePreview) URL.revokeObjectURL(imagePreview); setImageFile(file); setImagePreview(URL.createObjectURL(file)); } else { @@ -77,15 +79,13 @@ export default function QuestionModal({ }; 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 formData = new FormData(); + formData.append('file', file); + + const url = new URL(uploadUrl); + const relativePath = url.pathname.replace(/^\/api\/v1\//, '') + url.search; + + await axiosInstanceForMultipart.put(relativePath, formData); }; const resetImageState = () => { diff --git a/src/components/section/inquiry-section.tsx b/src/components/section/inquiry-section.tsx index 0af4adb0..c7bab714 100644 --- a/src/components/section/inquiry-section.tsx +++ b/src/components/section/inquiry-section.tsx @@ -43,7 +43,6 @@ export default function InquirySection({ page={page} onPageChange={setPage} onSelectQuestion={setSelectedQuestionId} - onOpenModal={() => setIsModalOpen(true)} /> ) : ( void; onSelectQuestion: (id: number) => void; - onOpenModal: () => void; } function ListView({ @@ -82,7 +80,6 @@ function ListView({ page, onPageChange, onSelectQuestion, - onOpenModal, }: ListViewProps) { const { data, isLoading } = useGetQuestions({ groupStudyId, From 5bfde42e26a2581b90b5cc3dbeed5253855ce96d Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:01:02 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20=ED=95=84=ED=84=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=8F=AC=EC=A7=80=EC=85=98=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=97=AD=ED=95=A0=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=EC=9C=BC=EB=A1=9C=20=EB=8C=80=EC=B2=B4,=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=EB=B0=A9=EC=8B=9D=20=EC=98=B5=EC=85=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/filtering/study-filter.tsx | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/components/filtering/study-filter.tsx b/src/components/filtering/study-filter.tsx index a3c0fd7c..bf745750 100644 --- a/src/components/filtering/study-filter.tsx +++ b/src/components/filtering/study-filter.tsx @@ -9,6 +9,10 @@ import { DropdownMenuTrigger, } from '@/components/ui/(shadcn)/ui/dropdown-menu'; import ToggleButton from '@/components/ui/toggle/button'; +import { + ROLE_OPTIONS_UI, + STUDY_METHOD_LABELS, +} from '@/features/study/group/const/group-study-const'; // 필터 옵션 타입 export interface StudyFilterValues { @@ -33,20 +37,10 @@ const STUDY_TYPE_OPTIONS = [ { value: 'LECTURE_STUDY', label: '강의스터디' }, ] as const; -// 포지션 옵션 -const POSITION_OPTIONS = [ - { value: 'BACKEND', label: '백엔드' }, - { value: 'FRONTEND', label: '프론트엔드' }, - { value: 'PLANNER', label: '기획자' }, - { value: 'DESIGNER', label: '디자이너' }, -] as const; - // 진행 방식 옵션 -const METHOD_OPTIONS = [ - { value: 'ONLINE', label: '온라인' }, - { value: 'OFFLINE', label: '오프라인' }, - { value: 'HYBRID', label: '병행' }, -] as const; +const METHOD_OPTIONS = Object.entries(STUDY_METHOD_LABELS).map( + ([value, label]) => ({ value, label }), +); // 경험 레벨 옵션 const EXPERIENCE_LEVEL_OPTIONS = [ @@ -214,7 +208,7 @@ export default function StudyFilter({ values, onChange }: StudyFilterProps) { From 4ae6597633e68d4a61aceea9b4b758f428f146f3 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:56:56 +0900 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20counterMax=20=EC=86=8D=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20maxCharCount=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=9D=BC=EA=B4=80=EC=84=B1=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/modals/create-mission-modal.tsx | 2 +- src/components/modals/edit-homework-modal.tsx | 2 +- src/components/modals/edit-mission-modal.tsx | 2 +- src/components/modals/submit-homework-modal.tsx | 2 +- src/components/ui/form/form-field.tsx | 8 ++++---- src/components/voting/voting-create-modal.tsx | 4 ++-- src/features/my-page/ui/profile-edit-modal.tsx | 2 +- src/features/my-page/ui/profile-info-edit-modal.tsx | 6 +++--- src/features/study/group/ui/delete-group-study-member.tsx | 2 +- src/features/study/group/ui/group-notice-modal.tsx | 2 +- src/features/study/group/ui/group-study-member-item.tsx | 1 + src/features/study/interview/ui/study-done-modal.tsx | 2 +- 12 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/components/modals/create-mission-modal.tsx b/src/components/modals/create-mission-modal.tsx index a4975506..769830f4 100644 --- a/src/components/modals/create-mission-modal.tsx +++ b/src/components/modals/create-mission-modal.tsx @@ -217,7 +217,7 @@ function CreateMissionForm({ label="수행 가이드" direction="vertical" required - counterMax={5000} + maxCharCount={5000} showCounterRight={false} > - {showCounterRight && typeof counterMax === 'number' && ( + {showCounterRight && typeof maxCharCount === 'number' && (
- {currentLen}/{counterMax} + {currentLen}/{maxCharCount}
)}
diff --git a/src/components/voting/voting-create-modal.tsx b/src/components/voting/voting-create-modal.tsx index 90448bdd..2f0cb7f3 100644 --- a/src/components/voting/voting-create-modal.tsx +++ b/src/components/voting/voting-create-modal.tsx @@ -159,7 +159,7 @@ function VotingCreateForm({ onClose, onSubmit }: VotingCreateFormProps) { required direction="vertical" showCounterRight - counterMax={200} + maxCharCount={200} > 가입 인사 {isLeader && member.id !== myId && discretionCount < 3 && ( diff --git a/src/features/study/interview/ui/study-done-modal.tsx b/src/features/study/interview/ui/study-done-modal.tsx index 5f2090e4..4b6c0f0b 100644 --- a/src/features/study/interview/ui/study-done-modal.tsx +++ b/src/features/study/interview/ui/study-done-modal.tsx @@ -213,7 +213,7 @@ function StudyDoneForm({ required direction="vertical" showCounterRight - counterMax={100} + maxCharCount={100} > Date: Thu, 26 Feb 2026 10:04:16 +0900 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20=EC=9E=AC=EB=9F=89=20=ED=8F=89?= =?UTF-8?q?=EA=B0=80=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=EB=A5=BC=20=EC=83=81?= =?UTF-8?q?=EC=88=98=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../group/ui/group-study-member-item.tsx | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/features/study/group/ui/group-study-member-item.tsx b/src/features/study/group/ui/group-study-member-item.tsx index f9afd924..2b36495c 100644 --- a/src/features/study/group/ui/group-study-member-item.tsx +++ b/src/features/study/group/ui/group-study-member-item.tsx @@ -3,7 +3,9 @@ import { useState } from 'react'; import DiscretionGradeHistoryList from '@/components/lists/discretion-grade-history-list'; import MissionProgressHistoryList from '@/components/lists/mission-progress-history-list'; -import DiscretionaryEvaluationModal from '@/components/modals/discretionary-evaluation-modal'; +import DiscretionaryEvaluationModal, { + EVALUATION_COUNT, +} from '@/components/modals/discretionary-evaluation-modal'; import EndGroupStudyModal from '@/components/modals/end-group-study'; import UserAvatar from '@/components/ui/avatar'; @@ -84,12 +86,18 @@ export default function GroupStudyMemberItem({
가입 인사 - {isLeader && member.id !== myId && discretionCount < 3 && ( - + {isLeader && member.id !== myId && ( + discretionCount < EVALUATION_COUNT ? ( + + ) : ( + + 재량 평가 {EVALUATION_COUNT}회 모두 완료 + + ) )}
From 956717ac5cdfde96fdc6cd3733b055c73ccf6eb8 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:04:27 +0900 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20=EC=9E=AC=EB=9F=89=20=ED=8F=89?= =?UTF-8?q?=EA=B0=80=20=EB=AA=A8=EB=8B=AC=EC=97=90=EC=84=9C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=B5=9C=EB=8C=80=20=EA=B8=B8=EC=9D=B4=20=EC=83=81=EC=88=98?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modals/discretionary-evaluation-modal.tsx | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/components/modals/discretionary-evaluation-modal.tsx b/src/components/modals/discretionary-evaluation-modal.tsx index 618f7f26..c4a4002b 100644 --- a/src/components/modals/discretionary-evaluation-modal.tsx +++ b/src/components/modals/discretionary-evaluation-modal.tsx @@ -9,6 +9,9 @@ import { useUpdateMemberDiscretion } from '@/hooks/queries/group-study-member-ap import { useToastStore } from '@/stores/use-toast-store'; import { TextAreaInput } from '../ui/input'; +export const EVALUATION_COUNT = 3; +const EVALUATION_CONTENT_MAX_LENGTH = 5000; + const DiscretionaryEvaluationFormSchema = z.object({ content: z.string().min(1, '평가 내역을 입력해주세요.'), }); @@ -26,7 +29,10 @@ export default function DiscretionaryEvaluationModal({ groupStudyId, memberId, }: DiscretionaryEvaluationModalProps) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); + const [hasSubmitted, setHasSubmitted] = useState(false); + + const { mutate, isPending } = useUpdateMemberDiscretion(); return ( @@ -35,6 +41,7 @@ export default function DiscretionaryEvaluationModal({ size="small" color="outlined" className="font-designer-14r w-fit" + disabled={isPending || hasSubmitted} > 재량평가 추가 @@ -49,7 +56,8 @@ export default function DiscretionaryEvaluationModal({ 재량 평가

- 재량 평가는 최대 3회까지 가능하며, 각 평가별 5점씩 부여됩니다. + 재량 평가는 최대 {EVALUATION_COUNT}회까지 가능하며, 각 평가별 + 5점씩 부여됩니다.

setOpen(false)} /> @@ -59,6 +67,9 @@ export default function DiscretionaryEvaluationModal({ groupStudyId={groupStudyId} memberId={memberId} onClose={() => setOpen(false)} + onSubmitSuccess={() => setHasSubmitted(true)} + mutate={mutate} + isPending={isPending} /> @@ -70,12 +81,18 @@ interface DiscretionaryEvaluationFormProps { groupStudyId: number; memberId: number; onClose: () => void; + onSubmitSuccess: () => void; + mutate: ReturnType['mutate']; + isPending: boolean; } function DiscretionaryEvaluationForm({ groupStudyId, memberId, onClose, + onSubmitSuccess, + mutate, + isPending, }: DiscretionaryEvaluationFormProps) { const methods = useForm({ resolver: zodResolver(DiscretionaryEvaluationFormSchema), @@ -87,11 +104,10 @@ function DiscretionaryEvaluationForm({ const { handleSubmit, formState } = methods; - const { mutate: updateMemberDiscretion } = useUpdateMemberDiscretion(); const showToast = useToastStore((state) => state.showToast); const onValidSubmit = (values: DiscretionaryEvaluationFormValues) => { - updateMemberDiscretion( + mutate( { id: groupStudyId, request: { @@ -102,6 +118,7 @@ function DiscretionaryEvaluationForm({ { onSuccess: () => { showToast('재량 평가가 성공적으로 제출되었습니다!'); + onSubmitSuccess(); onClose(); }, onError: () => { @@ -126,7 +143,7 @@ function DiscretionaryEvaluationForm({ name="content" label="평가 내역" direction="vertical" - counterMax={500} + maxCharCount={EVALUATION_CONTENT_MAX_LENGTH} showCounterRight={false} required > @@ -134,7 +151,7 @@ function DiscretionaryEvaluationForm({ id="content" placeholder="평가 내역을 입력해 주세요." className="min-h-[300px]" - maxLength={5000} + maxLength={EVALUATION_CONTENT_MAX_LENGTH} /> @@ -151,7 +168,7 @@ function DiscretionaryEvaluationForm({ size="large" type="submit" form="discretionary-evaluation" - disabled={!formState.isValid || formState.isSubmitting} + disabled={!formState.isValid || isPending} > 평가완료 From fa72bac1900e35cbd6a77afc852825673e2b7696 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Fri, 27 Feb 2026 00:47:43 +0900 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A7=88=EB=AC=B8=20=EC=9E=85=EB=A0=A5=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/image-upload-input.tsx | 31 ++++++++++++------- .../group/model/group-study-form.schema.ts | 13 ++++---- .../study/group/ui/step/step3-group.tsx | 8 ++++- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/components/ui/image-upload-input.tsx b/src/components/ui/image-upload-input.tsx index 59dda120..1ac8040b 100644 --- a/src/components/ui/image-upload-input.tsx +++ b/src/components/ui/image-upload-input.tsx @@ -1,17 +1,29 @@ 'use client'; +import { cva } from 'class-variance-authority'; import Image from 'next/image'; import { useState, DragEvent, ChangeEvent, useRef } from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; import Button from '@/components/ui/button'; +import CameraIcon from 'public/icons/camera.svg'; // 클라이언트에서 선제 차단하여 불필요한 413 에러 및 대용량 업로드 요청을 방지. const DEFAULT_MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB -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', -}; +const dropzoneVariants = cva( + 'rounded-100 flex w-full flex-col items-center justify-center border-2 p-500', + { + variants: { + dragging: { + true: 'border-border-brand bg-fill-brand-subtle-hover', + false: 'border-gray-300 border-dashed', + }, + }, + defaultVariants: { + dragging: false, + }, + }, +); export default function ImageUploadInput({ image, @@ -93,17 +105,12 @@ export default function ImageUploadInput({ onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} onDragOver={handleDragOver} - className={`${inputStyles.base} ${isDragging ? inputStyles.dragging : inputStyles.notDragging}`} + className={cn(dropzoneVariants({ dragging: isDragging }))} > {!image ? (
- 파일 업로드 +
))} + {formState.errors.interviewPost?.root?.message && ( +

+ {formState.errors.interviewPost.root.message} +

+ )} From b2445484c7868437a5a7a30a485a5e14422db353 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Fri, 27 Feb 2026 02:40:56 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20=EC=8A=A4=ED=84=B0=EB=94=94=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=B0=8F=20=EC=83=81=EC=84=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=20=EC=A1=B0=ED=9A=8C=EC=88=98=20?= =?UTF-8?q?=EB=B0=8F=20=EC=83=81=ED=83=9C=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EB=B0=B0=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/card/study-card.tsx | 70 +++++++---- .../pages/group-study-detail-page.tsx | 11 +- .../pages/premium-study-detail-page.tsx | 13 +- .../section/group-study-info-section.tsx | 8 +- .../my-participating-studies-section.tsx | 4 +- src/components/summary/study-info-summary.tsx | 3 +- src/components/ui/study-active-ticker.tsx | 112 ++++++++---------- .../ui/study-card-countdown-badge.tsx | 31 ++++- .../my-page/ui/my-study-info-card.tsx | 6 +- .../study/group/api/group-study-types.ts | 10 +- .../study/group/const/group-study-const.ts | 1 + 11 files changed, 167 insertions(+), 102 deletions(-) diff --git a/src/components/card/study-card.tsx b/src/components/card/study-card.tsx index 8ca11cb8..6c8ff6af 100644 --- a/src/components/card/study-card.tsx +++ b/src/components/card/study-card.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Clock5, Users } from 'lucide-react'; +import { Clock5, Eye, Users } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; import { GroupStudyListItemDto } from '@/api/openapi'; @@ -45,12 +45,25 @@ interface StudyCardProps { study: GroupStudyListItemDto; href: string; onClick?: () => void; + viewCount?: number; } -export default function StudyCard({ study, href, onClick }: StudyCardProps) { +function formatCount(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`; + + return String(n); +} + +export default function StudyCard({ + study, + href, + onClick, + viewCount, +}: StudyCardProps) { const studyType = study.basicInfo?.type as StudyType; const badgeColor = studyType ? STUDY_TYPE_BADGE_COLORS[studyType] : 'default'; const price = study.basicInfo?.price ?? 0; + const isCompleted = study.basicInfo?.status === 'COMPLETED'; return ( {study.simpleDetailInfo?.thumbnail?.resizedImages?.[0] ?.resizedImageUrl ? ( - {study.simpleDetailInfo?.title + <> + {study.simpleDetailInfo?.title + {isCompleted && ( +
+ )} + ) : (
ZERO ONE IT @@ -112,8 +131,9 @@ export default function StudyCard({ study, href, onClick }: StudyCardProps) { {study.simpleDetailInfo?.summary}

- {/* 활성 배지 (RECRUITING 일 때만) */} - {study.basicInfo?.status === 'RECRUITING' && + {/* 활성 배지 (RECRUITING / ENDING_SOON 일 때만) */} + {(study.basicInfo?.status === 'RECRUITING' || + study.basicInfo?.status === 'ENDING_SOON') && (() => { const remaining = (study.basicInfo?.maxMembersCount ?? 0) - @@ -196,15 +216,25 @@ export default function StudyCard({ study, href, onClick }: StudyCardProps) {
- {/* 가격 (0원이면 숨김) */} - {price > 0 && ( - - {price.toLocaleString()} - - 원 + {/* 가격 & 조회수 */} +
+ {price > 0 && ( + + {price.toLocaleString()} + + 원 + - - )} + )} + {viewCount !== undefined && viewCount > 0 && ( +
+ + + {formatCount(viewCount)} + +
+ )} +
diff --git a/src/components/pages/group-study-detail-page.tsx b/src/components/pages/group-study-detail-page.tsx index 4e7971c0..98b702bd 100644 --- a/src/components/pages/group-study-detail-page.tsx +++ b/src/components/pages/group-study-detail-page.tsx @@ -4,6 +4,7 @@ import { sendGTMEvent } from '@next/third-parties/google'; import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; import MoreMenu from '@/components/ui/dropdown/more-menu'; +import StudyActiveTicker from '@/components/ui/study-active-ticker'; import Tabs from '@/components/ui/tabs'; import { STUDY_DETAIL_TABS, StudyTabValue } from '@/config/constants'; import { useAuthReady } from '@/hooks/common/use-auth'; @@ -163,7 +164,15 @@ export default function StudyDetailPage({ groupStudyId={groupStudyId} onOpenChange={() => setShowStudyFormModal(!showStudyFormModal)} /> -
+ {/* 플로팅 정보 바 */} +
+ +
+
{studyDetail?.detailInfo.title} diff --git a/src/components/pages/premium-study-detail-page.tsx b/src/components/pages/premium-study-detail-page.tsx index 9da74ae4..f1d2a3d3 100644 --- a/src/components/pages/premium-study-detail-page.tsx +++ b/src/components/pages/premium-study-detail-page.tsx @@ -4,6 +4,7 @@ import { sendGTMEvent } from '@next/third-parties/google'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; import MoreMenu from '@/components/ui/dropdown/more-menu'; +import StudyActiveTicker from '@/components/ui/study-active-ticker'; import Tabs from '@/components/ui/tabs'; import { STUDY_DETAIL_TABS, StudyTabValue } from '@/config/constants'; import { @@ -72,6 +73,8 @@ export default function PremiumStudyDetailPage({ const { mutate: deleteGroupStudy } = useDeleteGroupStudyMutation(); const { mutate: completeStudy } = useCompleteGroupStudyMutation(); + console.log({ basic }); + const ModalContent = { end: { title: '스터디를 종료하시겠어요?', @@ -162,7 +165,15 @@ export default function PremiumStudyDetailPage({ onOpenChange={() => setShowStudyFormModal(!showStudyFormModal)} /> -
+ {/* 플로팅 정보 바 */} +
+ +
+

{studyDetail?.detailInfo.title} diff --git a/src/components/section/group-study-info-section.tsx b/src/components/section/group-study-info-section.tsx index 62b2ab24..a7598468 100644 --- a/src/components/section/group-study-info-section.tsx +++ b/src/components/section/group-study-info-section.tsx @@ -9,7 +9,6 @@ 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,12 +146,7 @@ export default function StudyInfoSection({

-
- +
- study.status === 'IN_PROGRESS' || study.status === 'RECRUITING', + study.status === 'IN_PROGRESS' || + study.status === 'RECRUITING' || + study.status === 'ENDING_SOON', ); // GROUP_STUDY인 경우 type 필드로 추가 필터링 diff --git a/src/components/summary/study-info-summary.tsx b/src/components/summary/study-info-summary.tsx index 21527511..d76ba0de 100644 --- a/src/components/summary/study-info-summary.tsx +++ b/src/components/summary/study-info-summary.tsx @@ -145,7 +145,8 @@ export default function SummaryStudyInfo({ data }: SummaryStudyInfoProps) { const isApplyDisabled = isLeader || myApplicationStatus?.status !== 'NONE' || - groupStudyStatus !== 'RECRUITING' || + (groupStudyStatus !== 'RECRUITING' && + groupStudyStatus !== 'ENDING_SOON') || approvedCount >= maxMembersCount || isDeadlinePassed; diff --git a/src/components/ui/study-active-ticker.tsx b/src/components/ui/study-active-ticker.tsx index 608c5059..0f16b149 100644 --- a/src/components/ui/study-active-ticker.tsx +++ b/src/components/ui/study-active-ticker.tsx @@ -1,96 +1,80 @@ 'use client'; -import dayjs from 'dayjs'; +import { Eye, Flame } from 'lucide-react'; 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 + startDate: string; viewCount?: number; + className?: string; } export default function StudyActiveTicker({ approvedCount, maxMembersCount, - startDate, viewCount = 0, + className = '', }: StudyActiveTickerProps) { const remaining = Math.max(0, maxMembersCount - approvedCount); + const [currentIndex, setCurrentIndex] = useState(0); const messages = [ - remaining > 3 - ? `🔥 마감까지 ${remaining}석` - : remaining > 0 - ? `🔥 마지막 ${remaining}자리` - : '🔥 모집 마감', - `지금 ${viewCount}명이 이 스터디를 보고 있어요.`, - `${approvedCount}명이 가입했고 현재 ${remaining}자리 남았어요.`, + { + icon: , + content: ( +

+ 지금{' '} + + {viewCount}명 + + 이 이 스터디를 보고 있어요. +

+ ), + }, + { + icon: , + content: ( +

+ + {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); + setCurrentIndex((prev) => (prev + 1) % messages.length); }, 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} - +
+
+ {messages.map((message, index) => ( +
+ {message.icon} + {message.content}
- - )} + ))} +
); } diff --git a/src/components/ui/study-card-countdown-badge.tsx b/src/components/ui/study-card-countdown-badge.tsx index da69bbf3..c9682b10 100644 --- a/src/components/ui/study-card-countdown-badge.tsx +++ b/src/components/ui/study-card-countdown-badge.tsx @@ -18,7 +18,26 @@ export default function StudyCardCountdownBadge({ }: Props) { const now = useNow(); - if (status !== 'RECRUITING') return null; + // 종료 배지 (gray) + if (status === 'COMPLETED') { + return ( + + 종료 + + ); + } + + // 진행 중 배지 (purple) + if (status === 'IN_PROGRESS') { + return ( + + 진행 중 + + ); + } + + const isRecruiting = status === 'RECRUITING' || status === 'ENDING_SOON'; + if (!isRecruiting) return null; if (remaining !== undefined && remaining <= 0) { return ( @@ -35,6 +54,14 @@ export default function StudyCardCountdownBadge({ const state = getCountdownState(diffMs); if (!state || !state.urgent) { + if (status === 'ENDING_SOON') { + return ( + + 마감 임박 + + ); + } + return ( 모집 중 @@ -44,7 +71,7 @@ export default function StudyCardCountdownBadge({ return ( 마감까지 {state.label} 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 1a6f2c6b..32048a85 100644 --- a/src/features/my-page/ui/my-study-info-card.tsx +++ b/src/features/my-page/ui/my-study-info-card.tsx @@ -44,7 +44,7 @@ export default function MyStudyInfoCard({ )}
- {status === 'RECRUITING' && ( + {(status === 'RECRUITING' || status === 'ENDING_SOON') && (
시작 전
@@ -79,7 +79,9 @@ export default function MyStudyInfoCard({
- {(status === 'RECRUITING' || status === 'IN_PROGRESS') && + {(status === 'RECRUITING' || + status === 'ENDING_SOON' || + status === 'IN_PROGRESS') && participantsCount < maxMembersCount && studyRole === 'LEADER' && ( diff --git a/src/features/study/group/api/group-study-types.ts b/src/features/study/group/api/group-study-types.ts index f0580317..f03528ea 100644 --- a/src/features/study/group/api/group-study-types.ts +++ b/src/features/study/group/api/group-study-types.ts @@ -15,7 +15,11 @@ export type ApplicationStatus = | 'REJECTED' // 승인 거절 | 'KICKED' // 강퇴 | 'EXIT'; // 탈퇴 -export type GroupStudyStatus = 'RECRUITING' | 'IN_PROGRESS' | 'COMPLETED'; +export type GroupStudyStatus = + | 'RECRUITING' + | 'ENDING_SOON' + | 'IN_PROGRESS' + | 'COMPLETED'; export type GroupStudyType = 'PROJECT' | 'STUDY'; export type HostType = 'ZEROONE' | 'GENERAL' | 'METOR'; export type Method = 'ONLINE' | 'OFFLINE'; @@ -125,7 +129,7 @@ export interface BasicInfoDetail extends BasicInfo { approvedCount: number; rejectedCount: number; kickedCount: number; - deletedAt: string | null; + deletedAt: string; leader: Leader; } @@ -360,7 +364,7 @@ export interface MemberStudyItem { startTime: string; endTime: string; studyRole: 'PARTICIPANT' | 'LEADER'; - status: 'RECRUITING' | 'IN_PROGRESS' | 'COMPLETED'; + status: 'RECRUITING' | 'ENDING_SOON' | 'IN_PROGRESS' | 'COMPLETED'; type: 'GROUP_STUDY' | 'ONE_ON_ONE_STUDY' | 'PREMIUM_STUDY'; } diff --git a/src/features/study/group/const/group-study-const.ts b/src/features/study/group/const/group-study-const.ts index 39b6382e..71244d60 100644 --- a/src/features/study/group/const/group-study-const.ts +++ b/src/features/study/group/const/group-study-const.ts @@ -68,6 +68,7 @@ export const EXPERIENCE_LEVEL_LABELS = { export const STUDY_STATUS_LABELS = { RECRUITING: '모집 중', + ENDING_SOON: '마감 임박', IN_PROGRESS: '진행 중', COMPLETED: '모집 완료', } as const; From e50330e7d1852295cec9fd715e37b615b236ed0f Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Fri, 27 Feb 2026 02:44:54 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EC=97=90=EC=84=9C=20=EC=B9=B4=EB=A9=94=EB=9D=BC=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=EC=9D=84=20SVG=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/image-upload-input.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/ui/image-upload-input.tsx b/src/components/ui/image-upload-input.tsx index 1ac8040b..5d3f9155 100644 --- a/src/components/ui/image-upload-input.tsx +++ b/src/components/ui/image-upload-input.tsx @@ -110,7 +110,12 @@ export default function ImageUploadInput({ {!image ? (
-