296 lines
8.6 KiB
JavaScript
296 lines
8.6 KiB
JavaScript
import { useEffect } from 'react'
|
|
|
|
const VISITOR_STORAGE_KEY = 'academy.analytics.visitor-id'
|
|
const VISITOR_COOKIE_NAME = 'academy_visitor_id'
|
|
const ONCE_PREFIX = 'academy.analytics.once:'
|
|
|
|
function getCsrfToken() {
|
|
if (typeof document === 'undefined') {
|
|
return ''
|
|
}
|
|
|
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
}
|
|
|
|
function getCookieValue(name) {
|
|
if (typeof document === 'undefined') {
|
|
return ''
|
|
}
|
|
|
|
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
|
|
return match ? decodeURIComponent(match[1]) : ''
|
|
}
|
|
|
|
function generateVisitorId() {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID()
|
|
}
|
|
|
|
return `academy-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
}
|
|
|
|
function ensureVisitorId() {
|
|
if (typeof window === 'undefined') {
|
|
return null
|
|
}
|
|
|
|
let visitorId = ''
|
|
|
|
try {
|
|
visitorId = window.localStorage.getItem(VISITOR_STORAGE_KEY) || ''
|
|
} catch {
|
|
visitorId = ''
|
|
}
|
|
|
|
if (!visitorId) {
|
|
visitorId = getCookieValue(VISITOR_COOKIE_NAME)
|
|
}
|
|
|
|
if (!visitorId) {
|
|
visitorId = generateVisitorId()
|
|
}
|
|
|
|
try {
|
|
window.localStorage.setItem(VISITOR_STORAGE_KEY, visitorId)
|
|
} catch {
|
|
// Ignore storage failures and continue.
|
|
}
|
|
|
|
if (typeof document !== 'undefined') {
|
|
document.cookie = `${VISITOR_COOKIE_NAME}=${encodeURIComponent(visitorId)}; path=/; max-age=31536000; SameSite=Lax`
|
|
}
|
|
|
|
return visitorId
|
|
}
|
|
|
|
function buildPayload(payload = {}) {
|
|
return {
|
|
...payload,
|
|
visitor_id: payload.visitor_id || ensureVisitorId(),
|
|
url: payload.url || (typeof window !== 'undefined' ? window.location.href : null),
|
|
_token: payload._token || getCsrfToken(),
|
|
}
|
|
}
|
|
|
|
function markOnce(onceKey) {
|
|
if (!onceKey || typeof window === 'undefined') {
|
|
return false
|
|
}
|
|
|
|
const storageKey = `${ONCE_PREFIX}${onceKey}`
|
|
|
|
try {
|
|
if (window.sessionStorage.getItem(storageKey)) {
|
|
return true
|
|
}
|
|
|
|
window.sessionStorage.setItem(storageKey, '1')
|
|
} catch {
|
|
return false
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
export async function postAcademyAction(url, payload = {}) {
|
|
if (!url || typeof window === 'undefined') {
|
|
return null
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
},
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(buildPayload(payload)),
|
|
}).catch(() => null)
|
|
|
|
if (!response?.ok) {
|
|
return null
|
|
}
|
|
|
|
const responseContentType = response.headers.get('content-type') || ''
|
|
if (!responseContentType.includes('application/json')) {
|
|
return null
|
|
}
|
|
|
|
return response.json().catch(() => null)
|
|
}
|
|
|
|
export function trackAcademyEvent(eventType, contentType, contentId, metadata = {}, options = {}) {
|
|
if (!eventType || !options?.url || typeof window === 'undefined') {
|
|
return Promise.resolve(false)
|
|
}
|
|
|
|
if (options.onceKey && markOnce(options.onceKey)) {
|
|
return Promise.resolve(false)
|
|
}
|
|
|
|
const payload = buildPayload({
|
|
event_type: eventType,
|
|
content_type: contentType || null,
|
|
content_id: contentId || null,
|
|
metadata,
|
|
route_name: options.pageName || null,
|
|
})
|
|
|
|
const body = JSON.stringify(payload)
|
|
|
|
if (options.useBeacon !== false && typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
|
try {
|
|
const blob = new Blob([body], { type: 'application/json' })
|
|
const queued = navigator.sendBeacon(options.url, blob)
|
|
|
|
if (queued) {
|
|
return Promise.resolve(true)
|
|
}
|
|
} catch {
|
|
// Fall back to fetch.
|
|
}
|
|
}
|
|
|
|
return fetch(options.url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
},
|
|
credentials: 'same-origin',
|
|
keepalive: options.keepalive === true,
|
|
body,
|
|
}).then(() => true).catch(() => false)
|
|
}
|
|
|
|
export function normalizeAcademySearchQuery(query = '') {
|
|
const normalizedWhitespace = String(query).trim().toLowerCase().replace(/\s+/g, ' ')
|
|
return normalizedWhitespace.replace(/[^a-z0-9\s\-_]+/g, '').trim()
|
|
}
|
|
|
|
export function trackAcademySearchResultClick(analytics, search, result) {
|
|
if (!analytics?.eventUrl || !search?.query || !result?.contentType || !result?.contentId) {
|
|
return
|
|
}
|
|
|
|
void trackAcademyEvent('academy_search_result_click', result.contentType, result.contentId, {
|
|
query: search.query,
|
|
normalized_query: search.normalizedQuery || normalizeAcademySearchQuery(search.query),
|
|
results_count: Number(search.resultsCount || 0),
|
|
position: result.position || null,
|
|
source: result.source || 'academy_search_results',
|
|
filters: search.filters || {},
|
|
}, {
|
|
url: analytics.eventUrl,
|
|
pageName: analytics.pageName,
|
|
keepalive: true,
|
|
})
|
|
}
|
|
|
|
function contentViewEventType(contentType) {
|
|
if (contentType === 'academy_lesson') return 'academy_lesson_view'
|
|
if (contentType === 'academy_course') return 'academy_course_view'
|
|
if (contentType === 'academy_prompt_pack') return 'academy_prompt_pack_view'
|
|
if (contentType === 'academy_challenge') return 'academy_challenge_view'
|
|
return 'academy_content_view'
|
|
}
|
|
|
|
export function trackUpgradeClick(analytics, metadata = {}) {
|
|
if (!analytics?.eventUrl) {
|
|
return
|
|
}
|
|
|
|
void trackAcademyEvent('academy_upgrade_click', analytics?.contentType || 'academy_upgrade', analytics?.contentId || null, metadata, {
|
|
url: analytics.eventUrl,
|
|
pageName: analytics.pageName,
|
|
useBeacon: false,
|
|
})
|
|
}
|
|
|
|
export function useAcademyPageAnalytics(analytics) {
|
|
useEffect(() => {
|
|
if (!analytics?.enabled || !analytics?.eventUrl || typeof window === 'undefined') {
|
|
return undefined
|
|
}
|
|
|
|
const baseKey = `${analytics.pageName || window.location.pathname}:${analytics.contentType || 'page'}:${analytics.contentId || 'none'}`
|
|
|
|
void trackAcademyEvent('academy_page_view', analytics.contentType || null, analytics.contentId || null, {
|
|
page_name: analytics.pageName,
|
|
}, {
|
|
url: analytics.eventUrl,
|
|
pageName: analytics.pageName,
|
|
onceKey: `${baseKey}:page-view`,
|
|
})
|
|
|
|
if (analytics.contentType || analytics.contentId) {
|
|
void trackAcademyEvent(contentViewEventType(analytics.contentType), analytics.contentType || null, analytics.contentId || null, {
|
|
page_name: analytics.pageName,
|
|
}, {
|
|
url: analytics.eventUrl,
|
|
pageName: analytics.pageName,
|
|
onceKey: `${baseKey}:content-view`,
|
|
})
|
|
}
|
|
|
|
if (analytics.isPremium && analytics.isLocked) {
|
|
void trackAcademyEvent('academy_premium_preview_view', analytics.contentType || null, analytics.contentId || null, {
|
|
page_name: analytics.pageName,
|
|
}, {
|
|
url: analytics.eventUrl,
|
|
pageName: analytics.pageName,
|
|
onceKey: `${baseKey}:premium-preview`,
|
|
})
|
|
}
|
|
|
|
const engagedTimer = window.setTimeout(() => {
|
|
void trackAcademyEvent('academy_engaged_view', analytics.contentType || null, analytics.contentId || null, {
|
|
page_name: analytics.pageName,
|
|
engaged_seconds: 15,
|
|
}, {
|
|
url: analytics.eventUrl,
|
|
pageName: analytics.pageName,
|
|
onceKey: `${baseKey}:engaged`,
|
|
})
|
|
}, 15000)
|
|
|
|
const sentMilestones = new Set()
|
|
|
|
const onScroll = () => {
|
|
const doc = document.documentElement
|
|
const scrollable = Math.max(1, doc.scrollHeight - window.innerHeight)
|
|
const percent = Math.min(100, Math.round((window.scrollY / scrollable) * 100))
|
|
|
|
;[
|
|
{ threshold: 50, eventType: 'academy_scroll_50' },
|
|
{ threshold: 75, eventType: 'academy_scroll_75' },
|
|
{ threshold: 100, eventType: 'academy_scroll_100' },
|
|
].forEach((milestone) => {
|
|
if (percent < milestone.threshold || sentMilestones.has(milestone.threshold)) {
|
|
return
|
|
}
|
|
|
|
sentMilestones.add(milestone.threshold)
|
|
void trackAcademyEvent(milestone.eventType, analytics.contentType || null, analytics.contentId || null, {
|
|
page_name: analytics.pageName,
|
|
scroll_percent: milestone.threshold,
|
|
}, {
|
|
url: analytics.eventUrl,
|
|
pageName: analytics.pageName,
|
|
onceKey: `${baseKey}:scroll-${milestone.threshold}`,
|
|
})
|
|
})
|
|
}
|
|
|
|
window.addEventListener('scroll', onScroll, { passive: true })
|
|
|
|
return () => {
|
|
window.clearTimeout(engagedTimer)
|
|
window.removeEventListener('scroll', onScroll)
|
|
}
|
|
}, [analytics?.contentId, analytics?.contentType, analytics?.enabled, analytics?.eventUrl, analytics?.isLocked, analytics?.isPremium, analytics?.pageName])
|
|
} |