Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render
This commit is contained in:
@@ -199,6 +199,30 @@ function contentViewEventType(contentType) {
|
||||
return 'academy_content_view'
|
||||
}
|
||||
|
||||
function analyticsMetadata(analytics, extra = {}) {
|
||||
const metadata = analytics?.metadata && typeof analytics.metadata === 'object' ? analytics.metadata : {}
|
||||
|
||||
return {
|
||||
page_name: analytics?.pageName,
|
||||
...metadata,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function analyticsTrackingKey(analytics) {
|
||||
if (analytics?.trackingKey) {
|
||||
return String(analytics.trackingKey)
|
||||
}
|
||||
|
||||
const metadata = analytics?.metadata && typeof analytics.metadata === 'object' ? analytics.metadata : {}
|
||||
const pairs = Object.entries(metadata)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}:${String(value)}`)
|
||||
|
||||
return pairs.join('|')
|
||||
}
|
||||
|
||||
export function trackUpgradeClick(analytics, metadata = {}) {
|
||||
if (!analytics?.eventUrl) {
|
||||
return
|
||||
@@ -217,20 +241,17 @@ export function useAcademyPageAnalytics(analytics) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const baseKey = `${analytics.pageName || window.location.pathname}:${analytics.contentType || 'page'}:${analytics.contentId || 'none'}`
|
||||
const trackingKey = analyticsTrackingKey(analytics)
|
||||
const baseKey = `${analytics.pageName || window.location.pathname}:${analytics.contentType || 'page'}:${analytics.contentId || 'none'}:${trackingKey || 'default'}`
|
||||
|
||||
void trackAcademyEvent('academy_page_view', analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
}, {
|
||||
void trackAcademyEvent('academy_page_view', analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics), {
|
||||
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,
|
||||
}, {
|
||||
void trackAcademyEvent(contentViewEventType(analytics.contentType), analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics), {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:content-view`,
|
||||
@@ -238,9 +259,7 @@ export function useAcademyPageAnalytics(analytics) {
|
||||
}
|
||||
|
||||
if (analytics.isPremium && analytics.isLocked) {
|
||||
void trackAcademyEvent('academy_premium_preview_view', analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
}, {
|
||||
void trackAcademyEvent('academy_premium_preview_view', analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics), {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:premium-preview`,
|
||||
@@ -248,10 +267,9 @@ export function useAcademyPageAnalytics(analytics) {
|
||||
}
|
||||
|
||||
const engagedTimer = window.setTimeout(() => {
|
||||
void trackAcademyEvent('academy_engaged_view', analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
void trackAcademyEvent('academy_engaged_view', analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics, {
|
||||
engaged_seconds: 15,
|
||||
}, {
|
||||
}), {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:engaged`,
|
||||
@@ -275,10 +293,9 @@ export function useAcademyPageAnalytics(analytics) {
|
||||
}
|
||||
|
||||
sentMilestones.add(milestone.threshold)
|
||||
void trackAcademyEvent(milestone.eventType, analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
void trackAcademyEvent(milestone.eventType, analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics, {
|
||||
scroll_percent: milestone.threshold,
|
||||
}, {
|
||||
}), {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:scroll-${milestone.threshold}`,
|
||||
@@ -292,5 +309,5 @@ export function useAcademyPageAnalytics(analytics) {
|
||||
window.clearTimeout(engagedTimer)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [analytics?.contentId, analytics?.contentType, analytics?.enabled, analytics?.eventUrl, analytics?.isLocked, analytics?.isPremium, analytics?.pageName])
|
||||
}, [analytics?.contentId, analytics?.contentType, analytics?.enabled, analytics?.eventUrl, analytics?.isLocked, analytics?.isPremium, analytics?.pageName, analytics?.trackingKey, JSON.stringify(analytics?.metadata || {})])
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import React from 'react'
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
|
||||
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(() => {})
|
||||
const storage = new Map([['academy.analytics.visitor-id', 'visitor-123']])
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => storage.get(String(key)) ?? null)
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key, value) => {
|
||||
storage.set(String(key), String(value))
|
||||
})
|
||||
globalThis.fetch = vi.fn(() => Promise.resolve({ ok: true, headers: { get: () => 'application/json' }, json: () => Promise.resolve({ ok: true }) }))
|
||||
}
|
||||
|
||||
function cleanupEnvironment() {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
document.head.innerHTML = ''
|
||||
}
|
||||
@@ -66,5 +73,73 @@ test('academy search click attribution falls back to keepalive fetch when sendBe
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
|
||||
expect(globalThis.fetch.mock.calls[0][1].keepalive).toBe(true)
|
||||
|
||||
cleanupEnvironment()
|
||||
})
|
||||
|
||||
test('academy page analytics includes custom metadata and varies page-view once keys by tracking context', async () => {
|
||||
prepareEnvironment()
|
||||
|
||||
const { useAcademyPageAnalytics } = await import('./academyAnalytics.js')
|
||||
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
|
||||
function TestPage({ analytics }) {
|
||||
useAcademyPageAnalytics(analytics)
|
||||
return React.createElement('div', null, 'Academy page')
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
React.createElement(TestPage, {
|
||||
analytics: {
|
||||
enabled: true,
|
||||
eventUrl: '/academy/analytics/events',
|
||||
pageName: 'academy_prompts_popular',
|
||||
contentType: 'academy_prompt_popular',
|
||||
contentId: null,
|
||||
trackingKey: 'period:30d',
|
||||
metadata: { period: '30d', period_days: 30 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
const firstPageView = globalThis.fetch.mock.calls
|
||||
.map((call) => JSON.parse(call[1].body))
|
||||
.find((payload) => payload.event_type === 'academy_page_view')
|
||||
|
||||
expect(firstPageView.metadata.period).toBe('30d')
|
||||
expect(firstPageView.metadata.period_days).toBe(30)
|
||||
|
||||
rerender(
|
||||
React.createElement(TestPage, {
|
||||
analytics: {
|
||||
enabled: true,
|
||||
eventUrl: '/academy/analytics/events',
|
||||
pageName: 'academy_prompts_popular',
|
||||
contentType: 'academy_prompt_popular',
|
||||
contentId: null,
|
||||
trackingKey: 'period:7d',
|
||||
metadata: { period: '7d', period_days: 7 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
const pageViews = globalThis.fetch.mock.calls
|
||||
.map((call) => JSON.parse(call[1].body))
|
||||
.filter((payload) => payload.event_type === 'academy_page_view')
|
||||
|
||||
expect(pageViews).toHaveLength(2)
|
||||
expect(pageViews[1].metadata.period).toBe('7d')
|
||||
|
||||
cleanupEnvironment()
|
||||
})
|
||||
@@ -23,6 +23,17 @@ function normalizeType(value, fallback = 'error') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function firstValidationError(errors) {
|
||||
if (!errors || typeof errors !== 'object') return ''
|
||||
|
||||
for (const value of Object.values(errors)) {
|
||||
if (Array.isArray(value) && value[0]) return String(value[0]).trim()
|
||||
if (typeof value === 'string' && value.trim()) return value.trim()
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
|
||||
const status = Number(error?.response?.status || 0)
|
||||
const payload = error?.response?.data || {}
|
||||
@@ -30,6 +41,7 @@ export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
|
||||
const mapped = REASON_MAP[reason]
|
||||
const errorCode = String(error?.code || '').toUpperCase()
|
||||
const rawMessage = typeof error?.message === 'string' ? error.message.trim() : ''
|
||||
const validationMessage = firstValidationError(payload?.errors)
|
||||
const timedOut = errorCode === 'ECONNABORTED' || /timeout/i.test(rawMessage)
|
||||
const requestTooLarge = status === 413
|
||||
|
||||
@@ -41,6 +53,7 @@ export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
|
||||
(requestTooLarge ? 'Server rejected this upload chunk as too large. Retrying with smaller chunks may help, or increase Nginx/PHP upload limits.' : '') ||
|
||||
(timedOut ? 'Upload request timed out before the server responded. Check Nginx/PHP-FPM body handling and try again.' : '') ||
|
||||
mapped?.message ||
|
||||
validationMessage ||
|
||||
(typeof payload?.message === 'string' && payload.message.trim()) ||
|
||||
rawMessage ||
|
||||
fallback
|
||||
|
||||
Reference in New Issue
Block a user