From e977b16f9bf6d910fd2161acf1c01b6e2591a75d Mon Sep 17 00:00:00 2001 From: Kangdy Date: Mon, 2 Mar 2026 21:17:03 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EC=A0=84=EC=97=AD=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=98=ED=82=B9=20=EC=BD=94=EB=93=9C=EB=A5=BC=20dataLayer.pu?= =?UTF-8?q?sh=20=EA=B0=9D=EC=B2=B4=20=ED=98=95=ED=83=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/create/page.tsx | 12 ++++---- app/meeting/[id]/page.tsx | 19 ++++++++---- app/recommend/page.tsx | 18 ++++++++---- app/result/[id]/page.tsx | 48 ++++--------------------------- components/join/joinForm.tsx | 16 ++++++----- components/share/shareContent.tsx | 14 +++++---- 6 files changed, 57 insertions(+), 70 deletions(-) diff --git a/app/create/page.tsx b/app/create/page.tsx index b29faa4..afac0be 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -7,7 +7,6 @@ import { useCreateMeeting } from '@/hooks/api/mutation/useCreateMeeting'; import type { MeetingCreateRequest } from '@/types/api'; import { useToast } from '@/hooks/useToast'; import Toast from '@/components/ui/toast'; -import { sendGAEvent } from '@next/third-parties/google'; export default function Page() { const [meetingName, setMeetingName] = useState(''); @@ -157,8 +156,11 @@ export default function Page() { // 방장임을 증명하는 마패(로컬스토리지) 발급! localStorage.setItem(`is_host_${meetingId}`, 'true'); - // 방 만든 브라우저가 누구인지 식별자를 담아서 이벤트 전송 - sendGAEvent('event', 'url_created', { + // ⭐️ 방 만든 브라우저가 누구인지 식별자를 담아서 이벤트 전송 (dataLayer 직접 Push) + const w = window as any; + w.dataLayer = w.dataLayer || []; + w.dataLayer.push({ + event: 'url_created', // 객체 내부의 키로 이벤트명 삽입 meeting_url_id: meetingId, participant_count_expected: capacity, browser_id: browserId, @@ -168,8 +170,8 @@ export default function Page() { // ----------------------------------- // purposes를 localStorage에 저장 (장소 추천 카테고리로 사용) - const purposes = getPurposes(); - if (purposes.length > 0) { + const purposesStr = getPurposes(); + if (purposesStr.length > 0) { // meetingType 저장 (회의 또는 친목) if (meetingType) { localStorage.setItem(`meeting_${meetingId}_meetingType`, meetingType); diff --git a/app/meeting/[id]/page.tsx b/app/meeting/[id]/page.tsx index 0e7af71..c09a781 100644 --- a/app/meeting/[id]/page.tsx +++ b/app/meeting/[id]/page.tsx @@ -15,7 +15,7 @@ import MeetingInfoSection from '@/components/meeting/MeetingInfoSection'; import { useToast } from '@/hooks/useToast'; import Toast from '@/components/ui/toast'; import { getMeetingUserId, removeMeetingUserId } from '@/lib/storage'; -import { sendGAEvent } from '@next/third-parties/google'; + interface StationInfo { line: string; name: string; @@ -73,7 +73,7 @@ export default function Page() { } }, [isError, error, id]); - // GA4 전송 전용 도우미 함수 + // GA4 전송 전용 도우미 함수 (GTM 친화적) const trackShareEvent = () => { if (typeof window !== 'undefined') { let browserId = localStorage.getItem('browser_id'); @@ -82,7 +82,10 @@ export default function Page() { localStorage.setItem('browser_id', browserId); } - sendGAEvent('event', 'share_link', { + const w = window as any; + w.dataLayer = w.dataLayer || []; + w.dataLayer.push({ + event: 'share_link', meeting_url_id: id, location: 'creation_complete', browser_id: browserId, @@ -145,7 +148,10 @@ export default function Page() { const isHost = localStorage.getItem(`is_host_${id}`) === 'true'; const userRole = isHost ? 'host' : 'participant'; - sendGAEvent('event', 'departure_location_submitted', { + const w = window as any; + w.dataLayer = w.dataLayer || []; + w.dataLayer.push({ + event: 'departure_location_submitted', meeting_url_id: id, user_cookie_id: browserId, role: userRole, @@ -176,7 +182,10 @@ export default function Page() { const userRole = isHost ? 'host' : 'participant'; const browserId = localStorage.getItem('browser_id'); - sendGAEvent('event', 'midpoint_calculated', { + const w = window as any; + w.dataLayer = w.dataLayer || []; + w.dataLayer.push({ + event: 'midpoint_calculated', meeting_url_id: id, browser_id: browserId, role: userRole, diff --git a/app/recommend/page.tsx b/app/recommend/page.tsx index 9e3b47c..089d0cc 100644 --- a/app/recommend/page.tsx +++ b/app/recommend/page.tsx @@ -6,7 +6,6 @@ import Image from 'next/image'; import KakaoMapRecommend from '@/components/map/kakaoMapRecommend'; import { useRecommend } from '@/hooks/api/query/useRecommend'; import { useCheckMeeting } from '@/hooks/api/query/useCheckMeeting'; -import { sendGAEvent } from '@next/third-parties/google'; function RecommendContent() { const router = useRouter(); @@ -147,14 +146,17 @@ function RecommendContent() { ) => { e.stopPropagation(); - // 카카오맵에서 보기 클릭 시 GA 전송 (external_map_opened) + // ⭐️ 카카오맵에서 보기 클릭 시 GA 전송 (GTM 친화적 dataLayer 직접 Push) if (typeof window !== 'undefined' && meetingId && place) { const browserId = localStorage.getItem('browser_id'); const isHost = localStorage.getItem(`is_host_${meetingId}`) === 'true'; const userRole = isHost ? 'host' : 'participant'; const candidateId = `place_${String(place.id).padStart(2, '0')}`; - sendGAEvent('event', 'external_map_opened', { + const w = window as any; + w.dataLayer = w.dataLayer || []; + w.dataLayer.push({ + event: 'external_map_opened', meeting_url_id: meetingId, user_cookie_id: browserId, role: userRole, @@ -169,12 +171,16 @@ function RecommendContent() { } }; - // 장소 리스트 중 하나 클릭 시 GA 전송 (place_list_viewed) + // ⭐️ 장소 리스트 중 하나 클릭 시 GA 전송 (GTM 친화적 dataLayer 직접 Push) const handlePlaceClick = (place: (typeof places)[0]) => { setSelectedPlaceId(place.id); - if (meetingId) { + if (typeof window !== 'undefined' && meetingId) { const candidateId = `place_${String(place.id).padStart(2, '0')}`; - sendGAEvent('event', 'place_list_viewed', { + + const w = window as any; + w.dataLayer = w.dataLayer || []; + w.dataLayer.push({ + event: 'place_list_viewed', meeting_url_id: meetingId, candidate_id: candidateId, place_category: place.category, diff --git a/app/result/[id]/page.tsx b/app/result/[id]/page.tsx index 66b921d..8363e4e 100644 --- a/app/result/[id]/page.tsx +++ b/app/result/[id]/page.tsx @@ -11,7 +11,6 @@ import { getMeetingUserId } from '@/lib/storage'; import { useQueryClient } from '@tanstack/react-query'; import Loading from '@/components/loading/loading'; import { getRandomHexColor } from '@/lib/color'; -import { sendGAEvent } from '@next/third-parties/google'; export default function Page() { const queryClient = useQueryClient(); @@ -149,37 +148,6 @@ export default function Page() { const [selectedResultId, setSelectedResultId] = useState(1); - // 중간지점 후보 조회 GA 이벤트 - const trackMidpointCandidateViewed = useCallback( - (candidateRankOrder: number, candidateId: string) => { - if (typeof window === 'undefined' || !id) return; - const browserId = localStorage.getItem('browser_id'); - const isHost = localStorage.getItem(`is_host_${id}`) === 'true'; - const userRole = isHost ? 'host' : 'participant'; - - sendGAEvent('event', 'midpoint_candidate_viewed', { - meeting_url_id: id, - user_cookie_id: browserId, - role: userRole, - candidate_rank_order: candidateRankOrder, - candidate_id: candidateId, - }); - }, - [id] - ); - - // 장소 리스트에서 결과보기 페이지로 돌아왔을 때 midpoint_candidate_viewed 전송 - useEffect(() => { - if (typeof window === 'undefined' || !id || locationResults.length === 0) return; - const fromRecommend = sessionStorage.getItem(`from_recommend_${id}`); - if (fromRecommend !== '1') return; - - sessionStorage.removeItem(`from_recommend_${id}`); - const selected = locationResults.find((r) => r.id === selectedResultId) ?? locationResults[0]; - const candidateId = `mid_${selected.endStation.replace(/\s+/g, '_')}`; - trackMidpointCandidateViewed(selected.id, candidateId); - }, [id, locationResults, selectedResultId, trackMidpointCandidateViewed]); - // 뒤로 가기 클릭 시 캐시 데이터 무효화 const clearRelatedCache = useCallback(() => { queryClient.removeQueries({ queryKey: ['midpoint', id] }); @@ -192,7 +160,7 @@ export default function Page() { }; const handleResultShareClick = (e: React.MouseEvent) => { - // 1. GA 데이터 먼저 전송! + // 1. GA 데이터 먼저 전송! (GTM 친화적 dataLayer 직접 Push) if (typeof window !== 'undefined') { let browserId = localStorage.getItem('browser_id'); if (!browserId) { @@ -200,7 +168,10 @@ export default function Page() { localStorage.setItem('browser_id', browserId); } - sendGAEvent('event', 'share_link', { + const w = window as any; + w.dataLayer = w.dataLayer || []; + w.dataLayer.push({ + event: 'share_link', meeting_url_id: id, location: 'place_list', // PM님 명세: 결과 리스트 페이지 browser_id: browserId, @@ -311,20 +282,13 @@ export default function Page() { if (meetingType) params.append('meetingType', meetingType); if (categoryParam) params.append('category', categoryParam); - if (typeof window !== 'undefined') { - sessionStorage.setItem(`from_recommend_${id}`, '1'); - } router.push(`/recommend?${params.toString()}`); }; return (
{ - setSelectedResultId(result.id); - const candidateId = `mid_${result.endStation.replace(/\s+/g, '_')}`; - trackMidpointCandidateViewed(result.id, candidateId); - }} + onClick={() => setSelectedResultId(result.id)} className={`flex cursor-pointer flex-col gap-3.75 rounded border bg-white p-5 ${ selectedResultId === result.id ? 'border-blue-5 border-2' diff --git a/components/join/joinForm.tsx b/components/join/joinForm.tsx index 9f91643..47003a8 100644 --- a/components/join/joinForm.tsx +++ b/components/join/joinForm.tsx @@ -10,7 +10,6 @@ import { useIsLoggedIn } from '@/hooks/useIsLoggedIn'; import { setMeetingUserId } from '@/lib/storage'; import Image from 'next/image'; import { getRandomHexColor } from '@/lib/color'; -import { sendGAEvent } from '@next/third-parties/google'; interface JoinFormProps { meetingId: string; @@ -66,7 +65,7 @@ export default function JoinForm({ meetingId }: JoinFormProps) { e.preventDefault(); if (!isFormValid || !meetingId) return; - // ⭐️ 1. 비즈니스 로직 실행 전 GA 이벤트 선(先) 전송! + // ⭐️ 1. 비즈니스 로직 실행 전 GA 이벤트 선(先) 전송! (dataLayer 방식 적용) if (typeof window !== 'undefined') { let browserId = localStorage.getItem('browser_id'); if (!browserId) { @@ -74,15 +73,18 @@ export default function JoinForm({ meetingId }: JoinFormProps) { localStorage.setItem('browser_id', browserId); } - // ⭐️ 개발자님의 완벽한 엣지케이스 방어 로직! - // 로컬스토리지에 '이 방의 생성자(방장)'라는 징표가 있는지 확인 + // 방장/참여자 구분 로직 const isHost = localStorage.getItem(`is_host_${meetingId}`) === 'true'; const userRole = isHost ? 'host' : 'participant'; - sendGAEvent('event', 'participant_registration', { + // dataLayer 직접 Push + const w = window as any; + w.dataLayer = w.dataLayer || []; + w.dataLayer.push({ + event: 'participant_registration', meeting_url_id: meetingId, - user_cookie_id: browserId, // 생성 때 썼던 그 browserId와 일치하게 됨! - role: userRole, // 완벽하게 방장/참여자 구분 완료 + user_cookie_id: browserId, + role: userRole, remember_me: isRemembered ? 'yes' : 'no', }); } diff --git a/components/share/shareContent.tsx b/components/share/shareContent.tsx index db580f3..661ff51 100644 --- a/components/share/shareContent.tsx +++ b/components/share/shareContent.tsx @@ -5,7 +5,6 @@ import Link from 'next/link'; import Toast from '@/components/ui/toast'; import { useShareMeeting } from '@/hooks/api/query/useShareMeeting'; import Image from 'next/image'; -import { sendGAEvent } from '@next/third-parties/google'; interface ShareContentProps { id: string; @@ -17,9 +16,7 @@ export default function ShareContent({ id }: ShareContentProps) { // 복사 버튼 클릭 시 실행할 래퍼 함수 생성 const handleCopyClickWithGA = () => { - handleCopyLink(); - - // 2. GA4 이벤트 전송 로직 인라인 처리 + // 1. GA4 이벤트 전송 로직 (GTM 친화적 순수 객체 형태 & 무조건 먼저 실행!) if (typeof window !== 'undefined') { // 브라우저 식별자(browser_id) 확인 및 생성 (Get or Create) let browserId = localStorage.getItem('browser_id'); @@ -29,12 +26,19 @@ export default function ShareContent({ id }: ShareContentProps) { localStorage.setItem('browser_id', browserId); } - sendGAEvent('event', 'share_link', { + // dataLayer 직접 Push (이벤트명을 키값으로!) + const w = window as any; + w.dataLayer = w.dataLayer || []; + w.dataLayer.push({ + event: 'share_link', meeting_url_id: id, location: 'creation_complete', browser_id: browserId, }); } + + // 2. 안전하게 전송 후 클립보드 복사 로직 실행 + handleCopyLink(); }; if (isError) notFound(); From 846ba2de7972e113353947bceaae2f0f4804252d Mon Sep 17 00:00:00 2001 From: Kangdy Date: Mon, 2 Mar 2026 21:32:04 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20GTM=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20Lint=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/create/page.tsx | 5 ++--- app/meeting/[id]/page.tsx | 13 ++++--------- app/recommend/page.tsx | 9 +++------ app/result/[id]/page.tsx | 5 ++--- components/join/joinForm.tsx | 6 ++---- components/share/shareContent.tsx | 5 ++--- lib/gtm.ts | 8 ++++++++ 7 files changed, 23 insertions(+), 28 deletions(-) create mode 100644 lib/gtm.ts diff --git a/app/create/page.tsx b/app/create/page.tsx index afac0be..19423de 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -7,6 +7,7 @@ import { useCreateMeeting } from '@/hooks/api/mutation/useCreateMeeting'; import type { MeetingCreateRequest } from '@/types/api'; import { useToast } from '@/hooks/useToast'; import Toast from '@/components/ui/toast'; +import { pushDataLayer } from '@/lib/gtm'; export default function Page() { const [meetingName, setMeetingName] = useState(''); @@ -157,9 +158,7 @@ export default function Page() { localStorage.setItem(`is_host_${meetingId}`, 'true'); // ⭐️ 방 만든 브라우저가 누구인지 식별자를 담아서 이벤트 전송 (dataLayer 직접 Push) - const w = window as any; - w.dataLayer = w.dataLayer || []; - w.dataLayer.push({ + pushDataLayer({ event: 'url_created', // 객체 내부의 키로 이벤트명 삽입 meeting_url_id: meetingId, participant_count_expected: capacity, diff --git a/app/meeting/[id]/page.tsx b/app/meeting/[id]/page.tsx index c09a781..2621d3f 100644 --- a/app/meeting/[id]/page.tsx +++ b/app/meeting/[id]/page.tsx @@ -15,6 +15,7 @@ import MeetingInfoSection from '@/components/meeting/MeetingInfoSection'; import { useToast } from '@/hooks/useToast'; import Toast from '@/components/ui/toast'; import { getMeetingUserId, removeMeetingUserId } from '@/lib/storage'; +import { pushDataLayer } from '@/lib/gtm'; interface StationInfo { line: string; @@ -82,9 +83,7 @@ export default function Page() { localStorage.setItem('browser_id', browserId); } - const w = window as any; - w.dataLayer = w.dataLayer || []; - w.dataLayer.push({ + pushDataLayer({ event: 'share_link', meeting_url_id: id, location: 'creation_complete', @@ -148,9 +147,7 @@ export default function Page() { const isHost = localStorage.getItem(`is_host_${id}`) === 'true'; const userRole = isHost ? 'host' : 'participant'; - const w = window as any; - w.dataLayer = w.dataLayer || []; - w.dataLayer.push({ + pushDataLayer({ event: 'departure_location_submitted', meeting_url_id: id, user_cookie_id: browserId, @@ -182,9 +179,7 @@ export default function Page() { const userRole = isHost ? 'host' : 'participant'; const browserId = localStorage.getItem('browser_id'); - const w = window as any; - w.dataLayer = w.dataLayer || []; - w.dataLayer.push({ + pushDataLayer({ event: 'midpoint_calculated', meeting_url_id: id, browser_id: browserId, diff --git a/app/recommend/page.tsx b/app/recommend/page.tsx index 089d0cc..2a78374 100644 --- a/app/recommend/page.tsx +++ b/app/recommend/page.tsx @@ -6,6 +6,7 @@ import Image from 'next/image'; import KakaoMapRecommend from '@/components/map/kakaoMapRecommend'; import { useRecommend } from '@/hooks/api/query/useRecommend'; import { useCheckMeeting } from '@/hooks/api/query/useCheckMeeting'; +import { pushDataLayer } from '@/lib/gtm'; function RecommendContent() { const router = useRouter(); @@ -153,9 +154,7 @@ function RecommendContent() { const userRole = isHost ? 'host' : 'participant'; const candidateId = `place_${String(place.id).padStart(2, '0')}`; - const w = window as any; - w.dataLayer = w.dataLayer || []; - w.dataLayer.push({ + pushDataLayer({ event: 'external_map_opened', meeting_url_id: meetingId, user_cookie_id: browserId, @@ -177,9 +176,7 @@ function RecommendContent() { if (typeof window !== 'undefined' && meetingId) { const candidateId = `place_${String(place.id).padStart(2, '0')}`; - const w = window as any; - w.dataLayer = w.dataLayer || []; - w.dataLayer.push({ + pushDataLayer({ event: 'place_list_viewed', meeting_url_id: meetingId, candidate_id: candidateId, diff --git a/app/result/[id]/page.tsx b/app/result/[id]/page.tsx index 8363e4e..1fb3084 100644 --- a/app/result/[id]/page.tsx +++ b/app/result/[id]/page.tsx @@ -11,6 +11,7 @@ import { getMeetingUserId } from '@/lib/storage'; import { useQueryClient } from '@tanstack/react-query'; import Loading from '@/components/loading/loading'; import { getRandomHexColor } from '@/lib/color'; +import { pushDataLayer } from '@/lib/gtm'; export default function Page() { const queryClient = useQueryClient(); @@ -168,9 +169,7 @@ export default function Page() { localStorage.setItem('browser_id', browserId); } - const w = window as any; - w.dataLayer = w.dataLayer || []; - w.dataLayer.push({ + pushDataLayer({ event: 'share_link', meeting_url_id: id, location: 'place_list', // PM님 명세: 결과 리스트 페이지 diff --git a/components/join/joinForm.tsx b/components/join/joinForm.tsx index 47003a8..223b361 100644 --- a/components/join/joinForm.tsx +++ b/components/join/joinForm.tsx @@ -10,6 +10,7 @@ import { useIsLoggedIn } from '@/hooks/useIsLoggedIn'; import { setMeetingUserId } from '@/lib/storage'; import Image from 'next/image'; import { getRandomHexColor } from '@/lib/color'; +import { pushDataLayer } from '@/lib/gtm'; interface JoinFormProps { meetingId: string; @@ -77,10 +78,7 @@ export default function JoinForm({ meetingId }: JoinFormProps) { const isHost = localStorage.getItem(`is_host_${meetingId}`) === 'true'; const userRole = isHost ? 'host' : 'participant'; - // dataLayer 직접 Push - const w = window as any; - w.dataLayer = w.dataLayer || []; - w.dataLayer.push({ + pushDataLayer({ event: 'participant_registration', meeting_url_id: meetingId, user_cookie_id: browserId, diff --git a/components/share/shareContent.tsx b/components/share/shareContent.tsx index 661ff51..9985c32 100644 --- a/components/share/shareContent.tsx +++ b/components/share/shareContent.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import Toast from '@/components/ui/toast'; import { useShareMeeting } from '@/hooks/api/query/useShareMeeting'; import Image from 'next/image'; +import { pushDataLayer } from '@/lib/gtm'; interface ShareContentProps { id: string; @@ -27,9 +28,7 @@ export default function ShareContent({ id }: ShareContentProps) { } // dataLayer 직접 Push (이벤트명을 키값으로!) - const w = window as any; - w.dataLayer = w.dataLayer || []; - w.dataLayer.push({ + pushDataLayer({ event: 'share_link', meeting_url_id: id, location: 'creation_complete', diff --git a/lib/gtm.ts b/lib/gtm.ts new file mode 100644 index 0000000..9f5df76 --- /dev/null +++ b/lib/gtm.ts @@ -0,0 +1,8 @@ +export const pushDataLayer = (data: Record) => { + if (typeof window !== 'undefined') { + const w = window as unknown as { dataLayer: object[] }; + + w.dataLayer = w.dataLayer || []; + w.dataLayer.push(data); + } +};