Compare commits
1 Commits
main
...
feature-me
| Author | SHA1 | Date |
|---|---|---|
|
|
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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
149
src/utils/api.ts
149
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<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> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue