mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Merge pull request #50 from yusufipk/feat/acquisition-analytics
feat(analytics): record where paying customers actually came from
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user