Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@ cost calculator, and local documentation at `/docs/`.
The production origin defaults to `https://profitctl.com`.
Set it to the actual public origin before deploying elsewhere.
- Cloudflare Pages: set `GO_VERSION=1.26.8` to match `go.mod`.
- `PUBLIC_OPENPANEL_CLIENT_ID`: the public ID of the ProfitCtl **write** client.
Set it in the Cloudflare Pages production build environment to enable analytics.
Leave it unset for development and previews. Never use the MCP read client or
put a client secret in a `PUBLIC_*` variable.

## Analytics

The shared layout loads the [official OpenPanel script](https://openpanel.dev/docs/sdks/script)
asynchronously on `profitctl.com` and `www.profitctl.com`. It records page views
and outgoing link clicks on the homepage and documentation. It does not identify
visitors by name or email, or enable custom attribute tracking.

The script stays unloaded on local and preview domains, when the public client
ID is absent, and when Do Not Track or Global Privacy Control is enabled.
Browser blockers can also prevent collection. The MCP credentials in Doppler
are for reading reports and are never included in the website.

Run `bun run test:analytics` for the domain, privacy, and duplicate-load checks.
These checks also run during the build. Local checks do not prove live event
receipt; verify a page view in ProfitCtl's OpenPanel project after an approved
production deployment.

## Run

Expand Down
3 changes: 2 additions & 1 deletion website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"packageManager": "bun@1.3.3",
"scripts": {
"dev": "node scripts/build-terminal-demo.mjs && astro dev",
"build": "node scripts/build-terminal-demo.mjs && bun scripts/check-terminal-demo.mjs && astro build && bun scripts/check-demo.mjs && node scripts/check-seo.mjs",
"build": "node scripts/check-analytics.mjs && node scripts/build-terminal-demo.mjs && bun scripts/check-terminal-demo.mjs && astro build && bun scripts/check-demo.mjs && node scripts/check-seo.mjs",
"test:analytics": "node scripts/check-analytics.mjs",
"preview": "astro preview",
"typecheck": "astro check && tsc --noEmit"
},
Expand Down
58 changes: 58 additions & 0 deletions website/scripts/check-analytics.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";

const layout = readFileSync(new URL("../src/layouts/ProductLayout.astro", import.meta.url), "utf8");
const script = layout.match(/<script is:inline data-openpanel-init[^>]*>([\s\S]*?)<\/script>/)?.[1];
assert.ok(script, "Shared layout must contain the OpenPanel initializer");

function visit(hostname, navigator = {}, existingClient) {
const loaded = [];
const window = existingClient ? { op: existingClient } : {};
const context = {
window,
location: { hostname },
navigator,
openPanelClientId: "public-test-client",
document: {
createElement: () => ({}),
head: { appendChild: (element) => loaded.push(element) },
},
};
runInNewContext(script, context, { timeout: 1000 });
return { loaded, window, context };
}

for (const host of ["localhost", "127.0.0.1", "profitctl.pages.dev", "preview.profitctl.pages.dev", "profitctl.com.example.org"]) {
const result = visit(host);
assert.equal(result.loaded.length, 0, `Do not load analytics on ${host}`);
assert.equal(result.window.op, undefined, `Do not queue events on ${host}`);
}

for (const signal of [{ doNotTrack: "1" }, { globalPrivacyControl: true }]) {
const result = visit("profitctl.com", signal);
assert.equal(result.loaded.length, 0, "Privacy signals must prevent the analytics request");
assert.equal(result.window.op, undefined, "Privacy signals must prevent queued events");
}

for (const host of ["profitctl.com", "www.profitctl.com"]) {
const result = visit(host);
assert.equal(result.loaded.length, 1, `Load the provider once on ${host}`);
assert.equal(result.loaded[0].src, "https://openpanel.dev/op1.js");
assert.equal(result.loaded[0].async, true);
const queue = JSON.parse(JSON.stringify(result.window.op.q));
assert.deepEqual(queue, [["init", {
clientId: "public-test-client",
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: false,
}]]);
runInNewContext(script, result.context, { timeout: 1000 });
assert.equal(result.loaded.length, 1, "Repeated initialization must not duplicate the provider");
}

const existing = () => {};
const result = visit("profitctl.com", {}, existing);
assert.equal(result.loaded.length, 0, "Preserve an existing OpenPanel client");
assert.equal(result.window.op, existing);
console.log("Analytics domain, privacy, public configuration, and duplicate-load checks passed.");
28 changes: 28 additions & 0 deletions website/src/layouts/ProductLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface Props {

const { title, description, pageType = "product" } = Astro.props;
const repoUrl = import.meta.env.PUBLIC_REPO_URL || "https://github.com/IntelIP/ProfitCtl";
const openPanelClientId = import.meta.env.PUBLIC_OPENPANEL_CLIENT_ID?.trim();
const canonicalOrigin = Astro.site || Astro.url.origin;
const canonicalURL = new URL(Astro.url.pathname, canonicalOrigin);
const imageURL = new URL("/images/profitctl-social-calculator.jpg", canonicalOrigin);
Expand Down Expand Up @@ -78,6 +79,33 @@ const schema = pageType === "docs" ? {
set:html={JSON.stringify(siteSchema).replaceAll("<", "\\u003c")}
/>
)}
{import.meta.env.PROD && openPanelClientId && (
<script is:inline data-openpanel-init define:vars={{ openPanelClientId }}>
(() => {
if (
!["profitctl.com", "www.profitctl.com"].includes(location.hostname) ||
navigator.doNotTrack === "1" ||
navigator.globalPrivacyControl === true ||
window.op
) return;

window.op = function () {
(window.op.q = window.op.q || []).push(Array.from(arguments));
};
window.op("init", {
clientId: openPanelClientId,
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: false,
});

const script = document.createElement("script");
script.src = "https://openpanel.dev/op1.js";
script.async = true;
document.head.appendChild(script);
})();
</script>
)}
</head>
<body>
<a class="skip-link" href="#content">Skip to content</a>
Expand Down
Loading