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
7 changes: 5 additions & 2 deletions app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export default function Page() {

// --- [GA4 이벤트 전송 로직 추가] ---
if (typeof window !== 'undefined') {
// 1. 브라우저 식별자(browser_id) 확인 및 생성 (Get or Create)
// 브라우저 식별자(browser_id) 확인 및 생성 (Get or Create)
let browserId = localStorage.getItem('browser_id');
if (!browserId) {
// 없으면 새로 발급해서 브라우저에 각인!
Expand All @@ -154,7 +154,10 @@ export default function Page() {
localStorage.setItem('browser_id', browserId);
}

// 2. 방 만든 브라우저가 누구인지 식별자를 담아서 이벤트 전송
// 방장임을 증명하는 마패(로컬스토리지) 발급!
localStorage.setItem(`is_host_${meetingId}`, 'true');

// 방 만든 브라우저가 누구인지 식별자를 담아서 이벤트 전송
sendGAEvent('event', 'url_created', {
meeting_url_id: meetingId,
participant_count_expected: capacity,
Expand Down
45 changes: 44 additions & 1 deletion app/meeting/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { sendGAEvent } from '@next/third-parties/google';

interface StationInfo {
line: string;
Expand Down Expand Up @@ -73,8 +74,33 @@ export default function Page() {
}
}, [isError, error, id]);

// GA4 전송 전용 도우미 함수
const trackShareEvent = () => {
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', {
meeting_url_id: id,
location: 'creation_complete',
browser_id: browserId,
});
}
};

// 공유하기 버튼 전용 핸들러
const handleShareClick = (e: React.MouseEvent<HTMLButtonElement>) => {
openModal('SHARE', { meetingId: id }, e);
trackShareEvent();
};

// 재촉하기 버튼 전용 핸들러
const handleNudgeClick = (e: React.MouseEvent<HTMLButtonElement>) => {
openModal('NUDGE', { meetingId: id }, e);
trackShareEvent();
};
Comment on lines +100 to 104
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

재촉하기(NUDGE) 액션이 share_link로 집계됩니다.

Line 102는 NUDGE 모달 오픈인데 Line 103에서 공유 이벤트(share_link)를 전송하고 있어 공유 지표가 오염될 수 있습니다. NUDGE 전용 이벤트로 분리하거나 최소한 location/event name을 구분해 주세요.

🔧 제안 수정안
 const handleNudgeClick = (e: React.MouseEvent<HTMLButtonElement>) => {
   openModal('NUDGE', { meetingId: id }, e);
-  trackShareEvent();
+  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', 'nudge_opened', {
+      meeting_url_id: id,
+      browser_id: browserId,
+      location: 'meeting_room',
+    });
+  }
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/meeting/`[id]/page.tsx around lines 100 - 104, The NUDGE handler
handleNudgeClick currently opens the NUDGE modal via openModal('NUDGE', {
meetingId: id }, e) but then calls trackShareEvent(), causing the action to be
recorded as a share_link; update handleNudgeClick to send a distinct telemetry
event (e.g., trackEvent('nudge_click') or trackShareEvent with an explicit
location/name param like trackShareEvent({ name: 'nudge', location:
'meeting_page' })) so NUDGE actions are not counted as share_link; modify the
call site in handleNudgeClick and adjust any track* helper signatures if needed
to accept an event name or metadata.


const handleSelectStation = (stationName: string | null) => {
Expand Down Expand Up @@ -109,6 +135,23 @@ export default function Page() {
{
onSuccess: () => {
refetch();

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_${id}`) === 'true';
const userRole = isHost ? 'host' : 'participant';

sendGAEvent('event', 'departure_location_submitted', {
meeting_url_id: id,
user_cookie_id: browserId,
role: userRole,
});
}
},
onError: () => {
setErrorMessage('출발지 등록에 실패했습니다.');
Expand Down Expand Up @@ -244,7 +287,7 @@ export default function Page() {
<button
type="button"
className="bg-blue-5 hover:bg-blue-8 flex h-21 w-full cursor-pointer items-center justify-between rounded p-4 text-left text-white transition-transform active:scale-[0.98]"
onClick={(e) => openModal('NUDGE', { meetingId: id }, e)}
onClick={handleNudgeClick}
>
<div className="flex flex-col gap-0.5">
<span className="text-lg leading-[1.44] font-semibold">
Expand Down
29 changes: 25 additions & 4 deletions app/result/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { sendGAEvent } from '@next/third-parties/google';

export default function Page() {
const queryClient = useQueryClient();
Expand Down Expand Up @@ -159,6 +160,26 @@ export default function Page() {
router.replace(`/meeting/${id}`);
};

const handleResultShareClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// 1. GA 데이터 먼저 전송!
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', {
meeting_url_id: id,
location: 'place_list', // PM님 명세: 결과 리스트 페이지
browser_id: browserId,
});
}

// 2. 안전하게 전송 후 모달 띄우기
openModal('SHARE', { meetingId: id }, e);
};
Comment on lines +163 to +181
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

GA 전송 예외가 공유 모달 오픈을 막을 수 있습니다.

현재는 Line 180 openModal이 GA 전송 뒤에 있어, 전송/스토리지 예외 시 공유 버튼이 무반응처럼 보일 수 있습니다. 공유 UX는 항상 열리고 분석은 best-effort로 처리해 주세요.

🔧 제안 수정안
 const handleResultShareClick = (e: React.MouseEvent<HTMLButtonElement>) => {
-  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', {
-      meeting_url_id: id,
-      location: 'place_list',
-      browser_id: browserId,
-    });
-  }
-
-  openModal('SHARE', { meetingId: id }, e);
+  try {
+    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', {
+        meeting_url_id: id,
+        location: 'place_list',
+        browser_id: browserId,
+      });
+    }
+  } catch (analyticsError) {
+    console.warn('GA tracking skipped:', analyticsError);
+  } finally {
+    openModal('SHARE', { meetingId: id }, e);
+  }
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleResultShareClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// 1. GA 데이터 먼저 전송!
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', {
meeting_url_id: id,
location: 'place_list', // PM님 명세: 결과 리스트 페이지
browser_id: browserId,
});
}
// 2. 안전하게 전송 후 모달 띄우기
openModal('SHARE', { meetingId: id }, e);
};
const handleResultShareClick = (e: React.MouseEvent<HTMLButtonElement>) => {
try {
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', {
meeting_url_id: id,
location: 'place_list',
browser_id: browserId,
});
}
} catch (analyticsError) {
console.warn('GA tracking skipped:', analyticsError);
} finally {
openModal('SHARE', { meetingId: id }, e);
}
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/result/`[id]/page.tsx around lines 163 - 181, The share handler
handleResultShareClick currently performs GA and localStorage operations before
calling openModal, so any exception (from localStorage or sendGAEvent) can
prevent the modal from opening; change the flow to guarantee openModal is
invoked regardless of analytics errors by either calling openModal first or
wrapping the analytics/localStorage block in a try/catch and treating
sendGAEvent/localStorage as best-effort (log errors but do not rethrow), keeping
references to sendGAEvent, localStorage.getItem/setItem and openModal so you
update those exact calls in handleResultShareClick.


useEffect(() => {
clearRelatedCache();

Expand Down Expand Up @@ -266,7 +287,7 @@ export default function Page() {
<div
key={result.id}
onClick={() => setSelectedResultId(result.id)}
className={`flex cursor-pointer flex-col gap-3.75 rounded-[4px] border bg-white p-5 ${
className={`flex cursor-pointer flex-col gap-3.75 rounded border bg-white p-5 ${
selectedResultId === result.id
? 'border-blue-5 border-2'
: 'border-gray-2 hover:bg-gray-1'
Expand Down Expand Up @@ -296,7 +317,7 @@ export default function Page() {
<div className="flex gap-2">
<button
onClick={handleRecommendClick}
className="bg-gray-8 hover:bg-gray-9 h-10 flex-1 cursor-pointer rounded-[4px] text-[15px] font-normal text-white transition-colors"
className="bg-gray-8 hover:bg-gray-9 h-10 flex-1 cursor-pointer rounded text-[15px] font-normal text-white transition-colors"
type="button"
>
주변 장소 추천
Expand All @@ -314,7 +335,7 @@ export default function Page() {
e
);
}}
className="bg-gray-1 hover:bg-gray-2 text-blue-5 h-10 flex-1 cursor-pointer rounded-[4px] text-[15px] font-normal transition-colors"
className="bg-gray-1 hover:bg-gray-2 text-blue-5 h-10 flex-1 cursor-pointer rounded text-[15px] font-normal transition-colors"
type="button"
>
환승 경로 보기
Expand All @@ -328,7 +349,7 @@ export default function Page() {
</div>

<button
onClick={(e) => openModal('SHARE', { meetingId: id }, e)}
onClick={handleResultShareClick}
className="bg-blue-5 hover:bg-blue-8 absolute right-5 bottom-0 left-5 flex h-12 items-center justify-center gap-2.5 rounded text-lg font-semibold text-white transition-transform active:scale-[0.98] md:right-0 md:left-0"
>
<Image src="/icon/share-white.svg" alt="공유 아이콘" width={20} height={20} />
Expand Down
23 changes: 23 additions & 0 deletions components/join/joinForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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;
Expand Down Expand Up @@ -65,6 +66,28 @@
e.preventDefault();
if (!isFormValid || !meetingId) return;

// ⭐️ 1. 비즈니스 로직 실행 전 GA 이벤트 선(先) 전송!
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', {
meeting_url_id: meetingId,
user_cookie_id: browserId, // 생성 때 썼던 그 browserId와 일치하게 됨!
role: userRole, // 완벽하게 방장/참여자 구분 완료
remember_me: isRemembered ? 'yes' : 'no',
});
}

// ⭐️ 2. 기존 참여 API 호출 로직
Comment on lines +69 to +90
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

등록 성공 이벤트를 API 호출 전에 전송하면 지표 왜곡과 기능 차단 위험이 있습니다.

현재 구조는 (1) 실제 등록 실패도 participant_registration으로 집계될 수 있고, (2) GA/로컬스토리지 예외 시 참여 API 호출이 스킵될 수 있습니다. 성공 이후 비차단 방식으로 보내는 쪽이 안전합니다.

🔧 제안 수정안
-    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', {
-        meeting_url_id: meetingId,
-        user_cookie_id: browserId,
-        role: userRole,
-        remember_me: isRemembered ? 'yes' : 'no',
-      });
-    }
-
     try {
       // `@ts-ignore`
       const result = await participantEnter.mutateAsync({
         meetingId,
         data: {
           userId: name,
           password,
         },
       });

       if (result.success) {
+        try {
+          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', {
+              meeting_url_id: meetingId,
+              user_cookie_id: browserId,
+              role: userRole,
+              remember_me: isRemembered ? 'yes' : 'no',
+            });
+          }
+        } catch (analyticsError) {
+          console.warn('GA tracking skipped:', analyticsError);
+        }
         setMeetingUserId(meetingId, name, isRemembered);
         router.push(`/meeting/${meetingId}`);
       } else {

Also applies to: 91-103

🤖 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 - 90, Move the sendGAEvent call
out of the pre-API block and instead invoke it only after the participant
registration API returns success; locate the sendGAEvent invocation in
joinForm.tsx (it uses meetingId, browserId, isRemembered and role computed from
localStorage.getItem(`is_host_${meetingId}`)) and relocate it to the success
branch of the existing participation API call (the block currently labeled "기존
참여 API 호출 로직" / the registration API promise resolution). Ensure the GA call is
non-blocking and wrapped in a try/catch (or .catch) so any GA/localStorage
errors do not prevent or alter the main registration flow, and preserve the same
payload fields (meeting_url_id, user_cookie_id, role, remember_me).

try {
// @ts-ignore
const result = await participantEnter.mutateAsync({
Expand All @@ -82,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
25 changes: 24 additions & 1 deletion components/share/shareContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { sendGAEvent } from '@next/third-parties/google';

interface ShareContentProps {
id: string;
Expand All @@ -14,6 +15,28 @@ export default function ShareContent({ id }: ShareContentProps) {
// useParams() 대신 부모(Page)에서 전달받은 id 사용
const { shareUrl, isError, isLoading, handleCopyLink, isVisible } = useShareMeeting(id);

// 복사 버튼 클릭 시 실행할 래퍼 함수 생성
const handleCopyClickWithGA = () => {
handleCopyLink();

// 2. GA4 이벤트 전송 로직 인라인 처리
if (typeof window !== 'undefined') {
// 브라우저 식별자(browser_id) 확인 및 생성 (Get or Create)
let browserId = localStorage.getItem('browser_id');
if (!browserId) {
const randomStr = Math.random().toString(36).substring(2, 15);
browserId = `bid_${randomStr}${Date.now().toString(36)}`;
localStorage.setItem('browser_id', browserId);
}

sendGAEvent('event', 'share_link', {
meeting_url_id: id,
location: 'creation_complete',
browser_id: browserId,
});
}
};

if (isError) notFound();
if (isLoading) return null;

Expand Down Expand Up @@ -44,7 +67,7 @@ export default function ShareContent({ id }: ShareContentProps) {
/>
<button
type="button"
onClick={handleCopyLink}
onClick={handleCopyClickWithGA}
className="bg-gray-1 text-gray-6 border-gray-1 hover:bg-gray-2 cursor-pointer rounded-r-sm border px-3.5 py-3 text-sm font-semibold whitespace-nowrap transition-colors"
>
복사
Expand Down
Loading