feat: Meta 픽셀 및 전환 추적 연동

- 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 <noreply@anthropic.com>
feature-meta
김성경 2026-07-24 10:28:51 +09:00
parent a853c714fd
commit f36fd5a3cb
6 changed files with 205 additions and 24 deletions

View File

@ -15,6 +15,20 @@
<meta property="og:type" content="website" />
<link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32">
<link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48">
<!-- Meta Pixel Code -->
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '1572679738203392');
fbq('track', 'PageView');
</script>
<!-- End Meta Pixel Code -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
@ -164,6 +178,10 @@
</script>
</head>
<body class="bg-[#121a1d]">
<!-- Meta Pixel noscript fallback (head의 noscript에는 img가 허용되지 않아 body에 위치) -->
<noscript><img height="1" width="1" style="display:none"
src="https://www.facebook.com/tr?id=1572679738203392&ev=PageView&noscript=1"
/></noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>

View File

@ -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) {

View File

@ -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<CompletionContentProps> = ({
setVideoStatus('complete');
setStatusMessage('');
saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse, videoId);
// Meta FirstVideoCreated 전환 이벤트 발화 (서버가 계정당 최초 1회 판정, 실패해도 무시)
trackFirstVideoCreated();
} else {
throw new Error(t('completion.videoUrlMissing'));
}

View File

@ -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<LoginSectionProps> = ({ 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);

View File

@ -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;
}

View File

@ -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<void> {
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<string, string> = {};
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<void> {
try {
let utm: Record<string, string> = {};
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<VideoStatusResponse> {
const response = await authenticatedFetch(`${API_URL}/video/status/${taskId}`, {
@ -764,17 +897,27 @@ function clearAllLocalData() {
// 로그아웃
export async function logout(): Promise<void> {
const refreshToken = getRefreshToken();
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 }),
});
// 응답과 관계없이 로컬 데이터 전체 삭제
clearAllLocalData();
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
} finally {
// 응답과 관계없이 로컬 데이터 전체 삭제
clearAllLocalData();
}
}
// 모든 기기에서 로그아웃
export async function logoutAll(): Promise<void> {