Files
multica/packages/core/analytics/download.ts
Naiyuan Qing 5335edd50d feat(web): /download page + desktop promotion across landing, login, onboarding (#1500)
* docs(download): add redesign plan and copy positioning source of truth

Captures motivation (Desktop is Multica's native form; CLI is a
distinct scenario for servers/remote boxes, not a Desktop fallback),
four-step execution plan, and every touchpoint's current-vs-new
copy in EN + ZH. Subsequent UI steps read strings from the
positioning doc instead of inventing them inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web): /download page with OS auto-detection

New landing-group route that serves as the single canonical download
destination. Auto-detects OS + arch via navigator.userAgentData
(Chromium) with UA-string fallback, then surfaces the matching
Desktop installer as the primary CTA. All platforms stay visible
below, plus a CLI section (positioned for servers / remote boxes /
headless setups, not as a lightweight Desktop) and a Cloud waitlist.

Version + asset URLs come from api.github.com/repos/.../releases/latest
with Vercel ISR (revalidate=300) so every release automatically
propagates — no manual redeploy. Optional GITHUB_TOKEN env var lifts
the 60/hr unauthenticated rate limit for local dev. Failure
degrades cleanly to "Version unavailable" + a link to GitHub
releases.

Also points landing hero + footer Download links at /download
(previously pointed at the GitHub releases page directly), and
re-exports CloudWaitlistExpand from @multica/views/onboarding so
the new Cloud section can reuse the existing form.

Intel Mac has no binary today (electron-builder targets mac arm64
only); the page is honest about it and routes Intel users to CLI.

i18n copy sourced verbatim from docs/download-positioning.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): rewrite Step 3 fork + web Welcome Desktop CTA

Welcome screen now self-segments: on web (runtimeInstructions
present), the primary CTA is "Download Desktop" with a benefit-led
subtitle ("Desktop bundles the runtime — nothing to install.
Continue on web to connect your own CLI.") that lets developers
with their own CLI recognize their path while guiding everyone
else toward the desktop app. Desktop branch drops the "3 minutes"
estimate in favor of the aha promise. Download button is a real
<a href> link so middle-click / copy-link / screen readers all
behave correctly.

Step 3 fork drops the stale isMac gate — Windows / Linux binaries
now ship, the macOS-only muted card was a lie. The single Desktop
card now routes to /download (not GitHub releases directly) so
users land on the auto-detect page. CLI card is reframed around
its real scenario (servers, remote dev boxes, headless) rather
than posing as a lightweight Desktop, and the CLI dialog's stall
tier redirects users to Desktop instead of Cloud waitlist when
the daemon never registers — Desktop is the genuine retreat.

cli-install-instructions gets a one-liner acknowledging the CLI's
server use case, mirroring the card copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web,auth): desktop promotion on login + solid landing hero download

LoginPage accepts a new `extra?: ReactNode` slot rendered below the
Google button. The web shell injects a hardcoded-EN "Prefer the
desktop app? Download →" nudge there — catching users at their
lowest-investment moment, before they've typed an email. Desktop's
login wrapper omits the slot (a download prompt inside the app
would be absurd), so only the web surface renders it.

Copy is English-only for now because the /login route sits outside
the landing group's LocaleProvider. Lifting locale detection into
the root layout would force every page dynamic and kill the Router
Cache — a trade-off not worth two strings. The `auth.login.extra*`
i18n keys added during Step 2 are removed for the same reason:
they're dead code without a LocaleProvider wrapping login.

Landing hero "Download Desktop" upgrades from ghost to solid and
swaps its handwritten monitor SVG for lucide-react's Download
icon. Both hero CTAs are now solid-weighted — the icon + distinct
label differentiates them. href already points to /download from
the Step 2 landing nav pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web/download): anchor dark LandingHeader with relative wrapper

LandingHeader's dark variant uses `absolute top-0 inset-x-0`, which
only reads correctly when wrapped by a positioned ancestor — see
multica-landing.tsx:14 for the canonical pattern. Without the
wrapper the header escaped to the initial containing block and
appeared fixed as users scrolled the page.

Also drops the <main> element around the body sections for
consistency with the rest of the landing group (neither
multica-landing nor about-page-client wraps in <main>).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(landing/hero): keep Download Desktop as ghost to preserve CTA hierarchy

Upgrading to solid alongside the existing "Start free trial" CTA
killed the primary / secondary distinction — both buttons were
white on dark, competing for attention. Revert to ghost so the
conversion CTA (trial) stays the visual primary. The lucide
Download icon swap stays (cleaner than the handwritten monitor
SVG).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(onboarding): update platform-fork assertions for /download route

The Desktop card in Step 3 now opens the new /download page instead
of GitHub releases, and the post-click feedback text changed to
match ("Continuing on the download page…" in place of "Downloading
Multica…"). Update the expectations and drop the isMac navigator
stub that was only needed when the component had a macOS-only
primary branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Merge origin/main into NevilleQingNY/download-redesign

Main added onboarding funnel analytics (#1489) that captures
`is_mac` as a dimension for each Step 3 path selection. This
branch had removed the `isMac` state because the UI no longer
branches on it (Windows / Linux desktop builds ship now). Git
auto-merged the two diffs into a file that referenced a deleted
variable.

Reintroduce `isMac` as a lazy client-only computation scoped to
analytics capture only — the UI stays platform-agnostic. Handlers
fire client-side so SSR safety isn't needed; a plain const reads
navigator on first render.

typecheck passes across all 6 packages; all 166 views tests
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(analytics): instrument download funnel across 5 surfaces + /download

Closes the gap left by PR #1489: onboarding analytics captured Step
3 path selection but missed the four surfaces that advertise the
desktop app earlier in the funnel (landing hero, landing footer,
login, Welcome), and the /download page itself had zero coverage —
so we could see the last-mile path but not the top-of-funnel entry
nor the page-to-installer conversion.

Three new events, wired via `@multica/core/analytics`:

1. `download_intent_expressed` fires on any CTA pointing at
   /download. `source` splits the five surfaces cleanly; every
   authenticated emission also writes `platform_preference=desktop`
   on the person (same convention Step 3 already uses).

2. `download_page_viewed` fires once per /download mount after OS
   detect resolves. Carries `detected_os`, `detected_arch`,
   `detect_confident` (Chromium userAgentData vs UA fallback), and
   `version_available` so the Safari-on-Mac arm64-default cohort
   and GitHub-rate-limited degraded sessions are each isolable.
   Also $set_once's `first_detected_os/arch` on the person so every
   downstream event gains a platform dimension without re-emitting.

3. `download_initiated` fires on every installer click — Hero's
   primary CTA and each All Platforms matrix row. `primary_cta`
   splits hero-recommended from manual picks; `matched_detect`
   quantifies detect accuracy from the single event (no cross-join
   to download_page_viewed needed).

Augments the existing `onboarding_runtime_path_selected` with a
`source: "step3"` property — literal today, reserved for future
surfaces reusing the same event name. `is_mac` kept for
backward-compat with PR #1489's dashboards; the new events use
`detected_os` + `detected_arch` instead.

New `setPersonPropertiesOnce` wire helper in
`packages/core/analytics/download.ts` for `$set_once` — mirrors
the backend's `Event.SetOnce` semantics.

docs/analytics.md update lands in the follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(analytics): document download_intent_expressed / page_viewed / initiated

Adds the three new download-funnel events to the frontend-only
section. Also notes the semantic shift on
onboarding_runtime_path_selected: its `path: "download_desktop"`
now signals Step 3 path choice, not actual download start —
download_intent_expressed is the new canonical "user expressed
intent to download desktop" signal across surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:25:01 +08:00

114 lines
3.7 KiB
TypeScript

/**
* Download funnel instrumentation.
*
* Complements the onboarding events added in PR #1489 by covering
* every surface that advertises the desktop app — landing hero,
* landing footer, login, Welcome (web branch), Step 3 — and the
* /download page itself. Without this layer we can see Step 3
* path selection but not the touchpoint that got the user there,
* nor the /download → installer conversion.
*
* Event names and property shapes are governed by docs/analytics.md;
* keep the two in sync when adding a new source or field.
*/
import posthog from "posthog-js";
import { captureEvent, setPersonProperties } from "./index";
/**
* Where the user clicked a CTA that points at `/download`. Typed union
* prevents drift across the five touchpoints and lets PostHog funnels
* split cleanly by top-of-funnel entry.
*/
export type DownloadIntentSource =
| "landing_hero"
| "landing_footer"
| "login"
| "welcome"
| "step3";
/**
* OS + arch detect result for the /download page. Mirrors the shape of
* `@/features/landing/utils/os-detect.ts` without importing it (that
* module lives in the web app; core packages can't depend on it). Keep
* these enums in lockstep.
*/
export interface DownloadDetectPayload {
detected_os: "mac" | "windows" | "linux" | "unknown";
detected_arch: "arm64" | "x64" | "unknown";
detect_confident: boolean;
version_available: boolean;
}
/**
* Specific installer the user chose on /download. Version is the GitHub
* tag name (e.g. "v0.2.13") so we can correlate adoption-by-release.
*/
export interface DownloadInitiatedPayload {
platform: "mac" | "windows" | "linux";
arch: "arm64" | "x64";
format: "dmg" | "zip" | "exe" | "appimage" | "deb" | "rpm";
version: string;
primary_cta: boolean;
matched_detect: boolean;
}
/**
* Fires when a user clicks any CTA that navigates to `/download`. We
* also write `platform_preference` to person properties so the backend
* can segment subsequent events — same convention the Step 3 handler
* already uses (see `step-platform-fork.tsx`).
*/
export function captureDownloadIntent(source: DownloadIntentSource): void {
captureEvent("download_intent_expressed", {
source,
});
setPersonProperties({ platform_preference: "desktop" });
}
/**
* Fires once on /download page mount, after OS detection resolves. The
* first detection for a given person is mirrored into person properties
* via `$set_once` so every downstream event gains a platform dimension
* without re-emitting.
*/
export function captureDownloadPageViewed(
payload: DownloadDetectPayload,
): void {
captureEvent("download_page_viewed", {
detected_os: payload.detected_os,
detected_arch: payload.detected_arch,
detect_confident: payload.detect_confident,
version_available: payload.version_available,
});
setPersonPropertiesOnce({
first_detected_os: payload.detected_os,
first_detected_arch: payload.detected_arch,
});
}
/**
* Fires when the user clicks a concrete installer link on `/download`.
* `primary_cta` marks the hero-level recommendation versus a manual
* pick from the All Platforms matrix; `matched_detect` captures
* whether the click matched what we detected (miss = detect got it
* wrong / user overrode).
*/
export function captureDownloadInitiated(
payload: DownloadInitiatedPayload,
): void {
captureEvent("download_initiated", { ...payload });
}
/**
* $set_once wire form. Mirrors the backend's `Event.SetOnce` path —
* first write wins, subsequent ones are no-ops on PostHog's side.
* Wrapping it here keeps call sites free of the no-op `$set_once`
* envelope quirk.
*/
function setPersonPropertiesOnce(props: Record<string, unknown>): void {
if (typeof window === "undefined") return;
posthog.capture("$set", { $set_once: props });
}