Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 a8ef87a097 Track which app card or icon visitors pick on the Apps pages
Adds an app_select event to the /apps icon row, the lineup cards and the
"More from the family" sibling cards, so link_location shows which entry
point sent visitors to each app page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:51:34 +07:00
bflatasticandClaude Opus 5.5 084aa1b4b4 Add Google Analytics to the marketing site
Loads GA4 (gtag.js) when NEXT_PUBLIC_GA_MEASUREMENT_ID is set and sends
events for the site's conversion paths: desktop/Crafting App downloads,
webapp and CTA clicks, social and contact links, pricing checkout,
the H3 promptbox and auth gate, beta leads, video plays, and shares.

Most link clicks are classified by href in one delegated listener, so
components only change where an action isn't a plain link. Analytics
stays off when the env var is unset (local dev, deploy previews).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:34:44 +07:00
19 changed files with 389 additions and 11 deletions

No files matched your search

@@ -4,6 +4,10 @@
# Manage the build command and publish directory here; leave those UI fields empty.
# The Next.js runtime handles routing, the /cdn-media proxy rewrite, and the
# legacy-route redirects declared in next.config.ts. Other media is in public/.
#
# Google Analytics: set NEXT_PUBLIC_GA_MEASUREMENT_ID (G-XXXXXXXXXX) in the
# Netlify UI, scoped to the Production context so deploy previews don't
# report. It is inlined at build time, so redeploy after changing it.
[build]
command = "npm run build"
@@ -22,6 +22,7 @@ import { AppIcon, ColorAccent, appThemeClass } from "@/components/apps/app-wordm
import { PageHeader } from "@/components/page/page-header";
import RevealManager from "@/components/reveal-manager";
import { Button } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import {
CRAFTING_APPS,
CRAFTING_APPS_COUNT_WORD,
@@ -108,6 +109,7 @@ export default function CraftingAppsPage() {
key={app.slug}
href={craftAppPath(app)}
className={`group flex w-18 flex-col items-center gap-2 sm:w-20 ${appThemeClass(app)}`}
{...trackAttrs("app_select", { app_name: app.slug })}
>
<AppIcon
app={app}
@@ -9,6 +9,7 @@ import SiteNav from "@/components/site-nav";
import SiteFooter from "@/components/site-footer";
import IntroConductor from "@/components/intro-conductor";
import LandingContext from "@/components/landing-context";
import Analytics from "@/components/analytics";
import MotionProvider from "@/components/motion-provider";
import ScrollRuler from "@/components/ruler/scroll-ruler";
import TunerPanel from "@/components/dev/tuner-panel";
@@ -138,6 +139,7 @@ export default function RootLayout({
</MotionProvider>
<TunerPanel />
<LandingContext />
<Analytics />
</body>
</html>
);
@@ -0,0 +1,41 @@
"use client";
import { useEffect } from "react";
import Script from "next/script";
import { GA_MEASUREMENT_ID, trackClick } from "@/lib/analytics";
// Loads gtag.js after hydration and installs the one delegated click
// listener behind the site's link and data-track events (lib/analytics.ts).
// Renders nothing when no measurement id is configured.
export default function Analytics() {
useEffect(() => {
if (!GA_MEASUREMENT_ID) return;
// Capture phase: runs before click-to-play facades swap themselves out,
// and before any handler can stop propagation. auxclick covers
// middle-click "open in new tab".
const onClick = (event: MouseEvent) => {
if (event.type === "auxclick" && event.button !== 1) return;
trackClick(event.target);
};
document.addEventListener("click", onClick, true);
document.addEventListener("auxclick", onClick, true);
return () => {
document.removeEventListener("click", onClick, true);
document.removeEventListener("auxclick", onClick, true);
};
}, []);
if (!GA_MEASUREMENT_ID) return null;
return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
strategy="afterInteractive"
/>
<Script id="ga-init" strategy="afterInteractive">
{`window.dataLayer=window.dataLayer||[];window.gtag=function(){dataLayer.push(arguments);};gtag("js",new Date());gtag("config",${JSON.stringify(GA_MEASUREMENT_ID)});`}
</Script>
</>
);
}
@@ -2,6 +2,7 @@ import Link from "next/link";
import { ArrowUpRightIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Badge } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import {
craftAppName,
craftAppPath,
@@ -30,6 +31,7 @@ export default function AppCard({
<Link
href={craftAppPath(app)}
data-reveal
{...trackAttrs("app_select", { app_name: app.slug })}
className={twMerge(
"group relative flex h-full flex-col bg-bg",
appThemeClass(app),
@@ -6,6 +6,7 @@ import DiscordButton from "@/components/discord-button";
import { GitHubIcon } from "@/components/icons";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { Badge, Button, CopyButton } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import {
CRAFTING_APPS,
craftAppBuildCommand,
@@ -203,6 +204,7 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
value={command}
variant="ghost"
className="h-9 border-l border-line px-3"
{...trackAttrs("copy_command", { app_name: app.slug })}
/>
</div>
<pre className="overflow-x-auto p-4 font-mono text-[13px] leading-relaxed text-ink">
@@ -1,5 +1,6 @@
import { twMerge } from "tailwind-merge";
import { Button, CopyButton } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
// Share strip: copy-link plus intent links for the networks our audience
// posts to. Hairline cells (gap-px over the line color) so it wraps cleanly
@@ -40,6 +41,7 @@ export default function ShareBar({
text: string;
className?: string;
}) {
const shareParams = { content_type: "page", item_id: new URL(url).pathname };
return (
<div
className={twMerge(
@@ -56,6 +58,7 @@ export default function ShareBar({
copiedLabel="Link copied"
variant="ghost"
className={CELL_CLASSES}
{...trackAttrs("share", { method: "copy_link", ...shareParams })}
/>
{TARGETS.map((target) => (
<Button
@@ -66,6 +69,7 @@ export default function ShareBar({
aria-label={`Share on ${target.name}`}
variant="ghost"
className={CELL_CLASSES}
{...trackAttrs("share", { method: target.name.toLowerCase(), ...shareParams })}
>
{target.name}
</Button>
@@ -4,6 +4,7 @@ import { useState, type FormEvent } from "react";
import { CheckIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Button, Input, Label } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { webappUrl } from "@/lib/links";
// Submissions go to a Google Form whose responses land in a Google Sheet.
@@ -83,6 +84,7 @@ export default function BetaForm() {
// Google Forms sends no CORS headers, so this is fire-and-forget: the
// response is opaque and "request completed" counts as success.
await fetch(FORM_RESPONSE_URL, { method: "POST", mode: "no-cors", body });
trackEvent("generate_lead", { form_name: "beta", user_type: userType ?? "" });
setSubmitted(true);
} catch {
setSubmitError(
@@ -3,6 +3,7 @@
import { useState, type FormEvent } from "react";
import { CheckIcon, EyeIcon, EyeOffIcon } from "lucide-react";
import { Button, Input, Modal } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { login, signup } from "@/lib/api";
import {
getLandingUrl,
@@ -118,8 +119,9 @@ function SignupForm({
maybeReferralCode: getReferralCode(),
});
setLoading(false);
if (result.success) onSuccess();
else setError(result.errorMessage || "Failed to create account");
if (!result.success) return setError(result.errorMessage || "Failed to create account");
trackEvent("sign_up", { method: "email", signup_source: signupSource });
onSuccess();
};
return (
@@ -162,8 +164,9 @@ function LoginForm({ onSuccess }: { onSuccess: () => void }) {
setLoading(true);
const result = await login({ usernameOrEmail, password });
setLoading(false);
if (result.success) onSuccess();
else setError(result.errorMessage || "Invalid credentials");
if (!result.success) return setError(result.errorMessage || "Invalid credentials");
trackEvent("login", { method: "email" });
onSuccess();
};
return (
@@ -3,6 +3,7 @@
import { useState } from "react";
import { PlayIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { trackAttrs } from "@/lib/analytics";
import type { ExampleVideo } from "@/lib/campaign-data";
// Click-to-play facades: the mp4s (several MB each) are only requested
@@ -69,6 +70,11 @@ function ExampleCard({
onClick={() => setActive(true)}
aria-label={`Play example: ${example.label}`}
className="group absolute inset-0 h-full w-full"
{...trackAttrs("video_start", {
video_provider: "self_hosted",
video_title: example.label,
video_url: example.src,
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -9,6 +9,7 @@ import {
XIcon,
} from "lucide-react";
import { Button, Select, Switch } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { generateVideo, getVideoJob } from "@/lib/api";
import { H3, H3_GATE_PERKS, H3_SAMPLE_PROMPTS } from "@/lib/campaign-data";
import { webappUrl } from "@/lib/links";
@@ -101,6 +102,7 @@ export default function H3PromptBox() {
setSubmitting(false);
if (!result.success) {
patch(id, { status: "failed", error: result.errorMessage });
trackEvent("video_generation", { model: H3.modelId, status: "failed" });
return;
}
@@ -114,12 +116,15 @@ export default function H3PromptBox() {
if (job.status === "complete") {
patch(id, { status: "complete", videoUrl: job.videoUrl });
stops.current.delete(id);
trackEvent("video_generation", { model: H3.modelId, status: "complete" });
} else if (job.status === "failed") {
patch(id, { status: "failed", error: job.error });
stops.current.delete(id);
trackEvent("video_generation", { model: H3.modelId, status: "failed" });
} else if (attempts >= POLL_MAX_ATTEMPTS) {
patch(id, { status: "failed", error: "Generation timed out" });
stops.current.delete(id);
trackEvent("video_generation", { model: H3.modelId, status: "timeout" });
} else {
setTimeout(poll, POLL_INTERVAL_MS);
}
@@ -136,6 +141,14 @@ export default function H3PromptBox() {
setError(`Prompt exceeds the ${MAX_PROMPT_LENGTH} character limit for this model`);
return;
}
trackEvent("prompt_submit", {
model: H3.modelId,
logged_in: loggedIn,
aspect_ratio: aspectRatio,
resolution,
duration_seconds: Number(duration),
sound,
});
if (!loggedIn) {
setGateOpen(true);
return;
@@ -2,6 +2,7 @@
import { useState } from "react";
import { PlayIcon } from "lucide-react";
import { trackAttrs } from "@/lib/analytics";
// Click-to-load YouTube embed: a thumbnail until the visitor asks for the
// player, so the landing never pays the iframe cost up front.
@@ -32,6 +33,11 @@ export default function LiteYouTube({
onClick={() => setActivated(true)}
className="group absolute inset-0 h-full w-full"
aria-label={`Play video: ${title}`}
{...trackAttrs("video_start", {
video_provider: "youtube",
video_title: title,
video_url: `https://www.youtube.com/watch?v=${videoId}`,
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import Link from "next/link";
import { ArrowDownToLineIcon, CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
import { Button } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { MEDIA_LABELS, corsMediaUrl, mediaDetailLabel, mediaFormat, mediaKind, mediaThumbnail, safeMediaUrl, type MediaDimensions, type MediaPrompt, type SharedMedia } from "@/lib/media";
import { getModelDisplayName, getModelIcon, getProviderDisplayName, getProviderIcon } from "@/lib/model-names";
@@ -35,6 +36,7 @@ export default function MediaDetails({ media, prompt, promptLoading, dimensions
}
async function download() {
trackEvent("media_download", { media_type: kind, file_extension: mediaFormat(media) });
setDownloading(true);
setMessage("");
const controller = new AbortController();
@@ -102,7 +104,10 @@ export default function MediaDetails({ media, prompt, promptLoading, dimensions
<h2 className="hud-label text-faint">Prompt</h2>
{prompt?.maybe_positive_prompt && (
<button type="button" className="text-muted hover:text-ink" aria-label={copied === "prompt" ? "Prompt copied" : "Copy prompt"}
onClick={() => copy(prompt.maybe_positive_prompt!, "prompt")}>
onClick={() => {
copy(prompt.maybe_positive_prompt!, "prompt");
trackEvent("copy_prompt", { media_type: kind });
}}>
{copied === "prompt" ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
</button>
)}
@@ -143,7 +148,10 @@ export default function MediaDetails({ media, prompt, promptLoading, dimensions
<Button onClick={download} loading={downloading} className="w-full">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />{downloading ? "Downloading" : "Download file"}
</Button>
<Button variant="secondary" className="w-full" onClick={() => copy(`${SHARE_URL_BASE}${encodeURIComponent(media.token)}`, "link")}>
<Button variant="secondary" className="w-full" onClick={() => {
copy(`${SHARE_URL_BASE}${encodeURIComponent(media.token)}`, "link");
trackEvent("share", { method: "copy_link", content_type: kind, item_id: media.token });
}}>
{copied === "link" ? <CheckIcon aria-hidden className="h-4 w-4" /> : <CopyIcon aria-hidden className="h-4 w-4" />}
{copied === "link" ? "Link copied" : "Copy share link"}
</Button>
@@ -11,6 +11,7 @@ import {
import { twMerge } from "tailwind-merge";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { Button, Modal } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import { mediaUrl } from "@/lib/links";
import {
PRESS_KIT_CATEGORIES,
@@ -148,6 +149,10 @@ function AssetCard({
onClick={onPlay}
aria-label={`Play ${asset.title}`}
className="group relative aspect-video w-full overflow-hidden bg-bg-sunken"
{...trackAttrs("video_start", {
video_provider: asset.type === "embed" ? "youtube" : "self_hosted",
video_title: asset.title,
})}
>
{thumbnail}
</button>
@@ -175,6 +180,7 @@ function AssetCard({
rel="noopener noreferrer"
variant="secondary"
className="w-full"
{...trackAttrs("press_kit_download", { asset_name: asset.title })}
>
<ArrowDownToLineIcon aria-hidden className="h-3.5 w-3.5" />
{asset.downloadLabel ?? "Download"}
@@ -3,6 +3,7 @@
import { useState } from "react";
import { CoinsIcon } from "lucide-react";
import { Button } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { webappUrl } from "@/lib/links";
import { useAccount } from "@/lib/use-account";
import CreditsModal from "./credits-modal";
@@ -28,7 +29,10 @@ export default function CreditsCta() {
<Button
type="button"
variant="secondary"
onClick={() => setOpen(true)}
onClick={() => {
setOpen(true);
trackEvent("view_credit_packs", {});
}}
className="mt-6"
>
<CoinsIcon aria-hidden className="h-3.5 w-3.5 text-accent-ink" />
@@ -4,6 +4,7 @@ import { useState } from "react";
import { CoinsIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Button, Modal } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { creditsPackCheckout } from "@/lib/api";
type CreditPack = {
@@ -35,6 +36,20 @@ export default function CreditsModal({
const purchase = async (pack: CreditPack) => {
setPurchasingId(pack.id);
setError(null);
trackEvent("begin_checkout", {
currency: "USD",
value: pack.priceUsd,
checkout_type: "credits",
items: [
{
item_id: pack.id,
item_name: `${pack.total.toLocaleString("en-US")} credits`,
item_category: "credits",
price: pack.priceUsd,
quantity: 1,
},
],
});
const result = await creditsPackCheckout(pack.id);
if (result.success) {
window.location.href = result.data.checkoutUrl;
@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Badge, Button, TabSelector } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import {
getPortalUrl,
subscriptionCheckout,
@@ -85,9 +86,26 @@ export default function PricingTable({
if (plan.slug === activePlanSlug) return;
setProcessingPlan(plan.slug);
setError(null);
const checkoutType = !user ? "signup" : hasActivePlan ? "switch" : "subscribe";
const price = cadence === "yearly" ? plan.yearlyPrice : plan.monthlyPrice;
trackEvent("begin_checkout", {
currency: "USD",
value: price,
checkout_type: checkoutType,
items: [
{
item_id: plan.slug,
item_name: plan.name,
item_category: "subscription",
item_variant: cadence,
price,
quantity: 1,
},
],
});
const body = { plan: plan.slug, cadence };
const redirected = redirect(
!user
checkoutType === "signup"
? await userSignupSubscriptionCheckout({
...body,
maybeReferralUrl: getReferrer(),
@@ -95,7 +113,7 @@ export default function PricingTable({
maybeReferralUsername: getReferralUsername(),
maybeReferralCode: getReferralCode(),
})
: hasActivePlan
: checkoutType === "switch"
? await switchPlan(body)
: await subscriptionCheckout(body),
);
@@ -103,6 +121,7 @@ export default function PricingTable({
};
const managePlan = async () => {
trackEvent("manage_subscription", {});
setManaging(true);
setError(null);
if (!redirect(await getPortalUrl())) setManaging(false);
@@ -115,7 +134,11 @@ export default function PricingTable({
<TabSelector
tabs={BILLING_TABS}
activeTab={cadence}
onTabChange={(id) => setCadence(id as BillingCadence)}
onTabChange={(id) => {
if (id === cadence) return;
setCadence(id as BillingCadence);
trackEvent("billing_cadence_change", { cadence: id });
}}
tabClassName="w-24"
/>
<Badge
@@ -363,6 +386,7 @@ function ContactButtons({ className }: { className?: string }) {
try {
await navigator.clipboard.writeText(CONTACT_EMAIL);
setCopied(true);
trackEvent("contact_click", { method: "copy_email" });
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard unavailable (insecure context) — the mailto still works.
@@ -86,6 +86,7 @@ export function Button({
</>
);
// Link buttons carry data-cta so analytics can report them as CTA clicks.
if (href) {
const anchorProps =
rest as unknown as AnchorHTMLAttributes<HTMLAnchorElement>;
@@ -96,6 +97,7 @@ export function Button({
target={target}
rel={rel}
className={classes}
data-cta=""
{...anchorProps}
>
{content}
@@ -103,7 +105,7 @@ export function Button({
);
}
return (
<Link href={href} className={classes} {...anchorProps}>
<Link href={href} className={classes} data-cta="" {...anchorProps}>
{content}
</Link>
);
@@ -0,0 +1,232 @@
// Google Analytics 4 (gtag.js). Off unless NEXT_PUBLIC_GA_MEASUREMENT_ID is
// set at build time, so local dev stays out of the reports.
//
// Page views come from GA4 Enhanced Measurement (it records App Router
// navigations via history changes), along with scrolls, outbound clicks and
// common file downloads. On top of that, the site sends the events below.
// Most link clicks need no per-component code: one delegated listener
// (see components/analytics.tsx) classifies them by href, and elements
// tagged with trackAttrs() report their event when clicked. Every delegated
// event also carries `link_location` (nav, footer, modal or the enclosing
// section id).
import { SOCIAL_LINKS, WEBAPP_URL } from "./links";
export const GA_MEASUREMENT_ID =
process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID?.trim() || undefined;
export type AnalyticsItem = {
item_id: string;
item_name: string;
item_category: "subscription" | "credits";
item_variant?: string;
price: number;
quantity: number;
};
type AnalyticsEvents = {
// Link clicks, classified by href.
app_download: {
app_name: string;
app_version: string;
platform: "macos" | "windows" | "linux" | "other";
file_name: string;
};
webapp_click: { destination: string; link_text: string };
social_click: { network: string };
contact_click: { method: "mailto" | "copy_email" };
cta_click: { link_url: string; link_text: string };
// Clicks on elements tagged with trackAttrs().
video_start: { video_provider: string; video_title: string; video_url?: string };
share: { method: string; content_type: string; item_id: string };
copy_command: { app_name: string };
app_select: { app_name: string };
press_kit_download: { asset_name: string };
// Sent from component code.
generate_lead: { form_name: string; user_type: string };
sign_up: { method: "email"; signup_source: string };
login: { method: "email" };
prompt_submit: {
model: string;
logged_in: boolean;
aspect_ratio: string;
resolution: string;
duration_seconds: number;
sound: boolean;
};
video_generation: { model: string; status: "complete" | "failed" | "timeout" };
billing_cadence_change: { cadence: string };
view_credit_packs: Record<string, never>;
begin_checkout: {
currency: "USD";
value: number;
checkout_type: "signup" | "subscribe" | "switch" | "credits";
items: AnalyticsItem[];
};
manage_subscription: Record<string, never>;
copy_prompt: { media_type: string };
media_download: { media_type: string; file_extension: string };
};
export type AnalyticsEvent = keyof AnalyticsEvents;
type ParamValue = string | number | boolean | AnalyticsItem[] | undefined;
type EventParams = Record<string, ParamValue>;
declare global {
interface Window {
gtag?: (...args: unknown[]) => void;
}
}
export function trackEvent<E extends AnalyticsEvent>(
event: E,
params: AnalyticsEvents[E],
): void {
send(event, params);
}
/**
* Data attributes that make an element report `event` when clicked. Works in
* server components (no handler needed); spread onto the element or Button.
*/
export function trackAttrs<E extends AnalyticsEvent>(
event: E,
params: AnalyticsEvents[E],
): Record<`data-track${string}`, string> {
const attrs: Record<`data-track${string}`, string> = { "data-track": event };
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) attrs[`data-track-${key}`] = String(value);
}
return attrs;
}
/** Delegated click handler: reports tagged elements and classified links. */
export function trackClick(target: EventTarget | null): void {
if (!(target instanceof Element)) return;
const tagged = target.closest<HTMLElement>("[data-track]");
if (tagged?.dataset.track) {
send(tagged.dataset.track, {
...taggedParams(tagged),
link_location: linkLocation(tagged),
});
return;
}
const anchor = target.closest<HTMLAnchorElement>("a[href]");
const link = anchor && classifyLink(anchor);
if (link) send(link.event, { ...link.params, link_location: linkLocation(anchor) });
}
function send(event: string, params: EventParams): void {
if (!GA_MEASUREMENT_ID) return;
window.gtag?.("event", event, params);
}
const WEBAPP_ORIGIN = new URL(WEBAPP_URL).origin;
// Hostname → network, derived from SOCIAL_LINKS.
const SOCIAL_NETWORKS = new Map(
Object.entries(SOCIAL_LINKS).map(([name, href]) => [
bareHostname(new URL(href).hostname),
name.toLowerCase(),
]),
);
// github.com/<org>/<repo>/releases/download/<tag>/<file>: the ArtCraft
// installers and every Crafting App download.
const RELEASE_ASSET_PATH = /^\/[^/]+\/([^/]+)\/releases\/download\/([^/]+)\/([^/]+)$/;
type ClassifiedLink = {
[E in AnalyticsEvent]: { event: E; params: AnalyticsEvents[E] };
}[AnalyticsEvent];
function classifyLink(anchor: HTMLAnchorElement): ClassifiedLink | null {
let url: URL;
try {
url = new URL(anchor.href);
} catch {
return null;
}
if (url.protocol === "mailto:") {
return { event: "contact_click", params: { method: "mailto" } };
}
const release = url.hostname === "github.com" && RELEASE_ASSET_PATH.exec(url.pathname);
if (release) {
const [, repo, tag, file] = release;
const fileName = decodeURIComponent(file);
return {
event: "app_download",
params: {
app_name: repo,
app_version: tag.replace(/^\D*/, ""),
platform: downloadPlatform(fileName),
file_name: fileName,
},
};
}
if (url.origin === WEBAPP_ORIGIN) {
return {
event: "webapp_click",
params: { destination: url.pathname, link_text: linkText(anchor) },
};
}
const network = SOCIAL_NETWORKS.get(bareHostname(url.hostname));
if (network) return { event: "social_click", params: { network } };
// Remaining Button links (internal CTAs, other outbound buttons). Plain
// text links are left to page views and Enhanced Measurement.
if (anchor.hasAttribute("data-cta")) {
return {
event: "cta_click",
params: {
link_url: url.origin === window.location.origin ? url.pathname + url.hash : url.href,
link_text: linkText(anchor),
},
};
}
return null;
}
function linkLocation(element: Element): string {
if (element.closest("#site-nav")) return "nav";
if (element.closest("footer")) return "footer";
if (element.closest('[role="dialog"]')) return "modal";
return element.closest("section[id]")?.id || "page";
}
// data-track-<param> attributes surface in the dataset as track<Param>.
function taggedParams(element: HTMLElement): EventParams {
const params: EventParams = {};
for (const [key, value] of Object.entries(element.dataset)) {
if (key.length > 5 && key.startsWith("track")) {
params[key.charAt(5).toLowerCase() + key.slice(6)] = value;
}
}
return params;
}
function downloadPlatform(fileName: string): AnalyticsEvents["app_download"]["platform"] {
if (/\.dmg$|macos/i.test(fileName)) return "macos";
if (/\.(exe|msi)$|windows/i.test(fileName)) return "windows";
if (/\.(appimage|deb|rpm)$|linux/i.test(fileName)) return "linux";
return "other";
}
// GA truncates parameter values at 100 characters.
function linkText(anchor: HTMLAnchorElement): string {
const text = anchor.getAttribute("aria-label") || anchor.textContent || "";
return text.replace(/\s+/g, " ").trim().slice(0, 100);
}
function bareHostname(hostname: string): string {
return hostname.replace(/^www\./, "");
}