From f36fd5a3cb32d33e3c4d9d03d1bfa53e71b6dfab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Fri, 24 Jul 2026 10:28:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Meta=20=ED=94=BD=EC=85=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20=EC=B6=94=EC=A0=81=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.html에 Meta Pixel 스크립트 및 noscript 폴백 추가 (PageView 자동 추적) - 전환 추적 유틸 추가 (trackViewContent, trackCompleteRegistration, trackFirstVideoCreated) — 서버 CAPI와 동일 event_id로 픽셀 발화해 중복제거 - 랜딩 진입 시 UTM 파라미터 first-touch 캡처 (localStorage 보존, 가입 전환 시 서버로 전달 후 제거) - 카카오 로그인 콜백에서 is_new_user 판정 시 CompleteRegistration 발화 (토큰 리다이렉트 경로 + code 콜백 경로 모두 처리) - 영상 생성 완료 시 FirstVideoCreated 발화 - KakaoCallbackResponse 타입을 백엔드 LoginResponse 구조에 맞게 수정 (user 객체 제거, is_new_user 평탄화) - logout이 refresh_token을 body로 전달하도록 수정, 실패해도 로컬 데이터는 항상 삭제되도록 try/finally 처리 Co-Authored-By: Claude Fable 5 --- index.html | 18 +++ src/App.tsx | 26 +++- src/pages/Dashboard/CompletionContent.tsx | 5 +- src/pages/Login/LoginSection.tsx | 10 +- src/types/api.ts | 13 +- src/utils/api.ts | 157 +++++++++++++++++++++- 6 files changed, 205 insertions(+), 24 deletions(-) diff --git a/index.html b/index.html index c5403f9..156c788 100755 --- a/index.html +++ b/index.html @@ -15,6 +15,20 @@ + + + + +
diff --git a/src/App.tsx b/src/App.tsx index 6df4f86..0a97d10 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,7 @@ import SocialConnectError from './pages/Social/SocialConnectError'; import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback'; import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage'; import VideoDetailPage from './components/VideoDetailPage'; -import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest } from './utils/api'; +import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api'; import { saveSearchHistory } from './components/SearchHistory/useSearchHistory'; import { CrawlingResponse } from './types/api'; @@ -123,6 +123,9 @@ const App: React.FC = () => { // 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인) useEffect(() => { + // 광고 유입 UTM 파라미터 캡처 (콜백 처리로 URL이 정리되기 전에 최우선 실행) + storeUtmFromUrl(); + const urlParams = new URLSearchParams(window.location.search); const accessToken = urlParams.get('access_token'); const refreshToken = urlParams.get('refresh_token'); @@ -136,17 +139,21 @@ const App: React.FC = () => { // 백엔드에서 토큰을 URL 파라미터로 전달한 경우 if (accessToken && refreshToken && !isProcessingCallback) { setIsProcessingCallback(true); - handleTokenCallback(accessToken, refreshToken); + handleTokenCallback(accessToken, refreshToken, urlParams.get('is_new_user') === 'true'); } // 기존 code 방식 (Redirect URI가 프론트엔드인 경우) - Social OAuth는 제외 else if (code && !isProcessingCallback && !isSocialOAuthCallback) { setIsProcessingCallback(true); handleKakaoCallback(code); } + // 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화 + else if (viewMode === 'landing') { + trackViewContent(); + } }, []); // 백엔드에서 토큰을 URL 파라미터로 전달받은 경우 처리 - const handleTokenCallback = (accessToken: string, refreshToken: string) => { + const handleTokenCallback = async (accessToken: string, refreshToken: string, isNewUser: boolean = false) => { try { // 토큰 저장 saveTokens(accessToken, refreshToken); @@ -155,8 +162,15 @@ const App: React.FC = () => { const url = new URL(window.location.href); url.searchParams.delete('access_token'); url.searchParams.delete('refresh_token'); + url.searchParams.delete('is_new_user'); window.history.replaceState({}, document.title, url.pathname); + // 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화 + // (서버가 계정당 최초 1회 판정 + UTM 저장, 실패해도 로그인 흐름에 영향 없음) + if (isNewUser) { + await trackCompleteRegistration(); + } + const savedData = localStorage.getItem(ANALYSIS_DATA_KEY); if (savedData) { // 분석 데이터가 있으면 에셋 관리(step 1)부터 시작 @@ -184,6 +198,12 @@ const App: React.FC = () => { try { const response = await kakaoCallback(code); + // 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화 + // (페이지 이동 전에 완료해야 요청이 유실되지 않음) + if (response.is_new_user) { + await trackCompleteRegistration(); + } + // 로그인 성공 - 서버에서 받은 redirect_url로 이동 window.location.href = response.redirect_url; } catch (err) { diff --git a/src/pages/Dashboard/CompletionContent.tsx b/src/pages/Dashboard/CompletionContent.tsx index 5a082d8..ab95013 100755 --- a/src/pages/Dashboard/CompletionContent.tsx +++ b/src/pages/Dashboard/CompletionContent.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete } from '../../utils/api'; +import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete, trackFirstVideoCreated } from '../../utils/api'; import SocialPostingModal from '../../components/SocialPostingModal'; import { useTutorial } from '../../components/Tutorial/useTutorial'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; @@ -266,6 +266,9 @@ const CompletionContent: React.FC = ({ setVideoStatus('complete'); setStatusMessage(''); saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse, videoId); + + // Meta FirstVideoCreated 전환 이벤트 발화 (서버가 계정당 최초 1회 판정, 실패해도 무시) + trackFirstVideoCreated(); } else { throw new Error(t('completion.videoUrlMissing')); } diff --git a/src/pages/Login/LoginSection.tsx b/src/pages/Login/LoginSection.tsx index cf3cfdf..daabcc7 100755 --- a/src/pages/Login/LoginSection.tsx +++ b/src/pages/Login/LoginSection.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { getKakaoLoginUrl, kakaoCallback } from '../../utils/api'; +import { getKakaoLoginUrl, kakaoCallback, trackCompleteRegistration } from '../../utils/api'; interface LoginSectionProps { onBack: () => void; @@ -28,7 +28,13 @@ const LoginSection: React.FC = ({ onBack, onLogin }) => { setError(null); try { - await kakaoCallback(code); + const response = await kakaoCallback(code); + + // 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화 + // (서버가 계정당 최초 1회 판정 — App.tsx 경로와 중복 호출돼도 무해) + if (response.is_new_user) { + await trackCompleteRegistration(); + } // URL에서 code 파라미터 제거 const url = new URL(window.location.href); diff --git a/src/types/api.ts b/src/types/api.ts index 6825d5c..3e90638 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -233,22 +233,13 @@ export interface KakaoLoginUrlResponse { auth_url: string; } -// 카카오 콜백 사용자 정보 -export interface KakaoCallbackUser { - id: number; - nickname: string; - email: string | null; - profile_image_url: string | null; - is_new_user: boolean; -} - -// 카카오 콜백 응답 (JWT 토큰) +// 카카오 콜백 응답 (JWT 토큰) — 백엔드 LoginResponse와 동일 구조 export interface KakaoCallbackResponse { access_token: string; refresh_token: string; token_type: string; expires_in: number; - user: KakaoCallbackUser; + is_new_user: boolean; redirect_url: string; } diff --git a/src/utils/api.ts b/src/utils/api.ts index e5d6a42..2bfb63c 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -309,6 +309,139 @@ export async function generateVideo(taskId: string, orientation: 'vertical' | 'h return response.json(); } +// ============================================================ +// Meta 전환 추적 (FirstVideoCreated) +// ============================================================ + +// Meta 픽셀이 심는 1st-party 쿠키 값 읽기 (_fbc/_fbp) +function getCookieValue(name: string): string | null { + const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; +} + +// 첫 영상 생성 완료(FirstVideoCreated) 전환 이벤트 발화 API +// 서버가 first_video_created_at null 여부로 계정당 최초 1회를 판정하고, +// fired=true일 때만 브라우저 픽셀(fbq)을 동일 event_id로 발화해 중복제거를 보장한다. +export async function trackFirstVideoCreated(): Promise { + try { + const response = await authenticatedFetch(`${API_URL}/tracking/meta/first-video-created`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fbc: getCookieValue('_fbc'), + fbp: getCookieValue('_fbp'), + event_source_url: window.location.href, + }), + }); + + if (!response.ok) return; + + const data: { fired: boolean; event_id: string | null } = await response.json(); + + const fbq = (window as unknown as { fbq?: (...args: unknown[]) => void }).fbq; + if (data.fired && data.event_id && typeof fbq === 'function') { + // 서버 CAPI 이벤트와 동일한 event_id로 발화 → Meta가 중복제거 + fbq('trackCustom', 'FirstVideoCreated', {}, { eventID: data.event_id }); + } + } catch (error) { + // 전환 추적 실패는 서비스 기능에 영향을 주지 않도록 무시 + console.error('[Tracking] FirstVideoCreated failed:', error); + } +} + +// UTM 파라미터 localStorage 키 (first-touch 보존) +const UTM_STORAGE_KEY = 'castad_utm'; +const UTM_PARAM_NAMES = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const; + +// fbq 전역 함수 안전 접근 +function getFbq(): ((...args: unknown[]) => void) | null { + const fbq = (window as unknown as { fbq?: (...args: unknown[]) => void }).fbq; + return typeof fbq === 'function' ? fbq : null; +} + +// 랜딩 진입 URL의 UTM 파라미터를 localStorage에 저장 (first-touch) +// 이미 저장된 값이 있으면 덮어쓰지 않아 최초 유입 경로를 보존한다. +export function storeUtmFromUrl(): void { + try { + if (localStorage.getItem(UTM_STORAGE_KEY)) return; + + const params = new URLSearchParams(window.location.search); + const utm: Record = {}; + for (const name of UTM_PARAM_NAMES) { + const value = params.get(name); + if (value) utm[name] = value; + } + + if (Object.keys(utm).length > 0) { + localStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(utm)); + } + } catch (error) { + console.error('[Tracking] storeUtmFromUrl failed:', error); + } +} + +// 랜딩 진입(ViewContent) 이벤트 발화 — 비로그인 시점이므로 브라우저 픽셀 단독 +export function trackViewContent(): void { + try { + const fbq = getFbq(); + if (fbq) { + fbq('track', 'ViewContent', {}, { eventID: crypto.randomUUID() }); + } + } catch (error) { + console.error('[Tracking] ViewContent failed:', error); + } +} + +// 회원가입 완료(CompleteRegistration) 전환 이벤트 발화 API +// 서버가 registration_tracked_at null 여부로 계정당 최초 1회를 판정하고 UTM을 함께 기록한다. +// fired=true일 때만 브라우저 픽셀(fbq)을 동일 event_id로 발화해 중복제거를 보장한다. +export async function trackCompleteRegistration(): Promise { + try { + let utm: Record = {}; + try { + utm = JSON.parse(localStorage.getItem(UTM_STORAGE_KEY) ?? '{}'); + } catch { + utm = {}; + } + + const response = await authenticatedFetch(`${API_URL}/tracking/meta/complete-registration`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fbc: getCookieValue('_fbc'), + fbp: getCookieValue('_fbp'), + event_source_url: window.location.href, + utm_source: utm.utm_source ?? null, + utm_medium: utm.utm_medium ?? null, + utm_campaign: utm.utm_campaign ?? null, + utm_content: utm.utm_content ?? null, + utm_term: utm.utm_term ?? null, + }), + }); + + if (!response.ok) return; + + const data: { fired: boolean; event_id: string | null } = await response.json(); + + if (data.fired && data.event_id) { + const fbq = getFbq(); + if (fbq) { + // 서버 CAPI 이벤트와 동일한 event_id로 발화 → Meta가 중복제거 + fbq('track', 'CompleteRegistration', {}, { eventID: data.event_id }); + } + // 가입 어트리뷰션 저장 완료 → 보관하던 UTM 제거 + localStorage.removeItem(UTM_STORAGE_KEY); + } + } catch (error) { + // 전환 추적 실패는 서비스 기능에 영향을 주지 않도록 무시 + console.error('[Tracking] CompleteRegistration failed:', error); + } +} + // 영상 상태 확인 API export async function getVideoStatus(taskId: string): Promise { const response = await authenticatedFetch(`${API_URL}/video/status/${taskId}`, { @@ -764,15 +897,25 @@ function clearAllLocalData() { // 로그아웃 export async function logout(): Promise { - const response = await authenticatedFetch(`${API_URL}/user/auth/logout`, { - method: 'POST', - }); + const refreshToken = getRefreshToken(); - // 응답과 관계없이 로컬 데이터 전체 삭제 - clearAllLocalData(); + try { + if (refreshToken) { + const response = await authenticatedFetch(`${API_URL}/user/auth/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } + } finally { + // 응답과 관계없이 로컬 데이터 전체 삭제 + clearAllLocalData(); } }