diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fbc1de31f4..b49876f064 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1 +1 @@ -https://char.com/docs/developers +https://char.com/docs/developers?utm_source=github&utm_medium=contributing&utm_campaign=organic diff --git a/README.md b/README.md index 0f7cb5e334..9b671c1432 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ You can also use it for taking notes for lectures or organizing your thoughts. brew install --cask fastrepl/fastrepl/char ``` -- [macOS](https://char.com/download) (public beta) +- [macOS](https://char.com/download?utm_source=github&utm_medium=readme&utm_campaign=organic) (public beta) - [Windows](https://github.com/fastrepl/char/issues/66) (q2 2026) - [Linux](https://github.com/fastrepl/char/issues/67) (q2 2026) @@ -74,7 +74,7 @@ Char plays nice with whatever stack you're running. Prefer a certain style? Choose from predefined templates like bullet points, agenda-based, or paragraph summary. Or create your own. -Check out our [template gallery](https://char.com/templates) and add your own [here](https://github.com/fastrepl/char/tree/main/apps/web/content/templates). +Check out our [template gallery](https://char.com/templates?utm_source=github&utm_medium=readme&utm_campaign=organic) and add your own [here](https://github.com/fastrepl/char/tree/main/apps/web/content/templates). ### AI Chat diff --git a/apps/desktop/src/auth/context.tsx b/apps/desktop/src/auth/context.tsx index 9868de540b..a89310867d 100644 --- a/apps/desktop/src/auth/context.tsx +++ b/apps/desktop/src/auth/context.tsx @@ -54,6 +54,7 @@ type AuthTokenHandlers = { setSessionFromTokens: ( accessToken: string, refreshToken: string, + opts?: { downloadIntentId?: string }, ) => Promise; }; @@ -118,6 +119,7 @@ async function initSession( let trackedIdentifySignature: string | null = null; let trackedSignedInUserId: string | null = null; +let pendingDownloadIntentId: string | null = null; async function getBillingAnalytics(accessToken: string) { const result = await authPluginCommands.decodeClaims(accessToken); @@ -179,6 +181,14 @@ async function trackAuthEvent( }); } + if (pendingDownloadIntentId) { + void analyticsCommands.event({ + event: "desktop_download_authenticated", + download_intent_id: pendingDownloadIntentId, + }); + pendingDownloadIntentId = null; + } + if (event === "SIGNED_IN" && trackedSignedInUserId !== session.user.id) { trackedSignedInUserId = session.user.id; void analyticsCommands.event({ event: "user_signed_in" }); @@ -188,6 +198,7 @@ async function trackAuthEvent( if (event === "SIGNED_OUT") { trackedIdentifySignature = null; trackedSignedInUserId = null; + pendingDownloadIntentId = null; } } @@ -206,18 +217,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, []); const setSessionFromTokens = useCallback( - async (accessToken: string, refreshToken: string) => { + async ( + accessToken: string, + refreshToken: string, + opts?: { downloadIntentId?: string }, + ) => { if (!supabase) { console.error("Supabase client not found"); return; } + pendingDownloadIntentId = opts?.downloadIntentId?.trim() || null; + const res = await supabase.auth.setSession({ access_token: accessToken, refresh_token: refreshToken, }); if (res.error) { + pendingDownloadIntentId = null; console.error(res.error); } else { setSession(res.data.session); @@ -231,13 +249,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const parsed = new URL(url); const accessToken = parsed.searchParams.get("access_token"); const refreshToken = parsed.searchParams.get("refresh_token"); + const downloadIntentId = parsed.searchParams.get("download_intent_id"); if (!accessToken || !refreshToken) { console.error("invalid_callback_url"); return; } - await setSessionFromTokens(accessToken, refreshToken); + await setSessionFromTokens(accessToken, refreshToken, { + downloadIntentId: downloadIntentId ?? undefined, + }); }, [setSessionFromTokens], ); @@ -338,6 +359,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ) { trackedIdentifySignature = null; trackedSignedInUserId = null; + pendingDownloadIntentId = null; await clearAuthStorage(); setSession(null); return; @@ -348,6 +370,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { trackedIdentifySignature = null; trackedSignedInUserId = null; + pendingDownloadIntentId = null; await clearAuthStorage(); setSession(null); } catch (e) { @@ -357,6 +380,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ) { trackedIdentifySignature = null; trackedSignedInUserId = null; + pendingDownloadIntentId = null; await clearAuthStorage(); setSession(null); } diff --git a/apps/desktop/src/calendar/components/shared.tsx b/apps/desktop/src/calendar/components/shared.tsx index 4c197e71a4..4d640c5c2e 100644 --- a/apps/desktop/src/calendar/components/shared.tsx +++ b/apps/desktop/src/calendar/components/shared.tsx @@ -2,6 +2,7 @@ import { Icon } from "@iconify-icon/react"; import type { ReactNode } from "react"; import { OutlookIcon } from "@hypr/ui/components/icons/outlook"; +import { withCharUtm } from "@hypr/utils"; export type CalendarProvider = { disabled: boolean; @@ -28,7 +29,10 @@ const _PROVIDERS = [ /> ), platform: "macos", - docsPath: "https://char.com/docs/calendar/apple", + docsPath: withCharUtm("https://char.com/docs/calendar/apple", { + source: "app", + medium: "settings", + }), nangoIntegrationId: undefined, }, { @@ -38,7 +42,10 @@ const _PROVIDERS = [ badge: "Beta", icon: , platform: "all", - docsPath: "https://char.com/docs/calendar/gcal", + docsPath: withCharUtm("https://char.com/docs/calendar/gcal", { + source: "app", + medium: "settings", + }), nangoIntegrationId: "google-calendar", }, { @@ -48,7 +55,10 @@ const _PROVIDERS = [ badge: "Beta", icon: , platform: "all", - docsPath: "https://char.com/docs/calendar/outlook", + docsPath: withCharUtm("https://char.com/docs/calendar/outlook", { + source: "app", + medium: "settings", + }), nangoIntegrationId: "outlook", }, ] as const satisfies readonly CalendarProvider[]; diff --git a/apps/desktop/src/changelog/index.tsx b/apps/desktop/src/changelog/index.tsx index e44d3fbd41..bdf981f615 100644 --- a/apps/desktop/src/changelog/index.tsx +++ b/apps/desktop/src/changelog/index.tsx @@ -11,7 +11,7 @@ import { BreadcrumbSeparator, } from "@hypr/ui/components/ui/breadcrumb"; import { Button } from "@hypr/ui/components/ui/button"; -import { safeFormat } from "@hypr/utils"; +import { safeFormat, withCharUtm } from "@hypr/utils"; import { useChangelogContent } from "./data"; @@ -182,7 +182,10 @@ function ChangelogHeader({ const formattedDate = date ? safeFormat(date, "MMM d, yyyy") : null; const webUrl = isNightly(version) ? githubReleaseUrl(version) - : `https://char.com/changelog/${version}`; + : withCharUtm(`https://char.com/changelog/${version}`, { + source: "app", + medium: "changelog", + }); return (
diff --git a/apps/desktop/src/settings/ai/llm/shared.tsx b/apps/desktop/src/settings/ai/llm/shared.tsx index 6d83418192..b2c92a18b0 100644 --- a/apps/desktop/src/settings/ai/llm/shared.tsx +++ b/apps/desktop/src/settings/ai/llm/shared.tsx @@ -11,6 +11,8 @@ import { } from "@lobehub/icons"; import type { ReactNode } from "react"; +import { withCharUtm } from "@hypr/utils"; + import { env } from "~/env"; import { CharProviderIcon } from "~/settings/ai/shared"; import { @@ -61,7 +63,13 @@ const _PROVIDERS = [ models: { label: "Available models", url: "https://lmstudio.ai/models" }, setup: { label: "Setup guide", - url: "https://char.com/docs/faq/local-llm-setup/#lm-studio-setup", + url: withCharUtm( + "https://char.com/docs/faq/local-llm-setup/#lm-studio-setup", + { + source: "app", + medium: "settings", + }, + ), }, }, }, @@ -80,7 +88,13 @@ const _PROVIDERS = [ models: { label: "Available models", url: "https://ollama.com/library" }, setup: { label: "Setup guide", - url: "https://char.com/docs/faq/local-llm-setup/#ollama-setup", + url: withCharUtm( + "https://char.com/docs/faq/local-llm-setup/#ollama-setup", + { + source: "app", + medium: "settings", + }, + ), }, }, }, diff --git a/apps/desktop/src/settings/data/index.tsx b/apps/desktop/src/settings/data/index.tsx index 226d86178e..871eba2b68 100644 --- a/apps/desktop/src/settings/data/index.tsx +++ b/apps/desktop/src/settings/data/index.tsx @@ -9,6 +9,7 @@ import { type ImportSourceKind, type ImportStats, } from "@hypr/plugin-importer"; +import { withCharUtm } from "@hypr/utils"; import { ImportPreview } from "./import-preview"; import { SourceItem } from "./source-item"; @@ -23,6 +24,15 @@ type DryRunResult = { stats: ImportStats; }; +const importDocsUrl = withCharUtm("https://char.com/docs/data/#import", { + source: "app", + medium: "settings", +}); +const exportDocsUrl = withCharUtm("https://char.com/docs/data/#export", { + source: "app", + medium: "settings", +}); + export function Data() { const [dryRunResult, setDryRunResult] = useState(null); const [successfulSource, setSuccessfulSource] = @@ -101,9 +111,7 @@ export function Data() { return (
- { - "Import data from other apps. Read more about [import](https://char.com/docs/data/#import) and [export](https://char.com/docs/data/#export)." - } + {`Import data from other apps. Read more about [import](${importDocsUrl}) and [export](${exportDocsUrl}).`}
diff --git a/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts b/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts index 9954ef2e9c..c11a2c3ae3 100644 --- a/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts +++ b/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts @@ -35,9 +35,12 @@ export function useDeeplinkHandler() { const unlisten = deeplink2Events.deepLinkEvent.listen(({ payload }) => { if (payload.to === "/auth/callback") { - const { access_token, refresh_token } = payload.search; + const { access_token, refresh_token, download_intent_id } = + payload.search; if (access_token && refresh_token && auth) { - void auth.setSessionFromTokens(access_token, refresh_token); + void auth.setSessionFromTokens(access_token, refresh_token, { + downloadIntentId: download_intent_id ?? undefined, + }); } } else if (payload.to === "/billing/refresh") { if (auth) { diff --git a/apps/web/content/docs/developers/12.analytics.mdx b/apps/web/content/docs/developers/12.analytics.mdx index e59c34fb78..6c149f5fa7 100644 --- a/apps/web/content/docs/developers/12.analytics.mdx +++ b/apps/web/content/docs/developers/12.analytics.mdx @@ -74,8 +74,8 @@ This enrichment applies to desktop frontend events and Rust plugin `event_fire_a | Event | Properties | Source | |------|------------|--------| | `hero_section_viewed` | `timestamp` | `apps/web/src/routes/_view/index.tsx` | -| `download_clicked` | Homepage: `platform`, `timestamp` | `apps/web/src/components/download-button.tsx` | -| `download_clicked` | Download page: `platform`, `spec`, `source` (`"download_page"`) | `apps/web/src/routes/_view/download/index.tsx` | +| `download_clicked` | Homepage: `platform`, `timestamp`, optional `download_intent_id`, optional `web_distinct_id` | `apps/web/src/components/download-button.tsx` | +| `download_clicked` | Download page: `platform`, `spec`, `source` (`"download_page"`), optional `download_intent_id`, optional `web_distinct_id` | `apps/web/src/routes/_view/download/index.tsx` | | `reminder_requested` | `platform`, `timestamp`, `email` | `apps/web/src/routes/_view/index.tsx` | | `os_waitlist_joined` | `platform`, `timestamp`, `email` | `apps/web/src/routes/_view/index.tsx` | @@ -91,13 +91,15 @@ Notes: |------|------------|--------| | `show_main_window` | none (plus auto-enriched desktop props) | `plugins/windows/src/ext.rs` | | `onboarding_step_viewed` | `step`, `platform` | `apps/desktop/src/onboarding/index.tsx` | +| `onboarding_login_skipped` | none | `apps/desktop/src/onboarding/index.tsx` | | `onboarding_completed` | none | `apps/desktop/src/onboarding/final.tsx` | | `user_signed_in` | none | `apps/desktop/src/auth/context.tsx` | +| `desktop_download_authenticated` | `download_intent_id` | `apps/desktop/src/auth/context.tsx` | | `trial_flow_client_error` | `properties.error` (nested object) | `apps/desktop/src/onboarding/account/trial.tsx` | -| `trial_flow_skipped` | `properties.reason` (`already_pro` or `already_trialing`) | `apps/desktop/src/onboarding/account/trial.tsx` | +| `trial_flow_skipped` | `properties.reason` (`already_paid` or `already_trialing`) | `apps/desktop/src/onboarding/account/trial.tsx` | | `data_imported` | `source` | `apps/desktop/src/settings/data/index.tsx` | | `note_created` | `has_event_id` | `apps/desktop/src/store/tinybase/store/sessions.ts`, `apps/desktop/src/shared/main/useNewNote.ts` | -| `file_uploaded` | Audio: `file_type = "audio"`; Transcript: `file_type = "transcript"`, `token_count` | `apps/desktop/src/session/components/floating/options-menu.tsx` | +| `file_uploaded` | Audio: `file_type = "audio"`; Transcript: `file_type = "transcript"`, `token_count` | `apps/desktop/src/stt/useUploadFile.ts` | | `session_started` | `has_calendar_event`, `stt_provider`, `stt_model` | `apps/desktop/src/stt/useStartListening.ts` | | `tab_opened` | `view` | `apps/desktop/src/store/zustand/tabs/basic.ts` | | `search_performed` | none | `apps/desktop/src/search/contexts/ui.tsx` | @@ -108,7 +110,7 @@ Notes: | `session_exported` | PDF export: `format = "pdf"`, `view_type`, `has_transcript`, `has_enhanced`, `has_memo` | `apps/desktop/src/session/components/outer-header/overflow/export-pdf.tsx` | | `session_exported` | Transcript export: `format = "vtt"`, `word_count` | `apps/desktop/src/session/components/outer-header/overflow/export-transcript.tsx` | | `session_deleted` | `includes_recording` (currently always `true`) | `apps/desktop/src/session/components/outer-header/overflow/delete.tsx` | -| `settings_changed` | `autostart`, `notification_detect`, `save_recordings`, `telemetry_consent` | `apps/desktop/src/settings/general/index.tsx` | +| `settings_changed` | `autostart`, `notification_detect`, `telemetry_consent` | `apps/desktop/src/settings/general/index.tsx` | | `ai_provider_configured` | `provider` | `apps/desktop/src/settings/ai/shared/index.tsx` | | `upgrade_clicked` | `plan` (`"lite"` or `"pro"`) | `apps/desktop/src/settings/general/account.tsx` | | `user_signed_out` | none | `apps/desktop/src/settings/general/account.tsx` | @@ -122,6 +124,7 @@ Notes: | `dismiss` | none | `plugins/notification/src/handler.rs` | | `collapsed_timeout` | none | `plugins/notification/src/handler.rs` | | `option_selected` | none | `plugins/notification/src/handler.rs` | +| `footer_action` | none | `plugins/notification/src/handler.rs` | ### API/server events @@ -133,6 +136,37 @@ Notes: | `trial_skipped` | `reason = "not_eligible"`, `source` | `crates/api-subscription/src/trial.rs`, `crates/api-subscription/src/routes/billing.rs` | | `trial_failed` | `reason` (`stripe_error`, `customer_error`, `rpc_error`), `source` | `crates/api-subscription/src/trial.rs`, `crates/api-subscription/src/routes/billing.rs` | +## UTM parameters on owned links + +We tag selected `char.com` links with UTM parameters so PostHog can attribute visits and installs back to the place the click came from. + +### Convention + +| Parameter | Values | +|-----------|--------| +| `utm_source` | `github`, `app`, `website` | +| `utm_medium` | `contributing`, `readme`, `settings`, `changelog`, `blog`, `docs` | +| `utm_campaign` | `organic` | + +### Current coverage + +- GitHub docs links in `README.md` and `CONTRIBUTING.md` +- Desktop links opened from settings and changelog screens +- Blog article CTAs that point to the homepage or download pages +- Docs CTAs that point to tracked marketing pages such as `/founders` or `/download/...` + +### Rules + +- Add UTMs only on owned `char.com` destinations we want to attribute. +- Preserve existing query params and place UTMs before `#fragments`. +- Do not add UTMs to relative links, blog-to-blog cross-links, third-party links, API endpoints, or legacy onboarding assets in `crates/db-user`. + +### Why this matters + +- `download_clicked` can be segmented by where the session originated. +- Acquisition and install conversion are easier to break down by channel. +- `utm_source` and `utm_medium` answer which owned surfaces are actually driving visits. + ## User journey funnel The user lifecycle is divided into 8 stages. Each stage lists the analytics signals that measure it, how identity linking works at that point, and known gaps. @@ -158,10 +192,10 @@ Goal: measure how many website visitors download and open the app. | Event | Properties | Where | |-------|------------|-------| -| `download_clicked` | Homepage: `platform`, `timestamp`; Download page: `platform`, `spec`, `source` | Web | +| `download_clicked` | Homepage: `platform`, `timestamp`; Download page: `platform`, `spec`, `source`; tracked download CTAs also attach `download_intent_id` | Web | | `show_main_window` | none (auto-enriched with `app_version`, `git_hash`, `bundle_id`) | Desktop | -Identity linking: `download_clicked` fires with anonymous browser ID. `show_main_window` fires with machine fingerprint. These two IDs are **not linked** at this point — there is no mechanism to pass the browser identity into the desktop app at download time. Conversion rate between these two events can only be measured at cohort level (e.g., X downloads this week, Y first app opens this week), not per-user. +Identity linking: `download_clicked` fires with an anonymous browser ID and a one-shot `download_intent_id`. `show_main_window` still fires with machine fingerprint, and there is still no install-time mechanism to pass the browser identity into the desktop app. That means click → first open remains cohort-level unless the user continues into desktop auth. Gap: no explicit install-complete event. Install is inferred from first `show_main_window`. @@ -176,13 +210,15 @@ Goal: measure onboarding progress and completion. | Event | Properties | Notes | |-------|------------|-------| | `onboarding_step_viewed` | `step`, `platform` | Fired per step. macOS steps: `permissions` → `login` → `calendar` → `final`. Other platforms: `login` → `calendar` → `final`. | +| `onboarding_login_skipped` | none | Desktop. Fired when the user skips the login step during onboarding. | | `user_signed_in` | none | Fires on sign-in. Desktop auth sync also updates `plan` and `trial_end_date` on sign-in, initial session restore, and token refresh. | +| `desktop_download_authenticated` | `download_intent_id` | Fires when a desktop sign-in completes from a tracked download flow. | | `trial_started` | `plan`, `source` | Server-side. Fires when trial is successfully created. | -| `trial_flow_skipped` | `properties.reason` (`already_pro` or `already_trialing`) | Desktop. User already has a subscription. | +| `trial_flow_skipped` | `properties.reason` (`already_paid` or `already_trialing`) | Desktop. User already has a paid plan or active trial. | | `trial_flow_client_error` | `properties.error` | Desktop. Error during trial activation. | | `onboarding_completed` | none | Fires when user clicks "Get Started" on the final onboarding screen. | -Identity linking: desktop sign-in opens `char.com/auth` **in the user's default browser**. The web auth callback calls `posthog.identify(supabaseUserId)`, which merges the anonymous browser ID with the Supabase user ID. The desktop then calls `identify(supabaseUserId)` with `$anon_distinct_id` = machine fingerprint. This links browser → Supabase user ID → machine fingerprint. Because the login opens in the same browser where the user may have previously clicked download, PostHog can retroactively merge `download_clicked` with the authenticated user — **but only if the same browser is used for both download and login**. +Identity linking: desktop sign-in opens `char.com/auth` **in the user's default browser**. The web auth callback calls `posthog.identify(supabaseUserId)`, which merges the anonymous browser ID with the Supabase user ID. The desktop then calls `identify(supabaseUserId)` with `$anon_distinct_id` = machine fingerprint. This links browser → Supabase user ID → machine fingerprint. For tracked download flows, the website also passes a `download_intent_id` through the desktop deeplink, and desktop sign-in emits `desktop_download_authenticated` with that same value. That gives us an explicit query key for click → desktop auth without relying only on cross-surface person merges. Same-browser login is still required if we want the original anonymous `download_clicked` event merged onto the authenticated user. diff --git a/apps/web/content/handbook/how-we-work/8.analytics-funnel.mdx b/apps/web/content/handbook/how-we-work/8.analytics-funnel.mdx index e70d929e8f..8c59eb96a1 100644 --- a/apps/web/content/handbook/how-we-work/8.analytics-funnel.mdx +++ b/apps/web/content/handbook/how-we-work/8.analytics-funnel.mdx @@ -17,13 +17,17 @@ We track the user lifecycle as an 8-stage funnel. Website acquisition uses conse [8. Paying] ``` +## UTM parameters + +Inbound traffic from GitHub, desktop CTAs, and website CTAs carries `utm_source`, `utm_medium`, and `utm_campaign=organic` so we can attribute visits and installs by surface. See the [developer analytics docs](/docs/developers/analytics#utm-parameters-on-owned-links) for the exact convention. + ## 1. Acquisition (website visits) -Standard consented website analytics tracking, plus PostHog pageview and session tracking. +Standard consented website analytics tracking, plus PostHog pageview and session tracking. PostHog also captures UTM parameters from the landing page URL for attributed sessions. ## 2. Converting visits to app installs -- **`download_clicked`** — `platform`, `spec`, `source` +- **`download_clicked`** — `platform`, `spec`, `source`, optional `download_intent_id` - **`show_main_window`** — fired whenever the app is opened. First occurrence marks install. Optional: @@ -34,6 +38,7 @@ Optional: - `onboarding_step_viewed` — freshly downloaded users see this on first open - `user_signed_in` + `identify()` — account created +- `desktop_download_authenticated` — connects a tracked download click to desktop auth with `download_intent_id` - **`onboarding_completed`** — finished onboarding flow ## 4. Activation (first summary) diff --git a/apps/web/src/components/download-button.tsx b/apps/web/src/components/download-button.tsx index e5f2439b0c..f1bc8490ad 100644 --- a/apps/web/src/components/download-button.tsx +++ b/apps/web/src/components/download-button.tsx @@ -4,6 +4,7 @@ import { cn } from "@hypr/utils"; import { usePlatform } from "@/hooks/use-platform"; import { useAnalytics } from "@/hooks/use-posthog"; +import { rememberDesktopAttribution } from "@/lib/desktop-attribution"; export function DownloadButton({ variant = "default", @@ -11,7 +12,7 @@ export function DownloadButton({ variant?: "default" | "compact"; }) { const platform = usePlatform(); - const { track } = useAnalytics(); + const { track, getDistinctId } = useAnalytics(); const getPlatformData = () => { switch (platform) { @@ -45,9 +46,16 @@ export function DownloadButton({ const { icon, label, href } = getPlatformData(); const handleClick = () => { + const webDistinctId = getDistinctId(); + const attribution = rememberDesktopAttribution(); + track("download_clicked", { platform: platform, timestamp: new Date().toISOString(), + ...(attribution + ? { download_intent_id: attribution.downloadIntentId } + : {}), + ...(webDistinctId ? { web_distinct_id: webDistinctId } : {}), }); }; diff --git a/apps/web/src/components/mdx/index.ts b/apps/web/src/components/mdx/index.ts index 20834c757e..f3fe66fe7f 100644 --- a/apps/web/src/components/mdx/index.ts +++ b/apps/web/src/components/mdx/index.ts @@ -2,7 +2,7 @@ export { Callout } from "./callout"; export { Clip } from "./clip"; export { CodeBlock } from "./code-block"; export { GithubEmbed } from "./github-embed"; -export { MDXLink } from "./link"; +export { createMDXLink, MDXLink } from "./link"; export { createMDXComponents, defaultMDXComponents } from "./mdx-components"; export { Mermaid } from "./mermaid"; export { Tweet } from "./tweet"; diff --git a/apps/web/src/components/mdx/link.tsx b/apps/web/src/components/mdx/link.tsx index d78a820828..0e0f50f297 100644 --- a/apps/web/src/components/mdx/link.tsx +++ b/apps/web/src/components/mdx/link.tsx @@ -1,62 +1,118 @@ import { Link } from "@tanstack/react-router"; -import { cn } from "@hypr/utils"; +import { cn, withCharUtm } from "@hypr/utils"; const linkClassName = "underline underline-offset-2 decoration-neutral-400 hover:decoration-neutral-600 transition-colors"; -export function MDXLink({ - href, - children, - className, - ...props -}: React.AnchorHTMLAttributes) { - if (!href) { - return {children}; +function normalizePathname(pathname: string) { + return pathname === "/" ? pathname : pathname.replace(/\/+$/, "") || "/"; +} + +function getTrackedWebsiteHref(href: string, utmMedium?: "blog" | "docs") { + if (!utmMedium) { + return href; } - const isHyprnoteUrl = href.startsWith("https://hyprnote.com"); - const isInternalPath = href.startsWith("/") || href.startsWith("."); - const isAnchor = href.startsWith("#"); + let url: URL; + try { + url = new URL(href); + } catch { + return href; + } - if (isHyprnoteUrl) { - const relativePath = href.replace("https://hyprnote.com", "") || "/"; - return ( - - {children} - - ); + if (!["char.com", "www.char.com"].includes(url.hostname)) { + return href; } - if (isAnchor) { - return ( - - {children} - - ); + const pathname = normalizePathname(url.pathname); + const shouldTrack = + utmMedium === "blog" + ? pathname === "/" || + pathname === "/download" || + pathname.startsWith("/download/") + : pathname === "/download" || + pathname.startsWith("/download/") || + pathname === "/founders"; + + if (!shouldTrack) { + return href; } - if (isInternalPath) { + return withCharUtm(href, { source: "website", medium: utmMedium }); +} + +export function createMDXLink({ + utmMedium, +}: { + utmMedium?: "blog" | "docs"; +} = {}) { + return function MDXLink({ + href, + children, + className, + ...props + }: React.AnchorHTMLAttributes) { + if (!href) { + return {children}; + } + + const resolvedHref = getTrackedWebsiteHref(href, utmMedium); + const isHyprnoteUrl = resolvedHref.startsWith("https://hyprnote.com"); + const isInternalPath = + resolvedHref.startsWith("/") || resolvedHref.startsWith("."); + const isAnchor = resolvedHref.startsWith("#"); + + if (isHyprnoteUrl) { + const relativePath = + resolvedHref.replace("https://hyprnote.com", "") || "/"; + return ( + + {children} + + ); + } + + if (isAnchor) { + return ( + + {children} + + ); + } + + if (isInternalPath) { + return ( + + {children} + + ); + } + return ( - + {children} - + ); - } - - return ( - - {children} - - ); + }; } + +export const MDXLink = createMDXLink(); diff --git a/apps/web/src/hooks/use-posthog.ts b/apps/web/src/hooks/use-posthog.ts index 8b9948393d..73d0629c37 100644 --- a/apps/web/src/hooks/use-posthog.ts +++ b/apps/web/src/hooks/use-posthog.ts @@ -34,6 +34,13 @@ export function useAnalytics() { [posthog, analyticsReady], ); + const getDistinctId = useCallback(() => { + if (!posthog) { + return null; + } + return posthog.get_distinct_id(); + }, [posthog]); + const reset = useCallback(() => { if (!analyticsReady || !posthog) { return; @@ -44,6 +51,7 @@ export function useAnalytics() { return { track, identify, + getDistinctId, reset, posthog, analyticsReady, diff --git a/apps/web/src/lib/desktop-attribution.ts b/apps/web/src/lib/desktop-attribution.ts new file mode 100644 index 0000000000..8383dc76e2 --- /dev/null +++ b/apps/web/src/lib/desktop-attribution.ts @@ -0,0 +1,79 @@ +const STORAGE_KEY = "char_desktop_attribution_v2"; +const MAX_AGE_MS = 24 * 60 * 60 * 1000; + +type DesktopAttribution = { + downloadIntentId: string; + savedAt: number; +}; + +function readStoredAttribution() { + if (typeof window === "undefined") { + return null; + } + + const rawValue = window.localStorage.getItem(STORAGE_KEY); + if (!rawValue) { + return null; + } + + try { + const parsedValue = JSON.parse(rawValue) as Partial; + + if ( + typeof parsedValue.downloadIntentId !== "string" || + typeof parsedValue.savedAt !== "number" + ) { + window.localStorage.removeItem(STORAGE_KEY); + return null; + } + + if (Date.now() - parsedValue.savedAt > MAX_AGE_MS) { + window.localStorage.removeItem(STORAGE_KEY); + return null; + } + + return { + downloadIntentId: parsedValue.downloadIntentId, + savedAt: parsedValue.savedAt, + }; + } catch { + window.localStorage.removeItem(STORAGE_KEY); + return null; + } +} + +function createDownloadIntentId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + return `download-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +export function rememberDesktopAttribution() { + if (typeof window === "undefined") { + return; + } + + const attribution = { + downloadIntentId: createDownloadIntentId(), + savedAt: Date.now(), + } satisfies DesktopAttribution; + + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify(attribution), + ); + + return attribution; +} + +export function consumeDesktopAttribution() { + const attribution = readStoredAttribution(); + + if (typeof window !== "undefined") { + window.localStorage.removeItem(STORAGE_KEY); + } + + return attribution; +} diff --git a/apps/web/src/routes/_view/blog/$slug.tsx b/apps/web/src/routes/_view/blog/$slug.tsx index 5f1c4267e3..f9bce1d264 100644 --- a/apps/web/src/routes/_view/blog/$slug.tsx +++ b/apps/web/src/routes/_view/blog/$slug.tsx @@ -8,7 +8,7 @@ import { cn } from "@hypr/utils"; import { AcquisitionLinkGrid } from "@/components/acquisition-link-grid"; import { CTASection } from "@/components/cta-section"; -import { defaultMDXComponents } from "@/components/mdx"; +import { createMDXComponents, createMDXLink } from "@/components/mdx"; import { useBlogToc } from "@/hooks/use-blog-toc"; import { CHAR_SITE_URL, @@ -18,6 +18,10 @@ import { } from "@/lib/seo"; import { AUTHOR_AVATARS } from "@/lib/team"; +const blogMDXComponents = createMDXComponents({ + a: createMDXLink({ utmMedium: "blog" }), +}); + export const Route = createFileRoute("/_view/blog/$slug")({ component: Component, loader: async ({ params }) => { @@ -209,7 +213,7 @@ function ArticleContent({ article }: { article: any }) { return (
- +
); diff --git a/apps/web/src/routes/_view/callback/auth.tsx b/apps/web/src/routes/_view/callback/auth.tsx index 41d0415387..41170f5098 100644 --- a/apps/web/src/routes/_view/callback/auth.tsx +++ b/apps/web/src/routes/_view/callback/auth.tsx @@ -12,6 +12,7 @@ import { CharLogo } from "@/components/sidebar"; import { exchangeOAuthCode, exchangeOtpToken } from "@/functions/auth"; import { desktopSchemeSchema } from "@/functions/desktop-flow"; import { useAnalytics } from "@/hooks/use-posthog"; +import { consumeDesktopAttribution } from "@/lib/desktop-attribution"; const validateSearch = z.object({ code: z.string().optional(), @@ -187,6 +188,9 @@ function Component() { const search = Route.useSearch(); const navigate = useNavigate(); const { identify: identifyPosthog } = useAnalytics(); + const [desktopAttribution] = useState(() => + search.flow === "desktop" ? consumeDesktopAttribution() : null, + ); const [copied, setCopied] = useState(false); useEffect(() => { @@ -208,13 +212,16 @@ function Component() { } catch (e) { console.error("Failed to decode JWT for identify:", e); } - }, [search.access_token, identifyPosthog]); + }, [identifyPosthog, search.access_token]); const getDeeplink = () => { if (search.access_token && search.refresh_token) { const params = new URLSearchParams(); params.set("access_token", search.access_token); params.set("refresh_token", search.refresh_token); + if (desktopAttribution?.downloadIntentId) { + params.set("download_intent_id", desktopAttribution.downloadIntentId); + } return `${search.scheme}://auth/callback?${params.toString()}`; } return null; diff --git a/apps/web/src/routes/_view/docs/-components.tsx b/apps/web/src/routes/_view/docs/-components.tsx index d932ceb17e..fc2069451e 100644 --- a/apps/web/src/routes/_view/docs/-components.tsx +++ b/apps/web/src/routes/_view/docs/-components.tsx @@ -4,11 +4,15 @@ import { allDocs } from "content-collections"; import { useMemo } from "react"; import { AcquisitionLinkGrid } from "@/components/acquisition-link-grid"; -import { defaultMDXComponents } from "@/components/mdx"; +import { createMDXComponents, createMDXLink } from "@/components/mdx"; import { TableOfContents } from "@/components/table-of-contents"; import { docsStructure } from "./-structure"; +const docsMDXComponents = createMDXComponents({ + a: createMDXLink({ utmMedium: "docs" }), +}); + export function DocLayout({ doc, showSectionTitle = true, @@ -95,7 +99,7 @@ function ArticleHeader({ function ArticleContent({ doc }: { doc: any }) { return (
- +
); } diff --git a/apps/web/src/routes/_view/download/index.tsx b/apps/web/src/routes/_view/download/index.tsx index d812b69284..823e2ba8f0 100644 --- a/apps/web/src/routes/_view/download/index.tsx +++ b/apps/web/src/routes/_view/download/index.tsx @@ -6,6 +6,7 @@ import { cn } from "@hypr/utils"; import { Image } from "@/components/image"; import { useAnalytics } from "@/hooks/use-posthog"; +import { rememberDesktopAttribution } from "@/lib/desktop-attribution"; export const Route = createFileRoute("/_view/download/")({ component: Component, @@ -105,14 +106,21 @@ function DownloadCard({ nightlyDownloadUrl: string; platform: string; }) { - const { track } = useAnalytics(); + const { track, getDistinctId } = useAnalytics(); const [isNightly, setIsNightly] = useState(false); const handleClick = () => { + const webDistinctId = getDistinctId(); + const attribution = rememberDesktopAttribution(); + track("download_clicked", { platform: isNightly ? `${platform}-nightly` : platform, spec, source: "download_page", + ...(attribution + ? { download_intent_id: attribution.downloadIntentId } + : {}), + ...(webDistinctId ? { web_distinct_id: webDistinctId } : {}), }); }; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c25e058549..270430d1d4 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,2 +1,3 @@ export * from "./cn"; export * from "./date"; +export * from "./url"; diff --git a/packages/utils/src/url.test.ts b/packages/utils/src/url.test.ts new file mode 100644 index 0000000000..6c8c662808 --- /dev/null +++ b/packages/utils/src/url.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { withCharUtm } from "./url"; + +describe("withCharUtm", () => { + it("appends UTMs to char links", () => { + expect( + withCharUtm("https://char.com/download", { + source: "github", + medium: "readme", + }), + ).toBe( + "https://char.com/download?utm_source=github&utm_medium=readme&utm_campaign=organic", + ); + }); + + it("preserves fragments and normalizes the homepage URL", () => { + expect( + withCharUtm("https://char.com#pricing", { + source: "website", + medium: "blog", + }), + ).toBe( + "https://char.com/?utm_source=website&utm_medium=blog&utm_campaign=organic#pricing", + ); + }); + + it("keeps existing query params", () => { + expect( + withCharUtm("https://char.com/download?spec=apple-silicon", { + source: "app", + medium: "settings", + }), + ).toBe( + "https://char.com/download?spec=apple-silicon&utm_source=app&utm_medium=settings&utm_campaign=organic", + ); + }); +}); diff --git a/packages/utils/src/url.ts b/packages/utils/src/url.ts new file mode 100644 index 0000000000..2212fda0ff --- /dev/null +++ b/packages/utils/src/url.ts @@ -0,0 +1,20 @@ +export function withCharUtm( + url: string, + { + source, + medium, + campaign = "organic", + }: { + source: string; + medium: string; + campaign?: string; + }, +) { + const parsed = new URL(url); + + parsed.searchParams.set("utm_source", source); + parsed.searchParams.set("utm_medium", medium); + parsed.searchParams.set("utm_campaign", campaign); + + return parsed.toString(); +} diff --git a/plugins/deeplink2/js/bindings.gen.ts b/plugins/deeplink2/js/bindings.gen.ts index 94de41ea09..305898a6d5 100644 --- a/plugins/deeplink2/js/bindings.gen.ts +++ b/plugins/deeplink2/js/bindings.gen.ts @@ -39,7 +39,7 @@ deepLinkEvent: "plugin:deeplink2:deep-link-event" /** user-defined types **/ -export type AuthCallbackSearch = { access_token: string; refresh_token: string } +export type AuthCallbackSearch = { access_token: string; refresh_token: string; download_intent_id: string | null } export type BillingRefreshSearch = Record export type DeepLink = { to: "/auth/callback"; search: AuthCallbackSearch } | { to: "/billing/refresh"; search: BillingRefreshSearch } | { to: "/integration/callback"; search: IntegrationCallbackSearch } export type DeepLinkEvent = DeepLink diff --git a/plugins/deeplink2/src/types/auth_callback.rs b/plugins/deeplink2/src/types/auth_callback.rs index defe5fe854..55fee24bd6 100644 --- a/plugins/deeplink2/src/types/auth_callback.rs +++ b/plugins/deeplink2/src/types/auth_callback.rs @@ -7,6 +7,7 @@ use specta::Type; pub struct AuthCallbackSearch { pub access_token: String, pub refresh_token: String, + pub download_intent_id: Option, } impl fmt::Debug for AuthCallbackSearch { @@ -14,6 +15,7 @@ impl fmt::Debug for AuthCallbackSearch { f.debug_struct("AuthCallbackSearch") .field("access_token", &"[REDACTED]") .field("refresh_token", &"[REDACTED]") + .field("download_intent_id", &self.download_intent_id) .finish() } }