From d043f4dcae6b9d7d884caac9540e8f8250a5d305 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sat, 17 Jan 2026 14:21:15 +0900 Subject: [PATCH 01/20] =?UTF-8?q?refactor:=20Award=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=ED=99=94=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Award 타입에 year(number), semester(SemesterTermType) 분리 - SemesterTerm 상수 추가 ('FIRST' | 'SECOND') - AwardEditor 새 타입에 맞게 로직 수정 - ClubIntroContent 로컬 타입 제거, @/types/club에서 import로 통일 --- .../ClubIntroContent/ClubIntroContent.tsx | 16 ++-------------- frontend/src/types/club.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx index a3ad1ab13..aabbfea50 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx @@ -1,29 +1,17 @@ import { useState } from 'react'; import { USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import { Award, FAQ, IdealCandidate, SemesterTerm } from '@/types/club'; import * as Styled from './ClubIntroContent.styles'; -export interface Award { - semester: string; - achievements: string[]; -} - -export interface IdealCandidate { - tags?: string[]; // TODO: tags가 추가될수도 있음 - content: string; -} -export interface Faq { - question: string; - answer: string; -} interface ClubIntroContentProps { activityDescription?: string; awards?: Award[]; idealCandidate?: IdealCandidate; benefits?: string; - faqs?: Faq[]; + faqs?: FAQ[]; } const ClubIntroContent = ({ diff --git a/frontend/src/types/club.ts b/frontend/src/types/club.ts index 37f1e69b8..7731bd5bb 100644 --- a/frontend/src/types/club.ts +++ b/frontend/src/types/club.ts @@ -41,8 +41,16 @@ export interface ClubDescription { recruitmentTarget: string; } +export const SemesterTerm = { + FIRST: 'FIRST', + SECOND: 'SECOND', +} as const; + +export type SemesterTermType = (typeof SemesterTerm)[keyof typeof SemesterTerm]; + export interface Award { - semester: string; + year: number; + semester: SemesterTermType; achievements: string[]; } From c340e4a6426e54751d3d28c2664bdc99eda81b37 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sat, 17 Jan 2026 14:22:10 +0900 Subject: [PATCH 02/20] =?UTF-8?q?refactor:=20Award=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=ED=99=94=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Award 타입에 year(number), semester(SemesterTermType) 분리 - SemesterTerm 상수 추가 ('FIRST' | 'SECOND') - AwardEditor 새 타입에 맞게 로직 수정 - ClubIntroContent 로컬 타입 제거, @/types/club 통일 - openFaqIndices → openFaqIndexes 리네임 --- .../components/AwardEditor/AwardEditor.tsx | 70 +++++++++++-------- .../ClubIntroContent/ClubIntroContent.tsx | 42 ++++++----- 2 files changed, 65 insertions(+), 47 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx b/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx index 9737129b0..e8f8a88c4 100644 --- a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx +++ b/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import deleteButton from '@/assets/images/icons/delete_button_icon.svg'; import selectIcon from '@/assets/images/icons/selectArrow.svg'; import { CustomDropDown } from '@/components/common/CustomDropDown/CustomDropDown'; -import { Award } from '@/types/club'; +import { Award, SemesterTerm, SemesterTermType } from '@/types/club'; import * as Styled from './AwardEditor.styles'; interface AwardEditorProps { @@ -12,12 +12,14 @@ interface AwardEditorProps { const START_YEAR = 2020; -const parseSemester = (semester: string): number => { - const match = semester.match(/(\d{4})\s+(\d)학기/); - if (!match) return 0; - const year = parseInt(match[1], 10); - const semesterNumber = parseInt(match[2], 10); - return year * 10 + semesterNumber; +const getSemesterSortValue = (award: Award): number => { + const semesterValue = award.semester === SemesterTerm.FIRST ? 1 : 2; + return award.year * 10 + semesterValue; +}; + +const formatSemesterLabel = (award: Award): string => { + const semesterLabel = award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; + return `${award.year} ${semesterLabel}`; }; const generateYearOptions = (currentYear: number) => { @@ -32,44 +34,49 @@ const generateYearOptions = (currentYear: number) => { }; const SEMESTER_OPTIONS = [ - { value: '1', label: '1학기' }, - { value: '2', label: '2학기' }, + { value: SemesterTerm.FIRST, label: '1학기' }, + { value: SemesterTerm.SECOND, label: '2학기' }, ] as const; const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { const currentYear = new Date().getFullYear(); const [selectedYear, setSelectedYear] = useState(currentYear.toString()); - const [selectedSemester, setSelectedSemester] = useState('1'); + const [selectedSemester, setSelectedSemester] = + useState(SemesterTerm.FIRST); const [isYearDropdownOpen, setIsYearDropdownOpen] = useState(false); const [isSemesterDropdownOpen, setIsSemesterDropdownOpen] = useState(false); - const [lastAddedSemester, setLastAddedSemester] = useState( - null, - ); + const [lastAddedKey, setLastAddedKey] = useState(null); const inputRefs = useRef>({}); const yearOptions = generateYearOptions(currentYear); const sortedAwards = [...awards].sort( (awardA, awardB) => - parseSemester(awardB.semester) - parseSemester(awardA.semester), + getSemesterSortValue(awardB) - getSemesterSortValue(awardA), ); + const getAwardKey = (award: Award): string => + `${award.year}-${award.semester}`; + const handleAddSemester = () => { - const semesterText = `${selectedYear} ${selectedSemester}학기`; + const year = parseInt(selectedYear, 10); - const isDuplicate = awards.some((award) => award.semester === semesterText); + const isDuplicate = awards.some( + (award) => award.year === year && award.semester === selectedSemester, + ); if (isDuplicate) { alert('이미 추가된 학기입니다.'); return; } const newAward: Award = { - semester: semesterText, + year, + semester: selectedSemester, achievements: [''], }; onChange([...awards, newAward]); - setLastAddedSemester(semesterText); + setLastAddedKey(getAwardKey(newAward)); }; const handleRemoveSemester = (semesterIndex: number) => { @@ -83,7 +90,7 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { : award, ); onChange(updatedAwards); - setLastAddedSemester(awards[semesterIndex].semester); + setLastAddedKey(getAwardKey(awards[semesterIndex])); }; const handleRemoveAchievement = ( @@ -122,21 +129,19 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { }; useEffect(() => { - if (lastAddedSemester) { - const award = awards.find( - (award) => award.semester === lastAddedSemester, - ); + if (lastAddedKey) { + const award = awards.find((award) => getAwardKey(award) === lastAddedKey); if (award) { const lastIndex = award.achievements.length - 1; - const key = `${lastAddedSemester}-${lastIndex}`; + const key = `${lastAddedKey}-${lastIndex}`; const inputRef = inputRefs.current[key]; if (inputRef) { inputRef.focus(); } } - setLastAddedSemester(null); + setLastAddedKey(null); } - }, [awards, lastAddedSemester]); + }, [awards, lastAddedKey]); return ( @@ -174,7 +179,7 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { setSelectedSemester(value as SemesterTermType)} open={isSemesterDropdownOpen} onToggle={(isOpen) => setIsSemesterDropdownOpen(!isOpen)} style={{ width: '100px' }} @@ -207,13 +212,16 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { {sortedAwards.map((award) => { + const awardKey = getAwardKey(award); const originalIndex = awards.findIndex( - (originalAward) => originalAward.semester === award.semester, + (originalAward) => getAwardKey(originalAward) === awardKey, ); return ( - + - {award.semester} + + {formatSemesterLabel(award)} + handleRemoveSemester(originalIndex)} > @@ -226,7 +234,7 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { { - const key = `${award.semester}-${achievementIndex}`; + const key = `${awardKey}-${achievementIndex}`; inputRefs.current[key] = element; }} placeholder='수상 내역을 입력하세요' diff --git a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx index aabbfea50..e57cf96cd 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx @@ -4,7 +4,12 @@ import useMixpanelTrack from '@/hooks/useMixpanelTrack'; import { Award, FAQ, IdealCandidate, SemesterTerm } from '@/types/club'; import * as Styled from './ClubIntroContent.styles'; +const formatSemesterLabel = (award: Award): string => { + const semesterLabel = award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; + return `${award.year} ${semesterLabel}`; +}; +const getAwardKey = (award: Award): string => `${award.year}-${award.semester}`; interface ClubIntroContentProps { activityDescription?: string; @@ -23,11 +28,11 @@ const ClubIntroContent = ({ }: ClubIntroContentProps) => { const trackEvent = useMixpanelTrack(); - const [openFaqIndices, setOpenFaqIndices] = useState([]); + const [openFaqIndexes, setOpenFaqIndexes] = useState([]); const handleToggleFaq = (index: number) => { - const isOpening = !openFaqIndices.includes(index); - setOpenFaqIndices((prev) => + const isOpening = !openFaqIndexes.includes(index); + setOpenFaqIndexes((prev) => prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index], ); @@ -54,18 +59,23 @@ const ClubIntroContent = ({ 동아리 성과 - {awards.map((award) => ( - - {award.semester} - - {award.achievements.map((item, idx) => ( - - {item} - - ))} - - - ))} + {awards.map((award) => { + const awardKey = getAwardKey(award); + return ( + + + {formatSemesterLabel(award)} + + + {award.achievements.map((item, idx) => ( + + {item} + + ))} + + + ); + })} )} @@ -90,7 +100,7 @@ const ClubIntroContent = ({ FAQ {faqs.map((faq, index) => { - const isOpen = openFaqIndices.includes(index); + const isOpen = openFaqIndexes.includes(index); return ( handleToggleFaq(index)}> From 35445e02bfaee0851e58c3feca34a9513dc81aa1 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 18 Jan 2026 16:18:32 +0900 Subject: [PATCH 03/20] =?UTF-8?q?fix:=20Award=20key=EC=97=90=20index=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EA=B3=A0=EC=9C=A0?= =?UTF-8?q?=EC=84=B1=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getAwardKey 함수에 index 파라미터 추가 - 같은 년도-학기에 여러 수상이 있어도 고유한 key 생성 - AwardEditor와 ClubIntroContent 모두 수정 - 백엔드 중복 방지 로직 부재에 대응 --- .../components/AwardEditor/AwardEditor.tsx | 33 ++++++++++++------- .../ClubIntroContent/ClubIntroContent.tsx | 12 ++++--- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx b/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx index e8f8a88c4..647490e01 100644 --- a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx +++ b/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx @@ -18,7 +18,8 @@ const getSemesterSortValue = (award: Award): number => { }; const formatSemesterLabel = (award: Award): string => { - const semesterLabel = award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; + const semesterLabel = + award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; return `${award.year} ${semesterLabel}`; }; @@ -41,8 +42,9 @@ const SEMESTER_OPTIONS = [ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { const currentYear = new Date().getFullYear(); const [selectedYear, setSelectedYear] = useState(currentYear.toString()); - const [selectedSemester, setSelectedSemester] = - useState(SemesterTerm.FIRST); + const [selectedSemester, setSelectedSemester] = useState( + SemesterTerm.FIRST, + ); const [isYearDropdownOpen, setIsYearDropdownOpen] = useState(false); const [isSemesterDropdownOpen, setIsSemesterDropdownOpen] = useState(false); const [lastAddedKey, setLastAddedKey] = useState(null); @@ -55,8 +57,8 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { getSemesterSortValue(awardB) - getSemesterSortValue(awardA), ); - const getAwardKey = (award: Award): string => - `${award.year}-${award.semester}`; + const getAwardKey = (award: Award, index: number): string => + `${award.year}-${award.semester}-${index}`; const handleAddSemester = () => { const year = parseInt(selectedYear, 10); @@ -75,8 +77,9 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { achievements: [''], }; - onChange([...awards, newAward]); - setLastAddedKey(getAwardKey(newAward)); + const newAwards = [...awards, newAward]; + onChange(newAwards); + setLastAddedKey(getAwardKey(newAward, newAwards.length - 1)); }; const handleRemoveSemester = (semesterIndex: number) => { @@ -90,7 +93,7 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { : award, ); onChange(updatedAwards); - setLastAddedKey(getAwardKey(awards[semesterIndex])); + setLastAddedKey(getAwardKey(awards[semesterIndex], semesterIndex)); }; const handleRemoveAchievement = ( @@ -130,7 +133,10 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { useEffect(() => { if (lastAddedKey) { - const award = awards.find((award) => getAwardKey(award) === lastAddedKey); + const awardIndex = awards.findIndex( + (award, index) => getAwardKey(award, index) === lastAddedKey, + ); + const award = awardIndex !== -1 ? awards[awardIndex] : null; if (award) { const lastIndex = award.achievements.length - 1; const key = `${lastAddedKey}-${lastIndex}`; @@ -211,11 +217,14 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { - {sortedAwards.map((award) => { - const awardKey = getAwardKey(award); + {sortedAwards.map((award, sortedIndex) => { const originalIndex = awards.findIndex( - (originalAward) => getAwardKey(originalAward) === awardKey, + (originalAward, idx) => + originalAward.year === award.year && + originalAward.semester === award.semester && + originalAward.achievements === award.achievements, ); + const awardKey = getAwardKey(award, originalIndex); return ( diff --git a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx index 9cbe57451..0da782fa2 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx @@ -1,15 +1,17 @@ import { useState } from 'react'; import { USER_EVENT } from '@/constants/eventName'; -import { Award, FAQ, IdealCandidate, SemesterTerm } from '@/types/club'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import { Award, FAQ, IdealCandidate, SemesterTerm } from '@/types/club'; import * as Styled from './ClubIntroContent.styles'; const formatSemesterLabel = (award: Award): string => { - const semesterLabel = award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; + const semesterLabel = + award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; return `${award.year} ${semesterLabel}`; }; -const getAwardKey = (award: Award): string => `${award.year}-${award.semester}`; +const getAwardKey = (award: Award, index: number): string => + `${award.year}-${award.semester}-${index}`; interface ClubIntroContentProps { activityDescription?: string; @@ -59,8 +61,8 @@ const ClubIntroContent = ({ 동아리 성과 - {awards.map((award) => { - const awardKey = getAwardKey(award); + {awards.map((award, index) => { + const awardKey = getAwardKey(award, index); return ( From 522cf2058d5d6f76052d35f88583db26e6cbd941 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sat, 17 Jan 2026 23:50:41 -0800 Subject: [PATCH 04/20] =?UTF-8?q?feat:=20eventsource=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 180 +++++++++++++++++++++++++++---------- frontend/package.json | 1 + 2 files changed, 135 insertions(+), 46 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dec18283a..eae25ed58 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@tanstack/react-query": "^5.66.0", "date-fns": "^4.1.0", "dotenv-webpack": "^8.1.0", + "eventsource": "^4.1.0", "framer-motion": "^12.23.12", "jest-fixed-jsdom": "^0.0.9", "mixpanel-browser": "^2.60.0", @@ -58,7 +59,6 @@ "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", "detect-port": "^2.1.0", - "dotenv-cli": "^11.0.0", "esbuild-loader": "^4.3.0", "eslint": "^9.17.0", "eslint-config-prettier": "^10.1.5", @@ -75,6 +75,7 @@ "path": "^0.12.7", "prettier": "^3.4.2", "react-refresh": "^0.16.0", + "rollup-plugin-visualizer": "^5.14.0", "storybook": "^10.1.11", "style-loader": "^4.0.0", "tough-cookie": "^6.0.0", @@ -7397,35 +7398,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/dotenv-cli": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-11.0.0.tgz", - "integrity": "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.6", - "dotenv": "^17.1.0", - "dotenv-expand": "^12.0.0", - "minimist": "^1.2.6" - }, - "bin": { - "dotenv": "cli.js" - } - }, - "node_modules/dotenv-cli/node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dotenv-defaults": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/dotenv-defaults/-/dotenv-defaults-2.0.2.tgz", @@ -7444,22 +7416,6 @@ "node": ">=10" } }, - "node_modules/dotenv-expand": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", - "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dotenv-webpack": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/dotenv-webpack/-/dotenv-webpack-8.1.0.tgz", @@ -8804,6 +8760,27 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.0.tgz", + "integrity": "sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -16185,6 +16162,117 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-visualizer": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.14.0.tgz", + "integrity": "sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "open": "^8.4.0", + "picomatch": "^4.0.2", + "source-map": "^0.7.4", + "yargs": "^17.5.1" + }, + "bin": { + "rollup-plugin-visualizer": "dist/bin/cli.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "rolldown": "1.x", + "rollup": "2.x || 3.x || 4.x" + }, + "peerDependenciesMeta": { + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/rrdom": { "version": "2.0.0-alpha.18", "resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.0.0-alpha.18.tgz", diff --git a/frontend/package.json b/frontend/package.json index b911f1502..ced01971f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@tanstack/react-query": "^5.66.0", "date-fns": "^4.1.0", "dotenv-webpack": "^8.1.0", + "eventsource": "^4.1.0", "framer-motion": "^12.23.12", "jest-fixed-jsdom": "^0.0.9", "mixpanel-browser": "^2.60.0", From 8be9ff7001bc9c425d1d3d8d7839cf23b93e909b Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sat, 17 Jan 2026 23:51:31 -0800 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20sse=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/types/applicants.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/types/applicants.ts b/frontend/src/types/applicants.ts index ad97a968e..1ab7cc678 100644 --- a/frontend/src/types/applicants.ts +++ b/frontend/src/types/applicants.ts @@ -29,3 +29,12 @@ export interface UpdateApplicantParams { status: ApplicationStatus; applicantId: string | undefined; } + +export interface ApplicantStatusEvent { + applicantId: string; + status: ApplicationStatus; + memo: string; + timestamp: string; + clubId: string; + applicationFormId: string; +} From 06ec81ad188384d4830dfe9b7053c5724ea66f96 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sat, 17 Jan 2026 23:58:41 -0800 Subject: [PATCH 06/20] =?UTF-8?q?feat:=20sse=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=ED=95=A8=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 --- frontend/src/apis/club.ts | 53 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/frontend/src/apis/club.ts b/frontend/src/apis/club.ts index 216ba6937..21546b213 100644 --- a/frontend/src/apis/club.ts +++ b/frontend/src/apis/club.ts @@ -1,4 +1,9 @@ +import { EventSource } from 'eventsource'; import API_BASE_URL from '@/constants/api'; +import { + ApplicantSSECallbacks, + ApplicantStatusEvent, +} from '@/types/applicants'; import { ClubDescription, ClubDetail } from '@/types/club'; import { secureFetch } from './auth/secureFetch'; import { handleResponse, withErrorHandling } from './utils/apiHelpers'; @@ -79,3 +84,51 @@ export const updateClubDetail = async ( await handleResponse(response, '클럽 정보 수정에 실패했습니다.'); }, 'Failed to update club detail'); }; + +export const createApplicantSSE = ( + applicationFormId: string, + callbacks: ApplicantSSECallbacks, +): EventSource | null => { + const accessToken = localStorage.getItem('accessToken'); + if (!accessToken) return null; + + let eventSource: EventSource | null = null; + + const connect = (): EventSource | null => { + const source = new EventSource( + `${API_BASE_URL}/api/club/applicant/${applicationFormId}/sse`, + { + fetch: (input, init) => + fetch(input, { + ...init, + headers: { + ...init?.headers, + Authorization: `Bearer ${accessToken}`, + }, + credentials: 'include', + }), + }, + ); + + source.addEventListener('applicant-status-changed', (e) => { + try { + const eventData: ApplicantStatusEvent = JSON.parse(e.data); + callbacks.onStatusChange(eventData); + } catch (parseError) { + console.error('SSE PARSING ERROR:', parseError); + } + }); + + source.onerror = () => { + source.close(); + setTimeout(() => { + eventSource = connect(); + }, 2000); + }; + + return source; + }; + + eventSource = connect(); + return eventSource; +}; From ae08944331cb652c5a3d22970ee114fdd38bff38 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sat, 17 Jan 2026 23:58:53 -0800 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20AdminClubContext=EB=82=B4=20sse?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=ED=95=B8=EB=93=A4=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/context/AdminClubContext.tsx | 64 ++++++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/frontend/src/context/AdminClubContext.tsx b/frontend/src/context/AdminClubContext.tsx index 449b7aa55..324beb25a 100644 --- a/frontend/src/context/AdminClubContext.tsx +++ b/frontend/src/context/AdminClubContext.tsx @@ -1,11 +1,24 @@ -import { createContext, useContext, useState } from 'react'; -import { ApplicantsInfo } from '@/types/applicants'; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { createApplicantSSE } from '@/apis/applicants/createApplicantSSE'; +import { + ApplicantsInfo, + ApplicantStatusEvent, + ApplicationStatus, +} from '@/types/applicants'; interface AdminClubContextType { clubId: string | null; setClubId: (id: string | null) => void; applicantsData: ApplicantsInfo | null; setApplicantsData: (data: ApplicantsInfo | null) => void; + applicationFormId: string | null; + setApplicationFormId: (id: string | null) => void; } const AdminClubContext = createContext( @@ -21,6 +34,51 @@ export const AdminClubProvider = ({ const [applicantsData, setApplicantsData] = useState( null, ); + const [applicationFormId, setApplicationFormId] = useState( + null, + ); + + // SSE 이벤트 핸들러 + const handleApplicantStatusChange = useCallback( + (event: ApplicantStatusEvent) => { + setApplicantsData((prevData) => { + if (!prevData) return null; + + const updatedApplicants = prevData.applicants.map((applicant) => + applicant.id === event.applicantId + ? { ...applicant, status: event.status, memo: event.memo } + : applicant, + ); + + return { + ...prevData, + applicants: updatedApplicants, + reviewRequired: updatedApplicants.filter( + (a) => a.status === ApplicationStatus.SUBMITTED, + ).length, + scheduledInterview: updatedApplicants.filter( + (a) => a.status === ApplicationStatus.INTERVIEW_SCHEDULED, + ).length, + accepted: updatedApplicants.filter( + (a) => a.status === ApplicationStatus.ACCEPTED, + ).length, + }; + }); + }, + [], + ); + + useEffect(() => { + if (!applicationFormId) return; + + const sseConnection = createApplicantSSE(applicationFormId, { + onStatusChange: handleApplicantStatusChange, + }); + + return () => { + sseConnection?.close(); + }; + }, [applicationFormId, handleApplicantStatusChange]); return ( {children} From e8f16803aeccdb8fe03e4574acaa1cef952f8480 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sat, 17 Jan 2026 23:59:01 -0800 Subject: [PATCH 08/20] =?UTF-8?q?feat:=20sse=20type=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/types/applicants.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/types/applicants.ts b/frontend/src/types/applicants.ts index 1ab7cc678..2e3c0eaa8 100644 --- a/frontend/src/types/applicants.ts +++ b/frontend/src/types/applicants.ts @@ -38,3 +38,7 @@ export interface ApplicantStatusEvent { clubId: string; applicationFormId: string; } + +export interface ApplicantSSECallbacks { + onStatusChange: (event: ApplicantStatusEvent) => void; +} From ef8382039d3c3f1d7df3a9ccbd53b3545b1f4754 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sat, 17 Jan 2026 23:59:46 -0800 Subject: [PATCH 09/20] =?UTF-8?q?feat:=20sse=EC=97=B0=EA=B2=B0=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=A7=80=EC=9B=90=EC=9E=90=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApplicantDetailPage/ApplicantDetailPage.tsx | 2 +- .../AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx index 6342f2011..563f703de 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx @@ -62,7 +62,7 @@ const ApplicantDetailPage = () => { setAppMemo(applicant.memo); setApplicantStatus(mapStatusToGroup(applicant.status).status); } - }, [applicant]); + }, [applicant, applicant?.status, applicant?.memo]); const updateApplicantDetail = useMemo( () => diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx index 626bf5625..3f9f0227c 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx @@ -23,7 +23,8 @@ const sortOptions = [ ] as const; const ApplicantsTab = () => { - const { clubId, applicantsData, setApplicantsData } = useAdminClubContext(); + const { clubId, applicantsData, setApplicantsData, setApplicationFormId } = + useAdminClubContext(); const { applicationFormId } = useParams<{ applicationFormId: string }>(); const navigate = useNavigate(); @@ -62,6 +63,13 @@ const ApplicantsTab = () => { (typeof sortOptions)[number] >(sortOptions[0]); + // SSE 연결 활성화 + useEffect(() => { + setApplicationFormId(applicationFormId ?? null); + return () => setApplicationFormId(null); + }, [applicationFormId, setApplicationFormId]); + + // 초기 데이터 로드 useEffect(() => { if (fetchData) { setApplicantsData(fetchData); From 904012f2e2cdd39d22f40051b649b3531657d9f6 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sun, 18 Jan 2026 00:07:14 -0800 Subject: [PATCH 10/20] fix: wrong import path --- frontend/src/context/AdminClubContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/context/AdminClubContext.tsx b/frontend/src/context/AdminClubContext.tsx index 324beb25a..07cb10cae 100644 --- a/frontend/src/context/AdminClubContext.tsx +++ b/frontend/src/context/AdminClubContext.tsx @@ -5,7 +5,7 @@ import { useEffect, useState, } from 'react'; -import { createApplicantSSE } from '@/apis/applicants/createApplicantSSE'; +import { createApplicantSSE } from '@/apis/club'; import { ApplicantsInfo, ApplicantStatusEvent, From cbc65c0cfd654e921a49b75988a361009aaaffd1 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sun, 18 Jan 2026 00:18:37 -0800 Subject: [PATCH 11/20] =?UTF-8?q?refactor:=20sse=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=8B=9C=EB=A7=88=EB=8B=A4=20=EC=83=88=EB=A1=9C=EC=9A=B4=20acc?= =?UTF-8?q?esstoken=20=EC=9D=BD=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/apis/club.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/apis/club.ts b/frontend/src/apis/club.ts index 21546b213..cefe50828 100644 --- a/frontend/src/apis/club.ts +++ b/frontend/src/apis/club.ts @@ -89,12 +89,12 @@ export const createApplicantSSE = ( applicationFormId: string, callbacks: ApplicantSSECallbacks, ): EventSource | null => { - const accessToken = localStorage.getItem('accessToken'); - if (!accessToken) return null; - let eventSource: EventSource | null = null; const connect = (): EventSource | null => { + const accessToken = localStorage.getItem('accessToken'); + if (!accessToken) return null; + const source = new EventSource( `${API_BASE_URL}/api/club/applicant/${applicationFormId}/sse`, { From 3fc96e232bce63ef62814a2e98d8003891b53b4e Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sun, 18 Jan 2026 00:22:48 -0800 Subject: [PATCH 12/20] =?UTF-8?q?refactor:=20error=20=EC=9E=AC=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/apis/club.ts | 6 ++---- frontend/src/context/AdminClubContext.tsx | 20 ++++++++++++++++---- frontend/src/types/applicants.ts | 1 + 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/frontend/src/apis/club.ts b/frontend/src/apis/club.ts index cefe50828..7ca770f44 100644 --- a/frontend/src/apis/club.ts +++ b/frontend/src/apis/club.ts @@ -119,11 +119,9 @@ export const createApplicantSSE = ( } }); - source.onerror = () => { + source.onerror = (error) => { source.close(); - setTimeout(() => { - eventSource = connect(); - }, 2000); + callbacks.onError(new Error(error?.message || 'SSE connection error')); }; return source; diff --git a/frontend/src/context/AdminClubContext.tsx b/frontend/src/context/AdminClubContext.tsx index 07cb10cae..53f0051ce 100644 --- a/frontend/src/context/AdminClubContext.tsx +++ b/frontend/src/context/AdminClubContext.tsx @@ -71,12 +71,24 @@ export const AdminClubProvider = ({ useEffect(() => { if (!applicationFormId) return; - const sseConnection = createApplicantSSE(applicationFormId, { - onStatusChange: handleApplicantStatusChange, - }); + let eventSource: EventSource | null = null; + + const sseConnect = () => { + eventSource = createApplicantSSE(applicationFormId, { + onStatusChange: handleApplicantStatusChange, + onError: (error) => { + console.error('SSE connection error:', error); + setTimeout(() => { + sseConnect(); + }, 2000); + }, + }); + }; + + sseConnect(); return () => { - sseConnection?.close(); + eventSource?.close(); }; }, [applicationFormId, handleApplicantStatusChange]); diff --git a/frontend/src/types/applicants.ts b/frontend/src/types/applicants.ts index 2e3c0eaa8..955a9295b 100644 --- a/frontend/src/types/applicants.ts +++ b/frontend/src/types/applicants.ts @@ -41,4 +41,5 @@ export interface ApplicantStatusEvent { export interface ApplicantSSECallbacks { onStatusChange: (event: ApplicantStatusEvent) => void; + onError: (error: Error) => void; } From c892166f083000899848a611d8ad9bd669812fcc Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sun, 18 Jan 2026 20:44:05 -0800 Subject: [PATCH 13/20] =?UTF-8?q?feat:=20=EB=AC=B4=ED=95=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AC=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/context/AdminClubContext.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/frontend/src/context/AdminClubContext.tsx b/frontend/src/context/AdminClubContext.tsx index 53f0051ce..887075bfa 100644 --- a/frontend/src/context/AdminClubContext.tsx +++ b/frontend/src/context/AdminClubContext.tsx @@ -3,6 +3,7 @@ import { useCallback, useContext, useEffect, + useRef, useState, } from 'react'; import { createApplicantSSE } from '@/apis/club'; @@ -37,6 +38,8 @@ export const AdminClubProvider = ({ const [applicationFormId, setApplicationFormId] = useState( null, ); + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef(null); // SSE 이벤트 핸들러 const handleApplicantStatusChange = useCallback( @@ -71,14 +74,14 @@ export const AdminClubProvider = ({ useEffect(() => { if (!applicationFormId) return; - let eventSource: EventSource | null = null; - const sseConnect = () => { - eventSource = createApplicantSSE(applicationFormId, { + eventSourceRef.current?.close(); + + eventSourceRef.current = createApplicantSSE(applicationFormId, { onStatusChange: handleApplicantStatusChange, onError: (error) => { console.error('SSE connection error:', error); - setTimeout(() => { + reconnectTimeoutRef.current = setTimeout(() => { sseConnect(); }, 2000); }, @@ -88,7 +91,12 @@ export const AdminClubProvider = ({ sseConnect(); return () => { - eventSource?.close(); + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + eventSourceRef.current?.close(); + eventSourceRef.current = null; }; }, [applicationFormId, handleApplicantStatusChange]); From af9b667b83604409274827d709eb57504ea9f49d Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sun, 18 Jan 2026 20:51:31 -0800 Subject: [PATCH 14/20] refactor: rename callbacks to eventHandlers --- frontend/src/apis/club.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/apis/club.ts b/frontend/src/apis/club.ts index 7ca770f44..f1db4626a 100644 --- a/frontend/src/apis/club.ts +++ b/frontend/src/apis/club.ts @@ -87,7 +87,7 @@ export const updateClubDetail = async ( export const createApplicantSSE = ( applicationFormId: string, - callbacks: ApplicantSSECallbacks, + eventHandlers: ApplicantSSECallbacks, ): EventSource | null => { let eventSource: EventSource | null = null; @@ -113,7 +113,7 @@ export const createApplicantSSE = ( source.addEventListener('applicant-status-changed', (e) => { try { const eventData: ApplicantStatusEvent = JSON.parse(e.data); - callbacks.onStatusChange(eventData); + eventHandlers.onStatusChange(eventData); } catch (parseError) { console.error('SSE PARSING ERROR:', parseError); } @@ -121,7 +121,9 @@ export const createApplicantSSE = ( source.onerror = (error) => { source.close(); - callbacks.onError(new Error(error?.message || 'SSE connection error')); + eventHandlers.onError( + new Error(error?.message || 'SSE connection error'), + ); }; return source; From 4682a96beb3a3db2480b0621bd073b307825fc1a Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sun, 18 Jan 2026 20:55:02 -0800 Subject: [PATCH 15/20] =?UTF-8?q?refactor:=20SSE=20=EC=9E=AC=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/context/AdminClubContext.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/context/AdminClubContext.tsx b/frontend/src/context/AdminClubContext.tsx index 887075bfa..4644427a1 100644 --- a/frontend/src/context/AdminClubContext.tsx +++ b/frontend/src/context/AdminClubContext.tsx @@ -39,7 +39,7 @@ export const AdminClubProvider = ({ null, ); const eventSourceRef = useRef(null); - const reconnectTimeoutRef = useRef(null); + const reconnectTimeoutRef = useRef(null); // SSE 이벤트 핸들러 const handleApplicantStatusChange = useCallback( @@ -81,7 +81,11 @@ export const AdminClubProvider = ({ onStatusChange: handleApplicantStatusChange, onError: (error) => { console.error('SSE connection error:', error); - reconnectTimeoutRef.current = setTimeout(() => { + + if (reconnectTimeoutRef.current) return; + + reconnectTimeoutRef.current = window.setTimeout(() => { + reconnectTimeoutRef.current = null; sseConnect(); }, 2000); }, From 41d41df64d2d0f9e238c0c37b216b8340d6af9b9 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Mon, 19 Jan 2026 14:06:19 +0900 Subject: [PATCH 16/20] =?UTF-8?q?feat:=20Award=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=20=ED=95=A8=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 - formatSemesterLabel: Award의 year와 semester를 포맷팅하여 문자열 반환 - getAwardKey: Award 객체와 인덱스로 고유 키 생성 - ClubIntroContent에서 사용하던 함수를 재사용 가능하도록 분리 --- frontend/src/utils/awardHelpers.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 frontend/src/utils/awardHelpers.ts diff --git a/frontend/src/utils/awardHelpers.ts b/frontend/src/utils/awardHelpers.ts new file mode 100644 index 000000000..849f64efb --- /dev/null +++ b/frontend/src/utils/awardHelpers.ts @@ -0,0 +1,13 @@ +import { Award, SemesterTerm } from '@/types/club'; + +export const formatSemesterLabel = (award: Award): string | null => { + if (award?.year && award?.semester) { + const semesterLabel = + award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; + return `${award.year} ${semesterLabel}`; + } + return null; +}; + +export const getAwardKey = (award: Award, index: number): string => + `${award.year}-${award.semester}-${index}`; From 03151987111835dd5dc568b4bbc97a364b6c5b0e Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Mon, 19 Jan 2026 14:06:28 +0900 Subject: [PATCH 17/20] =?UTF-8?q?test:=20awardHelpers=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatSemesterLabel 테스트 8개 (정상 케이스, null/undefined 처리) - getAwardKey 테스트 5개 (고유성 검증, 엣지 케이스) - Given 데이터 공통화로 테스트 가독성 향상 - 모든 테스트 통과 (13/13) --- frontend/src/utils/awardHelpers.test.ts | 99 +++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 frontend/src/utils/awardHelpers.test.ts diff --git a/frontend/src/utils/awardHelpers.test.ts b/frontend/src/utils/awardHelpers.test.ts new file mode 100644 index 000000000..a62aa2f2e --- /dev/null +++ b/frontend/src/utils/awardHelpers.test.ts @@ -0,0 +1,99 @@ +import { Award, SemesterTerm, SemesterTermType } from '@/types/club'; +import { formatSemesterLabel, getAwardKey } from './awardHelpers'; + +describe('awardHelpers', () => { + const createAward = ( + year: number, + semester: SemesterTermType, + achievements: string[] = [], + ): Award => ({ + year, + semester, + achievements, + }); + + const validAward2024First = createAward(2024, SemesterTerm.FIRST); + const validAward2024Second = createAward(2024, SemesterTerm.SECOND); + const validAward2023First = createAward(2023, SemesterTerm.FIRST); + const validAward2025Second = createAward(2025, SemesterTerm.SECOND); + + describe('formatSemesterLabel', () => { + it('1학기를 올바른 형식으로 반환해야 한다', () => { + expect(formatSemesterLabel(validAward2024First)).toBe('2024 1학기'); + }); + + it('2학기를 올바른 형식으로 반환해야 한다', () => { + expect(formatSemesterLabel(validAward2024Second)).toBe('2024 2학기'); + }); + + it('year가 없으면 null을 반환해야 한다', () => { + const award: Partial = { + semester: SemesterTerm.FIRST, + achievements: [], + }; + + expect(formatSemesterLabel(award as Award)).toBeNull(); + }); + + it('semester가 없으면 null을 반환해야 한다', () => { + const award: Partial = { + year: 2024, + achievements: [], + }; + + expect(formatSemesterLabel(award as Award)).toBeNull(); + }); + + it('year와 semester가 모두 없으면 null을 반환해야 한다', () => { + const award: Partial = { + achievements: [], + }; + + expect(formatSemesterLabel(award as Award)).toBeNull(); + }); + + it('award가 null이면 null을 반환해야 한다', () => { + expect(formatSemesterLabel(null as unknown as Award)).toBeNull(); + }); + + it('award가 undefined이면 null을 반환해야 한다', () => { + expect(formatSemesterLabel(undefined as unknown as Award)).toBeNull(); + }); + + it('다양한 연도를 올바르게 처리해야 한다', () => { + expect(formatSemesterLabel(validAward2023First)).toBe('2023 1학기'); + expect(formatSemesterLabel(validAward2025Second)).toBe('2025 2학기'); + }); + }); + + describe('getAwardKey', () => { + it('year, semester, index를 조합한 고유 키를 생성해야 한다', () => { + expect(getAwardKey(validAward2024First, 0)).toBe('2024-FIRST-0'); + }); + + it('인덱스가 다르면 다른 키를 생성해야 한다', () => { + const key1 = getAwardKey(validAward2024First, 0); + const key2 = getAwardKey(validAward2024First, 1); + + expect(key1).not.toBe(key2); + expect(key1).toBe('2024-FIRST-0'); + expect(key2).toBe('2024-FIRST-1'); + }); + + it('학기가 다르면 다른 키를 생성해야 한다', () => { + expect(getAwardKey(validAward2024First, 0)).toBe('2024-FIRST-0'); + expect(getAwardKey(validAward2024Second, 0)).toBe('2024-SECOND-0'); + }); + + it('연도가 다르면 다른 키를 생성해야 한다', () => { + const award2024 = createAward(2024, SemesterTerm.FIRST); + + expect(getAwardKey(validAward2023First, 0)).toBe('2023-FIRST-0'); + expect(getAwardKey(award2024, 0)).toBe('2024-FIRST-0'); + }); + + it('큰 인덱스 숫자를 올바르게 처리해야 한다', () => { + expect(getAwardKey(validAward2024First, 999)).toBe('2024-FIRST-999'); + }); + }); +}); From c486f8d006ce23f6c7df9acf5220114dacae9ae2 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Mon, 19 Jan 2026 14:06:47 +0900 Subject: [PATCH 18/20] =?UTF-8?q?refactor:=20ClubIntroContent=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FAQ 토글 상태를 Array에서 Set으로 변경 (O(n) → O(1) 조회) - handleToggleFaq를 useCallback으로 메모이제이션 - validAwards를 useMemo로 계산하여 불필요한 재계산 방지 - 유효하지 않은 award(year/semester 누락) 렌더링 방지 - formatSemesterLabel, getAwardKey 함수를 utils로 분리 및 import --- .../ClubIntroContent/ClubIntroContent.tsx | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx index 0da782fa2..3c807d68d 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx @@ -1,18 +1,10 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; -import { Award, FAQ, IdealCandidate, SemesterTerm } from '@/types/club'; +import { Award, FAQ, IdealCandidate } from '@/types/club'; +import { formatSemesterLabel, getAwardKey } from '@/utils/awardHelpers'; import * as Styled from './ClubIntroContent.styles'; -const formatSemesterLabel = (award: Award): string => { - const semesterLabel = - award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; - return `${award.year} ${semesterLabel}`; -}; - -const getAwardKey = (award: Award, index: number): string => - `${award.year}-${award.semester}-${index}`; - interface ClubIntroContentProps { activityDescription?: string; awards?: Award[]; @@ -30,21 +22,37 @@ const ClubIntroContent = ({ }: ClubIntroContentProps) => { const trackEvent = useMixpanelTrack(); - const [openFaqIndexes, setOpenFaqIndexes] = useState([]); + const [openFaqIndexes, setOpenFaqIndexes] = useState>(new Set()); + + const validAwards = useMemo( + () => awards?.filter((award) => formatSemesterLabel(award) !== null) || [], + [awards], + ); + + const handleToggleFaq = useCallback( + (index: number) => { + setOpenFaqIndexes((prev) => { + const newSet = new Set(prev); + const isOpening = !newSet.has(index); - const handleToggleFaq = (index: number) => { - const isOpening = !openFaqIndexes.includes(index); - setOpenFaqIndexes((prev) => - prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index], - ); + if (isOpening) { + newSet.add(index); + } else { + newSet.delete(index); + } - if (faqs && faqs[index]) { - trackEvent(USER_EVENT.FAQ_TOGGLE_CLICKED, { - question: faqs[index].question, - action: isOpening ? 'open' : 'close', + if (faqs?.[index]) { + trackEvent(USER_EVENT.FAQ_TOGGLE_CLICKED, { + question: faqs[index].question, + action: isOpening ? 'open' : 'close', + }); + } + + return newSet; }); - } - }; + }, + [faqs, trackEvent], + ); return ( @@ -57,17 +65,16 @@ const ClubIntroContent = ({ )} - {awards && awards.length > 0 && ( + {validAwards.length > 0 && ( 동아리 성과 - {awards.map((award, index) => { + {validAwards.map((award, index) => { + const semesterLabel = formatSemesterLabel(award)!; const awardKey = getAwardKey(award, index); return ( - - {formatSemesterLabel(award)} - + {semesterLabel} {award.achievements.map((item, idx) => ( @@ -102,7 +109,7 @@ const ClubIntroContent = ({ FAQ {faqs.map((faq, index) => { - const isOpen = openFaqIndexes.includes(index); + const isOpen = openFaqIndexes.has(index); return ( handleToggleFaq(index)}> From 197a2552329363dbc31fc0ca91940fa2694e3f69 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Mon, 19 Jan 2026 14:34:56 +0900 Subject: [PATCH 19/20] =?UTF-8?q?fix:=20setState=20updater=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EC=97=90=EC=84=9C=20=EB=B6=80=EC=9E=91=EC=9A=A9=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=ED=95=98=EC=97=AC=20React=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EC=A4=80=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trackEvent 호출을 setOpenFaqIndexes updater 함수 외부로 이동 - React 공식 문서의 updater 순수성 요구사항 준수 - StrictMode에서 trackEvent 중복 호출 방지 - openFaqIndexes를 useCallback 의존성 배열에 추가 --- .../ClubIntroContent/ClubIntroContent.tsx | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx index 3c807d68d..190ea61af 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx @@ -31,27 +31,23 @@ const ClubIntroContent = ({ const handleToggleFaq = useCallback( (index: number) => { + const isOpening = !openFaqIndexes.has(index); + setOpenFaqIndexes((prev) => { const newSet = new Set(prev); - const isOpening = !newSet.has(index); - - if (isOpening) { - newSet.add(index); - } else { - newSet.delete(index); - } - - if (faqs?.[index]) { - trackEvent(USER_EVENT.FAQ_TOGGLE_CLICKED, { - question: faqs[index].question, - action: isOpening ? 'open' : 'close', - }); - } - + if (isOpening) newSet.add(index); + else newSet.delete(index); return newSet; }); + + if (faqs?.[index]) { + trackEvent(USER_EVENT.FAQ_TOGGLE_CLICKED, { + question: faqs[index].question, + action: isOpening ? 'open' : 'close', + }); + } }, - [faqs, trackEvent], + [faqs, trackEvent, openFaqIndexes], ); return ( From bf225a698bdb6e2ad16b8ace4d4021d9478bfbe7 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Mon, 19 Jan 2026 17:25:51 +0900 Subject: [PATCH 20/20] =?UTF-8?q?fix:=20createApplicationSSE=20import=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/apis/club.ts | 53 ---------------------- frontend/src/apis/clubSSE.ts | 54 +++++++++++++++++++++++ frontend/src/context/AdminClubContext.tsx | 2 +- 3 files changed, 55 insertions(+), 54 deletions(-) create mode 100644 frontend/src/apis/clubSSE.ts diff --git a/frontend/src/apis/club.ts b/frontend/src/apis/club.ts index d75f5e650..b7037f139 100644 --- a/frontend/src/apis/club.ts +++ b/frontend/src/apis/club.ts @@ -1,9 +1,4 @@ -import { EventSource } from 'eventsource'; import API_BASE_URL from '@/constants/api'; -import { - ApplicantSSECallbacks, - ApplicantStatusEvent, -} from '@/types/applicants'; import { ClubDescription, ClubDetail } from '@/types/club'; import { secureFetch } from './auth/secureFetch'; import { handleResponse, withErrorHandling } from './utils/apiHelpers'; @@ -84,51 +79,3 @@ export const updateClubDetail = async ( await handleResponse(response, '클럽 정보 수정에 실패했습니다.'); }, 'Failed to update club detail'); }; - -export const createApplicantSSE = ( - applicationFormId: string, - eventHandlers: ApplicantSSECallbacks, -): EventSource | null => { - let eventSource: EventSource | null = null; - - const connect = (): EventSource | null => { - const accessToken = localStorage.getItem('accessToken'); - if (!accessToken) return null; - - const source = new EventSource( - `${API_BASE_URL}/api/club/applicant/${applicationFormId}/sse`, - { - fetch: (input, init) => - fetch(input, { - ...init, - headers: { - ...init?.headers, - Authorization: `Bearer ${accessToken}`, - }, - credentials: 'include', - }), - }, - ); - - source.addEventListener('applicant-status-changed', (e) => { - try { - const eventData: ApplicantStatusEvent = JSON.parse(e.data); - eventHandlers.onStatusChange(eventData); - } catch (parseError) { - console.error('SSE PARSING ERROR:', parseError); - } - }); - - source.onerror = (error) => { - source.close(); - eventHandlers.onError( - new Error(error?.message || 'SSE connection error'), - ); - }; - - return source; - }; - - eventSource = connect(); - return eventSource; -}; diff --git a/frontend/src/apis/clubSSE.ts b/frontend/src/apis/clubSSE.ts new file mode 100644 index 000000000..559bb7feb --- /dev/null +++ b/frontend/src/apis/clubSSE.ts @@ -0,0 +1,54 @@ +import { EventSource } from 'eventsource'; +import API_BASE_URL from '@/constants/api'; +import { + ApplicantSSECallbacks, + ApplicantStatusEvent, +} from '@/types/applicants'; + +export const createApplicantSSE = ( + applicationFormId: string, + eventHandlers: ApplicantSSECallbacks, +): EventSource | null => { + let eventSource: EventSource | null = null; + + const connect = (): EventSource | null => { + const accessToken = localStorage.getItem('accessToken'); + if (!accessToken) return null; + + const source = new EventSource( + `${API_BASE_URL}/api/club/applicant/${applicationFormId}/sse`, + { + fetch: (input, init) => + fetch(input, { + ...init, + headers: { + ...init?.headers, + Authorization: `Bearer ${accessToken}`, + }, + credentials: 'include', + }), + }, + ); + + source.addEventListener('applicant-status-changed', (e) => { + try { + const eventData: ApplicantStatusEvent = JSON.parse(e.data); + eventHandlers.onStatusChange(eventData); + } catch (parseError) { + console.error('SSE PARSING ERROR:', parseError); + } + }); + + source.onerror = (error) => { + source.close(); + eventHandlers.onError( + new Error(error?.message || 'SSE connection error'), + ); + }; + + return source; + }; + + eventSource = connect(); + return eventSource; +}; diff --git a/frontend/src/context/AdminClubContext.tsx b/frontend/src/context/AdminClubContext.tsx index 4644427a1..b379ec593 100644 --- a/frontend/src/context/AdminClubContext.tsx +++ b/frontend/src/context/AdminClubContext.tsx @@ -6,7 +6,7 @@ import { useRef, useState, } from 'react'; -import { createApplicantSSE } from '@/apis/club'; +import { createApplicantSSE } from '@/apis/clubSSE'; import { ApplicantsInfo, ApplicantStatusEvent,