Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
14 changes: 9 additions & 5 deletions app/meeting/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 9 additions & 6 deletions app/recommend/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
47 changes: 5 additions & 42 deletions app/result/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -149,37 +149,6 @@ export default function Page() {

const [selectedResultId, setSelectedResultId] = useState<number>(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] });
Expand All @@ -192,15 +161,16 @@ export default function Page() {
};

const handleResultShareClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// 1. GA 데이터 먼저 전송!
// 1. GA 데이터 먼저 전송! (GTM 친화적 dataLayer 직접 Push)
if (typeof window !== 'undefined') {
let browserId = localStorage.getItem('browser_id');
if (!browserId) {
browserId = `bid_${Math.random().toString(36).substring(2, 15)}${Date.now().toString(36)}`;
localStorage.setItem('browser_id', browserId);
}

sendGAEvent('event', 'share_link', {
pushDataLayer({
event: 'share_link',
meeting_url_id: id,
location: 'place_list', // PM님 명세: 결과 리스트 페이지
browser_id: browserId,
Expand Down Expand Up @@ -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 (
<div
key={result.id}
onClick={() => {
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'
Expand Down
14 changes: 7 additions & 7 deletions components/join/joinForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
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;
Expand Down Expand Up @@ -66,23 +66,23 @@
e.preventDefault();
if (!isFormValid || !meetingId) return;

// ⭐️ 1. 비즈니스 로직 실행 전 GA 이벤트 선(先) 전송!
// ⭐️ 1. 비즈니스 로직 실행 전 GA 이벤트 선(先) 전송! (dataLayer 방식 적용)
if (typeof window !== 'undefined') {
let browserId = localStorage.getItem('browser_id');
if (!browserId) {
browserId = `bid_${Math.random().toString(36).substring(2, 15)}${Date.now().toString(36)}`;
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',
});
}
Comment on lines +69 to 88
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

participant_registration 이벤트가 실패 케이스에도 기록됩니다.

현재는 API 성공 여부와 무관하게 전송되어 전환 지표가 과대 집계될 수 있습니다. 성공 시점으로 이동하거나, 시도 이벤트로 이름을 분리해 주세요.

수정 예시 (성공 시점 전송)
-    // ⭐️ 1. 비즈니스 로직 실행 전 GA 이벤트 선(先) 전송! (dataLayer 방식 적용)
-    if (typeof window !== 'undefined') {
-      let browserId = localStorage.getItem('browser_id');
-      if (!browserId) {
-        browserId = `bid_${Math.random().toString(36).substring(2, 15)}${Date.now().toString(36)}`;
-        localStorage.setItem('browser_id', browserId);
-      }
-
-      // 방장/참여자 구분 로직
-      const isHost = localStorage.getItem(`is_host_${meetingId}`) === 'true';
-      const userRole = isHost ? 'host' : 'participant';
-
-      pushDataLayer({
-        event: 'participant_registration',
-        meeting_url_id: meetingId,
-        user_cookie_id: browserId,
-        role: userRole,
-        remember_me: isRemembered ? 'yes' : 'no',
-      });
-    }
-
     // ⭐️ 2. 기존 참여 API 호출 로직
     try {
       // `@ts-ignore`
       const result = await participantEnter.mutateAsync({
@@
       if (result.success) {
+        if (typeof window !== 'undefined') {
+          let browserId = localStorage.getItem('browser_id');
+          if (!browserId) {
+            browserId = `bid_${Math.random().toString(36).substring(2, 15)}${Date.now().toString(36)}`;
+            localStorage.setItem('browser_id', browserId);
+          }
+          const isHost = localStorage.getItem(`is_host_${meetingId}`) === 'true';
+          const userRole = isHost ? 'host' : 'participant';
+          pushDataLayer({
+            event: 'participant_registration',
+            meeting_url_id: meetingId,
+            user_cookie_id: browserId,
+            role: userRole,
+            remember_me: isRemembered ? 'yes' : 'no',
+          });
+        }
         setMeetingUserId(meetingId, name, isRemembered);
         router.push(`/meeting/${meetingId}`);
       } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/join/joinForm.tsx` around lines 69 - 88, The current pushDataLayer
call (event 'participant_registration') inside joinForm.tsx runs before the API
result and thus records failures as conversions; update the logic so
pushDataLayer is invoked only after a successful registration response (move the
block into the API success callback/then branch where the registration response
is handled) and keep using meetingId, browserId, isRemembered and userRole when
sending, or if you need to track attempts separately rename the pre-call event
to 'participant_registration_attempt' and send that before the API while
reserving 'participant_registration' for the success path (use pushDataLayer and
the same payload keys).

Expand All @@ -105,7 +105,7 @@
setErrorMessage(result.data?.message || '모임 참여에 실패했습니다. 다시 시도해주세요.');
show();
}
} catch (error: any) {

Check warning on line 108 in components/join/joinForm.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
const errorData = error.data || error.response?.data;
setErrorMessage(errorData?.message || '모임 참여에 실패했습니다. 다시 시도해주세요.');
show();
Expand Down
13 changes: 8 additions & 5 deletions components/share/shareContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
Expand All @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions lib/gtm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const pushDataLayer = (data: Record<string, unknown>) => {
if (typeof window !== 'undefined') {
const w = window as unknown as { dataLayer: object[] };

w.dataLayer = w.dataLayer || [];
w.dataLayer.push(data);
}
};
Loading