Build world campaigns rewards and recaps
This commit is contained in:
212
resources/js/lib/worldAnalytics.js
Normal file
212
resources/js/lib/worldAnalytics.js
Normal file
@@ -0,0 +1,212 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user