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
25 changes: 24 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<boolean | null>(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)
Expand Down Expand Up @@ -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))
Expand All @@ -330,25 +345,29 @@ 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])

const handleExportHtml = useCallback(() => {
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])

Expand All @@ -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)
Expand Down Expand Up @@ -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'))
}

Expand Down Expand Up @@ -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'))
Expand Down
30 changes: 30 additions & 0 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number | boolean>

/** 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 */
}
}
22 changes: 18 additions & 4 deletions src/marketing/components/blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="mk-container mk-section-tight" style={{ paddingBlockStart: '3.5rem' }}>
<Breadcrumbs items={breadcrumbs} lang={lang} />
Expand All @@ -60,7 +62,12 @@ export function PageHero({ h1, intro, breadcrumbs, lang, cta }: PageHeroProps) {
))}
</div>
<div style={{ marginBlockStart: '2rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<a href={cta?.href ?? APP_PATH} className="mk-btn mk-btn-primary mk-btn-lg">
<a
href={cta?.href ?? APP_PATH}
className="mk-btn mk-btn-primary mk-btn-lg"
data-track="Open App"
data-track-location={trackLocation}
>
{cta?.label ?? chrome('openAppFree', lang)}
</a>
</div>
Expand Down Expand Up @@ -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 (
<section className="mk-section-tight">
<div className="mk-cta-band mk-reveal">
Expand All @@ -205,7 +214,12 @@ export function CtaBand({ lang, title, lead, href, label, children }: CtaBandPro
</p>
) : null}
<div style={{ marginBlockStart: '1.75rem', display: 'flex', justifyContent: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<a href={href ?? APP_PATH} className="mk-btn mk-btn-primary mk-btn-lg">
<a
href={href ?? APP_PATH}
className="mk-btn mk-btn-primary mk-btn-lg"
data-track="Open App"
data-track-location={trackLocation}
>
{label ?? chrome('openAppFree', lang)}
</a>
{children}
Expand Down
2 changes: 1 addition & 1 deletion src/marketing/layout/MarketingLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<e.length;i++){e[i].setAttribute('data-obs','1');if(o)o.observe(e[i]);else e[i].classList.add('in')}var v=document.querySelector('video[data-hero-video]');if(v&&!v.getAttribute('data-obs')){v.setAttribute('data-obs','1');var ok=function(){d.classList.add('has-vid')};if(v.readyState>=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<tk.attributes.length;ai++){var at=tk.attributes[ai];if(at.name.indexOf('data-track-')===0)dd[at.name.slice(11)]=at.value}try{window.va('event',{name:tk.getAttribute('data-track'),data:dd})}catch(e){}}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<e.length;i++){e[i].setAttribute('data-obs','1');if(o)o.observe(e[i]);else e[i].classList.add('in')}var v=document.querySelector('video[data-hero-video]');if(v&&!v.getAttribute('data-obs')){v.setAttribute('data-obs','1');var ok=function(){d.classList.add('has-vid')};if(v.readyState>=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
Expand Down
32 changes: 21 additions & 11 deletions src/marketing/layout/SiteFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,27 @@ export function SiteFooter({ lang }: SiteFooterProps) {
<div key={column.heading}>
<h3>{chrome(column.heading, lang)}</h3>
<ul>
{column.links.map((link) => (
<li key={link.to}>
{link.external ? (
<a href={link.to} target="_blank" rel="noopener">
{link.label}
</a>
) : (
<a href={link.to}>{link.label}</a>
)}
</li>
))}
{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 (
<li key={link.to}>
{link.external ? (
<a href={link.to} target="_blank" rel="noopener" {...track}>
{link.label}
</a>
) : (
<a href={link.to} {...track}>
{link.label}
</a>
)}
</li>
)
})}
</ul>
</div>
))}
Expand Down
18 changes: 16 additions & 2 deletions src/marketing/layout/SiteHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<svg viewBox="0 0 16 16" width="18" height="18" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
Expand All @@ -59,6 +61,8 @@ function LangSwitch({ lang, altPath }: { lang: MarketingLang; altPath: string })
className="mk-btn mk-btn-ghost mk-btn-md"
lang={lang === 'ar' ? 'en' : 'ar'}
dir={lang === 'ar' ? 'ltr' : 'rtl'}
data-track="Language Switch"
data-track-to={lang === 'ar' ? 'en' : 'ar'}
>
{chrome('lang.switch', lang)}
</a>
Expand Down Expand Up @@ -92,15 +96,25 @@ export function SiteHeader({ lang, altPath }: SiteHeaderProps) {
<ThemeToggle lang={lang} />
{altPath ? <LangSwitch lang={lang} altPath={altPath} /> : null}
<GithubLink lang={lang} />
<a href={APP_PATH} className="mk-btn mk-btn-primary mk-btn-md">
<a
href={APP_PATH}
className="mk-btn mk-btn-primary mk-btn-md"
data-track="Open App"
data-track-location="header"
>
{chrome('openApp', lang)}
</a>
</div>

{/* Compact cluster (<900px): primary CTA stays 1-tap; nav + secondary actions collapse into the menu.
zero-JS: native <details> opens the menu, ENHANCE_SCRIPT adds the niceties (never React state). */}
<div className="mk-header-compact">
<a href={APP_PATH} className="mk-btn mk-btn-primary mk-btn-sm mk-cta-compact">
<a
href={APP_PATH}
className="mk-btn mk-btn-primary mk-btn-sm mk-cta-compact"
data-track="Open App"
data-track-location="header-mobile"
>
{chrome('openApp', lang)}
</a>
<details className="mk-menu">
Expand Down
8 changes: 7 additions & 1 deletion src/marketing/pages/CheatSheetPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,13 @@ export function CheatSheetPage() {
the template.
</p>
<div style={{ marginBlockStart: '1.75rem' }}>
<a href={`${APP_PATH}?template=cheatsheet`} className="mk-btn mk-btn-primary mk-btn-lg">
<a
href={`${APP_PATH}?template=cheatsheet`}
className="mk-btn mk-btn-primary mk-btn-lg"
data-track="Open App"
data-track-location="cheatsheet"
data-track-template="cheatsheet"
>
Open as editable template
</a>
</div>
Expand Down
26 changes: 23 additions & 3 deletions src/marketing/pages/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,22 @@ export function Landing({ lang }: { lang: MarketingLang }) {
{c.sub}
</p>
<div style={{ marginBlockStart: '2.25rem', display: 'flex', justifyContent: 'center', gap: '0.875rem', flexWrap: 'wrap' }}>
<a href={APP_PATH} className="mk-btn mk-btn-primary mk-btn-lg">
<a
href={APP_PATH}
className="mk-btn mk-btn-primary mk-btn-lg"
data-track="Open App"
data-track-location="hero"
>
{c.ctaPrimary}
</a>
<a href={GITHUB_URL} target="_blank" rel="noopener" className="mk-btn mk-btn-ghost mk-btn-lg">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener"
className="mk-btn mk-btn-ghost mk-btn-lg"
data-track="GitHub"
data-track-location="hero"
>
{c.ctaSecondary}
</a>
</div>
Expand Down Expand Up @@ -192,7 +204,15 @@ export function Landing({ lang }: { lang: MarketingLang }) {
{SAMPLE_EXPORTS.map((sample) => {
const copy = sample[lang]
return (
<a key={sample.href} href={sample.href} target="_blank" rel="noopener" className="mk-card mk-reveal">
<a
key={sample.href}
href={sample.href}
target="_blank"
rel="noopener"
className="mk-card mk-reveal"
data-track="Sample Download"
data-track-sample={sample.href.split('/').pop()?.replace('.pdf', '') || 'sample'}
>
<span className="mk-card-icon" aria-hidden="true">
📄
</span>
Expand Down
Loading
Loading