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();
}
}