mirror of
https://github.com/usestrix/strix.git
synced 2026-08-17 17:30:27 +02:00
Track attribution, cta surfaces, and the email funnel in the viewer
This commit is contained in:
+139
-139
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Strix Results</title>
|
||||
<script type="module" crossorigin src="./assets/index-D10QfT5L.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-ApSY-bBm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Cldwq1Im.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
type LoadedRun,
|
||||
type RunsPayload,
|
||||
} from "@/data/serverSource";
|
||||
import { SIGNUP_URL, trackCta } from "@/lib/cta";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import PastRunsView from "@/components/PastRunsView";
|
||||
import EmailReportView from "@/components/EmailReportView";
|
||||
@@ -183,20 +183,20 @@ export default function App() {
|
||||
setView("overview");
|
||||
}, []);
|
||||
|
||||
const goEmail = useCallback((skipDisclosure: boolean) => {
|
||||
trackCta("email_report");
|
||||
const goEmail = useCallback((skipDisclosure: boolean, surface: string) => {
|
||||
trackCta("email_report", surface);
|
||||
setEmailPurpose("report");
|
||||
setEmailSkipDisclosure(skipDisclosure);
|
||||
setView("email");
|
||||
}, []);
|
||||
|
||||
// Sidebar entry keeps the disclosure (first place those users see it);
|
||||
const openEmail = useCallback(() => goEmail(false), [goEmail]);
|
||||
const openEmail = useCallback(() => goEmail(false, "sidebar"), [goEmail]);
|
||||
// the Overview CTA already states the tradeoff, so it starts the flow directly.
|
||||
const openEmailFromOverview = useCallback(() => goEmail(true), [goEmail]);
|
||||
const openEmailFromOverview = useCallback(() => goEmail(true, "overview"), [goEmail]);
|
||||
|
||||
const openVerify = useCallback(() => {
|
||||
trackCta("history_unlock");
|
||||
trackCta("history_unlock", "past_runs");
|
||||
setEmailPurpose("verify");
|
||||
setEmailSkipDisclosure(false);
|
||||
setView("email");
|
||||
@@ -208,7 +208,7 @@ export default function App() {
|
||||
}, [refreshRuns]);
|
||||
|
||||
const selectFeature = useCallback((slug: string) => {
|
||||
trackCta(`nav_${slug}`);
|
||||
trackCta(slug, "sidebar_nav");
|
||||
setActiveFeature(slug);
|
||||
setView("feature");
|
||||
}, []);
|
||||
@@ -244,10 +244,10 @@ export default function App() {
|
||||
<div className="border-b border-[#222]">
|
||||
<div className="max-w-[72rem] mx-auto px-6 py-4 flex items-center gap-1.5">
|
||||
<a
|
||||
href="https://app.strix.ai"
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo")}
|
||||
onClick={() => trackCta("logo", "topbar")}
|
||||
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100 lg:hidden"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
@@ -266,10 +266,10 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
<a
|
||||
href={SIGNUP_URL}
|
||||
href={ctaUrl(SIGNUP_URL, "run_in_cloud")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("run_in_cloud")}
|
||||
onClick={() => trackCta("run_in_cloud", "topbar")}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Run in the cloud
|
||||
@@ -657,7 +657,7 @@ function OverviewTab({
|
||||
{/* Near Recommendations: act on the fixes. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{RECOMMENDATION_CTAS.map((item) => (
|
||||
<ProTile key={item.slug} item={item} />
|
||||
<ProTile key={item.slug} item={item} surface="overview" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -666,7 +666,7 @@ function OverviewTab({
|
||||
<p className="mb-2 text-sm font-semibold text-white">Continuous coverage for your org</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{COVERAGE_CTAS.map((item) => (
|
||||
<ProTile key={item.slug} item={item} />
|
||||
<ProTile key={item.slug} item={item} surface="overview" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
sendReport,
|
||||
type AuthStatus,
|
||||
} from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* The email-report / email-verification flow rendered as its own page (not a
|
||||
@@ -87,6 +88,7 @@ export default function EmailReportView({
|
||||
setError(null);
|
||||
const result = await sendReport(activeRun);
|
||||
if (result.ok) {
|
||||
track("report_sent");
|
||||
setPassword(result.password);
|
||||
setFilename(result.filename);
|
||||
setStep("password");
|
||||
@@ -126,6 +128,7 @@ export default function EmailReportView({
|
||||
}
|
||||
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
|
||||
if (COMMON_FREE_DOMAINS.has(domain)) {
|
||||
track("work_email_required");
|
||||
setError(OTP_START_ERRORS.work_email_required);
|
||||
return;
|
||||
}
|
||||
@@ -134,9 +137,11 @@ export default function EmailReportView({
|
||||
const result = await otpStart(value);
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
track("email_submitted", { purpose });
|
||||
setNotice(`We sent a 6-digit code to ${value}.`);
|
||||
setStep("code");
|
||||
} else {
|
||||
if (result.error === "work_email_required") track("work_email_required");
|
||||
setError(OTP_START_ERRORS[result.error] ?? "Could not send a code. Try again.");
|
||||
}
|
||||
};
|
||||
@@ -155,6 +160,7 @@ export default function EmailReportView({
|
||||
setError("That code did not match. Check it and try again.");
|
||||
return;
|
||||
}
|
||||
track("email_verified", { purpose });
|
||||
setSentTo(result.email);
|
||||
onAuthChanged();
|
||||
if (verifyOnly) onExit("history");
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Users,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, PRICING_URL, trackCta } from "@/lib/cta";
|
||||
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import type { ProFeature } from "@/lib/pro-features";
|
||||
import { ProTag } from "@/components/ProCta";
|
||||
|
||||
@@ -70,20 +70,20 @@ export default function FeatureDetail({ feature }: { feature: ProFeature }) {
|
||||
|
||||
<div className="mt-6 flex flex-col items-center gap-3">
|
||||
<a
|
||||
href={SIGNUP_URL}
|
||||
href={ctaUrl(SIGNUP_URL, feature.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(feature.slug)}
|
||||
onClick={() => trackCta(feature.slug, "feature_page")}
|
||||
className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Start free
|
||||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
href={PRICING_URL}
|
||||
href={ctaUrl(PRICING_URL, feature.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(`${feature.slug}_pricing`)}
|
||||
onClick={() => trackCta(feature.slug, "feature_page_plans")}
|
||||
className="inline-flex items-center gap-1 text-xs text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
View all plans
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { SIGNUP_URL, trackCta } from "@/lib/cta";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import type { ProFeature } from "@/lib/pro-features";
|
||||
|
||||
/**
|
||||
@@ -69,14 +69,14 @@ export interface ProItem {
|
||||
* Card-style Pro feature tile: icon + name + one-liner + Pro tag + arrow.
|
||||
* Used in the top upsell row and inline CTA grids.
|
||||
*/
|
||||
export function ProTile({ item }: { item: ProItem }) {
|
||||
export function ProTile({ item, surface }: { item: ProItem; surface?: string }) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<a
|
||||
href={SIGNUP_URL}
|
||||
href={ctaUrl(SIGNUP_URL, item.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(item.slug)}
|
||||
onClick={() => trackCta(item.slug, surface)}
|
||||
title={item.desc}
|
||||
className="group block cursor-pointer rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-4 text-left transition-colors hover:border-[#444]"
|
||||
>
|
||||
@@ -140,19 +140,21 @@ export function ProInlineCta({
|
||||
desc,
|
||||
slug,
|
||||
icon: Icon,
|
||||
surface,
|
||||
}: {
|
||||
label: string;
|
||||
desc: string;
|
||||
slug: string;
|
||||
icon: React.ElementType;
|
||||
surface?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip text={desc}>
|
||||
<a
|
||||
href={SIGNUP_URL}
|
||||
href={ctaUrl(SIGNUP_URL, slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(slug)}
|
||||
onClick={() => trackCta(slug, surface)}
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-[#222] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-sm text-[#aaa] transition-colors hover:border-[#444] hover:text-white"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, trackCta } from "@/lib/cta";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { ProNavItem } from "@/components/ProCta";
|
||||
import { FEATURES, PLATFORM_ORDER } from "@/lib/pro-features";
|
||||
import type { View } from "@/App";
|
||||
@@ -57,10 +57,10 @@ export default function Sidebar({
|
||||
{/* Header: wordmark + Start free + signed-in chip */}
|
||||
<div className="px-1.5">
|
||||
<a
|
||||
href="https://app.strix.ai"
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo")}
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
@@ -68,10 +68,10 @@ export default function Sidebar({
|
||||
<span className="text-base font-medium tracking-tight text-white">Strix</span>
|
||||
</a>
|
||||
<a
|
||||
href={SIGNUP_URL}
|
||||
href={ctaUrl(SIGNUP_URL, "sidebar_start_free")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("sidebar_start_free")}
|
||||
onClick={() => trackCta("sidebar_start_free", "sidebar")}
|
||||
className="mt-3 flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-white px-3 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Explore full platform
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { AgentTranscript } from "./AgentTranscript";
|
||||
import { SIGNUP_URL, trackCta } from "@/lib/cta";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import type { TranscriptAgent, TranscriptEvent } from "@/data/serverSource";
|
||||
|
||||
/** Status -> the small leading dot color, matching the graph node styling. */
|
||||
@@ -101,10 +101,10 @@ export function AgentDetailModal({
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-[#222] px-5 py-3">
|
||||
<span className="text-xs text-[#888]">Steer agents live from your terminal.</span>
|
||||
<a
|
||||
href={SIGNUP_URL}
|
||||
href={ctaUrl(SIGNUP_URL, "steer_agent")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("steer_agent")}
|
||||
onClick={() => trackCta("steer_agent", "agents")}
|
||||
className="text-xs font-medium text-[#60a5fa] hover:underline"
|
||||
>
|
||||
Upgrade to Pro to steer agents from the web →
|
||||
|
||||
@@ -242,18 +242,21 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
|
||||
desc="Fix it for you and open a PR, retested."
|
||||
slug="autofix"
|
||||
icon={Wrench}
|
||||
surface="finding_detail"
|
||||
/>
|
||||
<ProInlineCta
|
||||
label="Sync to Jira / Linear"
|
||||
desc="Two-way sync findings to Jira, Linear, and Slack."
|
||||
slug="integrations"
|
||||
icon={GitMerge}
|
||||
surface="finding_detail"
|
||||
/>
|
||||
<ProInlineCta
|
||||
label="Catch this in PR reviews"
|
||||
desc="Pentest every pull request your team opens."
|
||||
slug="pr_reviews"
|
||||
icon={GitPullRequest}
|
||||
surface="finding_detail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,17 +4,40 @@ export const SIGNUP_URL = "https://app.strix.ai/api/auth/signup";
|
||||
export const DEMO_URL = "https://strix.ai/demo";
|
||||
export const PRICING_URL = "https://strix.ai/pricing";
|
||||
|
||||
// Best-effort, anonymous conversion tracking. The local server forwards this to
|
||||
// PostHog only if the user has telemetry enabled; it never blocks navigation.
|
||||
export function trackCta(cta: string): void {
|
||||
// Attribution params appended to every outbound CTA link so the destination
|
||||
// analytics can see the local viewer drove the click, with utm_content carrying
|
||||
// the CTA slug so we know which one.
|
||||
const CTA_PARAMS =
|
||||
"ref=oss_viewer&utm_source=oss_viewer&utm_medium=local_viewer&utm_campaign=oss_viewer";
|
||||
|
||||
export function ctaUrl(base: string, slug: string): string {
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
return `${base}${sep}${CTA_PARAMS}&utm_content=${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
// Best-effort, anonymous beacon. The local server forwards this to PostHog only
|
||||
// if the user has telemetry enabled; it never blocks navigation. Undefined
|
||||
// props are dropped so we only send what is set. NEVER pass PII here (no email,
|
||||
// code, or report content) - the props are limited to anonymous metadata.
|
||||
export function track(event: string, props: Record<string, string | undefined> = {}): void {
|
||||
try {
|
||||
const body = JSON.stringify({ event: "cta_clicked", cta });
|
||||
const body: Record<string, string> = { event };
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (value !== undefined) body[key] = value;
|
||||
}
|
||||
const payload = JSON.stringify(body);
|
||||
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
|
||||
navigator.sendBeacon("/api/event", body);
|
||||
navigator.sendBeacon("/api/event", payload);
|
||||
} else {
|
||||
void fetch("/api/event", { method: "POST", body, keepalive: true });
|
||||
void fetch("/api/event", { method: "POST", body: payload, keepalive: true });
|
||||
}
|
||||
} catch {
|
||||
/* analytics is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous conversion tracking for a sign-up/upsell click. `surface` records
|
||||
// where the click happened so one CTA slug can be reused across placements.
|
||||
export function trackCta(cta: string, surface?: string): void {
|
||||
track("cta_clicked", { cta, surface });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user