diff --git a/index.html b/index.html index 453075dd..9616d5f8 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,7 @@ THIP, 독서를 기록하는 가장 힙한 방법 + diff --git a/package.json b/package.json index 9aaac5c5..da28e12c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react-cookie": "^8.0.1", "react-datepicker": "^8.4.0", "react-dom": "^19.1.0", + "react-ga4": "^2.1.0", "react-router-dom": "^7.6.0", "zustand": "^5.0.4" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b569676..fae23ebd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) + react-ga4: + specifier: ^2.1.0 + version: 2.1.0 react-router-dom: specifier: ^7.6.0 version: 7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1404,6 +1407,9 @@ packages: peerDependencies: react: ^19.1.0 + react-ga4@2.1.0: + resolution: {integrity: sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==} + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -3026,6 +3032,8 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 + react-ga4@2.1.0: {} + react-is@16.13.1: {} react-refresh@0.17.0: {} diff --git a/src/api/notifications/postNotificationsCheck.ts b/src/api/notifications/postNotificationsCheck.ts new file mode 100644 index 00000000..cc575860 --- /dev/null +++ b/src/api/notifications/postNotificationsCheck.ts @@ -0,0 +1,25 @@ +import { apiClient } from '../index'; + +export interface PostNotificationsCheckRequest { + notificationId: number; +} + +export interface PostNotificationsCheckResponse> { + isSuccess: boolean; + code: number; + message: string; + data: { + route: string; // e.g., 'POST_DETAIL' + params?: Params; // e.g., { postId: 123 } + }; +} + +// 알림 확인(체크) 및 이동 정보 반환 API +export const postNotificationsCheck = async (notificationId: number) => { + const body: PostNotificationsCheckRequest = { notificationId }; + const response = await apiClient.post( + '/notifications/check', + body, + ); + return response.data; +}; diff --git a/src/lib/ga.ts b/src/lib/ga.ts index b2db1e4f..313da230 100644 --- a/src/lib/ga.ts +++ b/src/lib/ga.ts @@ -1,3 +1,5 @@ +import ReactGA from 'react-ga4'; + declare global { interface Window { dataLayer: unknown[]; @@ -6,33 +8,53 @@ declare global { } export const GA_ID = import.meta.env.VITE_GA_MEASUREMENT_ID as string | undefined; +const GA_DEBUG = (import.meta.env.VITE_GA_DEBUG as string | undefined) === 'true'; + +let isInitialized = false; + +function isLocalhost(): boolean { + const hn = window.location.hostname; + return hn === 'localhost' || hn === '127.0.0.1' || hn === '::1'; +} export function initGA() { + if (isInitialized) return; if (!GA_ID) return; + if (isLocalhost()) return; + + ReactGA.initialize(GA_ID, { + gaOptions: { anonymizeIp: true }, + testMode: false, + }); + + if (GA_DEBUG) { + console.info('[GA4] initialized:', GA_ID); + } - // gtag.js 로더 주입 - const script = document.createElement('script'); - script.async = true; - script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`; - document.head.appendChild(script); - - // gtag 초기화 - window.dataLayer = window.dataLayer || []; - window.gtag = function gtag(...args: unknown[]) { - window.dataLayer.push(args); - }; - - window.gtag('js', new Date()); - // SPA라면 초기 자동 page_view는 끄고 필요 시 수동 전송 - window.gtag('config', GA_ID, { send_page_view: false }); + isInitialized = true; } -// SPA 라우팅 시 수동 전송용 export function sendPageView(path: string) { - if (!GA_ID || !window.gtag) return; - window.gtag('event', 'page_view', { - page_title: document.title, - page_location: window.location.href, - page_path: path, - }); + if (!GA_ID || !isInitialized) return; + if (GA_DEBUG) { + console.info('[GA4] page_view:', path); + } + ReactGA.send({ hitType: 'pageview', page: path, title: document.title }); +} + +type EventParams = Record & { category?: string }; + +export function trackEvent(eventName: string, params?: EventParams) { + if (!GA_ID || !isInitialized) return; + const category = params?.category ?? eventName; + const entries = Object.entries(params || {}).filter(([k]) => k !== 'category') as Array< + [string, string | number | boolean | undefined] + >; + const rest = Object.fromEntries(entries) as Record; + + const payload = { category, ...rest }; + if (GA_DEBUG) { + console.info('[GA4] event:', eventName, payload); + } + ReactGA.event(eventName, payload); } diff --git a/src/main.tsx b/src/main.tsx index 0ce356e5..967f983d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,9 +1,8 @@ import { createRoot } from 'react-dom/client'; import './main.css'; import App from './App.tsx'; -import { initGA, sendPageView } from './lib/ga.ts'; +import { initGA } from './lib/ga.ts'; initGA(); -sendPageView(window.location.pathname); createRoot(document.getElementById('root')!).render(); diff --git a/src/pages/memory/Memory.tsx b/src/pages/memory/Memory.tsx index 3f429032..881f9329 100644 --- a/src/pages/memory/Memory.tsx +++ b/src/pages/memory/Memory.tsx @@ -6,6 +6,7 @@ import MemoryContent from '../../components/memory/MemoryContent/MemoryContent'; import MemoryAddButton from '../../components/memory/MemoryAddButton/MemoryAddButton'; import Snackbar from '../../components/common/Modal/Snackbar'; import GlobalCommentBottomSheet from '../../components/common/CommentBottomSheet/GlobalCommentBottomSheet'; +import { useCommentBottomSheetStore } from '@/stores/useCommentBottomSheetStore'; import { Container, FixedHeader, ScrollableContent, FloatingElements } from './Memory.styled'; import { getMemoryPosts } from '../../api/memory/getMemoryPosts'; import type { GetMemoryPostsParams, Post, Record } from '../../types/memory'; @@ -31,7 +32,7 @@ const convertPostToRecord = (post: Post): Record => { isWriter: post.isWriter, isLiked: post.isLiked, isLocked: post.isLocked, // 블러 처리 여부 추가 - pollOptions: post.voteItems.map((item) => { + pollOptions: post.voteItems.map(item => { const maxCount = Math.max(...post.voteItems.map(v => v.count || 0)); return { id: item.voteItemId.toString(), @@ -50,6 +51,7 @@ const Memory = () => { const navigate = useNavigate(); const location = useLocation(); const { roomId } = useParams<{ roomId: string }>(); + const { openCommentBottomSheet } = useCommentBottomSheetStore(); // 상태 관리 const [activeTab, setActiveTab] = useState('group'); @@ -151,6 +153,30 @@ const Memory = () => { loadMemoryPosts(); }, [loadMemoryPosts]); + // Notice에서 넘어온 state(page, focusPostId 등)로 초기 필터 적용 + useEffect(() => { + type MemoryLocationState = { + page?: number; + focusPostId?: number; + postType?: 'RECORD' | 'VOTE'; + openComments?: boolean; + } | null; + const state = (location.state as MemoryLocationState) || null; + const initialPage = state?.page; + if (initialPage && !selectedPageRange) { + setSelectedPageRange({ start: initialPage, end: initialPage }); + setActiveFilter('page'); + } + + // 댓글 모달 자동 오픈 처리 + if (state?.openComments && state.focusPostId && state.postType) { + openCommentBottomSheet(state.focusPostId, state.postType); + // 동일 경로 재진입 시 중복 오픈 방지를 위해 state 제거 + navigate(location.pathname, { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.state, roomId]); + // 새로운 기록이 추가되었을 때 처리 (작성 완료 후 돌아왔을 때) useEffect(() => { if (location.state?.newRecord) { diff --git a/src/pages/notice/Notice.tsx b/src/pages/notice/Notice.tsx index 730257ad..64c8ea0c 100644 --- a/src/pages/notice/Notice.tsx +++ b/src/pages/notice/Notice.tsx @@ -5,6 +5,7 @@ import TitleHeader from '@/components/common/TitleHeader'; import leftArrow from '../../assets/common/leftArrow.svg'; import { colors, typography } from '@/styles/global/global'; import { getNotifications, type NotificationItem } from '@/api/notifications/getNotifications'; +import { postNotificationsCheck } from '@/api/notifications/postNotificationsCheck'; const Notice = () => { const [selected, setSelected] = useState(''); @@ -78,16 +79,91 @@ const Notice = () => { }; }, [isLoading, isLast, nextCursor, loadNotifications]); - // const handleReadNotification = (index: number) => { - // setNotifications(prev => - // prev.map((item, idx) => (idx === index ? { ...item, isChecked: true } : item)), - // ); - // }; - const filteredNotifications = notifications; const tabs = ['피드', '모임']; + const handleNotificationClick = async (notif: NotificationItem) => { + try { + const res = await postNotificationsCheck(notif.notificationId); + if (!res.isSuccess) return; + + // UI 즉시 반영: 읽음 처리 + // setNotifications(prev => + // prev.map(item => + // item.notificationId === notif.notificationId ? { ...item, isChecked: true } : item, + // ), + // ); + + const { route, params } = res.data as { route: string; params?: Record }; + + // 서버 라우팅 키 → 실제 앱 경로 매핑 + switch (route) { + // 이동 없음 + case 'NONE': + break; + + // 피드 1번 (해당유저 피드로 이동) + case 'FEED_USER': { + const userId = (params?.userId as number) ?? undefined; + if (userId !== undefined) { + navigate(`/otherfeed/${userId}`); + } + break; + } + + // 피드 2~6번 (피드상세페이지로 이동) + case 'FEED_DETAIL': { + const feedId = (params?.feedId as number) ?? undefined; + if (feedId !== undefined) { + navigate(`/feed/${feedId}`); + } + break; + } + + // 모임 (모집조기마감 or 모임시작) + case 'ROOM_MAIN': { + const roomId = (params?.roomId as number) ?? undefined; + if (roomId !== undefined) navigate(`/group/detail/joined/${roomId}`); + break; + } + + // host일때, 누군가 모임 참여를 눌렀을 때 + case 'ROOM_DETAIL': { + const roomId = (params?.roomId as number) ?? undefined; + if (roomId !== undefined) navigate(`/group/detail/${roomId}`); + break; + } + + // 모임방 -> 기록장 -> 해당 기록 필터링 화면으로 이동 + case 'ROOM_POST_DETAIL': { + const roomId = (params?.roomId as number) ?? undefined; + const postId = (params?.postId as number) ?? undefined; + const page = (params?.page as number) ?? undefined; + const postType = params?.postType as 'RECORD' | 'VOTE'; + const shouldOpenComments = (params as { openComments?: boolean })?.openComments === true; + if (roomId !== undefined) { + navigate(`/rooms/${roomId}/memory`, { + state: { + focusPostId: postId, + postType, + page, + ...(shouldOpenComments ? { openComments: true } : {}), + }, + }); + } + break; + } + + default: + break; + } + } catch (e) { + // noop: 실패 시 네비게이션 없이 무시 + console.error('알림 확인 처리 실패:', e); + } + }; + return ( { handleReadNotification(idx)} + onClick={() => handleNotificationClick(notif)} > {!notif.isChecked && } @@ -171,6 +247,12 @@ const NotificationList = styled.div` padding: 0 20px 20px 20px; width: 100%; overflow-y: auto; + /* Hide scrollbar but keep scroll */ + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + &::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ + } `; const NotificationCard = styled.div<{ read: boolean }>`