Comprehensive admin + user dashboards (production-ready)
This commit is contained in:
+133
-52
@@ -1,22 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { Mic2, Plus, Sparkles, ArrowRight } from "lucide-react";
|
||||
import { Mic2, Plus, Sparkles, ArrowRight, Mic, Gauge, Crown, Infinity as InfinityIcon } from "lucide-react";
|
||||
import { requireAuth } from "@/lib/auth/guards";
|
||||
import { getEffectivePlan } from "@/lib/billing/subscription";
|
||||
import { getUsageSummary } from "@/lib/usage/meter";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { UNLIMITED, type UsageMetric } from "@/lib/billing/plans";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { EpisodeStatusBadge } from "@/components/app/episode-status-badge";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EpisodeStatusBadge } from "@/components/app/episode-status-badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
const METRIC_LABELS: Record<UsageMetric, string> = {
|
||||
script: "Scripts",
|
||||
audio: "Audio generations",
|
||||
art: "Cover art",
|
||||
repurpose: "Repurposed content",
|
||||
};
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await requireAuth();
|
||||
const { plan, key } = await getEffectivePlan(
|
||||
const { plan, key, subjectId } = await getEffectivePlan(
|
||||
session.user.id,
|
||||
session.session.activeOrganizationId
|
||||
);
|
||||
|
||||
const [episodeCount, recent] = await Promise.all([
|
||||
// Episodes from the last 8 weeks for the sparkline + a 30-day rolling count.
|
||||
const now = new Date();
|
||||
const eightWeeksAgo = new Date(now);
|
||||
eightWeeksAgo.setDate(eightWeeksAgo.getDate() - 7 * 8);
|
||||
|
||||
const [episodeCount, recent, recentForSpark, usage] = await Promise.all([
|
||||
prisma.episode.count({ where: { userId: session.user.id } }),
|
||||
prisma.episode.findMany({
|
||||
where: { userId: session.user.id },
|
||||
@@ -24,8 +41,38 @@ export default async function DashboardPage() {
|
||||
take: 5,
|
||||
select: { id: true, title: true, status: true, format: true, createdAt: true },
|
||||
}),
|
||||
prisma.episode.findMany({
|
||||
where: { userId: session.user.id, createdAt: { gte: eightWeeksAgo } },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
getUsageSummary(subjectId),
|
||||
]);
|
||||
|
||||
// Bucket episodes into 8 weekly counts (oldest → newest) for the sparkline.
|
||||
const weeklySpark = Array.from({ length: 8 }, (_, i) => {
|
||||
const start = new Date(eightWeeksAgo);
|
||||
start.setDate(start.getDate() + i * 7);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 7);
|
||||
return recentForSpark.filter((e) => e.createdAt >= start && e.createdAt < end).length;
|
||||
});
|
||||
const thisWeek = weeklySpark[weeklySpark.length - 1];
|
||||
|
||||
// Tightest metered limit: the metric closest to its cap (excluding unlimited).
|
||||
const metrics = (Object.keys(METRIC_LABELS) as UsageMetric[])
|
||||
.map((m) => {
|
||||
const limit = plan.limits[m];
|
||||
const used = usage[m];
|
||||
const unlimited = limit === UNLIMITED;
|
||||
const pct = unlimited ? 0 : Math.min(100, Math.round((used / Math.max(1, limit)) * 100));
|
||||
return { metric: m, used, limit, unlimited, pct };
|
||||
});
|
||||
const tightest =
|
||||
metrics
|
||||
.filter((m) => !m.unlimited)
|
||||
.sort((a, b) => b.pct - a.pct)[0] ?? metrics[0];
|
||||
const tightestAtLimit = !tightest.unlimited && tightest.used >= tightest.limit;
|
||||
|
||||
const firstName = session.user.name.split(" ")[0];
|
||||
|
||||
return (
|
||||
@@ -42,74 +89,108 @@ export default async function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<StatCard
|
||||
label="Episodes created"
|
||||
value={String(episodeCount)}
|
||||
icon={Mic}
|
||||
spark={weeklySpark.some((v) => v > 0) ? weeklySpark : undefined}
|
||||
hint={`${thisWeek} this week`}
|
||||
/>
|
||||
|
||||
{/* Live usage meter — tightest metered metric */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Episodes created</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-4xl font-extrabold tracking-tight">{episodeCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Current plan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<p className="font-display text-4xl font-extrabold capitalize tracking-tight">{plan.name}</p>
|
||||
{key === "free" && (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href="/billing">Upgrade</Link>
|
||||
</Button>
|
||||
<CardContent className="space-y-3 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{METRIC_LABELS[tightest.metric]} this month
|
||||
</span>
|
||||
<Gauge className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{tightest.used}</p>
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
{tightest.unlimited ? (
|
||||
<>
|
||||
<InfinityIcon className="h-4 w-4" /> Unlimited
|
||||
</>
|
||||
) : (
|
||||
<>of {tightest.limit}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{!tightest.unlimited ? (
|
||||
<Progress
|
||||
value={tightest.pct}
|
||||
indicatorClassName={tightestAtLimit ? "bg-warning" : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No limits on this metric.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Usage this month</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Button asChild size="sm" variant="ghost" className="-ml-2 h-8 px-2 text-brand">
|
||||
<Link href="/usage">
|
||||
View usage <ArrowRight className="h-4 w-4" />
|
||||
View all usage <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Plan card with inline upgrade */}
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Current plan</span>
|
||||
<Crown className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-display text-3xl font-extrabold capitalize tracking-tight">
|
||||
{plan.name}
|
||||
</p>
|
||||
{key !== "free" && <Badge variant="brand">Active</Badge>}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{plan.tagline}</p>
|
||||
{key !== "agency" && (
|
||||
<Button asChild size="sm" variant={key === "free" ? "default" : "outline"}>
|
||||
<Link href="/billing">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Upgrade
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>Recent episodes</CardTitle>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/episodes">View all</Link>
|
||||
</Button>
|
||||
{recent.length > 0 && (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/episodes">View all</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand/10 text-brand">
|
||||
<Mic2 className="h-6 w-6" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">No episodes yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create your first AI-produced episode to get started.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/episodes/new">
|
||||
<Sparkles className="h-4 w-4" /> Create your first episode
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<EmptyState
|
||||
bordered={false}
|
||||
icon={Mic2}
|
||||
title="No episodes yet"
|
||||
description="Create your first AI-produced episode to get started."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/episodes/new">
|
||||
<Sparkles className="h-4 w-4" /> Create your first episode
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{recent.map((ep) => (
|
||||
<li key={ep.id}>
|
||||
<Link
|
||||
href={`/episodes/${ep.id}`}
|
||||
className="flex items-center justify-between gap-3 py-3 hover:opacity-80"
|
||||
className="flex items-center justify-between gap-3 py-3 transition-colors hover:opacity-80"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{ep.title}</p>
|
||||
|
||||
Reference in New Issue
Block a user