Implement academy analytics, billing, and web stories updates

This commit is contained in:
2026-05-26 07:27:29 +02:00
parent 456c3d6bb0
commit 0b33a1b074
177 changed files with 27360 additions and 2685 deletions

View 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])
}

View File

@@ -0,0 +1,70 @@
function prepareEnvironment() {
document.head.innerHTML = '<meta name="csrf-token" content="csrf-token" />'
vi.spyOn(Storage.prototype, 'getItem').mockReturnValue('visitor-123')
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {})
globalThis.fetch = vi.fn(() => Promise.resolve({ ok: true, headers: { get: () => 'application/json' }, json: () => Promise.resolve({ ok: true }) }))
}
function cleanupEnvironment() {
vi.restoreAllMocks()
document.head.innerHTML = ''
}
test('academy search click attribution uses sendBeacon without blocking navigation', async () => {
prepareEnvironment()
const { trackAcademySearchResultClick } = await import('./academyAnalytics.js')
Object.defineProperty(navigator, 'sendBeacon', {
configurable: true,
value: vi.fn(() => true),
})
const result = trackAcademySearchResultClick({
eventUrl: '/academy/analytics/events',
pageName: 'academy_prompts_index',
}, {
query: 'robot mascot',
resultsCount: 12,
filters: { difficulty: 'beginner' },
}, {
contentType: 'academy_prompt',
contentId: 123,
position: 3,
})
expect(result).toBeUndefined()
expect(navigator.sendBeacon).toHaveBeenCalledTimes(1)
expect(globalThis.fetch).not.toHaveBeenCalled()
cleanupEnvironment()
})
test('academy search click attribution falls back to keepalive fetch when sendBeacon cannot queue', async () => {
prepareEnvironment()
const { trackAcademySearchResultClick } = await import('./academyAnalytics.js')
Object.defineProperty(navigator, 'sendBeacon', {
configurable: true,
value: vi.fn(() => false),
})
trackAcademySearchResultClick({
eventUrl: '/academy/analytics/events',
pageName: 'academy_prompts_index',
}, {
query: 'robot mascot',
resultsCount: 12,
filters: { difficulty: 'beginner' },
}, {
contentType: 'academy_prompt',
contentId: 123,
position: 3,
})
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
expect(globalThis.fetch.mock.calls[0][1].keepalive).toBe(true)
cleanupEnvironment()
})