Compare commits
3 Commits
a853c714fd
...
c4c46db0d9
| Author | SHA1 | Date | |
|---|---|---|---|
| c4c46db0d9 | |||
| f19cba7d75 | |||
| f36fd5a3cb |
18
index.html
18
index.html
@ -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>
|
||||
|
||||
26
src/App.tsx
26
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) {
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -462,8 +462,8 @@
|
||||
"monthOverMonth": "전월 대비 성장",
|
||||
"noAudienceData": "누적 데이터가 부족하여 실제 시청자 정보가 없습니다.",
|
||||
"mockContentNotice": "현재 표시된 콘텐츠는 실제 데이터가 아닌 샘플 데이터입니다.",
|
||||
"dataDelayTitle": "데이터 준비 중",
|
||||
"dataDelayDesc": "유튜브 정책에 따라 분석 데이터는 영상 업로드 후 최대 48시간이 지나야 반영됩니다. 나중에 다시 확인해 주세요.",
|
||||
"dataDelayTitle": "표시할 데이터 없음",
|
||||
"dataDelayDesc": "표시할 분석 데이터가 없습니다. 유튜브 정책상 업로드 후 최대 48시간이 지나야 데이터가 반영되며, 영상을 비공개로 업로드했거나 유튜브에서 삭제한 경우에도 통계가 집계되지 않습니다.",
|
||||
"sampleHide": "Sample 숨기기",
|
||||
"sampleShow": "Sample 보기",
|
||||
"recentVideosDesc": "ADO2에서 업로드한 최근 30개의 영상 통계가 표시됩니다.",
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
201
src/utils/api.ts
201
src/utils/api.ts
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user