nook marketing - utm, analytics, etc

This commit is contained in:
2026-10-03 11:03:26 -04:00
parent 3fe3516b9e
commit 96e401ecbb
11 changed files with 911 additions and 24 deletions

82
src/server/utm.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* Campaign parameters, parsed once and shared by every analytics writer.
*
* The three keys kept here are the ones a launch plan actually groups by:
* which channel (`utm_source`), through what (`utm_medium`), and which
* specific push (`utm_campaign`). Values are trimmed and length-capped,
* but never case-folded — an uppercase campaign name is a different
* campaign until the author says otherwise.
*
* `parseUtm` is variadic and first-non-empty-wins per key, which is what
* lets a logger pass both the URL it was called on and the referrer: a
* download click carries no query of its own, but the landing page it
* came from usually still does.
*/
export interface UtmParams {
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
}
const EMPTY: UtmParams = {
utmSource: null,
utmMedium: null,
utmCampaign: null
};
/** Longest value we will store; far above any real campaign name. */
const MAX_LEN = 200;
/**
* Reads the query string out of a full URL, a path with a query, or a bare
* `a=1&b=2` fragment. Returns null when there is nothing to read.
*/
function searchOf(input: string): URLSearchParams | null {
try {
return new URL(input).searchParams;
} catch {
// Not absolute — take everything after the first "?".
const qIndex = input.indexOf("?");
if (qIndex === -1) return null;
return new URLSearchParams(input.slice(qIndex + 1));
}
}
function read(
search: URLSearchParams,
key: string
): string | null {
const value = search.get(key)?.trim();
if (!value) return null;
return value.slice(0, MAX_LEN);
}
/**
* Extracts UTM parameters from any number of URL-ish strings.
* Earlier arguments win per key; later ones only fill gaps.
*
* @example
* parseUtm("https://nook.freno.me/?utm_source=hn&utm_campaign=launch")
* // { utmSource: "hn", utmMedium: null, utmCampaign: "launch" }
*/
export function parseUtm(
...inputs: (string | null | undefined)[]
): UtmParams {
const result: UtmParams = { ...EMPTY };
for (const input of inputs) {
if (!input) continue;
// Everything present already — nothing left for later inputs to fill.
if (result.utmSource && result.utmMedium && result.utmCampaign) break;
const search = searchOf(input);
if (!search) continue;
result.utmSource ||= read(search, "utm_source");
result.utmMedium ||= read(search, "utm_medium");
result.utmCampaign ||= read(search, "utm_campaign");
}
return result;
}