Track installer downloads by platform via license server API.

Fire-and-forget POST on Platforms download clicks with tests and configurable tracking URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-02 00:21:22 +08:00
parent 1110b3e533
commit 9013fa0b49
6 changed files with 114 additions and 4 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ttrpg-player-landing",
"private": true,
"version": "1.0.1",
"version": "1.0.2",
"description": "Marketing landing for TTRPG Player (НРИ Плеер)",
"type": "module",
"scripts": {
+22 -3
View File
@@ -7,6 +7,7 @@ import {
resolveDownloadHref,
resolvePlatformDownloadHref,
} from "../../lib/detectOs";
import { trackDownload } from "../../lib/trackDownload";
import { Section } from "../layout/Section";
import { Button } from "../ui/Button";
import { PlatformLogo } from "../ui/PlatformLogo";
@@ -28,11 +29,24 @@ export function Platforms() {
const hints = useMemo(() => readNavigatorHints(), []);
const osDownloadHref = useMemo(() => {
const { osDownloadHref, detectedOs } = useMemo(() => {
const os = detectOs(hints.userAgent);
return resolveDownloadHref(os, DOWNLOAD_LINKS, hints);
return {
osDownloadHref: resolveDownloadHref(os, DOWNLOAD_LINKS, hints),
detectedOs: os,
};
}, [hints]);
const trackOsDownload = () => {
const platform =
detectedOs === "windows" ||
detectedOs === "macos" ||
detectedOs === "linux"
? detectedOs
: "windows";
trackDownload(platform);
};
const platformDownloadHrefs = useMemo(
() =>
Object.fromEntries(
@@ -58,6 +72,9 @@ export function Platforms() {
href={platformDownloadHrefs[id]}
variant="ghost"
className={styles.downloadBtn}
onClick={() => {
trackDownload(id);
}}
>
{copy.cta.download}
</Button>
@@ -72,7 +89,9 @@ export function Platforms() {
</p>
<div className={styles.mainCta}>
<Button href={osDownloadHref}>{copy.cta.downloadForOs}</Button>
<Button href={osDownloadHref} onClick={trackOsDownload}>
{copy.cta.downloadForOs}
</Button>
</div>
</Section>
);
+1
View File
@@ -34,6 +34,7 @@ export function Button({
<a
className={cls}
href={href}
onClick={onClick}
{...(newTab
? { target: "_blank", rel: "noopener noreferrer" }
: undefined)}
+47
View File
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { afterEach, describe, it, mock } from "node:test";
import {
DEFAULT_LICENSE_TRACKING_BASE,
resolveTrackDownloadUrl,
trackDownload,
} from "./trackDownload";
describe("trackDownload", () => {
afterEach(() => {
mock.restoreAll();
});
it("builds default tracking URL", () => {
assert.equal(
resolveTrackDownloadUrl(DEFAULT_LICENSE_TRACKING_BASE),
"https://license.ttrpgplayer.ru/v1/track/download",
);
});
it("normalizes base URL without trailing slash", () => {
assert.equal(
resolveTrackDownloadUrl("https://license.example.test"),
"https://license.example.test/v1/track/download",
);
});
it("POSTs platform with keepalive and swallows errors", async () => {
const fetchMock = mock.fn(() => Promise.reject(new Error("network down")));
globalThis.fetch = fetchMock;
trackDownload("macos");
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(fetchMock.mock.calls.length, 1);
const call = fetchMock.mock.calls[0];
assert.ok(call);
const [url, init] = call.arguments as unknown as [string, RequestInit];
assert.equal(url, "https://license.ttrpgplayer.ru/v1/track/download");
assert.equal(init.method, "POST");
assert.equal(init.keepalive, true);
assert.deepEqual(init.headers, { "Content-Type": "application/json" });
assert.equal(init.body, JSON.stringify({ platform: "macos" }));
});
});
+35
View File
@@ -0,0 +1,35 @@
import type { PlatformId } from "../data/siteContent";
export const DEFAULT_LICENSE_TRACKING_BASE = "https://license.ttrpgplayer.ru/";
function normalizeBaseUrl(base: string): string {
return base.endsWith("/") ? base : `${base}/`;
}
export function resolveLicenseTrackingBase(): string {
const fromEnv =
typeof import.meta.env !== "undefined"
? import.meta.env.VITE_LICENSE_TRACKING_URL
: undefined;
if (typeof fromEnv === "string" && fromEnv.trim()) {
return normalizeBaseUrl(fromEnv.trim());
}
return DEFAULT_LICENSE_TRACKING_BASE;
}
export function resolveTrackDownloadUrl(base?: string): string {
return `${normalizeBaseUrl(base ?? resolveLicenseTrackingBase())}v1/track/download`;
}
export function trackDownload(platform: PlatformId): void {
if (typeof fetch === "undefined") return;
void fetch(resolveTrackDownloadUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ platform }),
keepalive: true,
}).catch(() => {
// fire-and-forget: ignore network errors
});
}
+8
View File
@@ -1 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_LICENSE_TRACKING_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}