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 }>`