From 4c1064caac31234c40787ac7046e1eb581ac0436 Mon Sep 17 00:00:00 2001 From: Sejeong Kim Date: Sun, 25 Jan 2026 18:08:38 +0900 Subject: [PATCH 01/12] =?UTF-8?q?fix:=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20optimistic=20update=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EC=A7=80=EC=97=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/hooks/report/useDeleteMyReport.ts | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/frontend/src/hooks/report/useDeleteMyReport.ts b/frontend/src/hooks/report/useDeleteMyReport.ts index 60827f0f..ee752395 100644 --- a/frontend/src/hooks/report/useDeleteMyReport.ts +++ b/frontend/src/hooks/report/useDeleteMyReport.ts @@ -1,20 +1,49 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import type { DeleteMyReport, ResponseDeleteMyReport } from '../../types/report/all' +import type { BriefReport, DeleteMyReport, ResponseDeleteMyReport } from '../../types/report/all' import { deleteMyReport } from '../../api/report' +type MyReportQueryData = { + reportList: BriefReport[] + totalElements: number +} + +type DeleteReportContext = { + previousData?: MyReportQueryData +} + export const useDeleteMyReport = ({ channelId }: { channelId: number | undefined }) => { const queryClient = useQueryClient() - return useMutation({ + return useMutation({ mutationFn: deleteMyReport, - onSuccess: () => { + + onMutate: async ({ reportId }) => { + if (typeof channelId !== 'number') return {} + const queryKey = ['my', 'report', channelId] + await queryClient.cancelQueries({ queryKey }) + const previousData = queryClient.getQueryData(queryKey) + queryClient.setQueryData(queryKey, (old) => { + if (!old) return old + return { + ...old, + reportList: old.reportList.filter((report) => report.reportId !== reportId), + totalElements: old.totalElements - 1, + } + }) + return { previousData } + }, + onError: (_error, _variables, context) => { + if (typeof channelId === 'number' && context?.previousData) { + queryClient.setQueryData(['my', 'report', channelId], context.previousData) + } + alert('리포트 삭제 중 오류가 발생했습니다.') + }, + + onSettled: () => { if (typeof channelId === 'number') { queryClient.invalidateQueries({ queryKey: ['my', 'report', channelId] }) queryClient.invalidateQueries({ queryKey: ['recommendedVideos'] }) } }, - onError: () => { - alert('리포트 삭제 중 오류가 발생했습니다.') - }, }) } From a385af59f897b9d5ffed1e503cf20dd2f203c3dd Mon Sep 17 00:00:00 2001 From: Sejeong Kim Date: Sun, 25 Jan 2026 18:55:16 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=EC=97=90=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=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 --- frontend/src/assets/icons/feedback.svg | 3 ++ .../_components/navbar/NavbarLinksList.tsx | 32 +++++++++++-------- .../layouts/_components/navbar/navbarLinks.ts | 19 +++++++++++ 3 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 frontend/src/assets/icons/feedback.svg diff --git a/frontend/src/assets/icons/feedback.svg b/frontend/src/assets/icons/feedback.svg new file mode 100644 index 00000000..fe334c66 --- /dev/null +++ b/frontend/src/assets/icons/feedback.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/layouts/_components/navbar/NavbarLinksList.tsx b/frontend/src/layouts/_components/navbar/NavbarLinksList.tsx index 13b62c60..e8d10cfc 100644 --- a/frontend/src/layouts/_components/navbar/NavbarLinksList.tsx +++ b/frontend/src/layouts/_components/navbar/NavbarLinksList.tsx @@ -1,6 +1,6 @@ import { memo } from 'react' import { NavbarLink, NavbarModalButton } from './NavbarLink' -import { LOGIN_LINK, NAVIGATE_LINKS, PLUS_LINK } from './navbarLinks' +import { FEEDBACK_LINK, LOGIN_LINK, NAVIGATE_LINKS, PLUS_LINK } from './navbarLinks' import { NavbarUserInfo } from './NavbarUserInfo' import { useAuthStore } from '../../../stores/authStore' import useIsMobile from '../../../hooks/main/useIsMobile' @@ -42,18 +42,24 @@ const NavbarLinksListComponent = ({ -
- {isAuth && user ? ( - - ) : ( - - )} +
+
+ +
+ +
+ {isAuth && user ? ( + + ) : ( + + )} +
) diff --git a/frontend/src/layouts/_components/navbar/navbarLinks.ts b/frontend/src/layouts/_components/navbar/navbarLinks.ts index 1364b41e..635cd68c 100644 --- a/frontend/src/layouts/_components/navbar/navbarLinks.ts +++ b/frontend/src/layouts/_components/navbar/navbarLinks.ts @@ -11,10 +11,12 @@ import HomeRedIcon from '../../../assets/icons/home_active.svg' import IdeaRedIcon from '../../../assets/icons/idea_active.svg' import MyChannelRedIcon from '../../../assets/icons/mychannel_active.svg' import StoreRedIcon from '../../../assets/icons/store_active.svg' +import FeedbackIcon from '../../../assets/icons/feedback.svg' import LoginIcon from '../../../assets/icons/login.svg' export type LinkItem = { to: string + isExternal?: boolean defaultIcon: string hoverIcon?: string activeIcon?: string @@ -26,6 +28,7 @@ export type LinkItem = { export const PLUS_LINK: LinkItem = { to: '', + isExternal: false, defaultIcon: PlusIcon, hoverIcon: PlusIcon, activeIcon: PlusIcon, @@ -37,6 +40,7 @@ export const PLUS_LINK: LinkItem = { export const NAVIGATE_LINKS: LinkItem[] = [ { to: '/', + isExternal: false, defaultIcon: HomeIcon, hoverIcon: HomeWhiteIcon, activeIcon: HomeRedIcon, @@ -46,6 +50,7 @@ export const NAVIGATE_LINKS: LinkItem[] = [ }, { to: '/idea', + isExternal: false, defaultIcon: IdeaIcon, hoverIcon: IdeaWhiteIcon, activeIcon: IdeaRedIcon, @@ -55,6 +60,7 @@ export const NAVIGATE_LINKS: LinkItem[] = [ }, { to: '/my', + isExternal: false, defaultIcon: MyChannelIcon, hoverIcon: MyChannelWhiteIcon, activeIcon: MyChannelRedIcon, @@ -64,6 +70,7 @@ export const NAVIGATE_LINKS: LinkItem[] = [ }, { to: '/library', + isExternal: false, defaultIcon: StoreIcon, hoverIcon: StoreWhiteIcon, activeIcon: StoreRedIcon, @@ -73,8 +80,20 @@ export const NAVIGATE_LINKS: LinkItem[] = [ }, ] +export const FEEDBACK_LINK: LinkItem = { + to: 'https://open.kakao.com/o/sTPlNEvh', + isExternal: true, + defaultIcon: FeedbackIcon, + hoverIcon: FeedbackIcon, + activeIcon: FeedbackIcon, + alt: '피드백 아이콘', + label: '피드백', + isCircle: false, +} + export const LOGIN_LINK: LinkItem = { to: '', + isExternal: false, defaultIcon: LoginIcon, hoverIcon: LoginIcon, activeIcon: LoginIcon, From 1101034f334539072c274b6f8ec4d2457b902dad Mon Sep 17 00:00:00 2001 From: Sejeong Kim Date: Mon, 2 Feb 2026 15:53:06 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20api=20=EC=9A=94=EC=B2=AD=20=ED=95=A8=EC=88=98=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 --- frontend/src/api/user.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 11fcd9f3..f18c5592 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -33,3 +33,8 @@ export const fetchMyProfile = async () => { const { data } = await axiosInstance.get('/members') return data } + +export const withdrawMember = async () => { + const { data } = await axiosInstance.delete('/members/withdraw') + return data +} From c3801bb5b5b362259ac78458e226734b82238abd Mon Sep 17 00:00:00 2001 From: Sejeong Kim Date: Mon, 2 Feb 2026 16:33:08 +0900 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/setting/userMutations.ts | 8 +++++++- frontend/src/pages/setting/SettingPage.tsx | 2 +- .../_components/{WithdrawlModal.tsx => WithdrawModal.tsx} | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) rename frontend/src/pages/setting/_components/{WithdrawlModal.tsx => WithdrawModal.tsx} (91%) diff --git a/frontend/src/hooks/setting/userMutations.ts b/frontend/src/hooks/setting/userMutations.ts index adeb0ecd..291c0e79 100644 --- a/frontend/src/hooks/setting/userMutations.ts +++ b/frontend/src/hooks/setting/userMutations.ts @@ -1,5 +1,5 @@ import { useMutation } from '@tanstack/react-query' -import { updateMemberAgree, updateMemberProfileImage, updateMemberSNS } from '../../api/user' +import { updateMemberAgree, updateMemberProfileImage, updateMemberSNS, withdrawMember } from '../../api/user' export const useUpdateMemberAgree = () => { return useMutation({ @@ -18,3 +18,9 @@ export const useUpdateMemberProfileImage = () => { mutationFn: updateMemberProfileImage, }) } + +export const useWithdrawMember = () => { + return useMutation({ + mutationFn: withdrawMember, + }) +} diff --git a/frontend/src/pages/setting/SettingPage.tsx b/frontend/src/pages/setting/SettingPage.tsx index 3f1673a5..d1e8e91a 100644 --- a/frontend/src/pages/setting/SettingPage.tsx +++ b/frontend/src/pages/setting/SettingPage.tsx @@ -3,7 +3,7 @@ import { Button } from './_components/SettingButton' import '../../styles/scrollbar.css' import CloseIcon from '../../assets/icons/delete_normal.svg?react' import LogoutIcon from '../../assets/icons/logout.svg?react' -import WithdrawlModal from './_components/WithdrawlModal' +import WithdrawlModal from './_components/WithdrawModal' import { useLogout } from '../../hooks/useLogout' import ProfileSettings from './_containers/ProfileSettings' import ConsentSettings from './_containers/ConsentSettings' diff --git a/frontend/src/pages/setting/_components/WithdrawlModal.tsx b/frontend/src/pages/setting/_components/WithdrawModal.tsx similarity index 91% rename from frontend/src/pages/setting/_components/WithdrawlModal.tsx rename to frontend/src/pages/setting/_components/WithdrawModal.tsx index 53ad0dc3..c16c09b7 100644 --- a/frontend/src/pages/setting/_components/WithdrawlModal.tsx +++ b/frontend/src/pages/setting/_components/WithdrawModal.tsx @@ -1,12 +1,12 @@ import Modal from '../../../components/Modal' import { Button } from './SettingButton' -type WithdrawlModalProps = { +type WithdrawModalProps = { onClose: () => void onConfirm: () => void } -export default function WithdrawlModal({ onClose, onConfirm }: WithdrawlModalProps) { +export default function WithdrawModal({ onClose, onConfirm }: WithdrawModalProps) { return ( Date: Mon, 2 Feb 2026 16:51:32 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/hooks/setting/useWithdrawSettings.ts | 31 +++++++++++++++++++ frontend/src/pages/setting/SettingPage.tsx | 21 +++++++------ .../setting/_components/WithdrawModal.tsx | 5 ++- 3 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 frontend/src/hooks/setting/useWithdrawSettings.ts diff --git a/frontend/src/hooks/setting/useWithdrawSettings.ts b/frontend/src/hooks/setting/useWithdrawSettings.ts new file mode 100644 index 00000000..a25d62cf --- /dev/null +++ b/frontend/src/hooks/setting/useWithdrawSettings.ts @@ -0,0 +1,31 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { useAuthStore } from '../../stores/authStore' +import { useWithdrawMember } from './userMutations' + +export function useWithdrawSettings() { + const navigate = useNavigate() + const queryClient = useQueryClient() + const clearAuth = useAuthStore((state) => state.actions.clearAuth) + const { mutate: withdraw, isPending } = useWithdrawMember() + + const confirmWithdraw = () => { + if (isPending) return + + withdraw(undefined, { + onSuccess: () => { + queryClient.clear() + clearAuth() + navigate('/') + }, + onError: () => { + alert('회원 탈퇴에 실패했습니다.') + }, + }) + } + + return { + confirmWithdraw, + isPending, + } +} diff --git a/frontend/src/pages/setting/SettingPage.tsx b/frontend/src/pages/setting/SettingPage.tsx index d1e8e91a..e6eb80cb 100644 --- a/frontend/src/pages/setting/SettingPage.tsx +++ b/frontend/src/pages/setting/SettingPage.tsx @@ -3,10 +3,11 @@ import { Button } from './_components/SettingButton' import '../../styles/scrollbar.css' import CloseIcon from '../../assets/icons/delete_normal.svg?react' import LogoutIcon from '../../assets/icons/logout.svg?react' -import WithdrawlModal from './_components/WithdrawModal' +import WithdrawModal from './_components/WithdrawModal' import { useLogout } from '../../hooks/useLogout' import ProfileSettings from './_containers/ProfileSettings' import ConsentSettings from './_containers/ConsentSettings' +import { useWithdrawSettings } from '../../hooks/setting/useWithdrawSettings' type SettingPageProps = { onClose?: () => void @@ -14,11 +15,13 @@ type SettingPageProps = { export default function SettingPage({ onClose }: SettingPageProps) { const [activeTab, setActiveTab] = useState<'profile' | 'consent'>('profile') - const [showWithdrawlModal, setShowWithdrawlModal] = useState(false) + const [showWithdrawModal, setShowWithdrawModal] = useState(false) const logout = useLogout() const [loggingOut, setLoggingOut] = useState(false) + const { confirmWithdraw, isPending } = useWithdrawSettings() + const handleClickLogout = async () => { if (loggingOut) return setLoggingOut(true) @@ -27,10 +30,6 @@ export default function SettingPage({ onClose }: SettingPageProps) { onClose?.() } - const handleWithdrawlConfirm = () => { - setShowWithdrawlModal(false) - } - return (
{activeTab === 'profile' && ( setShowWithdrawlModal(true)} + onOpenWithdraw={() => setShowWithdrawModal(true)} fetchEnabled={!loggingOut} /> )} @@ -88,8 +87,12 @@ export default function SettingPage({ onClose }: SettingPageProps) {
- {showWithdrawlModal && ( - setShowWithdrawlModal(false)} onConfirm={handleWithdrawlConfirm} /> + {showWithdrawModal && ( + setShowWithdrawModal(false)} + onConfirm={confirmWithdraw} + isPending={isPending} + /> )} ) diff --git a/frontend/src/pages/setting/_components/WithdrawModal.tsx b/frontend/src/pages/setting/_components/WithdrawModal.tsx index c16c09b7..1904a1b7 100644 --- a/frontend/src/pages/setting/_components/WithdrawModal.tsx +++ b/frontend/src/pages/setting/_components/WithdrawModal.tsx @@ -4,9 +4,10 @@ import { Button } from './SettingButton' type WithdrawModalProps = { onClose: () => void onConfirm: () => void + isPending: boolean } -export default function WithdrawModal({ onClose, onConfirm }: WithdrawModalProps) { +export default function WithdrawModal({ onClose, onConfirm, isPending }: WithdrawModalProps) { return ( @@ -25,6 +27,7 @@ export default function WithdrawModal({ onClose, onConfirm }: WithdrawModalProps - {/* 각 탭 렌더링 */} {activeTab === 'report' ? : } diff --git a/frontend/src/pages/library/_components/IdeaCard.tsx b/frontend/src/pages/library/_components/IdeaCard.tsx index da33002e..1bc3930c 100644 --- a/frontend/src/pages/library/_components/IdeaCard.tsx +++ b/frontend/src/pages/library/_components/IdeaCard.tsx @@ -2,13 +2,13 @@ import { memo, useMemo } from 'react' import BookmarkActive from '../../../assets/icons/bookmark_active.svg?react' import type { Idea } from '../../../types/idea' import useRemoveIdeaBookmark from '../../../hooks/idea/useRemoveIdeaBookmark' +import { trackEvent } from '../../../utils/analytics' export default memo(function IdeaCard({ item }: { item: Idea }) { const { mutate: updateBookmark } = useRemoveIdeaBookmark() const parsedHashTags: string[] = useMemo(() => { try { - // hashTag가 문자열로 오는 경우 배열로 파싱 return Array.isArray(item.hashTag) ? item.hashTag : JSON.parse(item.hashTag) } catch { alert('해시태그 업데이트에 실패하였습니다.') @@ -17,6 +17,12 @@ export default memo(function IdeaCard({ item }: { item: Idea }) { }, [item.hashTag]) const handleBookmarkDelete = () => { + trackEvent({ + category: 'Library', + action: 'Remove Idea Bookmark', + label: `Idea: ${item.ideaId}`, + }) + updateBookmark({ ideaId: item.ideaId }) } diff --git a/frontend/src/pages/library/_components/RecentReportCard.tsx b/frontend/src/pages/library/_components/RecentReportCard.tsx index 88ef79b5..dd5a1a67 100644 --- a/frontend/src/pages/library/_components/RecentReportCard.tsx +++ b/frontend/src/pages/library/_components/RecentReportCard.tsx @@ -2,6 +2,7 @@ import { memo, type MouseEvent } from 'react' import X from '../../../assets/icons/X.svg?react' import type { BriefReport } from '../../../types/report/all' import { formatRelativeTime, formatSimpleDate } from '../../../utils/format' +import { trackEvent } from '../../../utils/analytics' interface RecentReportCardProps { item: BriefReport @@ -12,9 +13,26 @@ interface RecentReportCardProps { export default memo(function RecentReportCard({ item, onDelete, handleClick }: RecentReportCardProps) { const handleDeleteClick = (e: MouseEvent) => { e.stopPropagation() + + trackEvent({ + category: 'Library', + action: 'Delete Report', + label: `Report: ${item.reportId}, Type: Video`, + }) + onDelete?.() } + const handleCardClick = () => { + trackEvent({ + category: 'Library', + action: 'Click Report Card', + label: `Report: ${item.reportId}, Type: Video`, + }) + + handleClick() + } + return (
@@ -30,7 +48,7 @@ export default memo(function RecentReportCard({ item, onDelete, handleClick }: R
-
+
{item.videoTitle}
diff --git a/frontend/src/pages/library/_components/RecentReportShortsCard.tsx b/frontend/src/pages/library/_components/RecentReportShortsCard.tsx index d04bd9ab..719bed19 100644 --- a/frontend/src/pages/library/_components/RecentReportShortsCard.tsx +++ b/frontend/src/pages/library/_components/RecentReportShortsCard.tsx @@ -2,6 +2,7 @@ import { memo, type MouseEvent } from 'react' import X from '../../../assets/icons/X.svg?react' import { formatRelativeTime, formatSimpleDate } from '../../../utils/format' import type { BriefReport } from '../../../types/report/all' +import { trackEvent } from '../../../utils/analytics' interface RecentReportShortsCardProps { item: BriefReport @@ -12,9 +13,26 @@ interface RecentReportShortsCardProps { export default memo(function RecentReportShortsCard({ item, onDelete, handleClick }: RecentReportShortsCardProps) { const handleDeleteClick = (e: MouseEvent) => { e.stopPropagation() + + trackEvent({ + category: 'Library', + action: 'Delete Report', + label: `Report: ${item.reportId}, Type: Shorts`, + }) + onDelete?.() } + const handleCardClick = () => { + trackEvent({ + category: 'Library', + action: 'Click Report Card', + label: `Report: ${item.reportId}, Type: Shorts`, + }) + + handleClick() + } + return (
@@ -30,7 +48,7 @@ export default memo(function RecentReportShortsCard({ item, onDelete, handleClic
-
+
{item.videoTitle}
diff --git a/frontend/src/pages/library/_components/ReportTab.tsx b/frontend/src/pages/library/_components/ReportTab.tsx index 671378dd..c2cb0e46 100644 --- a/frontend/src/pages/library/_components/ReportTab.tsx +++ b/frontend/src/pages/library/_components/ReportTab.tsx @@ -8,6 +8,7 @@ import { useAuthStore } from '../../../stores/authStore' import type { BriefReport, VideoType } from '../../../types/report/all' import { useDeleteMyReport } from '../../../hooks/report/useDeleteMyReport' import { ReportSkeleton } from './ReportSkeleton' +import { trackEvent } from '../../../utils/analytics' export default function ReportTab() { const navigate = useNavigate() @@ -38,6 +39,18 @@ export default function ReportTab() { deleteReport({ reportId }) } + const handleSubTabChange = (type: VideoType) => { + if (type === subTab) return + + trackEvent({ + category: 'Library', + action: 'Switch Report Sub Tab', + label: type === 'LONG' ? 'Video' : 'Shorts', + }) + + setSubTab(type) + } + useEffect(() => { setPage(1) setStartPage(1) @@ -51,18 +64,16 @@ export default function ReportTab() {
@@ -71,7 +82,6 @@ export default function ReportTab() {
{reportData.totalElements}개의 영상 리포트
- {/* 카드 리스트 */} {reportData.totalElements === 0 ? (

등록된 영상 리포트가 없습니다.

) : ( @@ -105,7 +115,7 @@ export default function ReportTab() {
{ const navigate = useNavigate() const handleVideoClick = () => { + trackEvent({ + category: 'Video', + action: 'Click Demo Video Card', + label: video.videoTitle, + }) navigate(`/report/demo/${reportId}`) } + return (
diff --git a/frontend/src/pages/main/_components/MyVideoCard.tsx b/frontend/src/pages/main/_components/MyVideoCard.tsx index d96bb879..317113fd 100644 --- a/frontend/src/pages/main/_components/MyVideoCard.tsx +++ b/frontend/src/pages/main/_components/MyVideoCard.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import type { NormalizedVideo } from '../../../types/main' import { VideoCardDisplay } from './VideoCardDisplay' import MyReportModal from '../../../components/MyReportModal' +import { trackEvent } from '../../../utils/analytics' interface MyVideoCardProps { video: NormalizedVideo @@ -10,9 +11,18 @@ interface MyVideoCardProps { export const MyVideoCard = ({ video }: MyVideoCardProps) => { const [open, setOpen] = useState(false) + const handleClick = () => { + trackEvent({ + category: 'Video', + action: 'Click My Video Card', + label: video.videoTitle, + }) + setOpen(true) + } + return ( <> -
setOpen(true)} className="cursor-pointer"> +
diff --git a/frontend/src/pages/my/_components/conceptbox.tsx b/frontend/src/pages/my/_components/conceptbox.tsx index 40502042..880aefe6 100644 --- a/frontend/src/pages/my/_components/conceptbox.tsx +++ b/frontend/src/pages/my/_components/conceptbox.tsx @@ -3,6 +3,7 @@ import Textarea from '../../../components/Textarea' import { EditButton } from '../../../components/EditButton' import { useUpdateChannelConcept } from '../../../hooks/channel/useUpdateIdentity' import { useAuthStore } from '../../../stores/authStore' +import { trackEvent } from '../../../utils/analytics' type Mode = 'VIEW' | 'EDIT' | 'ACTIVE_COMPLETE' @@ -23,7 +24,13 @@ const Conceptbox = ({ conceptValue, setConceptValue }: ConceptboxProps) => { ['VIEW']: { buttonColor: 'text-gray-900', label: '수정', - onClick: () => setMode('EDIT'), + onClick: () => { + trackEvent({ + category: 'My Channel', + action: 'Start Edit Concept', + }) + setMode('EDIT') + }, isDisabled: true, }, ['EDIT']: { @@ -42,10 +49,19 @@ const Conceptbox = ({ conceptValue, setConceptValue }: ConceptboxProps) => { { channelId, concept: conceptValue }, { onSuccess: (res) => { + trackEvent({ + category: 'My Channel', + action: 'Update Concept Success', + }) console.log('updateChannelConcept 응답 :', res) setConceptValue(res.updatedConcept) }, - onError: () => { + onError: (error) => { + trackEvent({ + category: 'My Channel', + action: 'Update Concept Error', + label: error?.message || 'Unknown error', + }) alert(' 콘셉트 저장 실패 ') }, } diff --git a/frontend/src/pages/my/_components/myShortsCard.tsx b/frontend/src/pages/my/_components/myShortsCard.tsx index b70bb5d1..81d85aee 100644 --- a/frontend/src/pages/my/_components/myShortsCard.tsx +++ b/frontend/src/pages/my/_components/myShortsCard.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import type { Video } from '../../../types/profile' import { formatKoreanNumber, formatRelativeTime } from '../../../utils/format' import MyReportModal from '../../../components/MyReportModal' +import { trackEvent } from '../../../utils/analytics' interface MyShortsCardProps { shorts: Video @@ -10,11 +11,20 @@ interface MyShortsCardProps { export default function MyShortsCard({ shorts }: MyShortsCardProps) { const [open, setOpen] = useState(false) + const handleClick = () => { + trackEvent({ + category: 'My Channel', + action: 'Click Video Card', + label: `Shorts: ${shorts.id}`, + }) + setOpen(true) + } + return ( <>
setOpen(true)} + onClick={handleClick} >
{shorts.title} diff --git a/frontend/src/pages/my/_components/myVideoCard.tsx b/frontend/src/pages/my/_components/myVideoCard.tsx index 7ccc3782..53e09053 100644 --- a/frontend/src/pages/my/_components/myVideoCard.tsx +++ b/frontend/src/pages/my/_components/myVideoCard.tsx @@ -2,6 +2,7 @@ import type { Video } from '../../../types/profile' import { useState } from 'react' import { formatKoreanNumber, formatRelativeTime } from '../../../utils/format' import MyReportModal from '../../../components/MyReportModal' +import { trackEvent } from '../../../utils/analytics' interface MyVideoCardProps { video: Video @@ -10,9 +11,18 @@ interface MyVideoCardProps { export default function MyVideoCard({ video }: MyVideoCardProps) { const [open, setOpen] = useState(false) + const handleClick = () => { + trackEvent({ + category: 'My Channel', + action: 'Click Video Card', + label: `Video: ${video.id}`, + }) + setOpen(true) + } + return ( <> -
setOpen(true)}> +
{video.title}
diff --git a/frontend/src/pages/my/_components/targetbox.tsx b/frontend/src/pages/my/_components/targetbox.tsx index 4f673a11..524245a8 100644 --- a/frontend/src/pages/my/_components/targetbox.tsx +++ b/frontend/src/pages/my/_components/targetbox.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { EditButton } from '../../../components/EditButton' import { useUpdateChannelTarget } from '../../../hooks/channel/useUpdateIdentity' import { useAuthStore } from '../../../stores/authStore' +import { trackEvent } from '../../../utils/analytics' type Mode = 'VIEW' | 'EDIT' | 'ACTIVE_COMPLETE' @@ -22,7 +23,13 @@ const Targetbox = ({ targetValue, setTargetValue }: TargetboxProps) => { ['VIEW']: { label: '수정', buttonColor: 'text-gray-900', - onClick: () => setMode('EDIT'), + onClick: () => { + trackEvent({ + category: 'My Channel', + action: 'Start Edit Target', + }) + setMode('EDIT') + }, }, ['EDIT']: { label: '완료', @@ -39,9 +46,18 @@ const Targetbox = ({ targetValue, setTargetValue }: TargetboxProps) => { { channelId, target: targetValue }, { onSuccess: (res) => { + trackEvent({ + category: 'My Channel', + action: 'Update Target Success', + }) setTargetValue(res.updatedTarget) }, - onError: () => { + onError: (error) => { + trackEvent({ + category: 'My Channel', + action: 'Update Target Error', + label: error?.message || 'Unknown error', + }) alert(' 타겟 저장 실패 ') }, } diff --git a/frontend/src/pages/my/_components/videolist.tsx b/frontend/src/pages/my/_components/videolist.tsx index dd35c0ce..8b8485c1 100644 --- a/frontend/src/pages/my/_components/videolist.tsx +++ b/frontend/src/pages/my/_components/videolist.tsx @@ -6,14 +6,15 @@ import { useGetChannelVideo } from '../../../hooks/my/useGetChannelVideo' import { mapResponseToVideoList } from '../../../lib/mappers/profile/mapResponseToVideo' import { useAuthStore } from '../../../stores/authStore' import { VideoSkeleton } from './Skeleton/VideoSkeleton' +import { trackEvent } from '../../../utils/analytics' export default function Videolist() { - const [videoCurrentPage, setVideoCurrentPage] = useState(1) //현재 페이지 값 + const [videoCurrentPage, setVideoCurrentPage] = useState(1) const [shortsCurrentPage, setShortsCurrentPage] = useState(1) - const [videoStartPage, setVideoStartPage] = useState(1) //가장 앞 페이지 값 + const [videoStartPage, setVideoStartPage] = useState(1) const [ShortsStartPage, setShortsStartPage] = useState(1) - const itemsPerPage = 12 // 페이지당 보여줄 아이템 개수 + const itemsPerPage = 12 const [activeTab, setActiveTab] = useState<'video' | 'shorts'>('video') const user = useAuthStore((state) => state.user) @@ -46,6 +47,18 @@ export default function Videolist() { const shortsData = shortsResponse ? mapResponseToVideoList(shortsResponse) : [] const shortsTotalItems = shortsResponse?.result.totalElements ?? 0 + const handleTabChange = (tab: 'video' | 'shorts') => { + if (tab === activeTab) return + + trackEvent({ + category: 'My Channel', + action: 'Switch Video Tab', + label: tab === 'video' ? 'Video' : 'Shorts', + }) + + setActiveTab(tab) + } + if (isVideoPending || isShortsPending) return if (isVideoError || isShortsError) return
에러
@@ -54,29 +67,26 @@ export default function Videolist() {
영상 리스트
{activeTab === 'video' ? ( - // Video 탭이 활성화된 경우 videoTotalItems === 0 ? (

업로드된 동영상이 없습니다.

) : ( @@ -86,8 +96,7 @@ export default function Videolist() { ))}
) - ) : // Shorts 탭이 활성화된 경우 - shortsTotalItems === 0 ? ( + ) : shortsTotalItems === 0 ? (

업로드된 Shorts가 없습니다.

) : (
diff --git a/frontend/src/pages/report/DummyReportPage.tsx b/frontend/src/pages/report/DummyReportPage.tsx index fc1c6842..f78aa295 100644 --- a/frontend/src/pages/report/DummyReportPage.tsx +++ b/frontend/src/pages/report/DummyReportPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useParams } from 'react-router-dom' import Metadata from '../../components/Metadata' @@ -9,6 +9,7 @@ import { useGetDummyVideoMeta } from '../../hooks/report' import { adaptVideoMeta } from '../../lib/mappers/report' import type { NormalizedVideoData } from '../../types/report/all' import { VideoSummarySkeleton } from './_components/VideoSummarySkeleton' +import { trackEvent } from '../../utils/analytics' export default function DummyReportPage() { const { reportId: reportIdParam } = useParams() @@ -31,6 +32,31 @@ export default function DummyReportPage() { ? adaptVideoMeta(videoData, isDummy) : undefined + useEffect(() => { + if (!isPending && videoData) { + trackEvent({ + category: 'Report', + action: 'View Report', + label: 'Demo Report', + }) + } + }, [isPending, videoData]) + + const handleTabChange = (tab: (typeof TABS)[number]) => { + if (tab.index === activeTab.index) return + + const fromTab = activeTab.label + const toTab = tab.label + + trackEvent({ + category: 'Report', + action: 'Switch Tab', + label: `${fromTab} to ${toTab} (Demo)`, + }) + + setActiveTab(tab) + } + const handleGuestModalClick = () => setIsOpenGuestModal(!isOpenGuestModal) return ( @@ -39,7 +65,7 @@ export default function DummyReportPage() {
{isPending ? : } - +
{isOpenGuestModal && } diff --git a/frontend/src/pages/report/ReportPage.tsx b/frontend/src/pages/report/ReportPage.tsx index 9e737f1e..db43a925 100644 --- a/frontend/src/pages/report/ReportPage.tsx +++ b/frontend/src/pages/report/ReportPage.tsx @@ -13,6 +13,7 @@ import { useGetInitialReportStatus, usePollReportStatus } from '../../hooks/repo import { META_KEY } from '../../constants/metaConfig' import type { NormalizedVideoData } from '../../types/report/all' import { adaptVideoMeta } from '../../lib/mappers/report' +import { trackEvent } from '../../utils/analytics' export default function ReportPage() { const navigate = useNavigate() @@ -27,14 +28,11 @@ export default function ReportPage() { const currentReportStatus = useReportStore((state) => state.statuses[reportId]) const pendingReportIds = useReportStore((state) => state.pendingReportIds) - // ✅ 페이지 진입 시 해당 리포트 ID로 상태가 없을 때만 일회성으로 서버에 상태 조회 const { isInvalidReportError } = useGetInitialReportStatus(reportId) - // ✅ 해당 리포트 ID가 PENDING 중일 경우 로컬 폴링 const needsPolling = useMemo(() => pendingReportIds.includes(reportId), [pendingReportIds, reportId]) usePollReportStatus(reportId, { enabled: needsPolling }) - // ✅ 리포트 생성에 실패한 경우 const isKnownToHaveFailed = useMemo(() => { if (!currentReportStatus) return false const { overviewStatus, analysisStatus } = currentReportStatus @@ -44,7 +42,6 @@ export default function ReportPage() { const isInvalidOrDeleted = isInvalidReportError const shouldShowError = isKnownToHaveFailed || isInvalidOrDeleted - // ✅ 리포트가 생성 중인 경우 const isGenerating = useMemo(() => pendingReportIds.includes(reportId), [pendingReportIds, reportId]) const handleCloseErrorModal = () => navigate('/', { replace: true }) @@ -65,12 +62,46 @@ export default function ReportPage() { ? adaptVideoMeta(videoData, false) : undefined - // 영상 정보 조회가 성공하면 로딩 스피너를 종료 useEffect(() => { if (!isPending) endGenerating() }, [isPending, endGenerating]) - const handleUpdateModalClick = () => setIsOpenUpdateModal(!isOpenUpdateModal) + useEffect(() => { + if (!isPending && videoData) { + trackEvent({ + category: 'Report', + action: 'View Report', + label: 'Real Report', + }) + } + }, [isPending, videoData]) + + const handleTabChange = (tab: (typeof TABS)[number]) => { + if (tab.index === activeTab.index) return + + const fromTab = activeTab.label + const toTab = tab.label + + trackEvent({ + category: 'Report', + action: 'Switch Tab', + label: `${fromTab} to ${toTab}`, + }) + + setActiveTab(tab) + } + + const handleUpdateModalClick = () => { + if (!isOpenUpdateModal) { + trackEvent({ + category: 'Report', + action: 'Click Update Button', + label: String(reportId), + }) + } + setIsOpenUpdateModal(!isOpenUpdateModal) + } + const handleResetTab = () => setActiveTab(TABS[0]) return ( @@ -81,12 +112,13 @@ export default function ReportPage() {
{isPending ? : } - +
{isOpenUpdateModal && ( @@ -105,10 +137,8 @@ export default function ReportPage() { {/* 우선순위에 따른 모달 렌더링 */} {shouldShowError ? ( - // 1순위: 생성 실패 에러 모달 ) : isGenerating ? ( - // 2순위: 생성 중 모달 ) : null} diff --git a/frontend/src/pages/report/_components/UpdateModal.tsx b/frontend/src/pages/report/_components/UpdateModal.tsx index b3c7ae7a..82f1069c 100644 --- a/frontend/src/pages/report/_components/UpdateModal.tsx +++ b/frontend/src/pages/report/_components/UpdateModal.tsx @@ -4,14 +4,16 @@ import usePostReportById from '../../../hooks/report/usePostReportById' import { useReportStore } from '../../../stores/reportStore' import { useQueryClient } from '@tanstack/react-query' import { useAuthStore } from '../../../stores/authStore' +import { trackEvent } from '../../../utils/analytics' interface UpdateModalProps { videoId: number + reportId: number handleModalClick: () => void handleResetTab: () => void } -export const UpdateModal = ({ videoId, handleModalClick, handleResetTab }: UpdateModalProps) => { +export const UpdateModal = ({ videoId, reportId, handleModalClick, handleResetTab }: UpdateModalProps) => { const navigate = useNavigate() const queryClient = useQueryClient() const user = useAuthStore((state) => state.user) @@ -20,23 +22,50 @@ export const UpdateModal = ({ videoId, handleModalClick, handleResetTab }: Updat const addPendingReportId = useReportStore((state) => state.actions.addPendingReportId) const { mutate: requestNewReport } = usePostReportById({ - onSuccess: ({ reportId }) => { - addPendingReportId(reportId) + onSuccess: ({ reportId: newReportId }) => { + addPendingReportId(newReportId) if (typeof channelId === 'number') { queryClient.invalidateQueries({ queryKey: ['my', 'report', channelId], }) } - navigate(`/report/${reportId}?video=${videoId}`) - handleResetTab() // 업데이트 후 탭 초기화 + trackEvent({ + category: 'Report', + action: 'Update Success', + label: String(newReportId), + }) + + navigate(`/report/${newReportId}?video=${videoId}`) + handleResetTab() }, - onError: () => { + onError: (error) => { + trackEvent({ + category: 'Report', + action: 'Update Error', + label: error.message || 'Unknown error', + }) + alert('리포트 업데이트 중 오류가 발생하였습니다.') }, }) + const handleCancelClick = () => { + trackEvent({ + category: 'Report', + action: 'Cancel Update', + label: String(reportId), + }) + handleModalClick() + } + const handleUpdateClick = () => { + trackEvent({ + category: 'Report', + action: 'Confirm Update', + label: String(reportId), + }) + requestNewReport({ videoId }) handleModalClick() } @@ -45,7 +74,7 @@ export const UpdateModal = ({ videoId, handleModalClick, handleResetTab }: Updat
+ {/* 각 탭 렌더링 */} {activeTab === 'report' ? : }
diff --git a/frontend/src/pages/library/_components/IdeaCard.tsx b/frontend/src/pages/library/_components/IdeaCard.tsx index 1bc3930c..1823568a 100644 --- a/frontend/src/pages/library/_components/IdeaCard.tsx +++ b/frontend/src/pages/library/_components/IdeaCard.tsx @@ -9,6 +9,7 @@ export default memo(function IdeaCard({ item }: { item: Idea }) { const parsedHashTags: string[] = useMemo(() => { try { + // hashTag가 문자열로 오는 경우 배열로 파싱 return Array.isArray(item.hashTag) ? item.hashTag : JSON.parse(item.hashTag) } catch { alert('해시태그 업데이트에 실패하였습니다.') diff --git a/frontend/src/pages/library/_components/ReportTab.tsx b/frontend/src/pages/library/_components/ReportTab.tsx index c2cb0e46..eb0601ea 100644 --- a/frontend/src/pages/library/_components/ReportTab.tsx +++ b/frontend/src/pages/library/_components/ReportTab.tsx @@ -82,6 +82,7 @@ export default function ReportTab() {
{reportData.totalElements}개의 영상 리포트
+ {/* 카드 리스트 */} {reportData.totalElements === 0 ? (

등록된 영상 리포트가 없습니다.

) : ( @@ -115,7 +116,7 @@ export default function ReportTab() {
{ const navigate = useNavigate() @@ -13,9 +14,12 @@ export const UrlInputForm = () => { const [videoId, setVideoId] = useState(null) const [isFocused, setIsFocused] = useState(false) - const { register, handleSubmit, isActive, error } = useUrlInput((newReportId, newVideoId) => { - setReportId(newReportId) - setVideoId(newVideoId) + const { register, handleSubmit, isActive, error } = useUrlInput({ + onRequestUrlSuccess: (newReportId, newVideoId) => { + setReportId(newReportId) + setVideoId(newVideoId) + }, + onTrackEvent: trackEvent, }) const { data: videoData, isPending } = useGetVideoData(videoId ?? undefined) @@ -47,9 +51,8 @@ export const UrlInputForm = () => { )} > void @@ -18,9 +19,12 @@ export const UrlInputModal = ({ onClose }: UrlInputModalProps) => { const [isFocused, setIsFocused] = useState(false) const modalRef = useRef(null) - const { register, handleSubmit, isActive, error } = useUrlInput((newReportId, newVideoId) => { - setReportId(newReportId) - setVideoId(newVideoId) + const { register, handleSubmit, isActive, error } = useUrlInput({ + onRequestUrlSuccess: (newReportId, newVideoId) => { + setReportId(newReportId) + setVideoId(newVideoId) + }, + onTrackEvent: trackEvent, }) const { data: videoData, isPending } = useGetVideoData(videoId ?? undefined) @@ -72,9 +76,8 @@ export const UrlInputModal = ({ onClose }: UrlInputModalProps) => { >
('video') const user = useAuthStore((state) => state.user) @@ -68,8 +68,8 @@ export default function Videolist() {
{activeTab === 'video' ? ( + // Video 탭이 활성화된 경우 videoTotalItems === 0 ? (

업로드된 동영상이 없습니다.

) : ( @@ -96,15 +97,16 @@ export default function Videolist() { ))}
) - ) : shortsTotalItems === 0 ? ( -

업로드된 Shorts가 없습니다.

- ) : ( -
- {shortsData.map((short) => ( - - ))} -
- )} + ) : // Shorts 탭이 활성화된 경우 + shortsTotalItems === 0 ? ( +

업로드된 Shorts가 없습니다.

+ ) : ( +
+ {shortsData.map((short) => ( + + ))} +
+ )} {(activeTab === 'video' ? videoTotalItems : shortsTotalItems) > 0 && (
diff --git a/frontend/src/pages/report/ReportPage.tsx b/frontend/src/pages/report/ReportPage.tsx index db43a925..82833c86 100644 --- a/frontend/src/pages/report/ReportPage.tsx +++ b/frontend/src/pages/report/ReportPage.tsx @@ -30,9 +30,11 @@ export default function ReportPage() { const { isInvalidReportError } = useGetInitialReportStatus(reportId) + // ✅ 페이지 진입 시 해당 리포트 ID로 상태가 없을 때만 일회성으로 서버에 상태 조회 const needsPolling = useMemo(() => pendingReportIds.includes(reportId), [pendingReportIds, reportId]) usePollReportStatus(reportId, { enabled: needsPolling }) + // ✅ 해당 리포트 ID가 PENDING 중일 경우 로컬 폴링 const isKnownToHaveFailed = useMemo(() => { if (!currentReportStatus) return false const { overviewStatus, analysisStatus } = currentReportStatus @@ -42,6 +44,7 @@ export default function ReportPage() { const isInvalidOrDeleted = isInvalidReportError const shouldShowError = isKnownToHaveFailed || isInvalidOrDeleted + // ✅ 리포트가 생성 중인 경우 const isGenerating = useMemo(() => pendingReportIds.includes(reportId), [pendingReportIds, reportId]) const handleCloseErrorModal = () => navigate('/', { replace: true }) @@ -66,6 +69,7 @@ export default function ReportPage() { if (!isPending) endGenerating() }, [isPending, endGenerating]) + // 영상 정보 조회가 성공하면 로딩 스피너를 종료 useEffect(() => { if (!isPending && videoData) { trackEvent({ @@ -137,8 +141,10 @@ export default function ReportPage() { {/* 우선순위에 따른 모달 렌더링 */} {shouldShowError ? ( + // 1순위: 생성 실패 에러 모달 ) : isGenerating ? ( + // 2순위: 생성 중 모달 ) : null} diff --git a/frontend/src/pages/report/_components/UpdateModal.tsx b/frontend/src/pages/report/_components/UpdateModal.tsx index 82f1069c..5311bd2e 100644 --- a/frontend/src/pages/report/_components/UpdateModal.tsx +++ b/frontend/src/pages/report/_components/UpdateModal.tsx @@ -37,7 +37,7 @@ export const UpdateModal = ({ videoId, reportId, handleModalClick, handleResetTa }) navigate(`/report/${newReportId}?video=${videoId}`) - handleResetTab() + handleResetTab() // 업데이트 후 탭 초기화 }, onError: (error) => { trackEvent({ diff --git a/frontend/src/pages/report/_components/analysis/TabAnalysis.tsx b/frontend/src/pages/report/_components/analysis/TabAnalysis.tsx index 75913ca3..65e42ff3 100644 --- a/frontend/src/pages/report/_components/analysis/TabAnalysis.tsx +++ b/frontend/src/pages/report/_components/analysis/TabAnalysis.tsx @@ -1,4 +1,3 @@ -import { useEffect, useRef } from 'react' import { AlgorithmOptimization } from './AlgorithmOptimization' import { ViewerExitAnalysis } from './ViewerExitAnalysis' import { Skeleton } from './Skeleton' @@ -6,6 +5,7 @@ import useGetReportAnalysis from '../../../../hooks/report/useGetReportAnalysis' import { useGetDummyAnalysis } from '../../../../hooks/report/useGetDummyReport' import { useReportStore } from '../../../../stores/reportStore' import { trackEvent } from '../../../../utils/analytics' +import { useScrollTracking } from '../../../../hooks/useScrollTracking' interface TabAnalysisProps { reportId: number @@ -15,7 +15,6 @@ interface TabAnalysisProps { export const TabAnalysis = ({ reportId, isDummy = false }: TabAnalysisProps) => { const analysisStatus = useReportStore((state) => state.statuses[reportId]?.analysisStatus) const isCompleted = analysisStatus === 'COMPLETED' - const hasTrackedScroll = useRef(false) const { data: realData, isLoading: isRealLoading } = useGetReportAnalysis({ reportId, @@ -30,31 +29,18 @@ export const TabAnalysis = ({ reportId, isDummy = false }: TabAnalysisProps) => const analysisData = isDummy ? dummyData : realData const isLoading = isDummy ? isDummyLoading : !isCompleted || isRealLoading - useEffect(() => { - if (isLoading || hasTrackedScroll.current) return - - const scrollContainer = document.getElementById('scroll-container') - if (!scrollContainer) return - - const handleScroll = () => { - const scrollTop = scrollContainer.scrollTop - const scrollHeight = scrollContainer.scrollHeight - const clientHeight = scrollContainer.clientHeight - const scrollPercentage = (scrollTop / (scrollHeight - clientHeight)) * 100 - - if (scrollPercentage > 50 && !hasTrackedScroll.current) { - trackEvent({ - category: 'Report Content', - action: 'Scroll Analysis Tab', - label: `${Math.round(scrollPercentage)}%`, - }) - hasTrackedScroll.current = true - } - } - - scrollContainer.addEventListener('scroll', handleScroll) - return () => scrollContainer.removeEventListener('scroll', handleScroll) - }, [isLoading]) + useScrollTracking({ + containerId: 'scroll-container', + threshold: 50, + enabled: !isLoading, + onThresholdReached: (scrollPercentage) => { + trackEvent({ + category: 'Report Content', + action: 'Scroll Analysis Tab', + label: `${Math.round(scrollPercentage)}%`, + }) + }, + }) if (isLoading || !analysisData) return diff --git a/frontend/src/pages/report/_components/overview/CommentFeedback.tsx b/frontend/src/pages/report/_components/overview/CommentFeedback.tsx index f6395903..95230347 100644 --- a/frontend/src/pages/report/_components/overview/CommentFeedback.tsx +++ b/frontend/src/pages/report/_components/overview/CommentFeedback.tsx @@ -16,6 +16,8 @@ const Comments = ({ comments }: { comments: Comment[] | undefined }) => { return (
{comments?.map((comment, idx) => ( + // 현재 서버에서 모든 commentId 값이 동일하게 내려오는 버그가 있으므로 (예: 0) + // React key 충돌 예방을 위해 idx를 덧붙여 임시 유니크 key 생성
{comment.content}
@@ -57,6 +59,7 @@ export const CommentFeedback = ({ data, isDummy }: OverviewDataProps & { isDummy data.adviceComment ?? 0, ] + // 모든 값이 0이면 최소값 1로 세팅해서 차트가 보이도록 if (values.every((v) => v === 0)) return [1, 1, 1, 1] return values From 5281c1d615c8f1f01689f39b8b1cca935e23e4dd Mon Sep 17 00:00:00 2001 From: MunJinYeong Date: Tue, 3 Feb 2026 16:39:14 +0900 Subject: [PATCH 10/12] =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/layouts/RootLayout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/layouts/RootLayout.tsx b/frontend/src/layouts/RootLayout.tsx index e3415a8c..d074dbad 100644 --- a/frontend/src/layouts/RootLayout.tsx +++ b/frontend/src/layouts/RootLayout.tsx @@ -10,7 +10,7 @@ import { useFetchAndSetUser } from '../hooks/channel/useFetchAndSetUser' import { NavbarModalsContainer } from '../pages/auth' import { SettingModalContainer } from '../pages/setting/_components/SettingModalContainer' import AuthWatcher from '../components/AuthWatcher' -import { PageViewTracker } from '../components/PageViewTracker' +import { GoogleAnalytics } from '../components/GoogleAnalytics' export default function RootLayout() { const location = useLocation() @@ -35,7 +35,7 @@ export default function RootLayout() { return ( <> - + From d8b9352c92fe5515eb454407250138f7841248be Mon Sep 17 00:00:00 2001 From: yoonvinjeong Date: Thu, 5 Feb 2026 14:32:24 +0900 Subject: [PATCH 11/12] =?UTF-8?q?chore:=20App.tsx=EC=97=90=EC=84=9C=20Goog?= =?UTF-8?q?leAnalytics=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4cdd3c81..0259284a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,14 +5,13 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { GlobalReportPoller } from './components/GlobalReportPoller' import { queryClient } from './utils/queryClient' import GlobalModal from './components/GlobalModal' -import { GoogleAnalytics } from './components/GoogleAnalytics' function App() { return ( <> - + {/* 전역 폴러 */} @@ -23,6 +22,3 @@ function App() { } export default App - - - From f94445db9a770d855eb6030ed0dbbe55031cbad5 Mon Sep 17 00:00:00 2001 From: yoonvinjeong Date: Thu, 5 Feb 2026 15:26:39 +0900 Subject: [PATCH 12/12] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=83=81=ED=83=9C=EC=9D=BC=20=EC=8B=9C=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EB=AA=A8=EB=8B=AC=20=EB=8B=AB=ED=9E=88=EA=B2=8C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/setting/useWithdrawSettings.ts | 3 ++- frontend/src/pages/setting/SettingPage.tsx | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/setting/useWithdrawSettings.ts b/frontend/src/hooks/setting/useWithdrawSettings.ts index a25d62cf..b1084c2d 100644 --- a/frontend/src/hooks/setting/useWithdrawSettings.ts +++ b/frontend/src/hooks/setting/useWithdrawSettings.ts @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom' import { useAuthStore } from '../../stores/authStore' import { useWithdrawMember } from './userMutations' -export function useWithdrawSettings() { +export function useWithdrawSettings(onSuccessAction?: () => void) { const navigate = useNavigate() const queryClient = useQueryClient() const clearAuth = useAuthStore((state) => state.actions.clearAuth) @@ -16,6 +16,7 @@ export function useWithdrawSettings() { onSuccess: () => { queryClient.clear() clearAuth() + onSuccessAction?.() navigate('/') }, onError: () => { diff --git a/frontend/src/pages/setting/SettingPage.tsx b/frontend/src/pages/setting/SettingPage.tsx index 07e5b2dd..f7026aef 100644 --- a/frontend/src/pages/setting/SettingPage.tsx +++ b/frontend/src/pages/setting/SettingPage.tsx @@ -8,19 +8,21 @@ import { useLogout } from '../../hooks/useLogout' import ProfileSettings from './_containers/ProfileSettings' import ConsentSettings from './_containers/ConsentSettings' import { useWithdrawSettings } from '../../hooks/setting/useWithdrawSettings' +import { useAuthStore } from '../../stores/authStore' type SettingPageProps = { onClose?: () => void } export default function SettingPage({ onClose }: SettingPageProps) { + const isLoggedIn = useAuthStore((state) => !!state.isAuth) const [activeTab, setActiveTab] = useState<'profile' | 'consent'>('profile') const [showWithdrawModal, setShowWithdrawModal] = useState(false) const logout = useLogout() const [loggingOut, setLoggingOut] = useState(false) - const { confirmWithdraw, isPending } = useWithdrawSettings() + const { confirmWithdraw, isPending } = useWithdrawSettings(() => onClose?.()) const handleClickLogout = async () => { if (loggingOut) return @@ -87,7 +89,7 @@ export default function SettingPage({ onClose }: SettingPageProps) {
- {showWithdrawModal && ( + {isLoggedIn && showWithdrawModal && ( {} : () => setShowWithdrawModal(false)} onConfirm={confirmWithdraw}