Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions website/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from 'next'
import { Outfit, Space_Mono } from 'next/font/google'
import LoadingVeil from '@/components/loader/LoadingVeil'
import GoogleAnalytics from '@/components/analytics/GoogleAnalytics'
import './globals.css'

const outfit = Outfit({
Expand Down Expand Up @@ -71,6 +72,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
hydration, while everything beneath it finishes assembling. */}
<LoadingVeil />
{children}
<GoogleAnalytics />
</body>
</html>
)
Expand Down
1 change: 1 addition & 0 deletions website/components/Cymatics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default function Cymatics() {
return (
<section
id="cymatics"
data-analytics-section="cymatics"
style={{
position: 'relative',
minHeight: '100svh',
Expand Down
3 changes: 3 additions & 0 deletions website/components/DownloadButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export default function DownloadButton({ size = 'lg', accent = 'var(--ink)', cla
<a
className="dl-primary"
href={href}
data-analytics-event="download_click"
data-analytics-label={label}
data-analytics-platform={platform ?? 'unknown'}
{...(platform === 'win' || platform === 'mac'
? {}
: { target: '_blank', rel: 'noreferrer noopener' })}
Expand Down
4 changes: 3 additions & 1 deletion website/components/DownloadCTA.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default function DownloadCTA() {

return (
<section
data-analytics-section="download_cta"
style={{
position: 'relative',
overflow: 'hidden',
Expand Down Expand Up @@ -96,7 +97,8 @@ export default function DownloadCTA() {
color: 'var(--slate)'
}}
>
Linea follows compatible Windows media players. Mac downloads retain the legacy Spotify integration.
Linea follows compatible Windows media players. Mac downloads retain the legacy Spotify
integration.
</p>
</div>

Expand Down
5 changes: 4 additions & 1 deletion website/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ export default function Footer() {
const pat = PATTERNS.weave

return (
<footer style={{ borderTop: '1px solid var(--line)', background: 'var(--surface-alt)' }}>
<footer
data-analytics-section="footer"
style={{ borderTop: '1px solid var(--line)', background: 'var(--surface-alt)' }}
>
<h2 className="sr-only">Linea</h2>

<div
Expand Down
1 change: 1 addition & 0 deletions website/components/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const LINKS = [
export default function Nav() {
return (
<header
data-analytics-section="navigation"
className="nav-shade"
style={{
position: 'fixed',
Expand Down
1 change: 1 addition & 0 deletions website/components/OpenSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default function OpenSource({ release }: { release: ReleaseInfo }) {
return (
<section
id="source"
data-analytics-section="open_source"
style={{
position: 'relative',
overflow: 'hidden',
Expand Down
1 change: 1 addition & 0 deletions website/components/UnderTheHood.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const ITEMS: Item[] = [
export default function UnderTheHood() {
return (
<section
data-analytics-section="under_the_hood"
style={{
borderTop: '1px solid var(--line)',
borderBottom: '1px solid var(--line)',
Expand Down
204 changes: 204 additions & 0 deletions website/components/analytics/GoogleAnalytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
'use client'

import Script from 'next/script'
import { usePathname } from 'next/navigation'
import { useEffect, useRef } from 'react'
import { useReportWebVitals } from 'next/web-vitals'
import { GA_MEASUREMENT_ID, trackEvent } from '@/lib/analytics'

const SCROLL_MILESTONES = [25, 50, 75, 90, 100]

function cleanLabel(value: string | null | undefined) {
return value?.replace(/\s+/g, ' ').trim().slice(0, 100) || 'unknown'
}

function interactionLabel(element: HTMLElement) {
return cleanLabel(
element.dataset.analyticsLabel ||
element.getAttribute('aria-label') ||
element.getAttribute('title') ||
element.textContent
)
}

function interactionLocation(element: HTMLElement) {
const region = element.closest<HTMLElement>('[data-analytics-section], section, header, footer')
return cleanLabel(
region?.dataset.analyticsSection ||
region?.id ||
region?.getAttribute('aria-label') ||
region?.tagName.toLowerCase()
)
}

function ClientAnalytics() {
const pathname = usePathname()
const viewedSections = useRef(new Set<string>())
const scrollMilestones = useRef(new Set<number>())

useEffect(() => {
trackEvent('page_view', {
page_title: document.title,
page_location: window.location.href,
page_path: pathname
})

viewedSections.current.clear()
scrollMilestones.current.clear()
}, [pathname])

useEffect(() => {
const onClick = (event: MouseEvent) => {
if (!(event.target instanceof Element)) return
const element = event.target.closest<HTMLElement>(
'a, button, input, select, [data-analytics-event]'
)
if (!element) return

const label = interactionLabel(element)
const location = interactionLocation(element)
const override = element.dataset.analyticsEvent
const link = element instanceof HTMLAnchorElement ? element : null

if (override) {
const linkUrl = link ? new URL(link.href, window.location.href).href : undefined
trackEvent(override, {
interaction_label: label,
interaction_location: location,
interaction_value: element.dataset.analyticsValue,
platform: element.dataset.analyticsPlatform,
link_url: linkUrl
})
return
}

if (link) {
const url = new URL(link.href, window.location.href)
if (url.pathname === '/download') {
trackEvent('download_click', {
platform: url.searchParams.get('platform') || 'unknown',
link_text: label,
interaction_location: location
})
} else if (url.origin !== window.location.origin) {
trackEvent('outbound_link_click', {
link_domain: url.hostname,
link_url: url.href,
link_text: label,
interaction_location: location
})
} else {
trackEvent('navigation_click', {
link_path: `${url.pathname}${url.hash}`,
link_text: label,
interaction_location: location
})
}
return
}

trackEvent('ui_interaction', {
interaction_label: label,
interaction_location: location
})
}

document.addEventListener('click', onClick, { capture: true })
return () => document.removeEventListener('click', onClick, { capture: true })
}, [])

useEffect(() => {
const sections = document.querySelectorAll<HTMLElement>('[data-analytics-section]')
if (!sections.length) return

const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
const section = (entry.target as HTMLElement).dataset.analyticsSection
if (!section || viewedSections.current.has(section)) continue
viewedSections.current.add(section)
trackEvent('section_view', { section_name: section, page_path: window.location.pathname })
}
},
{ threshold: 0.35 }
)

sections.forEach((section) => observer.observe(section))
return () => observer.disconnect()
}, [pathname])

useEffect(() => {
const onScroll = () => {
const available = document.documentElement.scrollHeight - window.innerHeight
const percentage =
available <= 0 ? 100 : Math.min(100, Math.round((window.scrollY / available) * 100))

for (const milestone of SCROLL_MILESTONES) {
if (percentage < milestone || scrollMilestones.current.has(milestone)) continue
scrollMilestones.current.add(milestone)
trackEvent('scroll_depth', {
percent_scrolled: milestone,
page_path: window.location.pathname
})
}
}

window.addEventListener('scroll', onScroll, { passive: true })
onScroll()
return () => window.removeEventListener('scroll', onScroll)
}, [pathname])

useEffect(() => {
let activeSeconds = 0
const milestones = new Set<number>()
const timer = window.setInterval(() => {
if (document.visibilityState !== 'visible') return
activeSeconds += 1
for (const seconds of [30, 60, 180]) {
if (activeSeconds < seconds || milestones.has(seconds)) continue
milestones.add(seconds)
trackEvent('engagement_milestone', { seconds, page_path: window.location.pathname })
}
}, 1_000)

return () => window.clearInterval(timer)
}, [pathname])

useReportWebVitals((metric) => {
trackEvent('web_vital', {
metric_name: metric.name,
metric_value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
metric_delta: Math.round(metric.name === 'CLS' ? metric.delta * 1000 : metric.delta),
metric_id: metric.id
})
})

return null
}

export default function GoogleAnalytics() {
if (!GA_MEASUREMENT_ID) return null

return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', '${GA_MEASUREMENT_ID}', {
send_page_view: false,
transport_type: 'beacon'
});
`}
</Script>
<ClientAnalytics />
</>
)
}
4 changes: 2 additions & 2 deletions website/components/changelog/Changelog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export default function Changelog() {
})}
</div>

<section className={`${s.screen} ${s.opener}`}>
<section className={`${s.screen} ${s.opener}`} data-analytics-section="changelog_intro">
<div className={s.openerInner}>
<p className="mono">Linea — dispatches</p>
<h1>
Expand Down Expand Up @@ -166,7 +166,7 @@ export default function Changelog() {
)
)}

<section className={`${s.screen} ${s.closer}`}>
<section className={`${s.screen} ${s.closer}`} data-analytics-section="changelog_end">
<div className={s.closerInner}>
<h2>That is everything so far.</h2>
<p>The next letter is already being written.</p>
Expand Down
1 change: 1 addition & 0 deletions website/components/changelog/Dispatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default function Dispatch({ release: r, song, accentHex }: Props) {
<section
ref={ref}
className={`${s.screen} ${s.dispatch}`}
data-analytics-section={`release_${r.version}`}
data-notes={open}
style={{ ['--accent-c' as string]: accent, ['--song-c' as string]: accent }}
>
Expand Down
10 changes: 7 additions & 3 deletions website/components/changelog/Interlude.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,18 @@ export default function Interlude({ run }: { run: Release[] }) {
}, [])

return (
<section ref={ref} className={`${s.screen} ${s.interlude}`}>
<section
ref={ref}
className={`${s.screen} ${s.interlude}`}
data-analytics-section="changelog_corrections"
>
<div className={`${s.interludeInner} ${shown ? s.in : ''}`}>
<div className={s.interludeLead}>
<p className="mono">In between · {run.length} corrections</p>
<h2>Three releases in one evening, and not one of them was a feature.</h2>
<p>
The build did not open. Then it opened but could not log in. Then it logged in and
lied about why the lyrics were missing. No letters for these — just the record.
The build did not open. Then it opened but could not log in. Then it logged in and lied
about why the lyrics were missing. No letters for these — just the record.
</p>
</div>

Expand Down
5 changes: 4 additions & 1 deletion website/components/demo/OverlayDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,10 @@ export default function OverlayDemo({ act = 'lyrics', choreograph = true, classN
}

return (
<div className={`${s.demoStage} ${className ?? ''}`}>
<div
className={`${s.demoStage} ${className ?? ''}`}
data-analytics-section="interactive_lyrics_demo"
>
<div
ref={panelRef}
className={s.app}
Expand Down
1 change: 1 addition & 0 deletions website/components/showcase/Showcase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export default function Showcase() {
return (
<section
id="top"
data-analytics-section="showcase"
ref={rootRef}
className={s.showcase}
style={{ '--handoff': 0 } as React.CSSProperties}
Expand Down
2 changes: 2 additions & 0 deletions website/components/theme/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export default function ThemeToggle({ small }: { small?: boolean }) {
role="switch"
aria-checked={dark}
aria-label={dark ? 'Switch to light theme' : 'Switch to dark theme'}
data-analytics-event="theme_toggle"
data-analytics-value={dark ? 'light' : 'dark'}
className={small ? `${s.themeToggle} ${s.small}` : s.themeToggle}
onClick={toggle}
>
Expand Down
Loading
Loading