diff --git a/app/create/page.tsx b/app/create/page.tsx index b29faa4..19423de 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -7,7 +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 { sendGAEvent } from '@next/third-parties/google'; +import { pushDataLayer } from '@/lib/gtm'; export default function Page() { const [meetingName, setMeetingName] = useState(''); @@ -157,8 +157,9 @@ export default function Page() { // 방장임을 증명하는 마패(로컬스토리지) 발급! localStorage.setItem(`is_host_${meetingId}`, 'true'); - // 방 만든 브라우저가 누구인지 식별자를 담아서 이벤트 전송 - sendGAEvent('event', 'url_created', { + // ⭐️ 방 만든 브라우저가 누구인지 식별자를 담아서 이벤트 전송 (dataLayer 직접 Push) + pushDataLayer({ + event: 'url_created', // 객체 내부의 키로 이벤트명 삽입 meeting_url_id: meetingId, participant_count_expected: capacity, browser_id: browserId, @@ -168,8 +169,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..2621d3f 100644 --- a/app/meeting/[id]/page.tsx +++ b/app/meeting/[id]/page.tsx @@ -15,7 +15,8 @@ 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'; +import { pushDataLayer } from '@/lib/gtm'; + interface StationInfo { line: string; name: string; @@ -73,7 +74,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 +83,8 @@ export default function Page() { localStorage.setItem('browser_id', browserId); } - sendGAEvent('event', 'share_link', { + pushDataLayer({ + event: 'share_link', meeting_url_id: id, location: 'creation_complete', browser_id: browserId, @@ -145,7 +147,8 @@ export default function Page() { const isHost = localStorage.getItem(`is_host_${id}`) === 'true'; const userRole = isHost ? 'host' : 'participant'; - sendGAEvent('event', 'departure_location_submitted', { + pushDataLayer({ + event: 'departure_location_submitted', meeting_url_id: id, user_cookie_id: browserId, role: userRole, @@ -176,7 +179,8 @@ export default function Page() { const userRole = isHost ? 'host' : 'participant'; const browserId = localStorage.getItem('browser_id'); - sendGAEvent('event', 'midpoint_calculated', { + pushDataLayer({ + 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..2a78374 100644 --- a/app/recommend/page.tsx +++ b/app/recommend/page.tsx @@ -6,7 +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 { sendGAEvent } from '@next/third-parties/google'; +import { pushDataLayer } from '@/lib/gtm'; function RecommendContent() { const router = useRouter(); @@ -147,14 +147,15 @@ 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', { + pushDataLayer({ + event: 'external_map_opened', meeting_url_id: meetingId, user_cookie_id: browserId, role: userRole, @@ -169,12 +170,14 @@ 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', { + + pushDataLayer({ + 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..1fb3084 100644 --- a/app/result/[id]/page.tsx +++ b/app/result/[id]/page.tsx @@ -11,7 +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 { sendGAEvent } from '@next/third-parties/google'; +import { pushDataLayer } from '@/lib/gtm'; export default function Page() { const queryClient = useQueryClient(); @@ -149,37 +149,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 +161,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 +169,8 @@ export default function Page() { localStorage.setItem('browser_id', browserId); } - sendGAEvent('event', 'share_link', { + pushDataLayer({ + event: 'share_link', meeting_url_id: id, location: 'place_list', // PM님 명세: 결과 리스트 페이지 browser_id: browserId, @@ -311,20 +281,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..223b361 100644 --- a/components/join/joinForm.tsx +++ b/components/join/joinForm.tsx @@ -10,7 +10,7 @@ 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'; +import { pushDataLayer } from '@/lib/gtm'; interface JoinFormProps { meetingId: string; @@ -66,7 +66,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 +74,15 @@ 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', { + pushDataLayer({ + 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..9985c32 100644 --- a/components/share/shareContent.tsx +++ b/components/share/shareContent.tsx @@ -5,7 +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 { sendGAEvent } from '@next/third-parties/google'; +import { pushDataLayer } from '@/lib/gtm'; interface ShareContentProps { id: string; @@ -17,9 +17,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 +27,17 @@ export default function ShareContent({ id }: ShareContentProps) { localStorage.setItem('browser_id', browserId); } - sendGAEvent('event', 'share_link', { + // dataLayer 직접 Push (이벤트명을 키값으로!) + pushDataLayer({ + event: 'share_link', meeting_url_id: id, location: 'creation_complete', browser_id: browserId, }); } + + // 2. 안전하게 전송 후 클립보드 복사 로직 실행 + handleCopyLink(); }; if (isError) notFound(); 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); + } +};