Merge pull request #50 from yusufipk/feat/acquisition-analytics

feat(analytics): record where paying customers actually came from
This commit is contained in:
Yusuf İpek
2026-08-05 18:47:51 +03:00
committed by GitHub
49 changed files with 3797 additions and 26 deletions
+9
View File
@@ -1,3 +1,5 @@
import { after } from 'next/server';
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
import { isInviteCodeRequired } from '@/lib/feature-flags';
import { getInvitationPreviewByToken } from '@/lib/invitations';
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
@@ -8,6 +10,13 @@ interface RegisterPageProps {
}
export default async function RegisterPage({ searchParams }: RegisterPageProps) {
// Reaching this page is the funnel step. Recording it here rather than from the
// browser also keeps it honest: a prefetch of this route is filtered out by
// readPageVisitor, so signup starts can never outnumber the landing views
// above them.
const visitor = await readPageVisitor();
after(() => recordVisitorEvent('SIGNUP_STARTED', visitor));
const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
const githubEnabled = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
+12 -1
View File
@@ -1,6 +1,8 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { after } from 'next/server';
import { ComparisonPage } from '@/components/marketing/comparison-page';
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
import { auth } from '@/lib/auth';
import { comparisonPages, getComparisonPage } from '@/lib/marketing/comparison-pages';
import { buildComparisonJsonLd, buildComparisonMetadata } from '@/lib/marketing/metadata';
@@ -38,6 +40,15 @@ export default async function MarketingSlugPage({ params }: MarketingSlugPagePro
}
const session = await auth();
const isLoggedIn = Boolean(session?.user);
// A comparison page is a landing page: for most of these visitors it is the
// first thing they see, so it belongs in the same visitor count as `/`.
if (!isLoggedIn) {
const visitor = await readPageVisitor();
after(() => recordVisitorEvent('LANDING_VIEW', visitor));
}
const structuredData = buildComparisonJsonLd({
title: page.title,
description: page.metaDescription,
@@ -57,7 +68,7 @@ export default async function MarketingSlugPage({ params }: MarketingSlugPagePro
}}
/>
))}
<ComparisonPage page={page} isLoggedIn={Boolean(session?.user)} />
<ComparisonPage page={page} isLoggedIn={isLoggedIn} />
</>
);
}
+365
View File
@@ -0,0 +1,365 @@
import { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
AT_RISK_SILENT_DAYS,
conversionRates,
getScoreboard,
type FunnelRates,
} from '@/lib/analytics/scoreboard';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import { AlertTriangle, CreditCard, TrendingUp, Users } from 'lucide-react';
export const metadata: Metadata = {
title: 'Growth | OpenFrame',
description: 'Acquisition funnel and retention scoreboard',
};
function formatMoney(cents: number | null, currency: string) {
if (cents === null) return '—';
const safeCurrency = /^[a-zA-Z]{3}$/.test(currency) ? currency.toUpperCase() : 'USD';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: safeCurrency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(cents / 100);
}
function formatWeek(date: Date) {
return date.toISOString().slice(0, 10);
}
function formatDate(date: Date | null) {
return date ? date.toISOString().slice(0, 10) : 'never';
}
/** A percentage with the count it was computed from, because n matters here. */
function Rate({ rate, of }: { rate: number | null; of: number }) {
if (rate === null) return <span className="text-muted-foreground"></span>;
return (
<span>
{Math.round(rate * 100)}%<span className="text-muted-foreground"> /{of}</span>
</span>
);
}
const WEEK_COLUMNS: Array<{ key: string; label: string }> = [
{ key: 'visitors', label: 'Visitors' },
{ key: 'signups', label: 'Signup' },
{ key: 'firstVideo', label: 'Video' },
{ key: 'shareLinks', label: 'Share link' },
{ key: 'externalFeedback', label: 'Ext. feedback' },
{ key: 'trials', label: 'Trial' },
{ key: 'newPaid', label: 'New paid' },
{ key: 'canceled', label: 'Canceled' },
{ key: 'activePaid', label: 'Active paid' },
];
export default async function AdminGrowthPage() {
const session = await auth();
if (!session?.user?.isAdmin) {
redirect('/');
}
if (!isProductAnalyticsEnabled()) {
return (
<div className="flex-1 space-y-4 px-4 md:px-8">
<h2 className="text-3xl font-bold tracking-tight">Growth</h2>
<Card>
<CardContent className="pt-6 text-sm text-muted-foreground">
Acquisition tracking is off on this deployment. Set{' '}
<code className="font-mono">OPENFRAME_ENABLE_ANALYTICS=true</code> to start recording
the funnel. Nothing is collected until you do, and nothing is ever sent anywhere but
this instance&apos;s own database.
</CardContent>
</Card>
</div>
);
}
const scoreboard = await getScoreboard();
const latest = scoreboard.weeks[scoreboard.weeks.length - 1];
const window = scoreboard.weeks.reduce(
(sum, week) => ({
visitors: sum.visitors + week.visitors,
signups: sum.signups + week.signups,
firstVideo: sum.firstVideo + week.firstVideo,
shareLinks: sum.shareLinks + week.shareLinks,
externalFeedback: sum.externalFeedback + week.externalFeedback,
trials: sum.trials + week.trials,
newPaid: sum.newPaid + week.newPaid,
}),
{
visitors: 0,
signups: 0,
firstVideo: 0,
shareLinks: 0,
externalFeedback: 0,
trials: 0,
newPaid: 0,
}
);
const overall: FunnelRates = conversionRates(window);
return (
<div className="flex-1 space-y-4 px-4 md:px-8">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Growth</h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active paid</CardTitle>
<CreditCard className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{scoreboard.currentActivePaid ?? '—'}</div>
<p className="text-xs text-muted-foreground">from Stripe, right now</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">MRR</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatMoney(scoreboard.currentMrrCents, scoreboard.currency)}
</div>
<p className="text-xs text-muted-foreground">from Stripe, right now</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Visitors this week</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{latest?.visitors ?? 0}</div>
<p className="text-xs text-muted-foreground">
{latest ? `week of ${formatWeek(latest.weekStart)}` : 'no data yet'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">At risk</CardTitle>
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{scoreboard.atRisk.length}</div>
<p className="text-xs text-muted-foreground">
paid, silent for {AT_RISK_SILENT_DAYS}+ days
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Weekly funnel</CardTitle>
<p className="text-sm text-muted-foreground">
Weeks start Monday, UTC. Active paid is the running net of subscriptions started minus
canceled, so it can drift from the Stripe figure above; the difference is the drift.
</p>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">Week</th>
{WEEK_COLUMNS.map((column) => (
<th key={column.key} className="py-2 pr-4 text-right font-medium">
{column.label}
</th>
))}
<th className="py-2 pr-4 text-right font-medium">MRR</th>
</tr>
</thead>
<tbody>
{scoreboard.weeks.map((week) => (
<tr key={week.weekStart.toISOString()} className="border-b last:border-0">
<td className="py-2 pr-4 font-mono text-xs">{formatWeek(week.weekStart)}</td>
{WEEK_COLUMNS.map((column) => (
<td key={column.key} className="py-2 pr-4 text-right tabular-nums">
{week[column.key as keyof typeof week] as number}
</td>
))}
<td className="py-2 pr-4 text-right tabular-nums">
{formatMoney(week.mrrCents, scoreboard.currency)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Where it narrows</CardTitle>
<p className="text-sm text-muted-foreground">
Every step over the whole {scoreboard.weeks.length}-week window, with the denominator
beside it. The lowest rate is the step to work on.
</p>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-3 lg:grid-cols-5 text-sm">
<div>
<div className="text-muted-foreground">Visitor to signup</div>
<div className="text-lg font-semibold">
<Rate rate={overall.visitorToSignup} of={window.visitors} />
</div>
</div>
<div>
<div className="text-muted-foreground">Signup to first video</div>
<div className="text-lg font-semibold">
<Rate rate={overall.signupToFirstVideo} of={window.signups} />
</div>
</div>
<div>
<div className="text-muted-foreground">Video to share link</div>
<div className="text-lg font-semibold">
<Rate rate={overall.firstVideoToShare} of={window.firstVideo} />
</div>
</div>
<div>
<div className="text-muted-foreground">Share to outside feedback</div>
<div className="text-lg font-semibold">
<Rate rate={overall.shareToFeedback} of={window.shareLinks} />
</div>
</div>
<div>
<div className="text-muted-foreground">Trial to paid</div>
<div className="text-lg font-semibold">
<Rate rate={overall.trialToPaid} of={window.trials} />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">By source</CardTitle>
<p className="text-sm text-muted-foreground">
Rolling {scoreboard.channelWindowDays} days rather than one week: a weekly per-source
cell holds single digits at this volume, and a percentage computed from three visits
reads exactly as confidently as one computed from three hundred.
</p>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">Source</th>
<th className="py-2 pr-4 text-right font-medium">Visitors</th>
<th className="py-2 pr-4 text-right font-medium">Signup</th>
<th className="py-2 pr-4 text-right font-medium">Trial</th>
<th className="py-2 pr-4 text-right font-medium">Paid</th>
<th className="py-2 pr-4 text-right font-medium">Visitor to signup</th>
</tr>
</thead>
<tbody>
{scoreboard.channels.length === 0 && (
<tr>
<td colSpan={6} className="py-4 text-muted-foreground">
Nothing recorded in this window yet.
</td>
</tr>
)}
{scoreboard.channels.map((row) => (
<tr key={row.channel} className="border-b last:border-0">
<td className="py-2 pr-4">{row.channel.toLowerCase()}</td>
<td className="py-2 pr-4 text-right tabular-nums">{row.visitors}</td>
<td className="py-2 pr-4 text-right tabular-nums">{row.signups}</td>
<td className="py-2 pr-4 text-right tabular-nums">{row.trials}</td>
<td className="py-2 pr-4 text-right tabular-nums">{row.paid}</td>
<td className="py-2 pr-4 text-right tabular-nums">
<Rate
rate={row.visitors > 0 ? row.signups / row.visitors : null}
of={row.visitors}
/>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Paid accounts</CardTitle>
<p className="text-sm text-muted-foreground">
Value events are videos, share links, outside feedback, approvals and projects. Rows
marked at risk have produced none for {AT_RISK_SILENT_DAYS} days.
{scoreboard.paidAccountsTruncated && (
<>
{' '}
Quietest {scoreboard.paidAccountLimit} only; there are more paid accounts than this
table shows.
</>
)}
</p>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">Account</th>
<th className="py-2 pr-4 font-medium">Status</th>
<th className="py-2 pr-4 font-medium">Source</th>
<th className="py-2 pr-4 text-right font-medium">7d</th>
<th className="py-2 pr-4 text-right font-medium">30d</th>
<th className="py-2 pr-4 font-medium">Last activity</th>
</tr>
</thead>
<tbody>
{scoreboard.paidAccounts.length === 0 && (
<tr>
<td colSpan={6} className="py-4 text-muted-foreground">
No active or trialing accounts.
</td>
</tr>
)}
{scoreboard.paidAccounts.map((account) => {
const atRisk = scoreboard.atRisk.some((row) => row.userId === account.userId);
return (
<tr key={account.userId} className="border-b last:border-0">
<td className="py-2 pr-4">
{account.name || account.email || account.userId}
{atRisk && (
<span className="ml-2 rounded bg-destructive/10 px-1.5 py-0.5 text-xs text-destructive">
at risk
</span>
)}
</td>
<td className="py-2 pr-4 text-muted-foreground">
{account.status.toLowerCase()}
</td>
<td className="py-2 pr-4 text-muted-foreground">
{account.channel?.toLowerCase() ?? '—'}
{account.selfReported && account.selfReported !== account.channel && (
<span className="text-xs">
{' '}
(said {account.selfReported.toLowerCase()})
</span>
)}
</td>
<td className="py-2 pr-4 text-right tabular-nums">{account.valueEvents7}</td>
<td className="py-2 pr-4 text-right tabular-nums">{account.valueEvents30}</td>
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
{formatDate(account.lastValueEventAt)}
</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}
+15 -1
View File
@@ -2,7 +2,7 @@ import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
import { Header } from '@/components/layout';
import Link from 'next/link';
import { LayoutDashboard, MessageSquareQuote, Users } from 'lucide-react';
import { LayoutDashboard, MessageSquareQuote, TrendingUp, Users } from 'lucide-react';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
@@ -39,6 +39,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<MessageSquareQuote className="h-4 w-4" />
Feedback
</Link>
<Link
href="/admin/growth"
className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50"
>
<TrendingUp className="h-4 w-4" />
Growth
</Link>
</nav>
</div>
{/* Desktop Nav */}
@@ -66,6 +73,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<MessageSquareQuote className="h-4 w-4" />
Feedback
</Link>
<Link
href="/admin/growth"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50 transition-colors"
>
<TrendingUp className="h-4 w-4" />
Growth
</Link>
</nav>
</div>
</aside>
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { conversionRates, getScoreboard } from '@/lib/analytics/scoreboard';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import { logError } from '@/lib/logger';
// The same numbers /admin/growth renders, as JSON, so the Monday digest can pull
// the scoreboard instead of somebody retyping it into a table.
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
if (!session.user.isAdmin) {
return apiErrors.forbidden('Admin access required');
}
if (!isProductAnalyticsEnabled()) {
return apiErrors.badRequest('Analytics are disabled by this host');
}
const weeksParam = Number(request.nextUrl.searchParams.get('weeks'));
const scoreboard = await getScoreboard({
weeks: Number.isSafeInteger(weeksParam) && weeksParam > 0 ? weeksParam : undefined,
});
const response = successResponse({
...scoreboard,
weeks: scoreboard.weeks.map((week) => ({ ...week, rates: conversionRates(week) })),
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error building the growth scoreboard:', error);
return apiErrors.internalError('Failed to build the scoreboard');
}
}
@@ -6,6 +6,7 @@ import { notifyUsers } from '@/lib/notifications';
import { rateLimit } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
type RouteParams = { params: Promise<{ requestId: string }> };
@@ -202,6 +203,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
});
if (updated.status === 'APPROVED') {
await recordEvent({
name: 'APPROVAL_COMPLETED',
dedupeKey: eventKey('APPROVAL_COMPLETED', requestId),
userId: approvalRequest.version.video.project.ownerId,
});
notifyUsers([updated.requestedById], {
type: 'approval_completed',
projectName: updated.version.video.project.name,
+10
View File
@@ -17,6 +17,8 @@ import {
sendVerificationEmail,
} from '@/lib/email-verification';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
import { recordSignupCompleted } from '@/lib/analytics/signup';
import { readRequestVisitor } from '@/lib/analytics/visitor';
export async function POST(request: NextRequest) {
try {
@@ -132,6 +134,14 @@ export async function POST(request: NextRequest) {
}
}
// Ties the account to the first touch stored in this browser's cookie and
// claims the visitor events that led here. Recorded after the invitation has
// been accepted, so an account that gets rolled back never leaves a signup.
await recordSignupCompleted({
userId: user.id,
visitor: await readRequestVisitor(request),
});
// Send verification email if SMTP is configured
if (emailVerificationRequired) {
const verificationToken = await createVerificationToken(normalizedEmail);
+10
View File
@@ -11,6 +11,7 @@ import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
function getAppOrigin(request: NextRequest) {
if (isTrustedSameOriginRequest(request)) {
@@ -84,6 +85,15 @@ export async function POST(request: NextRequest) {
throw new Error('Stripe did not return a checkout URL');
}
// Keyed on the Stripe session, so an abandoned checkout followed by a second
// attempt counts twice. That is the intent: the gap between checkouts started
// and subscriptions started is the number worth watching.
await recordEvent({
name: 'CHECKOUT_STARTED',
dedupeKey: eventKey('CHECKOUT_STARTED', checkoutSession.id),
userId: session.user.id,
});
const response = successResponse({ url: checkoutSession.url });
return withCacheControl(response, 'private, no-store');
} catch (error) {
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest } from 'next/server';
import { rateLimit } from '@/lib/rate-limit';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { readRequestVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
// The one funnel event that cannot be observed from the server: a click on a
// call to action, which never reaches us as a request of its own.
//
// Everything else in the funnel is recorded where it actually happens, so this
// endpoint accepts exactly one event name. An anonymous caller must not be able
// to post `SUBSCRIPTION_STARTED` into the scoreboard, and the cheapest way to
// guarantee that is to make the allowed set a single literal.
const ALLOWED_EVENTS = new Set(['cta_clicked']);
export async function POST(request: NextRequest) {
// Answers 204 whatever happens. This endpoint reports nothing back to the page
// that called it, so there is no reason to tell a caller which of their
// attempts landed.
const noContent = new Response(null, {
status: 204,
headers: { 'Cache-Control': 'private, no-store' },
});
// Both cheap and both free of side effects, so they come before the limiter.
// Checking the flag here rather than only inside the recorder keeps a host who
// never turned analytics on from paying a rate-limit write for every anonymous
// POST to an endpoint they are not using.
if (!isProductAnalyticsEnabled()) return noContent;
if (!isTrustedSameOriginRequest(request)) return noContent;
// 204 rather than the limiter's 429: a beacon has nobody to tell, and a
// flooder should not be handed a signal for when the window resets.
const limited = await rateLimit(request, 'analytics-beacon');
if (limited) return noContent;
const body = await request.json().catch(() => null);
const name = typeof body?.name === 'string' ? body.name : '';
if (!ALLOWED_EVENTS.has(name)) return noContent;
await recordVisitorEvent('CTA_CLICKED', await readRequestVisitor(request));
return noContent;
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimitHeaders } from '@/lib/rate-limit';
import { setSelfReportedSource } from '@/lib/analytics/record';
import { isAcquisitionChannel } from '@/lib/analytics/cookies';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
// "How did you hear about us?", answered on the first onboarding screen.
//
// It is stored beside the cookie-derived channel rather than instead of it. The
// cookie is precise but loses cross-device visits and cleared browsers; the
// answer survives both, and it is the only thing that can name a channel no UTM
// tag ever carries, like being told about it by a friend.
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
// Keyed by account, like /api/onboarding/complete beside it. An IP key would
// be the wrong bucket twice over: without TRUSTED_PROXY_MODE every caller
// resolves to 127.0.0.1, so five answers an hour would be five for the whole
// deployment, and with it a shared office address would lock out everyone
// after one colleague answered.
const config = RATE_LIMIT_CONFIGS['onboarding-source'];
const limit = await checkRateLimit(session.user.id, 'onboarding-source', config);
if (!limit.allowed) {
return new Response(JSON.stringify({ error: 'Too many requests. Please try again later.' }), {
status: 429,
headers: {
'Content-Type': 'application/json',
...rateLimitHeaders(limit, config.maxRequests),
},
});
}
if (!isProductAnalyticsEnabled()) {
return apiErrors.badRequest('Analytics are disabled by this host');
}
const body = await request.json().catch(() => null);
const source = body?.source;
if (!isAcquisitionChannel(source)) {
return apiErrors.badRequest('Unknown source');
}
const note = typeof body?.note === 'string' ? body.note : null;
await setSelfReportedSource({ userId: session.user.id, selfReported: source, note });
const response = successResponse({ recorded: true });
return withCacheControl(response, 'private, no-store');
}
@@ -8,6 +8,7 @@ import { db } from '@/lib/db';
import { rateLimit } from '@/lib/rate-limit';
import { MAX_SHARE_PASSWORD_LENGTH } from '@/lib/share-links';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -141,7 +142,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
const { projectId, videoId } = await params;
const { error } = await requireShareManagementAccess(projectId, videoId, session.user.id);
const { error, video } = await requireShareManagementAccess(
projectId,
videoId,
session.user.id
);
if (error) return error;
const body = await request.json().catch(() => ({}));
@@ -244,6 +249,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.internalError('Failed to create video share link');
}
// Keyed on the link id, so re-issuing the token for a link that already
// exists updates the row and records nothing: the share was created once.
await recordEvent({
name: 'SHARE_LINK_CREATED',
dedupeKey: eventKey('SHARE_LINK_CREATED', link.id),
userId: video?.project.ownerId ?? null,
});
const response = successResponse(serializeShareLink(request, videoId, link));
return withCacheControl(response, 'private, no-store');
@@ -8,6 +8,7 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -268,6 +269,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}).catch((err) => logError('Notification failed:', err));
}
await recordEvent({
name: 'VIDEO_ADDED',
dedupeKey: eventKey('VIDEO_ADDED', video.id),
userId: project.ownerId,
});
const response = successResponse(video, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
+10
View File
@@ -7,6 +7,7 @@ import { buildBillingAccessWhereInput } from '@/lib/billing';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
// GET /api/projects - List all projects for the authenticated user
export async function GET(request: NextRequest) {
@@ -187,6 +188,15 @@ export async function POST(request: NextRequest) {
return createdProject;
});
// Attributed to the workspace owner rather than the caller: the funnel asks
// which account is progressing, and a team member creating a project moves
// the owner's account, not their own.
await recordEvent({
name: 'PROJECT_CREATED',
dedupeKey: eventKey('PROJECT_CREATED', project.id),
userId: workspace.ownerId,
});
const response = successResponse(project, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
@@ -13,6 +13,7 @@ import {
getGuestIdentityFromRequest,
setGuestIdentityCookie,
} from '@/lib/guest-identity';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import {
extractImageFileNameFromProxyUrl,
extractAudioFileNameFromProxyUrl,
@@ -547,6 +548,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
}
// Feedback arriving from outside the team is the moment this product
// becomes worth paying for, so it is the activation step of the funnel.
// Keyed on the account, not the comment: what matters is the first time an
// account ever received one.
if (isGuest) {
await recordEvent({
name: 'FIRST_GUEST_COMMENT',
dedupeKey: eventKey('FIRST_GUEST_COMMENT', project.workspace.ownerId),
userId: project.workspace.ownerId,
});
}
const viewerUserId = session?.user?.id ?? null;
const viewerGuestIdentityId = viewerUserId
? null
+7
View File
@@ -5,6 +5,7 @@ import { rateLimit } from '@/lib/rate-limit';
import { buildBillingAccessWhereInput, getWorkspaceCreationEligibility } from '@/lib/billing';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
// GET /api/workspaces - List all workspaces for the authenticated user
export async function GET(request: NextRequest) {
@@ -136,6 +137,12 @@ export async function POST(request: NextRequest) {
},
});
await recordEvent({
name: 'WORKSPACE_CREATED',
dedupeKey: eventKey('WORKSPACE_CREATED', workspace.id),
userId: workspace.ownerId,
});
const response = successResponse(workspace, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
+74 -3
View File
@@ -107,7 +107,45 @@ function ToggleButton({
// ─── Step 1: Welcome ───────────────────────────────────────────────────────────
function StepWelcome({ userName, onNext }: { userName: string; onNext: () => void }) {
// Asked here rather than on the registration form. The whole point of measuring
// this funnel is the signup conversion rate, and a question added to the form
// would move the number being measured.
const SOURCE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'GITHUB', label: 'GitHub' },
{ value: 'YOUTUBE', label: 'YouTube' },
{ value: 'GOOGLE', label: 'A search engine' },
{ value: 'REVIEW_LINK', label: 'A review or comparison site' },
{ value: 'REFERRAL', label: 'Someone recommended it' },
{ value: 'COMMUNITY', label: 'Reddit, X, Discord or a forum' },
{ value: 'OUTBOUND', label: 'An email from us' },
{ value: 'OTHER', label: 'Somewhere else' },
];
function StepWelcome({
userName,
askSource,
onNext,
}: {
userName: string;
askSource: boolean;
onNext: () => void;
}) {
const [source, setSource] = useState<string>('');
const [note, setNote] = useState('');
const handleNext = () => {
// Never blocks the wizard. An unanswered or failed question costs one row in
// a cross-check column; a broken Get Started button costs the account.
if (askSource && source) {
void fetch('/api/onboarding/source', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source, note: source === 'OTHER' ? note : undefined }),
}).catch(() => undefined);
}
onNext();
};
return (
<div className="text-center space-y-8">
<div className="mx-auto w-24 h-24 rounded-full bg-primary/10 flex items-center justify-center">
@@ -122,7 +160,36 @@ function StepWelcome({ userName, onNext }: { userName: string; onNext: () => voi
manage versions, and streamline approvals all in one place.
</p>
</div>
<Button onClick={onNext} size="lg" className="w-full sm:w-auto px-10 h-12 text-base">
{askSource && (
<div className="mx-auto max-w-sm space-y-3 text-left">
<Label htmlFor="acquisition-source" className="text-sm text-muted-foreground">
How did you hear about us? (optional)
</Label>
<Select value={source} onValueChange={setSource}>
<SelectTrigger id="acquisition-source" className="w-full">
<SelectValue placeholder="Pick one" />
</SelectTrigger>
<SelectContent>
{SOURCE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{source === 'OTHER' && (
<Input
value={note}
onChange={(event) => setNote(event.target.value)}
maxLength={200}
placeholder="Where, roughly?"
/>
)}
</div>
)}
<Button onClick={handleNext} size="lg" className="w-full sm:w-auto px-10 h-12 text-base">
Get Started
<ChevronRight className="h-5 w-5 ml-1" />
</Button>
@@ -691,10 +758,12 @@ export function OnboardingWizard({
userName,
canCreateWorkspace,
availableWorkspaces,
askAcquisitionSource,
}: {
userName: string;
canCreateWorkspace: boolean;
availableWorkspaces: Array<{ id: string; name: string; isOwner: boolean }>;
askAcquisitionSource: boolean;
}) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
@@ -761,7 +830,9 @@ export function OnboardingWizard({
{/* Step content */}
<Card className="border-border/50 shadow-lg">
<CardContent className="pt-10 pb-10 px-10">
{currentStep === 1 && <StepWelcome userName={userName} onNext={goNext} />}
{currentStep === 1 && (
<StepWelcome userName={userName} askSource={askAcquisitionSource} onNext={goNext} />
)}
{currentStep === 2 && (
<StepWorkspace
canCreateWorkspace={canCreateWorkspace}
+2
View File
@@ -1,6 +1,7 @@
import { auth } from '@/lib/auth';
import { buildBillingAccessWhereInput, getBillingOverview } from '@/lib/billing';
import { db } from '@/lib/db';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import { redirect } from 'next/navigation';
import { OnboardingWizard } from './onboarding-wizard';
@@ -43,6 +44,7 @@ export default async function OnboardingPage() {
<OnboardingWizard
userName={userName}
canCreateWorkspace={billing.workspaceCreation.canCreateWorkspace}
askAcquisitionSource={isProductAnalyticsEnabled()}
availableWorkspaces={creatableWorkspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
+11 -1
View File
@@ -1,8 +1,18 @@
import { after } from 'next/server';
import { LandingPage } from '@/components/LandingPage';
import { auth } from '@/lib/auth';
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
export default async function HomePage() {
const session = await auth();
const isLoggedIn = Boolean(session?.user);
return <LandingPage isLoggedIn={Boolean(session?.user)} />;
// Signed-in users land here too, and counting them would put existing
// customers at the top of the acquisition funnel.
if (!isLoggedIn) {
const visitor = await readPageVisitor();
after(() => recordVisitorEvent('LANDING_VIEW', visitor));
}
return <LandingPage isLoggedIn={isLoggedIn} />;
}