Compare commits

...

3 Commits

Author SHA1 Message Date
c4c46db0d9 안내문구 수정 2026-07-28 16:06:24 +09:00
f19cba7d75 fix: Meta 클릭 ID(fbc) 폴백 및 이벤트 ID 생성 폴백 추가 2026-07-28 12:00:34 +09:00
f36fd5a3cb 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>
2026-07-24 10:28:51 +09:00
8 changed files with 253 additions and 28 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

@ -463,8 +463,8 @@
"monthOverMonth": "Month-over-Month Growth",
"noAudienceData": "Not enough data to show real audience insights.",
"mockContentNotice": "The content shown below is sample data, not your actual content.",
"dataDelayTitle": "Data is being prepared",
"dataDelayDesc": "According to YouTube's policy, analytics data may take up to 48 hours to be reflected after a video is uploaded. Please check back later.",
"dataDelayTitle": "No data to display",
"dataDelayDesc": "There is no analytics data to display. YouTube may take up to 48 hours to reflect data after upload, and statistics are also not collected if the video was uploaded as private or has been deleted from YouTube.",
"sampleHide": "Hide Sample",
"sampleShow": "Show Sample",
"recentVideosDesc": "Showing stats for the latest 30 videos uploaded via ADO2.",

View File

@ -462,8 +462,8 @@
"monthOverMonth": "전월 대비 성장",
"noAudienceData": "누적 데이터가 부족하여 실제 시청자 정보가 없습니다.",
"mockContentNotice": "현재 표시된 콘텐츠는 실제 데이터가 아닌 샘플 데이터입니다.",
"dataDelayTitle": "데이터 준비 중",
"dataDelayDesc": "유튜브 정책에 따라 분석 데이터는 영상 업로드 후 최대 48시간이 지나야 반영됩니다. 나중에 다시 확인해 주세요.",
"dataDelayTitle": "표시할 데이터 없음",
"dataDelayDesc": "표시할 분석 데이터가 없습니다. 유튜브 정책상 업로드 후 최대 48시간이 지나야 데이터가 반영되며, 영상을 비공개로 업로드했거나 유튜브에서 삭제한 경우에도 통계가 집계되지 않습니다.",
"sampleHide": "Sample 숨기기",
"sampleShow": "Sample 보기",
"recentVideosDesc": "ADO2에서 업로드한 최근 30개의 영상 통계가 표시됩니다.",

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,183 @@ export async function generateVideo(taskId: string, orientation: 'vertical' | 'h
return response.json();
}
// ============================================================
// Meta 전환 추적
// ============================================================
// 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;
}
// URL의 fbclid로 조립해둔 fbc 폴백 값을 보관하는 localStorage 키
// UTM과 분리해 두어야 가입 완료 후 UTM을 정리해도 이후 이벤트에서 계속 쓸 수 있다.
const FBC_STORAGE_KEY = 'castad_fbc';
// Meta 클릭 ID(fbc) 조회
// 픽셀이 심은 _fbc 쿠키를 우선 사용하고, 없으면 랜딩 시 fbclid로 조립해둔 값을 쓴다.
// 애드블로커·쿠키 정리 등으로 쿠키가 유실돼도 광고 클릭 기여를 잃지 않기 위한 폴백이다.
function getFbc(): string | null {
const cookie = getCookieValue('_fbc');
if (cookie) return cookie;
try {
return localStorage.getItem(FBC_STORAGE_KEY);
} catch {
return 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: getFbc(),
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;
// 이벤트 중복제거용 UUID 생성
// crypto.randomUUID는 보안 컨텍스트(https/localhost) 전용이라 미지원 환경 폴백 포함
function generateEventId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
// 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, fbclid)를 localStorage에 보관한다.
export function storeUtmFromUrl(): void {
try {
const params = new URLSearchParams(window.location.search);
// UTM: 이미 저장된 값이 있으면 덮어쓰지 않아 최초 유입 경로(first-touch)를 보존
if (!localStorage.getItem(UTM_STORAGE_KEY)) {
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));
}
}
// fbclid: 광고 클릭 기여는 최신 클릭이 가져가므로 새 값이 오면 덮어쓴다
// (Meta 픽셀의 _fbc 쿠키도 동일하게 last-touch로 동작)
// 형식: fb.{subdomainIndex}.{creationTime(ms)}.{fbclid}
const fbclid = params.get('fbclid');
if (fbclid) {
localStorage.setItem(FBC_STORAGE_KEY, `fb.1.${Date.now()}.${fbclid}`);
}
} catch (error) {
console.error('[Tracking] storeUtmFromUrl failed:', error);
}
}
// 랜딩 진입(ViewContent) 이벤트 발화 — 비로그인 시점이므로 브라우저 픽셀 단독
export function trackViewContent(): void {
try {
const fbq = getFbq();
if (fbq) {
fbq('track', 'ViewContent', {}, { eventID: generateEventId() });
console.log('[Tracking] ViewContent fired');
} else {
console.warn('[Tracking] ViewContent skipped: fbq not loaded');
}
} catch (error) {
const detail = error instanceof Error ? `${error.name}: ${error.message}\n${error.stack}` : String(error);
console.error('[Tracking] ViewContent failed:', detail);
}
}
// 회원가입 완료(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: getFbc(),
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,15 +941,25 @@ function clearAllLocalData() {
// 로그아웃
export async function logout(): Promise<void> {
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();
}
}