diff --git a/src/utils/api.ts b/src/utils/api.ts index 2bfb63c..4caa1fd 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -310,7 +310,7 @@ export async function generateVideo(taskId: string, orientation: 'vertical' | 'h } // ============================================================ -// Meta 전환 추적 (FirstVideoCreated) +// Meta 전환 추적 // ============================================================ // Meta 픽셀이 심는 1st-party 쿠키 값 읽기 (_fbc/_fbp) @@ -319,6 +319,24 @@ function getCookieValue(name: string): string | null { 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로 발화해 중복제거를 보장한다. @@ -330,7 +348,7 @@ export async function trackFirstVideoCreated(): Promise { 'Content-Type': 'application/json', }, body: JSON.stringify({ - fbc: getCookieValue('_fbc'), + fbc: getFbc(), fbp: getCookieValue('_fbp'), event_source_url: window.location.href, }), @@ -355,27 +373,49 @@ export async function trackFirstVideoCreated(): Promise { 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 파라미터를 localStorage에 저장 (first-touch) -// 이미 저장된 값이 있으면 덮어쓰지 않아 최초 유입 경로를 보존한다. +// 랜딩 진입 URL에서 광고 유입 정보(UTM, fbclid)를 localStorage에 보관한다. 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; + + // UTM: 이미 저장된 값이 있으면 덮어쓰지 않아 최초 유입 경로(first-touch)를 보존 + if (!localStorage.getItem(UTM_STORAGE_KEY)) { + 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)); + } } - 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); @@ -387,10 +427,14 @@ export function trackViewContent(): void { try { const fbq = getFbq(); if (fbq) { - fbq('track', 'ViewContent', {}, { eventID: crypto.randomUUID() }); + fbq('track', 'ViewContent', {}, { eventID: generateEventId() }); + console.log('[Tracking] ViewContent fired'); + } else { + console.warn('[Tracking] ViewContent skipped: fbq not loaded'); } } catch (error) { - console.error('[Tracking] ViewContent failed:', error); + const detail = error instanceof Error ? `${error.name}: ${error.message}\n${error.stack}` : String(error); + console.error('[Tracking] ViewContent failed:', detail); } } @@ -412,7 +456,7 @@ export async function trackCompleteRegistration(): Promise { 'Content-Type': 'application/json', }, body: JSON.stringify({ - fbc: getCookieValue('_fbc'), + fbc: getFbc(), fbp: getCookieValue('_fbp'), event_source_url: window.location.href, utm_source: utm.utm_source ?? null,