Files
SkinbaseNova/resources/js/lib/worldAnalytics.js

212 lines
5.7 KiB
JavaScript

const WORLD_ANALYTICS_ENDPOINT = '/api/worlds/analytics/events'
const VISITOR_STORAGE_KEY = 'skinbase:world-analytics-visitor'
const SOURCE_PARAM = 'world_source'
const SOURCE_DETAIL_PARAM = 'world_source_detail'
const IMPRESSION_KEYS = new Set()
const ALLOWED_SOURCES = new Set([
'homepage_spotlight',
'homepage_worlds_rail',
'worlds_index',
'navigation',
'upload_flow',
'challenge_page',
'news_article',
'profile',
'direct',
'unknown',
])
function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
function randomToken() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `w-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
}
function normalizeSourceSurface(value) {
const normalized = String(value || '').trim()
return ALLOWED_SOURCES.has(normalized) ? normalized : ''
}
function sanitizeDetail(value) {
return String(value || '').trim().slice(0, 80)
}
function impressionKey({ worldId, sourceSurface, sourceDetail = '', sectionKey = '' }) {
return [worldId, sourceSurface, sanitizeDetail(sourceDetail), String(sectionKey || '').trim()].join(':')
}
export function worldAnalyticsVisitorToken() {
try {
const existing = window.localStorage?.getItem(VISITOR_STORAGE_KEY)
if (existing) {
return existing
}
const next = randomToken()
window.localStorage?.setItem(VISITOR_STORAGE_KEY, next)
return next
} catch {
return randomToken()
}
}
export function withWorldSource(url, sourceSurface, sourceDetail = '') {
if (!url) {
return url
}
try {
const parsed = new URL(url, window.location.origin)
if (parsed.origin !== window.location.origin) {
return url
}
const normalizedSource = normalizeSourceSurface(sourceSurface)
if (normalizedSource) {
parsed.searchParams.set(SOURCE_PARAM, normalizedSource)
}
const normalizedDetail = sanitizeDetail(sourceDetail)
if (normalizedDetail) {
parsed.searchParams.set(SOURCE_DETAIL_PARAM, normalizedDetail)
}
return `${parsed.pathname}${parsed.search}${parsed.hash}`
} catch {
return url
}
}
export function resolveWorldLandingSource() {
try {
const locationUrl = new URL(window.location.href)
const explicitSource = normalizeSourceSurface(locationUrl.searchParams.get(SOURCE_PARAM))
const explicitDetail = sanitizeDetail(locationUrl.searchParams.get(SOURCE_DETAIL_PARAM))
if (explicitSource) {
return {
sourceSurface: explicitSource,
sourceDetail: explicitDetail,
}
}
if (!document.referrer) {
return { sourceSurface: 'direct', sourceDetail: '' }
}
const referrer = new URL(document.referrer)
if (referrer.origin !== window.location.origin) {
return { sourceSurface: 'unknown', sourceDetail: 'external_referrer' }
}
const path = referrer.pathname || '/'
if (path === '/') {
return { sourceSurface: 'homepage_spotlight', sourceDetail: 'referrer' }
}
if (path === '/worlds') {
return { sourceSurface: 'worlds_index', sourceDetail: 'referrer' }
}
if (/^\/groups\/[^/]+\/challenges\//.test(path)) {
return { sourceSurface: 'challenge_page', sourceDetail: 'referrer' }
}
if (path.startsWith('/upload') || path.startsWith('/studio/artworks')) {
return { sourceSurface: 'upload_flow', sourceDetail: 'referrer' }
}
if (path.startsWith('/news') || path.startsWith('/stories')) {
return { sourceSurface: 'news_article', sourceDetail: 'referrer' }
}
if (path.startsWith('/@') || path.startsWith('/profile')) {
return { sourceSurface: 'profile', sourceDetail: 'referrer' }
}
} catch {
return { sourceSurface: 'unknown', sourceDetail: '' }
}
return { sourceSurface: 'unknown', sourceDetail: '' }
}
export async function trackWorldAnalytics(eventType, payload = {}) {
try {
if (!eventType || !payload.world_id) {
return
}
await fetch(WORLD_ANALYTICS_ENDPOINT, {
method: 'POST',
credentials: 'same-origin',
keepalive: true,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
event_type: eventType,
visitor_token: worldAnalyticsVisitorToken(),
...payload,
}),
})
} catch {
// Best-effort analytics only.
}
}
export function trackWorldSourceClick({ worldId, worldTitle = '', sourceSurface = '', sourceDetail = '' }) {
const normalizedSource = normalizeSourceSurface(sourceSurface)
if (!worldId || !normalizedSource) {
return
}
trackWorldAnalytics('world_source_clicked', {
world_id: worldId,
source_surface: normalizedSource,
source_detail: sanitizeDetail(sourceDetail),
entity_type: 'world',
entity_id: worldId,
entity_title: worldTitle,
})
}
export function trackWorldSourceImpression({
worldId,
worldTitle = '',
sourceSurface = '',
sourceDetail = '',
sectionKey = '',
}) {
const normalizedSource = normalizeSourceSurface(sourceSurface)
if (!worldId || !normalizedSource) {
return
}
const key = impressionKey({ worldId, sourceSurface: normalizedSource, sourceDetail, sectionKey })
if (IMPRESSION_KEYS.has(key)) {
return
}
IMPRESSION_KEYS.add(key)
trackWorldAnalytics('world_source_impression', {
world_id: worldId,
source_surface: normalizedSource,
source_detail: sanitizeDetail(sourceDetail),
section_key: String(sectionKey || '').trim().slice(0, 80),
entity_type: 'world',
entity_id: worldId,
entity_title: worldTitle,
})
}