From 48c0991997588fef4482fab58d94a8cf3aab957f Mon Sep 17 00:00:00 2001 From: aashir-tech Date: Tue, 7 Jul 2026 14:09:55 +0530 Subject: [PATCH] feat: custom analytics events across the app + marketing site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires Vercel Web Analytics custom events on both surfaces, with an identical window.va('event', {name, data}) payload shape: App (src/lib/analytics.ts → trackEvent, over @vercel/analytics track): - Export PDF {skin, paper} — the core success action - Document Exported {format} — word/html/markdown - Template Used {template, source} — picker + deep link - Skin Applied {skin, source} — deep link - Import {source} — docx/html/github - AI Action {action} — no document content - Onboarding Completed — completing transition only Marketing (prerendered, JS-stripped → data-track attrs fired by the inline ENHANCE_SCRIPT, no React): - Open App {location} — header/hero/cta-band/footer/ template-page/skin-page/cheatsheet… (conversion attribution) - GitHub {location} · Sample Download {sample} · Language Switch {to} Properties are flat and PII-free. Verified end-to-end against the built output: both paths emit the exact package payload shape. --- src/App.tsx | 25 +++++++++++++++++- src/lib/analytics.ts | 30 ++++++++++++++++++++++ src/marketing/components/blocks.tsx | 22 +++++++++++++--- src/marketing/layout/MarketingLayout.tsx | 2 +- src/marketing/layout/SiteFooter.tsx | 32 ++++++++++++++++-------- src/marketing/layout/SiteHeader.tsx | 18 +++++++++++-- src/marketing/pages/CheatSheetPage.tsx | 8 +++++- src/marketing/pages/Landing.tsx | 26 ++++++++++++++++--- src/marketing/pages/SkinPage.tsx | 9 ++++++- src/marketing/pages/TemplatePage.tsx | 9 ++++++- 10 files changed, 156 insertions(+), 25 deletions(-) create mode 100644 src/lib/analytics.ts diff --git a/src/App.tsx b/src/App.tsx index a820019..f3e7dc8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -94,6 +94,7 @@ import { AiInputDialog, type AiInputRequest } from '@/components/layout/AiInputD import { AI_PROMPTS } from '@/lib/aiPrompts' import { DEFAULT_AI_CONFIG, isAiConfigured, runAi, type AiConfig } from '@/lib/ai' import { ACCEPTED_IMPORT, importFile } from '@/io/importers' +import { trackEvent } from '@/lib/analytics' import { exportHtml, exportMarkdown, exportWord } from '@/io/exporters' import type { AppLockApi } from '@/hooks/useAppLock' import type { DocumentSkin, PdfConfig, ViewMode } from '@/types' @@ -150,6 +151,19 @@ export default function App({ lock }: AppProps) { setOnboarding((prev) => (prev[step] ? prev : { ...prev, [step]: true })), [setOnboarding], ) + // Track the completing transition only — never on load for returning users. + const onboardingBaselineRef = useRef(null) + useEffect(() => { + const done = onboarding.template && onboarding.edit && onboarding.export + if (onboardingBaselineRef.current === null) { + onboardingBaselineRef.current = done + return + } + if (done && !onboardingBaselineRef.current) { + onboardingBaselineRef.current = true + trackEvent('Onboarding Completed') + } + }, [onboarding]) const isDesktop = useMediaQuery('(min-width: 1024px)') const [configOpen, setConfigOpen] = useState(false) @@ -305,6 +319,7 @@ export default function App({ lock }: AppProps) { try { const result = await importFile(file) library.createDoc(result.markdown, result.name) + trackEvent('Import', { source: file.name.split('.').pop()?.toLowerCase() || 'file' }) toast.success(`${t('toast.imported')} “${file.name}”`) } catch (error) { toast.error(getErrorMessage(error)) @@ -330,13 +345,15 @@ export default function App({ lock }: AppProps) { return } setPrintOpen(true) + trackEvent('Export PDF', { skin: effectiveConfig.skin, paper: effectiveConfig.paperSize }) markOnboarding('export') - }, [bodyEmpty, t, markOnboarding]) + }, [bodyEmpty, t, markOnboarding, effectiveConfig.skin, effectiveConfig.paperSize]) const handleExportWord = useCallback(() => { const doc = ensureDoc() if (!doc) return exportWord(doc, effectiveConfig, effectiveConfig.meta.title || 'document') + trackEvent('Document Exported', { format: 'word' }) toast.success(t('toast.wordExported')) }, [ensureDoc, effectiveConfig, t]) @@ -344,11 +361,13 @@ export default function App({ lock }: AppProps) { const doc = ensureDoc() if (!doc) return exportHtml(doc, effectiveConfig, effectiveConfig.meta.title || 'document') + trackEvent('Document Exported', { format: 'html' }) toast.success(t('toast.htmlExported')) }, [ensureDoc, effectiveConfig, t]) const handleExportMarkdown = useCallback(() => { exportMarkdown(markdown, effectiveConfig.meta.title || 'document') + trackEvent('Document Exported', { format: 'markdown' }) toast.success(t('toast.markdownExported')) }, [markdown, effectiveConfig.meta.title, t]) @@ -368,6 +387,7 @@ export default function App({ lock }: AppProps) { const handleTemplate = useCallback( (template: DocumentTemplate) => { markOnboarding('template') + trackEvent('Template Used', { template: template.id, source: 'picker' }) // Résumé templates first collect the user's header details. if (template.resume) { setPendingResume(template) @@ -420,10 +440,12 @@ export default function App({ lock }: AppProps) { const { data } = parseFrontmatter(template.content) if (Object.keys(data).length > 0) setConfig((prev) => applyFrontmatter(prev, data)) toast.success(`${t('toast.templateLoaded')} “${name}”`) + trackEvent('Template Used', { template: template.id, source: 'deeplink' }) markOnboarding('template') } if (skin) { setConfig((prev) => ({ ...prev, skin })) + trackEvent('Skin Applied', { skin, source: 'deeplink' }) toast.success(t('toast.skinApplied')) } @@ -687,6 +709,7 @@ export default function App({ lock }: AppProps) { const handleAiAction = useCallback( (action: AiToolbarAction) => { + if (action !== 'settings') trackEvent('AI Action', { action }) switch (action) { case 'improve': return runSelectionTransform(AI_PROMPTS.improve, t('ai.improving')) diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..87f10ac --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,30 @@ +import { track } from '@vercel/analytics' + +/** + * Custom analytics events fired from the editor SPA. Names are stable strings + * (they become the event labels in the Vercel dashboard) — keep them in sync + * with the marketing pages' `data-track` attributes, which fire the same + * `window.va('event', …)` call by hand (marketing pages ship no framework JS). + * + * Properties must be flat and must never contain document content or other PII + * — only structural facts (which template, which skin, which format). + */ +export type AppAnalyticsEvent = + | 'Export PDF' + | 'Document Exported' + | 'Template Used' + | 'Skin Applied' + | 'Import' + | 'AI Action' + | 'Onboarding Completed' + +type EventProps = Record + +/** Fire a custom event. No-ops off-Vercel; never throws into the app. */ +export function trackEvent(name: AppAnalyticsEvent, props?: EventProps): void { + try { + track(name, props) + } catch { + /* analytics must never break the editor */ + } +} diff --git a/src/marketing/components/blocks.tsx b/src/marketing/components/blocks.tsx index ffde878..1f9e236 100644 --- a/src/marketing/components/blocks.tsx +++ b/src/marketing/components/blocks.tsx @@ -43,9 +43,11 @@ interface PageHeroProps { breadcrumbs: { name: string; path: string }[] lang: MarketingLang cta?: { label: string; href: string } + /** Analytics location label for the hero CTA. */ + trackLocation?: string } -export function PageHero({ h1, intro, breadcrumbs, lang, cta }: PageHeroProps) { +export function PageHero({ h1, intro, breadcrumbs, lang, cta, trackLocation = 'page-hero' }: PageHeroProps) { return (
@@ -60,7 +62,12 @@ export function PageHero({ h1, intro, breadcrumbs, lang, cta }: PageHeroProps) { ))}
- + {cta?.label ?? chrome('openAppFree', lang)}
@@ -192,9 +199,11 @@ interface CtaBandProps { href?: string label?: string children?: ReactNode + /** Analytics location label for the CTA. */ + trackLocation?: string } -export function CtaBand({ lang, title, lead, href, label, children }: CtaBandProps) { +export function CtaBand({ lang, title, lead, href, label, children, trackLocation = 'cta-band' }: CtaBandProps) { return (
@@ -205,7 +214,12 @@ export function CtaBand({ lang, title, lead, href, label, children }: CtaBandPro

) : null}
- + {label ?? chrome('openAppFree', lang)} {children} diff --git a/src/marketing/layout/MarketingLayout.tsx b/src/marketing/layout/MarketingLayout.tsx index 44e33ed..c519930 100644 --- a/src/marketing/layout/MarketingLayout.tsx +++ b/src/marketing/layout/MarketingLayout.tsx @@ -14,7 +14,7 @@ import { SiteHeader } from './SiteHeader' * content is simply visible — never hidden. */ const ENHANCE_SCRIPT = - "(function(){var d=document.documentElement;if(d.getAttribute('data-mk-enhanced'))return;d.setAttribute('data-mk-enhanced','1');try{var s=localStorage.getItem('scripto:mk-theme');if(s==='light')d.classList.remove('dark');else if(s==='dark')d.classList.add('dark')}catch(e){}d.classList.add('js');document.addEventListener('click',function(ev){var t=ev.target;var c=t&&t.closest?t.closest('[data-copy]'):null;if(c){var box=c.closest('.mk-codebox');var pre=box&&box.querySelector('pre');if(pre&&navigator.clipboard){navigator.clipboard.writeText(pre.innerText).then(function(){var o=c.textContent;c.textContent='Copied!';setTimeout(function(){c.textContent=o},1400)})}return}var b=t&&t.closest?t.closest('[data-theme-toggle]'):null;if(!b)return;var k=d.classList.toggle('dark');try{localStorage.setItem('scripto:mk-theme',k?'dark':'light')}catch(e){}});function mkMenu(){return document.querySelector('details.mk-menu')}document.addEventListener('click',function(ev){var t=ev.target;var m=mkMenu();if(!m||!m.open||!t||!t.closest)return;if(t.closest('.mk-menu-panel a')){m.open=false;return}if(!t.closest('details.mk-menu'))m.open=false});document.addEventListener('keydown',function(ev){if(ev.key!=='Escape')return;var m=mkMenu();if(m&&m.open){m.open=false;var s=m.querySelector('summary');if(s)s.focus()}});addEventListener('resize',function(){if(innerWidth>=900){var m=mkMenu();if(m&&m.open)m.open=false}});document.addEventListener('toggle',function(ev){var m=ev.target;if(!m||!m.classList||!m.classList.contains('mk-menu'))return;var s=m.querySelector('summary');if(s){s.setAttribute('aria-expanded',m.open?'true':'false');var lb=s.getAttribute(m.open?'data-label-close':'data-label-open');if(lb)s.setAttribute('aria-label',lb)}d.classList.toggle('mk-menu-open',m.open)},true);var o='IntersectionObserver'in window?new IntersectionObserver(function(t){t.forEach(function(n){n.isIntersecting&&(n.target.classList.add('in'),o.unobserve(n.target))})},{rootMargin:'0px 0px -8% 0px',threshold:0.08}):null;function scan(){var e=document.querySelectorAll('.mk-reveal:not([data-obs])');for(var i=0;i=2)ok();else v.addEventListener('loadeddata',ok,{once:true})}}if(document.readyState==='loading')addEventListener('DOMContentLoaded',scan);scan();addEventListener('load',scan);setTimeout(scan,400);setTimeout(scan,1600)})();" + "(function(){var d=document.documentElement;if(d.getAttribute('data-mk-enhanced'))return;d.setAttribute('data-mk-enhanced','1');try{var s=localStorage.getItem('scripto:mk-theme');if(s==='light')d.classList.remove('dark');else if(s==='dark')d.classList.add('dark')}catch(e){}d.classList.add('js');document.addEventListener('click',function(ev){var t=ev.target;var tk=t&&t.closest?t.closest('[data-track]'):null;if(tk&&window.va){var dd={};for(var ai=0;ai=900){var m=mkMenu();if(m&&m.open)m.open=false}});document.addEventListener('toggle',function(ev){var m=ev.target;if(!m||!m.classList||!m.classList.contains('mk-menu'))return;var s=m.querySelector('summary');if(s){s.setAttribute('aria-expanded',m.open?'true':'false');var lb=s.getAttribute(m.open?'data-label-close':'data-label-open');if(lb)s.setAttribute('aria-label',lb)}d.classList.toggle('mk-menu-open',m.open)},true);var o='IntersectionObserver'in window?new IntersectionObserver(function(t){t.forEach(function(n){n.isIntersecting&&(n.target.classList.add('in'),o.unobserve(n.target))})},{rootMargin:'0px 0px -8% 0px',threshold:0.08}):null;function scan(){var e=document.querySelectorAll('.mk-reveal:not([data-obs])');for(var i=0;i=2)ok();else v.addEventListener('loadeddata',ok,{once:true})}}if(document.readyState==='loading')addEventListener('DOMContentLoaded',scan);scan();addEventListener('load',scan);setTimeout(scan,400);setTimeout(scan,1600)})();" interface MarketingLayoutProps { lang: MarketingLang diff --git a/src/marketing/layout/SiteFooter.tsx b/src/marketing/layout/SiteFooter.tsx index d2f2bb3..21226cd 100644 --- a/src/marketing/layout/SiteFooter.tsx +++ b/src/marketing/layout/SiteFooter.tsx @@ -95,17 +95,27 @@ export function SiteFooter({ lang }: SiteFooterProps) {

{chrome(column.heading, lang)}

    - {column.links.map((link) => ( -
  • - {link.external ? ( - - {link.label} - - ) : ( - {link.label} - )} -
  • - ))} + {column.links.map((link) => { + const track = + link.to === APP_PATH + ? { 'data-track': 'Open App', 'data-track-location': 'footer' } + : link.to === GITHUB_URL + ? { 'data-track': 'GitHub', 'data-track-location': 'footer' } + : undefined + return ( +
  • + {link.external ? ( + + {link.label} + + ) : ( + + {link.label} + + )} +
  • + ) + })}
))} diff --git a/src/marketing/layout/SiteHeader.tsx b/src/marketing/layout/SiteHeader.tsx index 73120f2..b9e6bd8 100644 --- a/src/marketing/layout/SiteHeader.tsx +++ b/src/marketing/layout/SiteHeader.tsx @@ -43,6 +43,8 @@ function GithubLink({ lang }: { lang: MarketingLang }) { target="_blank" rel="noopener" aria-label={chrome('nav.github', lang)} + data-track="GitHub" + data-track-location="header" >
@@ -100,7 +109,12 @@ export function SiteHeader({ lang, altPath }: SiteHeaderProps) { {/* Compact cluster (<900px): primary CTA stays 1-tap; nav + secondary actions collapse into the menu. zero-JS: native
opens the menu, ENHANCE_SCRIPT adds the niceties (never React state). */}
- + {chrome('openApp', lang)}
diff --git a/src/marketing/pages/CheatSheetPage.tsx b/src/marketing/pages/CheatSheetPage.tsx index 046cd36..701c159 100644 --- a/src/marketing/pages/CheatSheetPage.tsx +++ b/src/marketing/pages/CheatSheetPage.tsx @@ -108,7 +108,13 @@ export function CheatSheetPage() { the template.

diff --git a/src/marketing/pages/Landing.tsx b/src/marketing/pages/Landing.tsx index 5b5f31c..5f60fff 100644 --- a/src/marketing/pages/Landing.tsx +++ b/src/marketing/pages/Landing.tsx @@ -101,10 +101,22 @@ export function Landing({ lang }: { lang: MarketingLang }) { {c.sub}

@@ -192,7 +204,15 @@ export function Landing({ lang }: { lang: MarketingLang }) { {SAMPLE_EXPORTS.map((sample) => { const copy = sample[lang] return ( - + diff --git a/src/marketing/pages/SkinPage.tsx b/src/marketing/pages/SkinPage.tsx index 375002d..a6e06d8 100644 --- a/src/marketing/pages/SkinPage.tsx +++ b/src/marketing/pages/SkinPage.tsx @@ -57,7 +57,13 @@ export function SkinPage() { Best for: {blurb.bestFor}

diff --git a/src/marketing/pages/TemplatePage.tsx b/src/marketing/pages/TemplatePage.tsx index e20cfce..d1e38e2 100644 --- a/src/marketing/pages/TemplatePage.tsx +++ b/src/marketing/pages/TemplatePage.tsx @@ -96,7 +96,13 @@ export function TemplatePage() { typeset PDF — free, no signup, nothing uploaded.