Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8ef87a097 | ||
|
|
084aa1b4b4 |
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\./, "");
|
||||
}
|
||||
Reference in new issue
Block a user