Implement academy analytics, billing, and web stories updates
This commit is contained in:
296
resources/js/lib/academyAnalytics.js
Normal file
296
resources/js/lib/academyAnalytics.js
Normal file
@@ -0,0 +1,296 @@
|
||||
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])
|
||||
}
|
||||
Reference in New Issue
Block a user