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",
|
"name": "ttrpg-player-landing",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"description": "Marketing landing for TTRPG Player (НРИ Плеер)",
|
"description": "Marketing landing for TTRPG Player (НРИ Плеер)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
resolveDownloadHref,
|
resolveDownloadHref,
|
||||||
resolvePlatformDownloadHref,
|
resolvePlatformDownloadHref,
|
||||||
} from "../../lib/detectOs";
|
} from "../../lib/detectOs";
|
||||||
|
import { trackDownload } from "../../lib/trackDownload";
|
||||||
import { Section } from "../layout/Section";
|
import { Section } from "../layout/Section";
|
||||||
import { Button } from "../ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
import { PlatformLogo } from "../ui/PlatformLogo";
|
import { PlatformLogo } from "../ui/PlatformLogo";
|
||||||
@@ -28,11 +29,24 @@ export function Platforms() {
|
|||||||
|
|
||||||
const hints = useMemo(() => readNavigatorHints(), []);
|
const hints = useMemo(() => readNavigatorHints(), []);
|
||||||
|
|
||||||
const osDownloadHref = useMemo(() => {
|
const { osDownloadHref, detectedOs } = useMemo(() => {
|
||||||
const os = detectOs(hints.userAgent);
|
const os = detectOs(hints.userAgent);
|
||||||
return resolveDownloadHref(os, DOWNLOAD_LINKS, hints);
|
return {
|
||||||
|
osDownloadHref: resolveDownloadHref(os, DOWNLOAD_LINKS, hints),
|
||||||
|
detectedOs: os,
|
||||||
|
};
|
||||||
}, [hints]);
|
}, [hints]);
|
||||||
|
|
||||||
|
const trackOsDownload = () => {
|
||||||
|
const platform =
|
||||||
|
detectedOs === "windows" ||
|
||||||
|
detectedOs === "macos" ||
|
||||||
|
detectedOs === "linux"
|
||||||
|
? detectedOs
|
||||||
|
: "windows";
|
||||||
|
trackDownload(platform);
|
||||||
|
};
|
||||||
|
|
||||||
const platformDownloadHrefs = useMemo(
|
const platformDownloadHrefs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
@@ -58,6 +72,9 @@ export function Platforms() {
|
|||||||
href={platformDownloadHrefs[id]}
|
href={platformDownloadHrefs[id]}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={styles.downloadBtn}
|
className={styles.downloadBtn}
|
||||||
|
onClick={() => {
|
||||||
|
trackDownload(id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{copy.cta.download}
|
{copy.cta.download}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -72,7 +89,9 @@ export function Platforms() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={styles.mainCta}>
|
<div className={styles.mainCta}>
|
||||||
<Button href={osDownloadHref}>{copy.cta.downloadForOs}</Button>
|
<Button href={osDownloadHref} onClick={trackOsDownload}>
|
||||||
|
{copy.cta.downloadForOs}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function Button({
|
|||||||
<a
|
<a
|
||||||
className={cls}
|
className={cls}
|
||||||
href={href}
|
href={href}
|
||||||
|
onClick={onClick}
|
||||||
{...(newTab
|
{...(newTab
|
||||||
? { target: "_blank", rel: "noopener noreferrer" }
|
? { target: "_blank", rel: "noopener noreferrer" }
|
||||||
: undefined)}
|
: 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" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_LICENSE_TRACKING_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user