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 ; return ( {Math.round(rate * 100)}% /{of} ); } 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 (

Growth

Acquisition tracking is off on this deployment. Set{' '} OPENFRAME_ENABLE_ANALYTICS=true to start recording the funnel. Nothing is collected until you do, and nothing is ever sent anywhere but this instance's own database.
); } 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 (

Growth

Active paid
{scoreboard.currentActivePaid ?? '—'}

from Stripe, right now

MRR
{formatMoney(scoreboard.currentMrrCents, scoreboard.currency)}

from Stripe, right now

Visitors this week
{latest?.visitors ?? 0}

{latest ? `week of ${formatWeek(latest.weekStart)}` : 'no data yet'}

At risk
{scoreboard.atRisk.length}

paid, silent for {AT_RISK_SILENT_DAYS}+ days

Weekly funnel

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.

{WEEK_COLUMNS.map((column) => ( ))} {scoreboard.weeks.map((week) => ( {WEEK_COLUMNS.map((column) => ( ))} ))}
Week {column.label} MRR
{formatWeek(week.weekStart)} {week[column.key as keyof typeof week] as number} {formatMoney(week.mrrCents, scoreboard.currency)}
Where it narrows

Every step over the whole {scoreboard.weeks.length}-week window, with the denominator beside it. The lowest rate is the step to work on.

Visitor to signup
Signup to first video
Video to share link
Share to outside feedback
Trial to paid
By source

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.

{scoreboard.channels.length === 0 && ( )} {scoreboard.channels.map((row) => ( ))}
Source Visitors Signup Trial Paid Visitor to signup
Nothing recorded in this window yet.
{row.channel.toLowerCase()} {row.visitors} {row.signups} {row.trials} {row.paid} 0 ? row.signups / row.visitors : null} of={row.visitors} />
Paid accounts

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. )}

{scoreboard.paidAccounts.length === 0 && ( )} {scoreboard.paidAccounts.map((account) => { const atRisk = scoreboard.atRisk.some((row) => row.userId === account.userId); return ( ); })}
Account Status Source 7d 30d Last activity
No active or trialing accounts.
{account.name || account.email || account.userId} {atRisk && ( at risk )} {account.status.toLowerCase()} {account.channel?.toLowerCase() ?? '—'} {account.selfReported && account.selfReported !== account.channel && ( {' '} (said {account.selfReported.toLowerCase()}) )} {account.valueEvents7} {account.valueEvents30} {formatDate(account.lastValueEventAt)}
); }