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:
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,7 @@ export function Button({
|
||||
<a
|
||||
className={cls}
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
{...(newTab
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: undefined)}
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
Vendored
+8
@@ -1 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_LICENSE_TRACKING_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user