Comprehensive admin + user dashboards (production-ready)
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { bustFlagCache } from "@/lib/flags";
|
||||
import { stripe } from "@/lib/billing/stripe";
|
||||
import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss";
|
||||
import type { GenerationType } from "@/lib/queue/jobs";
|
||||
|
||||
type ActionResult = { ok: boolean; error?: string };
|
||||
|
||||
async function adminSession() {
|
||||
const s = await getServerSession();
|
||||
@@ -50,6 +56,7 @@ export async function toggleFeatureFlagAction(
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
await prisma.featureFlag.upsert({ where: { key }, create: { key, enabled }, update: { enabled } });
|
||||
bustFlagCache(); // make the new value take effect immediately on this node
|
||||
await audit(s.user.id, "flag.toggle", key, { enabled });
|
||||
revalidatePath("/admin/flags");
|
||||
return { ok: true };
|
||||
@@ -66,3 +73,314 @@ export async function reviewContentFlagAction(
|
||||
revalidatePath("/admin/moderation");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─────────────────────────── Phase 4: billing ops ───────────────────────────
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** Grant a comped subscription (no payment provider) to a user. */
|
||||
export async function compPlanAction(
|
||||
userId: string,
|
||||
plan: "creator" | "pro" | "agency",
|
||||
interval: "month" | "year"
|
||||
): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const now = new Date();
|
||||
const periodEnd = new Date(now.getTime() + (interval === "year" ? 365 : 30) * DAY_MS);
|
||||
|
||||
// One row per (referenceId, provider:"comp"): reuse an existing comp row if present.
|
||||
const existing = await prisma.subscription.findFirst({
|
||||
where: { referenceId: userId, provider: "comp" },
|
||||
});
|
||||
const data = {
|
||||
plan,
|
||||
status: "active",
|
||||
billingInterval: interval,
|
||||
periodStart: now,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: false,
|
||||
};
|
||||
if (existing) {
|
||||
await prisma.subscription.update({ where: { id: existing.id }, data });
|
||||
} else {
|
||||
await prisma.subscription.create({
|
||||
data: { referenceId: userId, provider: "comp", ...data },
|
||||
});
|
||||
}
|
||||
|
||||
await audit(s.user.id, "subscription.comp", userId, { plan, interval });
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
revalidatePath("/admin/subscriptions");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Cancel a subscription. Stripe cancels at period end; comp/paypal flip to canceled. */
|
||||
export async function cancelSubscriptionAdminAction(subId: string): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const sub = await prisma.subscription.findUnique({ where: { id: subId } });
|
||||
if (!sub) return { ok: false, error: "Subscription not found." };
|
||||
|
||||
try {
|
||||
if (sub.provider === "stripe") {
|
||||
if (!sub.stripeSubscriptionId) return { ok: false, error: "Missing Stripe subscription id." };
|
||||
await stripe().subscriptions.update(sub.stripeSubscriptionId, { cancel_at_period_end: true });
|
||||
await prisma.subscription.update({
|
||||
where: { id: subId },
|
||||
data: { cancelAtPeriodEnd: true },
|
||||
});
|
||||
} else {
|
||||
await prisma.subscription.update({
|
||||
where: { id: subId },
|
||||
data: { status: "canceled", cancelAtPeriodEnd: true },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : "Cancel failed." };
|
||||
}
|
||||
|
||||
await audit(s.user.id, "subscription.cancel", subId, { provider: sub.provider });
|
||||
revalidatePath("/admin/subscriptions");
|
||||
revalidatePath(`/admin/users/${sub.referenceId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Refund the latest charge on a Stripe subscription. PayPal/comp are unsupported here. */
|
||||
export async function refundLatestAction(subId: string): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const sub = await prisma.subscription.findUnique({ where: { id: subId } });
|
||||
if (!sub) return { ok: false, error: "Subscription not found." };
|
||||
if (sub.provider === "paypal") {
|
||||
return { ok: false, error: "Refund PayPal payments from the PayPal dashboard." };
|
||||
}
|
||||
if (sub.provider === "comp") {
|
||||
return { ok: false, error: "Comped subscriptions have no payment to refund." };
|
||||
}
|
||||
if (!sub.stripeSubscriptionId) return { ok: false, error: "Missing Stripe subscription id." };
|
||||
|
||||
try {
|
||||
const stripeSub = await stripe().subscriptions.retrieve(sub.stripeSubscriptionId, {
|
||||
expand: ["latest_invoice.payment_intent"],
|
||||
});
|
||||
const invoice = stripeSub.latest_invoice;
|
||||
if (!invoice || typeof invoice === "string") {
|
||||
return { ok: false, error: "No invoice found for this subscription." };
|
||||
}
|
||||
// payment_intent is expanded above; normalize to its id (cast via unknown to be
|
||||
// resilient to Stripe type changes across API versions).
|
||||
const pi = (invoice as unknown as { payment_intent?: string | { id: string } | null })
|
||||
.payment_intent;
|
||||
const paymentIntentId = typeof pi === "string" ? pi : pi?.id;
|
||||
if (!paymentIntentId) {
|
||||
return { ok: false, error: "No payment to refund on the latest invoice." };
|
||||
}
|
||||
const refund = await stripe().refunds.create({ payment_intent: paymentIntentId });
|
||||
await audit(s.user.id, "subscription.refund", subId, {
|
||||
refundId: refund.id,
|
||||
paymentIntent: paymentIntentId,
|
||||
amount: refund.amount,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : "Refund failed." };
|
||||
}
|
||||
|
||||
revalidatePath("/admin/subscriptions");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─────────────────────────── Phase 4: job ops ───────────────────────────
|
||||
|
||||
/** Requeue a job: reset the episode to QUEUED and enqueue a fresh generation. */
|
||||
export async function retryJobAction(jobId: string): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const job = await prisma.generationJob.findUnique({
|
||||
where: { id: jobId },
|
||||
include: { episode: { select: { id: true } } },
|
||||
});
|
||||
if (!job) return { ok: false, error: "Job not found." };
|
||||
|
||||
const type = (job.type as GenerationType) ?? "full";
|
||||
try {
|
||||
await prisma.episode.update({
|
||||
where: { id: job.episodeId },
|
||||
data: { status: "QUEUED", errorMessage: null },
|
||||
});
|
||||
await prisma.generationJob.create({
|
||||
data: { episodeId: job.episodeId, type: job.type, status: "queued" },
|
||||
});
|
||||
await enqueueEpisodeGeneration({ episodeId: job.episodeId, type });
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : "Retry failed." };
|
||||
}
|
||||
|
||||
await audit(s.user.id, "job.retry", jobId, { episodeId: job.episodeId, type });
|
||||
revalidatePath("/admin/jobs");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Cancel a job: mark it failed and fail the episode if it was still in progress. */
|
||||
export async function cancelJobAction(jobId: string): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const job = await prisma.generationJob.findUnique({
|
||||
where: { id: jobId },
|
||||
include: { episode: { select: { id: true, status: true } } },
|
||||
});
|
||||
if (!job) return { ok: false, error: "Job not found." };
|
||||
|
||||
await prisma.generationJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "failed", error: job.error ?? "Canceled by admin", finishedAt: new Date() },
|
||||
});
|
||||
|
||||
// Only fail the episode if it hasn't already reached a terminal state.
|
||||
const inProgress = !["READY", "FAILED", "DRAFT"].includes(job.episode.status);
|
||||
if (inProgress) {
|
||||
await prisma.episode.update({
|
||||
where: { id: job.episodeId },
|
||||
data: { status: "FAILED", errorMessage: "Canceled by admin" },
|
||||
});
|
||||
}
|
||||
|
||||
await audit(s.user.id, "job.cancel", jobId, { episodeId: job.episodeId });
|
||||
revalidatePath("/admin/jobs");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─────────────────────────── Phase 5: feature flags ───────────────────────────
|
||||
|
||||
/** Update a flag's rollout %, enabled state, and metadata in one shot. */
|
||||
export async function setRolloutAction(
|
||||
key: string,
|
||||
rolloutPct: number,
|
||||
enabled: boolean,
|
||||
metadata?: unknown
|
||||
): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const pct = Math.max(0, Math.min(100, Math.round(rolloutPct)));
|
||||
// null/undefined clears the column; any JSON value is stored as-is.
|
||||
const meta: Prisma.InputJsonValue | typeof Prisma.JsonNull =
|
||||
metadata == null ? Prisma.JsonNull : (metadata as Prisma.InputJsonValue);
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { key },
|
||||
create: { key, enabled, rolloutPct: pct, metadata: meta },
|
||||
update: { enabled, rolloutPct: pct, metadata: meta },
|
||||
});
|
||||
bustFlagCache();
|
||||
await audit(s.user.id, "flag.rollout", key, { rolloutPct: pct, enabled });
|
||||
revalidatePath("/admin/flags");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Delete a feature flag row entirely. */
|
||||
export async function deleteFlagAction(key: string): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
await prisma.featureFlag.delete({ where: { key } });
|
||||
bustFlagCache();
|
||||
await audit(s.user.id, "flag.delete", key);
|
||||
revalidatePath("/admin/flags");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─────────────────────────── Phase 5: plan tuning ───────────────────────────
|
||||
|
||||
export interface PlanUpdateInput {
|
||||
priceMonthly: number;
|
||||
priceYearly: number;
|
||||
limits: {
|
||||
script: number;
|
||||
audio: number;
|
||||
art: number;
|
||||
repurpose: number;
|
||||
seats: number;
|
||||
maxEpisodeMinutes: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Override a plan's price/limits in the DB (an override on lib/billing/plans.ts). */
|
||||
export async function updatePlanAction(key: string, input: PlanUpdateInput): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const existing = await prisma.plan.findUnique({ where: { key } });
|
||||
if (!existing) return { ok: false, error: "Plan not found." };
|
||||
|
||||
await prisma.plan.update({
|
||||
where: { key },
|
||||
data: {
|
||||
priceMonthly: Math.max(0, Math.round(input.priceMonthly)),
|
||||
priceYearly: Math.max(0, Math.round(input.priceYearly)),
|
||||
limits: input.limits as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
await audit(s.user.id, "plan.update", key, {
|
||||
priceMonthly: input.priceMonthly,
|
||||
priceYearly: input.priceYearly,
|
||||
});
|
||||
revalidatePath("/admin/settings");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─────────────────────────── Phase 5: audit export ───────────────────────────
|
||||
|
||||
function csvCell(value: unknown): string {
|
||||
const str = value == null ? "" : typeof value === "string" ? value : JSON.stringify(value);
|
||||
// Quote always; escape embedded quotes by doubling.
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/** Build a CSV of the currently-filtered audit log (up to a sane cap). */
|
||||
export async function exportAuditCsvAction(filters: {
|
||||
action?: string;
|
||||
actor?: string;
|
||||
}): Promise<{ ok: boolean; csv?: string; error?: string }> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const where: Prisma.AuditLogWhereInput = {};
|
||||
if (filters.action) where.action = filters.action;
|
||||
if (filters.actor) where.actor = { email: { contains: filters.actor, mode: "insensitive" } };
|
||||
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5000,
|
||||
include: { actor: { select: { email: true } } },
|
||||
});
|
||||
|
||||
const header = ["When", "Actor", "Actor type", "Action", "Target", "Details"];
|
||||
const lines = [header.map(csvCell).join(",")];
|
||||
for (const r of rows) {
|
||||
lines.push(
|
||||
[
|
||||
r.createdAt.toISOString(),
|
||||
r.actor?.email ?? "",
|
||||
r.actorType,
|
||||
r.action,
|
||||
r.target ?? "",
|
||||
r.metadata ?? "",
|
||||
]
|
||||
.map(csvCell)
|
||||
.join(",")
|
||||
);
|
||||
}
|
||||
|
||||
await audit(s.user.id, "audit.export", undefined, {
|
||||
rows: rows.length,
|
||||
action: filters.action ?? null,
|
||||
});
|
||||
return { ok: true, csv: lines.join("\r\n") };
|
||||
}
|
||||
|
||||
@@ -1,94 +1,82 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Wallet, Sparkles, AudioLines } from "lucide-react";
|
||||
import { getCostBreakdown } from "@/lib/admin/cost";
|
||||
import { getAiCostSeries } from "@/lib/admin/series";
|
||||
import { parseRange } from "@/lib/admin/range";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CostChart, type CostPoint } from "@/components/admin/cost-chart";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { ChartCard } from "@/components/admin/ui/chart-card";
|
||||
import { BarSeries } from "@/components/admin/ui/charts";
|
||||
import { RangePicker } from "@/components/admin/ui/table-controls";
|
||||
import { DataTable, type Column } from "@/components/admin/ui/data-table";
|
||||
import { CHART } from "@/components/admin/ui/chart-theme";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · AI usage" };
|
||||
|
||||
export default async function AdminAiUsagePage() {
|
||||
const since = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
|
||||
const logs = await prisma.aiCostLog.findMany({
|
||||
where: { createdAt: { gte: since } },
|
||||
select: { provider: true, operation: true, costUsd: true, createdAt: true },
|
||||
});
|
||||
type Outlier = { userId: string; email: string; cost: number };
|
||||
|
||||
// Daily totals by provider for the last 14 days.
|
||||
const byDay = new Map<string, CostPoint>();
|
||||
for (let i = 13; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
|
||||
byDay.set(key, { date: key, openai: 0, elevenlabs: 0 });
|
||||
}
|
||||
let totalOpenai = 0;
|
||||
let totalEleven = 0;
|
||||
const byOperation: Record<string, number> = {};
|
||||
for (const log of logs) {
|
||||
const d = log.createdAt;
|
||||
const key = `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
|
||||
const point = byDay.get(key);
|
||||
const cost = Number(log.costUsd);
|
||||
if (point) {
|
||||
if (log.provider === "elevenlabs") point.elevenlabs += cost;
|
||||
else point.openai += cost;
|
||||
}
|
||||
if (log.provider === "elevenlabs") totalEleven += cost;
|
||||
else totalOpenai += cost;
|
||||
byOperation[log.operation] = (byOperation[log.operation] ?? 0) + cost;
|
||||
}
|
||||
const data = Array.from(byDay.values()).map((p) => ({
|
||||
date: p.date,
|
||||
openai: Math.round(p.openai * 100) / 100,
|
||||
elevenlabs: Math.round(p.elevenlabs * 100) / 100,
|
||||
}));
|
||||
export default async function AdminAiUsagePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ range?: string }>;
|
||||
}) {
|
||||
const range = parseRange((await searchParams).range);
|
||||
const [breakdown, series] = await Promise.all([getCostBreakdown(range), getAiCostSeries(range)]);
|
||||
const usd = (n: number) => `$${n.toFixed(2)}`;
|
||||
|
||||
const outlierColumns: Column<Outlier>[] = [
|
||||
{ key: "email", header: "User", cell: (o) => <span className="font-medium">{o.email}</span> },
|
||||
{ key: "cost", header: "Spend", align: "right", cell: (o) => <span className="font-semibold">{usd(o.cost)}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="AI usage & cost" description="Spend across providers over the last 14 days." />
|
||||
<PageHeader
|
||||
title="AI usage & cost"
|
||||
description="Spend across OpenAI and ElevenLabs."
|
||||
action={<RangePicker value={range} />}
|
||||
/>
|
||||
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-3">
|
||||
<Stat label="OpenAI (14d)" value={`$${totalOpenai.toFixed(2)}`} />
|
||||
<Stat label="ElevenLabs (14d)" value={`$${totalEleven.toFixed(2)}`} />
|
||||
<Stat label="Total (14d)" value={`$${(totalOpenai + totalEleven).toFixed(2)}`} />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<StatCard label="OpenAI" value={usd(breakdown.byProvider.openai)} icon={Sparkles} />
|
||||
<StatCard label="ElevenLabs" value={usd(breakdown.byProvider.elevenlabs)} icon={AudioLines} />
|
||||
<StatCard label="Total spend" value={usd(breakdown.total)} icon={Wallet} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily AI spend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CostChart data={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="mt-6">
|
||||
<ChartCard title="Daily AI spend" description="Stacked by provider.">
|
||||
<BarSeries
|
||||
data={series}
|
||||
xKey="date"
|
||||
format={(v) => `$${v}`}
|
||||
series={[
|
||||
{ key: "openai", name: "OpenAI", color: CHART.brand },
|
||||
{ key: "elevenlabs", name: "ElevenLabs", color: CHART.success },
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>By operation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{["script", "audio", "art", "repurpose"].map((op) => (
|
||||
<div key={op} className="rounded-lg border p-3">
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<ChartCard title="By operation" description="Where the spend goes.">
|
||||
<div className="grid grid-cols-2 gap-3 py-2">
|
||||
{(["script", "audio", "art", "repurpose"] as const).map((op) => (
|
||||
<div key={op} className="rounded-xl border border-border p-3">
|
||||
<p className="text-xs capitalize text-muted-foreground">{op}</p>
|
||||
<p className="mt-1 text-lg font-semibold">${(byOperation[op] ?? 0).toFixed(2)}</p>
|
||||
<p className="mt-1 font-display text-lg font-bold">{usd(breakdown.byOperation[op] ?? 0)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ChartCard>
|
||||
<ChartCard title="Top spenders" description="Highest AI cost per user this period.">
|
||||
<DataTable
|
||||
columns={outlierColumns}
|
||||
rows={breakdown.outliers}
|
||||
getRowKey={(o) => o.userId}
|
||||
empty="No usage in this period."
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { listAudit, getAuditActions, AUDIT_PAGE_SIZE } from "@/lib/admin/audit";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
|
||||
import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
|
||||
import { AuditExport } from "@/components/admin/audit-export";
|
||||
import { AuditMetaViewer } from "@/components/admin/audit-meta-viewer";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Audit log" };
|
||||
|
||||
export default async function AdminAuditPage() {
|
||||
const logs = await prisma.auditLog.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: { actor: { select: { email: true } } },
|
||||
});
|
||||
type Row = Awaited<ReturnType<typeof listAudit>>["rows"][number];
|
||||
|
||||
export default async function AdminAuditPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(sp.page ?? "1"));
|
||||
const [{ rows, total }, actions] = await Promise.all([
|
||||
listAudit({ action: sp.action, actor: sp.q, page }),
|
||||
getAuditActions(),
|
||||
]);
|
||||
|
||||
const columns: Column<Row>[] = [
|
||||
{
|
||||
key: "when",
|
||||
header: "When",
|
||||
cell: (l) => <span className="whitespace-nowrap text-muted-foreground">{l.createdAt.toLocaleString()}</span>,
|
||||
},
|
||||
{ key: "actor", header: "Actor", cell: (l) => l.actor?.email ?? l.actorType },
|
||||
{ key: "action", header: "Action", cell: (l) => <Badge variant="outline">{l.action}</Badge> },
|
||||
{
|
||||
key: "target",
|
||||
header: "Target",
|
||||
cell: (l) => <span className="font-mono text-xs text-muted-foreground">{l.target ?? "—"}</span>,
|
||||
},
|
||||
{
|
||||
key: "meta",
|
||||
header: "Details",
|
||||
cell: (l) => <AuditMetaViewer metadata={l.metadata} action={l.action} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Audit log" description="Recent administrative actions." />
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground">No audit entries yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/40 text-left text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="p-3 font-medium">When</th>
|
||||
<th className="p-3 font-medium">Actor</th>
|
||||
<th className="p-3 font-medium">Action</th>
|
||||
<th className="p-3 font-medium">Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{logs.map((l) => (
|
||||
<tr key={l.id} className="hover:bg-muted/20">
|
||||
<td className="p-3 text-muted-foreground">{l.createdAt.toLocaleString()}</td>
|
||||
<td className="p-3">{l.actor?.email ?? l.actorType}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">{l.action}</Badge>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">{l.target ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<PageHeader title="Audit log" description="Every administrative action, recorded." />
|
||||
<TableToolbar>
|
||||
<SearchInput placeholder="Filter by actor email…" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterSelect
|
||||
param="action"
|
||||
placeholder="Action"
|
||||
allLabel="All actions"
|
||||
options={actions.map((a) => ({ value: a, label: a }))}
|
||||
/>
|
||||
<AuditExport filters={{ action: sp.action, actor: sp.q }} />
|
||||
</div>
|
||||
</TableToolbar>
|
||||
<DataTable columns={columns} rows={rows} getRowKey={(l) => l.id} empty="No audit entries yet." />
|
||||
<div className="mt-4">
|
||||
<Pagination page={page} pageSize={AUDIT_PAGE_SIZE} total={total} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function AdminError({ error, reset }: { error: Error; reset: () => void }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-display text-lg font-bold">Something went wrong</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{error.message || "This admin view failed to load."}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={reset}>
|
||||
<RotateCw className="h-4 w-4" /> Try again
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { FlagsClient } from "@/components/admin/flags-client";
|
||||
import { getAdminFlags } from "@/lib/admin/flags";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Feature flags" };
|
||||
|
||||
export default async function AdminFlagsPage() {
|
||||
const flags = await prisma.featureFlag.findMany({ orderBy: { key: "asc" } });
|
||||
const flags = await getAdminFlags();
|
||||
const serialized = flags.map((f) => ({
|
||||
...f,
|
||||
updatedAt: f.updatedAt ? f.updatedAt.toISOString() : null,
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Feature flags" description="Toggle features without a deploy." />
|
||||
<FlagsClient flags={flags.map((f) => ({ key: f.key, enabled: f.enabled }))} />
|
||||
<PageHeader
|
||||
title="Feature flags"
|
||||
description="Toggle features, tune rollout, and attach metadata — no deploy required."
|
||||
/>
|
||||
<FlagsClient flags={serialized} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,88 +1,139 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Activity, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import { Activity, CheckCircle2, AlertTriangle, Cpu, Webhook, Database } from "lucide-react";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getWorkerHealth, getQueueStats } from "@/lib/queue/health";
|
||||
import { getJobStatusCounts, getEpisodeStatusCounts, listWebhookEvents } from "@/lib/admin/ops";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { ChartCard } from "@/components/admin/ui/chart-card";
|
||||
import { AutoRefresh } from "@/components/admin/ui/auto-refresh";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Health" };
|
||||
export const metadata: Metadata = { title: "Admin · System health" };
|
||||
|
||||
export default async function AdminHealthPage() {
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const [jobGroups, episodeGroups, recentFailures, queuePending] = await Promise.all([
|
||||
prisma.generationJob.groupBy({ by: ["status"], _count: true }),
|
||||
prisma.episode.groupBy({ by: ["status"], _count: true }),
|
||||
prisma.episode.count({ where: { status: "FAILED", updatedAt: { gte: dayAgo } } }),
|
||||
prisma.generationJob.count({ where: { status: { in: ["queued", "running"] } } }),
|
||||
const t0 = Date.now();
|
||||
await prisma.$queryRawUnsafe("SELECT 1");
|
||||
const dbMs = Date.now() - t0;
|
||||
|
||||
const [workers, queues, jobCounts, episodeCounts, webhooks] = await Promise.all([
|
||||
getWorkerHealth(),
|
||||
getQueueStats(),
|
||||
getJobStatusCounts(),
|
||||
getEpisodeStatusCounts(),
|
||||
listWebhookEvents({ page: 1, pageSize: 1 }),
|
||||
]);
|
||||
|
||||
const jobCounts = Object.fromEntries(jobGroups.map((g) => [g.status, g._count]));
|
||||
const queueHealthy = recentFailures < 5;
|
||||
const anyWorker = workers.length > 0;
|
||||
const workersAlive = anyWorker && workers.every((w) => w.alive);
|
||||
const queueDepth = queues.reduce((s, q) => s + q.queued + q.active, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="System health" description="Generation pipeline and queue status." />
|
||||
<AutoRefresh seconds={10} />
|
||||
<PageHeader title="System health" description="Live worker, queue, and delivery status." />
|
||||
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Activity className="h-4 w-4" /> Queue
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{queuePending}</p>
|
||||
<Badge variant={queueHealthy ? "success" : "warning"}>
|
||||
{queueHealthy ? "Healthy" : "Degraded"}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Failures (24h)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-2">
|
||||
{recentFailures === 0 ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-success" />
|
||||
) : (
|
||||
<AlertTriangle className="h-5 w-5 text-warning" />
|
||||
)}
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{recentFailures}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Running jobs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{jobCounts["running"] ?? 0}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label="Worker"
|
||||
value={anyWorker ? (workersAlive ? "Alive" : "Stale") : "No heartbeat"}
|
||||
icon={Cpu}
|
||||
hint={anyWorker ? `last beat ${workers[0].secondsAgo}s ago` : "worker never started"}
|
||||
/>
|
||||
<StatCard label="Queue depth" value={String(queueDepth)} icon={Activity} hint={`${jobCounts.running} running`} />
|
||||
<StatCard label="Failed jobs" value={String(jobCounts.failed)} icon={AlertTriangle} />
|
||||
<StatCard
|
||||
label="Webhook failures (24h)"
|
||||
value={String(webhooks.recentFailures)}
|
||||
icon={Webhook}
|
||||
hint={`${webhooks.recentTotal} delivered`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Generation jobs</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" /> Workers
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{["queued", "running", "completed", "failed"].map((s) => (
|
||||
<div key={s} className="flex items-center justify-between text-sm">
|
||||
<span className="capitalize text-muted-foreground">{s}</span>
|
||||
<span className="font-medium">{jobCounts[s] ?? 0}</span>
|
||||
{workers.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
No worker heartbeat yet. Start the worker (<code>npm run worker:dev</code>).
|
||||
</p>
|
||||
) : (
|
||||
workers.map((w) => (
|
||||
<div key={w.name} className="flex items-center justify-between rounded-xl border border-border p-3">
|
||||
<div>
|
||||
<p className="font-medium">{w.name}</p>
|
||||
<p className="text-xs text-muted-foreground">last beat {w.secondsAgo}s ago</p>
|
||||
</div>
|
||||
))}
|
||||
<Badge variant={w.alive ? "success" : "destructive"}>{w.alive ? "alive" : "stale"}</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" /> Database
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between rounded-xl border border-border p-3">
|
||||
<span className="text-sm text-muted-foreground">Query latency</span>
|
||||
<Badge variant={dbMs < 200 ? "success" : dbMs < 800 ? "warning" : "destructive"}>{dbMs} ms</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-3">
|
||||
<ChartCard className="lg:col-span-2" title="Queues" description="Per-queue job state (from pg-boss).">
|
||||
{queues.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No queue activity yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">Queue</th>
|
||||
<th className="py-2 text-right font-medium">Queued</th>
|
||||
<th className="py-2 text-right font-medium">Active</th>
|
||||
<th className="py-2 text-right font-medium">Retry</th>
|
||||
<th className="py-2 text-right font-medium">Failed</th>
|
||||
<th className="py-2 text-right font-medium">Done</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{queues.map((q) => (
|
||||
<tr key={q.queue}>
|
||||
<td className="py-2 font-mono text-xs">{q.queue}</td>
|
||||
<td className="py-2 text-right">{q.queued}</td>
|
||||
<td className="py-2 text-right">{q.active}</td>
|
||||
<td className="py-2 text-right">{q.retry}</td>
|
||||
<td className="py-2 text-right text-destructive">{q.failed}</td>
|
||||
<td className="py-2 text-right text-muted-foreground">{q.completed}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Episodes by status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{episodeGroups.map((g) => (
|
||||
{episodeCounts.map((g) => (
|
||||
<div key={g.status} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{g.status}</span>
|
||||
<span className="font-medium">{g._count}</span>
|
||||
<span className="font-medium">{g.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Clock, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { listJobs, JOBS_PAGE_SIZE, getJobStatusCounts } from "@/lib/admin/ops";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
|
||||
import { FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
|
||||
import { JobRowActions } from "@/components/admin/job-row-actions";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Jobs" };
|
||||
|
||||
type Row = Awaited<ReturnType<typeof listJobs>>["rows"][number];
|
||||
|
||||
const STATUS_VARIANT: Record<string, BadgeProps["variant"]> = {
|
||||
queued: "secondary",
|
||||
running: "warning",
|
||||
completed: "success",
|
||||
failed: "destructive",
|
||||
};
|
||||
|
||||
function duration(start: Date | null, end: Date | null): string {
|
||||
if (!start || !end) return "—";
|
||||
return `${Math.max(0, Math.round((end.getTime() - start.getTime()) / 1000))}s`;
|
||||
}
|
||||
|
||||
export default async function AdminJobsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(sp.page ?? "1"));
|
||||
const [{ rows, total }, counts] = await Promise.all([
|
||||
listJobs({ status: sp.status, page }),
|
||||
getJobStatusCounts(),
|
||||
]);
|
||||
|
||||
const columns: Column<Row>[] = [
|
||||
{
|
||||
key: "episode",
|
||||
header: "Episode",
|
||||
cell: (j) => (
|
||||
<Link href={`/episodes/${j.episode.id}`} className="truncate font-medium hover:text-brand">
|
||||
{j.episode.title}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ key: "type", header: "Type", cell: (j) => <span className="capitalize">{j.type}</span> },
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
cell: (j) => <Badge variant={STATUS_VARIANT[j.status] ?? "secondary"}>{j.status}</Badge>,
|
||||
},
|
||||
{ key: "attempts", header: "Attempts", align: "right", cell: (j) => j.attempts },
|
||||
{
|
||||
key: "duration",
|
||||
header: "Duration",
|
||||
align: "right",
|
||||
cell: (j) => <span className="text-muted-foreground">{duration(j.startedAt, j.finishedAt)}</span>,
|
||||
},
|
||||
{
|
||||
key: "error",
|
||||
header: "Error",
|
||||
cell: (j) =>
|
||||
j.error ? (
|
||||
<span className="truncate font-mono text-xs text-destructive" title={j.error}>
|
||||
{j.error.slice(0, 40)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (j) => <JobRowActions job={{ id: j.id, status: j.status }} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Generation jobs" description="The episode pipeline, job by job." />
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="Queued" value={String(counts.queued)} icon={Clock} />
|
||||
<StatCard label="Running" value={String(counts.running)} icon={Loader2} />
|
||||
<StatCard label="Completed" value={String(counts.completed)} icon={CheckCircle2} />
|
||||
<StatCard label="Failed" value={String(counts.failed)} icon={XCircle} />
|
||||
</div>
|
||||
|
||||
<TableToolbar>
|
||||
<div />
|
||||
<FilterSelect
|
||||
param="status"
|
||||
placeholder="Status"
|
||||
allLabel="All status"
|
||||
options={[
|
||||
{ value: "queued", label: "Queued" },
|
||||
{ value: "running", label: "Running" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
]}
|
||||
/>
|
||||
</TableToolbar>
|
||||
<DataTable columns={columns} rows={rows} getRowKey={(j) => j.id} empty="No jobs yet." />
|
||||
<div className="mt-4">
|
||||
<Pagination page={page} pageSize={JOBS_PAGE_SIZE} total={total} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 w-52 rounded-xl bg-secondary" />
|
||||
<div className="h-4 w-72 rounded-lg bg-secondary/70" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-28 rounded-2xl border border-border bg-card" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-80 rounded-2xl border border-border bg-card" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getModerationQueue } from "@/lib/admin/ops";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
import { ModerationActions } from "@/components/admin/moderation-actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Moderation" };
|
||||
|
||||
const SEVERITY: Record<string, BadgeProps["variant"]> = {
|
||||
high: "destructive",
|
||||
medium: "warning",
|
||||
low: "secondary",
|
||||
};
|
||||
|
||||
export default async function AdminModerationPage() {
|
||||
const flags = await prisma.contentFlag.findMany({
|
||||
where: { status: "open" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { episode: { select: { id: true, title: true } } },
|
||||
});
|
||||
const flags = await getModerationQueue();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Content moderation" description="Review flagged episodes." />
|
||||
<PageHeader
|
||||
title="Content moderation"
|
||||
description="Episodes auto-flagged by AI moderation, awaiting review."
|
||||
/>
|
||||
{flags.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<ShieldCheck className="h-10 w-10 text-success" />
|
||||
<p className="font-medium">Nothing to review</p>
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-success/12 text-success">
|
||||
<ShieldCheck className="h-6 w-6" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-display text-lg font-bold">All clear</p>
|
||||
<p className="text-sm text-muted-foreground">There are no open content flags.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{flags.map((f) => (
|
||||
<Card key={f.id}>
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div>
|
||||
<p className="font-medium">{f.episode.title}</p>
|
||||
<CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link href={`/episodes/${f.episode.id}`} className="font-medium hover:text-brand">
|
||||
{f.episode.title}
|
||||
</Link>
|
||||
<Badge variant={SEVERITY[f.severity] ?? "secondary"}>{f.severity}</Badge>
|
||||
<Badge variant="outline" className="capitalize">{f.source}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{f.reason}</p>
|
||||
</div>
|
||||
<ModerationActions flagId={f.id} />
|
||||
|
||||
+72
-79
@@ -1,96 +1,89 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Users, CreditCard, Mic2, DollarSign, TrendingUp, AlertTriangle } from "lucide-react";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PLANS, PLAN_ORDER, type PlanKey } from "@/lib/billing/plans";
|
||||
import { DollarSign, TrendingUp, Users, CreditCard, UserPlus, Mic2, Wallet, CheckCircle2 } from "lucide-react";
|
||||
import { getOverview } from "@/lib/admin/metrics";
|
||||
import { getSignupSeries, getRevenueSeries } from "@/lib/admin/series";
|
||||
import { parseRange } from "@/lib/admin/range";
|
||||
import { PLANS, PLAN_ORDER } from "@/lib/billing/plans";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { ChartCard } from "@/components/admin/ui/chart-card";
|
||||
import { BarSeries, Donut } from "@/components/admin/ui/charts";
|
||||
import { RangePicker } from "@/components/admin/ui/table-controls";
|
||||
import { CHART, TIER_COLORS } from "@/components/admin/ui/chart-theme";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin" };
|
||||
export const metadata: Metadata = { title: "Admin · Overview" };
|
||||
|
||||
export default async function AdminOverviewPage() {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [userCount, newUsers, activeSubs, episodeCount, failedCount, spend] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
prisma.subscription.findMany({
|
||||
where: { status: { in: ["active", "trialing"] } },
|
||||
select: { plan: true },
|
||||
}),
|
||||
prisma.episode.count(),
|
||||
prisma.episode.count({ where: { status: "FAILED" } }),
|
||||
prisma.aiCostLog.aggregate({ _sum: { costUsd: true }, where: { createdAt: { gte: startOfMonth } } }),
|
||||
export default async function AdminOverviewPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ range?: string }>;
|
||||
}) {
|
||||
const range = parseRange((await searchParams).range);
|
||||
const [m, signups, revenue] = await Promise.all([
|
||||
getOverview(range),
|
||||
getSignupSeries(range),
|
||||
getRevenueSeries(range),
|
||||
]);
|
||||
|
||||
const tierCounts: Record<string, number> = {};
|
||||
let mrr = 0;
|
||||
for (const sub of activeSubs) {
|
||||
tierCounts[sub.plan] = (tierCounts[sub.plan] ?? 0) + 1;
|
||||
mrr += PLANS[(sub.plan as PlanKey) in PLANS ? (sub.plan as PlanKey) : "free"].priceMonthly;
|
||||
}
|
||||
const aiSpend = Number(spend._sum.costUsd ?? 0);
|
||||
const errorRate = episodeCount > 0 ? Math.round((failedCount / episodeCount) * 100) : 0;
|
||||
const tierData = PLAN_ORDER.map((k) => ({
|
||||
name: PLANS[k].name,
|
||||
value: m.tierCounts[k] ?? 0,
|
||||
color: TIER_COLORS[k],
|
||||
})).filter((d) => d.value > 0);
|
||||
const usd = (n: number) => `$${n.toFixed(2)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Overview" description="Platform health at a glance." />
|
||||
<PageHeader
|
||||
title="Overview"
|
||||
description="Revenue, growth, and platform health at a glance."
|
||||
action={<RangePicker value={range} />}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Kpi icon={DollarSign} label="MRR" value={formatPrice(mrr)} hint={`${activeSubs.length} active subs`} />
|
||||
<Kpi icon={Users} label="Users" value={String(userCount)} hint={`+${newUsers} in 30 days`} />
|
||||
<Kpi icon={Mic2} label="Episodes generated" value={String(episodeCount)} />
|
||||
<Kpi icon={TrendingUp} label="AI spend (MTD)" value={`$${aiSpend.toFixed(2)}`} />
|
||||
<Kpi icon={CreditCard} label="Paying customers" value={String(activeSubs.filter((s) => s.plan !== "free").length)} />
|
||||
<Kpi icon={AlertTriangle} label="Episode error rate" value={`${errorRate}%`} />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="MRR" value={formatPrice(m.mrr)} icon={DollarSign} hint={`${m.activeSubs} active subs`} />
|
||||
<StatCard label="ARR" value={formatPrice(m.arr)} icon={TrendingUp} />
|
||||
<StatCard label="Paying customers" value={String(m.paying)} icon={CreditCard} hint={`ARPU ${formatPrice(m.arpu)}`} />
|
||||
<StatCard label="Total users" value={String(m.totalUsers)} icon={Users} />
|
||||
<StatCard label="Signups" value={String(m.signups)} delta={m.signupsDelta} icon={UserPlus} />
|
||||
<StatCard label="Episodes" value={String(m.episodes)} delta={m.episodesDelta} icon={Mic2} />
|
||||
<StatCard label="AI spend" value={usd(m.aiSpend)} delta={m.aiSpendDelta} invertDelta icon={Wallet} />
|
||||
<StatCard label="Success rate" value={`${m.successRate}%`} icon={CheckCircle2} hint={`${m.failedEpisodes} failed · ${m.churned} churned`} />
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Subscriptions by tier</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{PLAN_ORDER.map((key) => (
|
||||
<div key={key} className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium capitalize">{PLANS[key].name}</span>
|
||||
<Badge variant="secondary">{tierCounts[key] ?? 0}</Badge>
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-3">
|
||||
<ChartCard className="lg:col-span-2" title="Revenue movement" description="New vs churned MRR over the period.">
|
||||
<BarSeries
|
||||
data={revenue}
|
||||
xKey="date"
|
||||
stacked={false}
|
||||
format={(v) => `$${v}`}
|
||||
series={[
|
||||
{ key: "newMrr", name: "New MRR", color: CHART.brand },
|
||||
{ key: "churnedMrr", name: "Churned", color: CHART.warning },
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
<ChartCard title="Plan distribution" description="Active subscriptions by tier.">
|
||||
{tierData.length > 0 ? (
|
||||
<Donut data={tierData} colors={tierData.map((d) => d.color)} />
|
||||
) : (
|
||||
<p className="py-16 text-center text-sm text-muted-foreground">No active subscriptions yet.</p>
|
||||
)}
|
||||
</ChartCard>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{formatPrice(PLANS[key].priceMonthly)}/mo</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<ChartCard title="New signups" description="Account creations per day.">
|
||||
<BarSeries
|
||||
data={signups}
|
||||
xKey="date"
|
||||
height={240}
|
||||
series={[{ key: "signups", name: "Signups", color: CHART.brand }]}
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DollarSign, TrendingUp, Wallet, UserMinus } from "lucide-react";
|
||||
import { getOverview } from "@/lib/admin/metrics";
|
||||
import { getRevenueSeries } from "@/lib/admin/series";
|
||||
import { getRevenueExtras } from "@/lib/admin/billing";
|
||||
import { parseRange } from "@/lib/admin/range";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { ChartCard } from "@/components/admin/ui/chart-card";
|
||||
import { BarSeries } from "@/components/admin/ui/charts";
|
||||
import { RangePicker } from "@/components/admin/ui/table-controls";
|
||||
import { DataTable, type Column } from "@/components/admin/ui/data-table";
|
||||
import { CHART, TIER_COLORS } from "@/components/admin/ui/chart-theme";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Revenue" };
|
||||
|
||||
type Churn = { customer: string; plan: string; provider: string; at: Date };
|
||||
|
||||
export default async function AdminRevenuePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ range?: string }>;
|
||||
}) {
|
||||
const range = parseRange((await searchParams).range);
|
||||
const [m, revenue, extras] = await Promise.all([
|
||||
getOverview(range),
|
||||
getRevenueSeries(range),
|
||||
getRevenueExtras(range),
|
||||
]);
|
||||
|
||||
const churnColumns: Column<Churn>[] = [
|
||||
{ key: "customer", header: "Customer", cell: (c) => <span className="font-medium">{c.customer}</span> },
|
||||
{ key: "plan", header: "Plan", cell: (c) => <span className="capitalize">{c.plan}</span> },
|
||||
{ key: "provider", header: "Provider", cell: (c) => <span className="capitalize">{c.provider}</span> },
|
||||
{ key: "at", header: "Canceled", cell: (c) => <span className="text-muted-foreground">{c.at.toLocaleDateString()}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Revenue"
|
||||
description="Recurring revenue, growth, and churn."
|
||||
action={<RangePicker value={range} />}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="MRR" value={formatPrice(m.mrr)} icon={DollarSign} hint={`${m.paying} paying`} />
|
||||
<StatCard label="ARR" value={formatPrice(m.arr)} icon={TrendingUp} />
|
||||
<StatCard label="ARPU" value={formatPrice(m.arpu)} icon={Wallet} />
|
||||
<StatCard label="Churned (period)" value={String(m.churned)} icon={UserMinus} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-3">
|
||||
<ChartCard className="lg:col-span-2" title="New vs churned MRR" description="Movement over the period.">
|
||||
<BarSeries
|
||||
data={revenue}
|
||||
xKey="date"
|
||||
stacked={false}
|
||||
format={(v) => `$${v}`}
|
||||
series={[
|
||||
{ key: "newMrr", name: "New MRR", color: CHART.brand },
|
||||
{ key: "churnedMrr", name: "Churned", color: CHART.warning },
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
<ChartCard title="MRR by tier" description="Where recurring revenue comes from.">
|
||||
<div className="space-y-3 py-2">
|
||||
{(["creator", "pro", "agency"] as const).map((tier) => {
|
||||
const cents = extras.tierMrr[tier] ?? 0;
|
||||
const pct = m.mrr > 0 ? Math.round((cents / m.mrr) * 100) : 0;
|
||||
return (
|
||||
<div key={tier} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="capitalize">{tier}</span>
|
||||
<span className="font-medium">{formatPrice(cents)}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${pct}%`, backgroundColor: TIER_COLORS[tier] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-muted-foreground">
|
||||
Recent churn <Badge variant="secondary">{extras.churnList.length}</Badge>
|
||||
</h2>
|
||||
<DataTable
|
||||
columns={churnColumns}
|
||||
rows={extras.churnList}
|
||||
getRowKey={(c, i) => `${c.customer}-${i}`}
|
||||
empty="No cancellations in this period. 🎉"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Info } from "lucide-react";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PLANS, PLAN_ORDER, type PlanKey, type PlanLimits } from "@/lib/billing/plans";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { PlanEditor, type EditablePlan, type PlanLimitsValue } from "@/components/admin/plan-editor";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Settings" };
|
||||
|
||||
// Only paid tiers are editable here (Free has no price to tune).
|
||||
const PAID_TIERS = PLAN_ORDER.filter((k) => k !== "free") as Exclude<PlanKey, "free">[];
|
||||
|
||||
function coerceLimits(raw: unknown, fallback: PlanLimits): PlanLimitsValue {
|
||||
const obj = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||
const num = (k: keyof PlanLimits) =>
|
||||
typeof obj[k] === "number" ? (obj[k] as number) : fallback[k];
|
||||
return {
|
||||
script: num("script"),
|
||||
audio: num("audio"),
|
||||
art: num("art"),
|
||||
repurpose: num("repurpose"),
|
||||
seats: num("seats"),
|
||||
maxEpisodeMinutes: num("maxEpisodeMinutes"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
const dbPlans = await prisma.plan.findMany();
|
||||
const byKey = new Map(dbPlans.map((p) => [p.key, p]));
|
||||
|
||||
const editable: EditablePlan[] = PAID_TIERS.map((key) => {
|
||||
const code = PLANS[key];
|
||||
const db = byKey.get(key);
|
||||
return {
|
||||
key,
|
||||
name: code.name,
|
||||
priceMonthly: db?.priceMonthly ?? code.priceMonthly,
|
||||
priceYearly: db?.priceYearly ?? code.priceYearly,
|
||||
limits: db ? coerceLimits(db.limits, code.limits) : { ...code.limits },
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Tune plan prices and limits without a deploy."
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex items-start gap-3 rounded-2xl border border-border bg-brand/5 p-4 text-sm">
|
||||
<Info className="mt-0.5 h-5 w-5 shrink-0 text-brand" />
|
||||
<p className="text-muted-foreground">
|
||||
<code className="text-foreground">lib/billing/plans.ts</code> is the code source of
|
||||
truth for tiers and features. Values saved here are stored in the{" "}
|
||||
<code className="text-foreground">Plan</code> table and act as a runtime{" "}
|
||||
<span className="font-semibold text-foreground">override</span> for price and limits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{editable.map((plan) => (
|
||||
<PlanEditor key={plan.key} plan={plan} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +1,118 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PLANS, type PlanKey } from "@/lib/billing/plans";
|
||||
import { DollarSign, TrendingUp, CreditCard } from "lucide-react";
|
||||
import { listSubscriptions, SUBS_PAGE_SIZE, type AdminSubRow } from "@/lib/admin/billing";
|
||||
import { getOverview } from "@/lib/admin/metrics";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
|
||||
import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
|
||||
import { SubscriptionRowActions } from "@/components/admin/subscription-row-actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Subscriptions" };
|
||||
|
||||
export default async function AdminSubscriptionsPage() {
|
||||
const subs = await prisma.subscription.findMany({ orderBy: { createdAt: "desc" }, take: 200 });
|
||||
|
||||
const refIds = subs.map((s) => s.referenceId);
|
||||
const [users, orgs] = await Promise.all([
|
||||
prisma.user.findMany({ where: { id: { in: refIds } }, select: { id: true, name: true, email: true } }),
|
||||
prisma.organization.findMany({ where: { id: { in: refIds } }, select: { id: true, name: true } }),
|
||||
export default async function AdminSubscriptionsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(sp.page ?? "1"));
|
||||
const [m, { rows, total }] = await Promise.all([
|
||||
getOverview("30d"),
|
||||
listSubscriptions({ status: sp.status, provider: sp.provider, search: sp.q, page }),
|
||||
]);
|
||||
const nameByRef = new Map<string, string>();
|
||||
for (const u of users) nameByRef.set(u.id, u.email);
|
||||
for (const o of orgs) nameByRef.set(o.id, o.name);
|
||||
|
||||
const active = subs.filter((s) => ["active", "trialing"].includes(s.status));
|
||||
const mrr = active.reduce((sum, s) => sum + (PLANS[s.plan as PlanKey]?.priceMonthly ?? 0), 0);
|
||||
const stripeCount = active.filter((s) => s.provider === "stripe").length;
|
||||
const paypalCount = active.filter((s) => s.provider === "paypal").length;
|
||||
const columns: Column<AdminSubRow>[] = [
|
||||
{
|
||||
key: "customer",
|
||||
header: "Customer",
|
||||
cell: (s) => <span className="truncate font-medium">{s.customer}</span>,
|
||||
},
|
||||
{ key: "plan", header: "Plan", cell: (s) => <span className="capitalize">{s.plan}</span> },
|
||||
{ key: "provider", header: "Provider", cell: (s) => <span className="capitalize">{s.provider}</span> },
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
cell: (s) => (
|
||||
<Badge variant={["active", "trialing"].includes(s.status) ? "success" : "secondary"}>
|
||||
{s.cancelAtPeriodEnd ? "cancels soon" : s.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "renews",
|
||||
header: "Renews",
|
||||
cell: (s) => (
|
||||
<span className="text-muted-foreground">{s.periodEnd ? s.periodEnd.toLocaleDateString() : "—"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (s) => (
|
||||
<SubscriptionRowActions
|
||||
sub={{
|
||||
id: s.id,
|
||||
referenceId: s.referenceId,
|
||||
provider: s.provider,
|
||||
status: s.status,
|
||||
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Subscriptions" description="Revenue and active subscriptions." />
|
||||
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-4">
|
||||
<Stat label="MRR" value={formatPrice(mrr)} />
|
||||
<Stat label="ARR" value={formatPrice(mrr * 12)} />
|
||||
<Stat label="Stripe" value={String(stripeCount)} />
|
||||
<Stat label="PayPal" value={String(paypalCount)} />
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="MRR" value={formatPrice(m.mrr)} icon={DollarSign} />
|
||||
<StatCard label="ARR" value={formatPrice(m.arr)} icon={TrendingUp} />
|
||||
<StatCard label="Stripe" value={String(m.providerSplit.stripe)} icon={CreditCard} />
|
||||
<StatCard
|
||||
label="PayPal"
|
||||
value={String(m.providerSplit.paypal)}
|
||||
icon={CreditCard}
|
||||
hint={m.providerSplit.comp ? `${m.providerSplit.comp} comped` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/40 text-left text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="p-3 font-medium">Customer</th>
|
||||
<th className="p-3 font-medium">Plan</th>
|
||||
<th className="p-3 font-medium">Provider</th>
|
||||
<th className="p-3 font-medium">Status</th>
|
||||
<th className="p-3 font-medium">Renews</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{subs.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-muted/20">
|
||||
<td className="p-3">{nameByRef.get(s.referenceId) ?? s.referenceId}</td>
|
||||
<td className="p-3 capitalize">{s.plan}</td>
|
||||
<td className="p-3 capitalize">{s.provider}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={["active", "trialing"].includes(s.status) ? "success" : "secondary"}>
|
||||
{s.cancelAtPeriodEnd ? "cancels soon" : s.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{s.periodEnd ? s.periodEnd.toLocaleDateString() : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{subs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||
No subscriptions yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<TableToolbar>
|
||||
<SearchInput placeholder="Search customer…" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<FilterSelect
|
||||
param="status"
|
||||
placeholder="Status"
|
||||
allLabel="All status"
|
||||
options={[
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "trialing", label: "Trialing" },
|
||||
{ value: "past_due", label: "Past due" },
|
||||
{ value: "canceled", label: "Canceled" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
param="provider"
|
||||
placeholder="Provider"
|
||||
allLabel="All providers"
|
||||
options={[
|
||||
{ value: "stripe", label: "Stripe" },
|
||||
{ value: "paypal", label: "PayPal" },
|
||||
{ value: "comp", label: "Comped" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</TableToolbar>
|
||||
<DataTable columns={columns} rows={rows} getRowKey={(s) => s.id} empty="No subscriptions match your filters." />
|
||||
<div className="mt-4">
|
||||
<Pagination page={page} pageSize={SUBS_PAGE_SIZE} total={total} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CreditCard, DollarSign, Mic, Activity } from "lucide-react";
|
||||
import { getUserDetail } from "@/lib/admin/users";
|
||||
import { UNLIMITED, type UsageMetric } from "@/lib/billing/plans";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { DataTable, type Column } from "@/components/admin/ui/data-table";
|
||||
import { UserDetailActions } from "@/components/admin/user-detail-actions";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · User detail" };
|
||||
|
||||
const USAGE_METRICS: { metric: UsageMetric; label: string }[] = [
|
||||
{ metric: "script", label: "Scripts" },
|
||||
{ metric: "audio", label: "Audio" },
|
||||
{ metric: "art", label: "Cover art" },
|
||||
{ metric: "repurpose", label: "Repurpose" },
|
||||
];
|
||||
|
||||
function initials(name: string): string {
|
||||
return (
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase())
|
||||
.join("") || "?"
|
||||
);
|
||||
}
|
||||
|
||||
type UserDetail = NonNullable<Awaited<ReturnType<typeof getUserDetail>>>;
|
||||
type AuditRow = UserDetail["audit"][number];
|
||||
|
||||
export default async function AdminUserDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const detail = await getUserDetail(id);
|
||||
if (!detail) notFound();
|
||||
|
||||
const { user, subscription, plan, usage, episodes, episodeCount, lifetimeCost, audit } = detail;
|
||||
|
||||
const usageSummary = USAGE_METRICS.map(({ metric, label }) => {
|
||||
const used = usage[metric] ?? 0;
|
||||
const limit = plan.limits[metric];
|
||||
const unlimited = limit === UNLIMITED;
|
||||
const pct = unlimited ? 0 : limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
||||
return { metric, label, used, limit, unlimited, pct };
|
||||
});
|
||||
|
||||
const auditColumns: Column<AuditRow>[] = [
|
||||
{
|
||||
key: "when",
|
||||
header: "When",
|
||||
cell: (l) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
{l.createdAt.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "actor", header: "Actor", cell: (l) => l.actor?.email ?? l.actorType },
|
||||
{ key: "action", header: "Action", cell: (l) => <Badge variant="outline">{l.action}</Badge> },
|
||||
{
|
||||
key: "meta",
|
||||
header: "Details",
|
||||
cell: (l) =>
|
||||
l.metadata ? (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{JSON.stringify(l.metadata).slice(0, 80)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="User detail"
|
||||
description="Account, usage, and history for this customer."
|
||||
/>
|
||||
|
||||
{/* Identity header */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-14 w-14">
|
||||
{user.image && <AvatarImage src={user.image} alt={user.name} />}
|
||||
<AvatarFallback className="text-base">{initials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-display text-xl font-extrabold tracking-tight">{user.name}</p>
|
||||
{user.role === "admin" && <Badge>admin</Badge>}
|
||||
{user.banned ? (
|
||||
<Badge variant="destructive">banned</Badge>
|
||||
) : (
|
||||
<Badge variant="success">active</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{user.email}</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Joined {user.createdAt.toLocaleDateString()} · <span className="font-mono">{user.id}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UserDetailActions
|
||||
user={{ id: user.id, role: user.role ?? "user", banned: !!user.banned }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Key stats */}
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label="Plan"
|
||||
value={plan.name}
|
||||
icon={CreditCard}
|
||||
hint={
|
||||
subscription
|
||||
? `${subscription.provider} · ${subscription.status}`
|
||||
: "No active subscription"
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Lifetime AI cost"
|
||||
value={`$${lifetimeCost.toFixed(2)}`}
|
||||
icon={DollarSign}
|
||||
hint="Across all generations"
|
||||
/>
|
||||
<StatCard label="Episodes" value={String(episodeCount)} icon={Mic} hint="Total created" />
|
||||
<StatCard
|
||||
label="Subscription"
|
||||
value={subscription ? formatPrice(plan.priceMonthly) : "Free"}
|
||||
icon={Activity}
|
||||
hint={subscription?.periodEnd ? `Renews ${subscription.periodEnd.toLocaleDateString()}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Usage vs plan limits */}
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Usage this period</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{usageSummary.map((u) => (
|
||||
<div key={u.metric} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{u.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{u.used} / {u.unlimited ? "∞" : u.limit}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={u.pct}
|
||||
indicatorClassName={
|
||||
!u.unlimited && u.pct >= 90 ? "bg-destructive" : u.pct >= 75 ? "bg-warning" : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent episodes */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent episodes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{episodes.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No episodes yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{episodes.map((ep) => (
|
||||
<li key={ep.id} className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/episodes/${ep.id}`}
|
||||
className="truncate font-medium hover:text-brand"
|
||||
>
|
||||
{ep.title}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ep.format} · {ep.createdAt.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{ep.status}</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Audit history */}
|
||||
<div className="mt-6 space-y-3">
|
||||
<h2 className="font-display text-lg font-bold tracking-tight">Audit history</h2>
|
||||
<DataTable
|
||||
columns={auditColumns}
|
||||
rows={audit}
|
||||
getRowKey={(l) => l.id}
|
||||
empty="No audit entries for this user."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,104 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { listUsers, USERS_PAGE_SIZE, type AdminUserRow } from "@/lib/admin/users";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { UsersTable } from "@/components/admin/users-table";
|
||||
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
|
||||
import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
|
||||
import { UserRowActions } from "@/components/admin/user-row-actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Users" };
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const [users, subs] = await Promise.all([
|
||||
prisma.user.findMany({ orderBy: { createdAt: "desc" }, take: 200 }),
|
||||
prisma.subscription.findMany({
|
||||
where: { status: { in: ["active", "trialing"] } },
|
||||
select: { referenceId: true, plan: true },
|
||||
}),
|
||||
]);
|
||||
const planByRef = new Map(subs.map((s) => [s.referenceId, s.plan]));
|
||||
export default async function AdminUsersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(sp.page ?? "1"));
|
||||
const { rows, total } = await listUsers({
|
||||
search: sp.q,
|
||||
plan: sp.plan,
|
||||
role: sp.role,
|
||||
status: sp.status,
|
||||
sort: sp.sort,
|
||||
page,
|
||||
});
|
||||
|
||||
const columns: Column<AdminUserRow>[] = [
|
||||
{
|
||||
key: "user",
|
||||
header: "User",
|
||||
sortKey: "name",
|
||||
cell: (u) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{u.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{u.email}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "plan", header: "Plan", cell: (u) => <span className="capitalize">{u.plan}</span> },
|
||||
{
|
||||
key: "role",
|
||||
header: "Role",
|
||||
cell: (u) =>
|
||||
u.role === "admin" ? <Badge>admin</Badge> : <span className="text-muted-foreground">user</span>,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
cell: (u) =>
|
||||
u.banned ? <Badge variant="destructive">banned</Badge> : <Badge variant="success">active</Badge>,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "Joined",
|
||||
sortKey: "createdAt",
|
||||
cell: (u) => <span className="text-muted-foreground">{u.createdAt.toLocaleDateString()}</span>,
|
||||
},
|
||||
{ key: "actions", header: "", align: "right", cell: (u) => <UserRowActions user={u} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Users" description={`${users.length} most recent users.`} />
|
||||
<UsersTable
|
||||
users={users.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
role: u.role ?? "user",
|
||||
banned: !!u.banned,
|
||||
plan: planByRef.get(u.id) ?? "free",
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
}))}
|
||||
<PageHeader title="Users" description={`${total} total user${total === 1 ? "" : "s"}.`} />
|
||||
<TableToolbar>
|
||||
<SearchInput placeholder="Search name or email…" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<FilterSelect
|
||||
param="plan"
|
||||
placeholder="Plan"
|
||||
allLabel="All plans"
|
||||
options={[
|
||||
{ value: "free", label: "Free" },
|
||||
{ value: "creator", label: "Creator" },
|
||||
{ value: "pro", label: "Pro" },
|
||||
{ value: "agency", label: "Agency" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
param="role"
|
||||
placeholder="Role"
|
||||
allLabel="All roles"
|
||||
options={[
|
||||
{ value: "admin", label: "Admin" },
|
||||
{ value: "user", label: "User" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
param="status"
|
||||
placeholder="Status"
|
||||
allLabel="All status"
|
||||
options={[
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "banned", label: "Banned" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</TableToolbar>
|
||||
<DataTable columns={columns} rows={rows} getRowKey={(u) => u.id} empty="No users match your filters." />
|
||||
<div className="mt-4">
|
||||
<Pagination page={page} pageSize={USERS_PAGE_SIZE} total={total} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Webhook, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import { listWebhookEvents, WEBHOOKS_PAGE_SIZE } from "@/lib/admin/ops";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
|
||||
import { FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Webhooks" };
|
||||
|
||||
type Row = Awaited<ReturnType<typeof listWebhookEvents>>["rows"][number];
|
||||
|
||||
const VARIANT: Record<string, BadgeProps["variant"]> = {
|
||||
processed: "success",
|
||||
failed: "destructive",
|
||||
skipped: "secondary",
|
||||
};
|
||||
|
||||
export default async function AdminWebhooksPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(sp.page ?? "1"));
|
||||
const { rows, total, recentFailures, recentTotal } = await listWebhookEvents({
|
||||
provider: sp.provider,
|
||||
status: sp.status,
|
||||
page,
|
||||
});
|
||||
|
||||
const columns: Column<Row>[] = [
|
||||
{ key: "provider", header: "Provider", cell: (w) => <span className="capitalize">{w.provider}</span> },
|
||||
{ key: "type", header: "Event", cell: (w) => <span className="font-mono text-xs">{w.type}</span> },
|
||||
{ key: "status", header: "Status", cell: (w) => <Badge variant={VARIANT[w.status] ?? "secondary"}>{w.status}</Badge> },
|
||||
{
|
||||
key: "error",
|
||||
header: "Error",
|
||||
cell: (w) =>
|
||||
w.error ? (
|
||||
<span className="truncate font-mono text-xs text-destructive" title={w.error}>
|
||||
{w.error.slice(0, 40)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "when",
|
||||
header: "When",
|
||||
cell: (w) => <span className="whitespace-nowrap text-muted-foreground">{w.createdAt.toLocaleString()}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Webhook deliveries" description="Stripe & PayPal events received." />
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-3">
|
||||
<StatCard label="Delivered (24h)" value={String(recentTotal)} icon={Webhook} />
|
||||
<StatCard label="Succeeded (24h)" value={String(recentTotal - recentFailures)} icon={CheckCircle2} />
|
||||
<StatCard label="Failed (24h)" value={String(recentFailures)} icon={AlertTriangle} />
|
||||
</div>
|
||||
|
||||
<TableToolbar>
|
||||
<div />
|
||||
<div className="flex gap-2">
|
||||
<FilterSelect
|
||||
param="provider"
|
||||
placeholder="Provider"
|
||||
allLabel="All providers"
|
||||
options={[
|
||||
{ value: "stripe", label: "Stripe" },
|
||||
{ value: "paypal", label: "PayPal" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
param="status"
|
||||
placeholder="Status"
|
||||
allLabel="All status"
|
||||
options={[
|
||||
{ value: "processed", label: "Processed" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "skipped", label: "Skipped" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</TableToolbar>
|
||||
<DataTable columns={columns} rows={rows} getRowKey={(w) => w.id} empty="No webhook deliveries recorded yet." />
|
||||
<div className="mt-4">
|
||||
<Pagination page={page} pageSize={WEBHOOKS_PAGE_SIZE} total={total} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { ShieldCheck, ArrowLeft } from "lucide-react";
|
||||
import { requireAdmin } from "@/lib/auth/guards";
|
||||
import { AdminSidebar } from "@/components/admin/admin-sidebar";
|
||||
import { AdminMobileNav } from "@/components/admin/admin-mobile-nav";
|
||||
import { UserMenu } from "@/components/app/user-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -14,12 +15,15 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-border bg-background/85 px-4 backdrop-blur-md md:px-6">
|
||||
<div className="flex items-center gap-1">
|
||||
<AdminMobileNav />
|
||||
<Link href="/admin" className="flex items-center gap-2.5 font-display font-bold tracking-tight">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-foreground text-background">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</span>
|
||||
<span>PodcastYes Admin</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/dashboard">
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { HeaderSkeleton, Skeleton, ListSkeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function ApiKeysLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<Skeleton className="h-20 rounded-2xl" />
|
||||
<ListSkeleton rows={3} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { HeaderSkeleton, Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function BillingLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<Skeleton className="mb-8 h-24 rounded-2xl" />
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-80 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { HeaderSkeleton, StatRowSkeleton, ListSkeleton, Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<StatRowSkeleton count={3} />
|
||||
<div className="mt-6 space-y-3">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<ListSkeleton rows={5} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+125
-44
@@ -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}</>
|
||||
)}
|
||||
</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">
|
||||
</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>
|
||||
)}
|
||||
<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>
|
||||
{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>
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export default function EpisodeLoading() {
|
||||
return (
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="h-9 w-72 rounded-xl bg-secondary" />
|
||||
<div className="h-4 w-48 rounded-lg bg-secondary/70" />
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-1">
|
||||
<div className="aspect-square rounded-2xl border border-border bg-secondary" />
|
||||
<div className="h-32 rounded-2xl border border-border bg-card" />
|
||||
<div className="h-11 rounded-full bg-secondary" />
|
||||
</div>
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<div className="h-14 rounded-2xl border border-border bg-card" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-40 rounded-2xl border border-border bg-card" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from "next/link";
|
||||
import { MicOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function EpisodeNotFound() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-5 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-secondary text-muted-foreground">
|
||||
<MicOff className="h-7 w-7" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="font-display text-2xl font-extrabold tracking-tight">Episode not found</h1>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
This episode doesn't exist, or you don't have access to it.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/episodes">Back to episodes</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,11 @@ export default async function EpisodePage({ params }: { params: Promise<{ id: st
|
||||
<PageHeader
|
||||
title={episode.title}
|
||||
description={`${episode.format.replace("_", "-").toLowerCase()} · ${episode.language.toUpperCase()} · ${episode.targetLengthMin} min`}
|
||||
action={!inProgress ? <EpisodeActions episodeId={episode.id} /> : undefined}
|
||||
action={
|
||||
!inProgress ? (
|
||||
<EpisodeActions episodeId={episode.id} initialShareId={episode.shareId} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{episode.status === "FAILED" || inProgress ? (
|
||||
@@ -68,6 +72,7 @@ export default async function EpisodePage({ params }: { params: Promise<{ id: st
|
||||
<AudioPlayer
|
||||
storageKey={episode.audioAsset.storageKey}
|
||||
durationSec={episode.audioAsset.durationSec}
|
||||
episodeId={episode.id}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
+162
-20
@@ -1,13 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEffectivePlan } from "@/lib/billing/subscription";
|
||||
import { enforceLimit, LimitExceededError } from "@/lib/usage/limits";
|
||||
import { reserveLimit, LimitExceededError } from "@/lib/usage/limits";
|
||||
import { refundUsage } from "@/lib/usage/meter";
|
||||
import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss";
|
||||
import { rateLimit, LIMITS } from "@/lib/ratelimit";
|
||||
import { isFlagEnabled } from "@/lib/flags";
|
||||
import { moderateText } from "@/lib/ai/moderation";
|
||||
import type { UsageMetric } from "@/lib/billing/plans";
|
||||
import type { GenerationType } from "@/lib/queue/jobs";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
@@ -42,6 +47,10 @@ export async function createEpisodeAction(input: CreateEpisodeInput): Promise<Cr
|
||||
return { ok: false, error: `Too many requests. Try again in ${rl.retryAfterSec}s.` };
|
||||
}
|
||||
|
||||
if (!(await isFlagEnabled("episode_generation_enabled"))) {
|
||||
return { ok: false, error: "Episode generation is temporarily paused. Please try again shortly." };
|
||||
}
|
||||
|
||||
const parsed = createSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." };
|
||||
@@ -49,7 +58,7 @@ export async function createEpisodeAction(input: CreateEpisodeInput): Promise<Cr
|
||||
const data = parsed.data;
|
||||
const activeOrgId = session.session.activeOrganizationId;
|
||||
|
||||
const { plan } = await getEffectivePlan(session.user.id, activeOrgId);
|
||||
const { plan, subjectId, subjectType } = await getEffectivePlan(session.user.id, activeOrgId);
|
||||
if (data.targetLengthMin > plan.limits.maxEpisodeMinutes) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -58,10 +67,33 @@ export async function createEpisodeAction(input: CreateEpisodeInput): Promise<Cr
|
||||
};
|
||||
}
|
||||
|
||||
// Screen the requested topic before spending any quota or AI budget.
|
||||
if (await isFlagEnabled("ai_moderation_enabled")) {
|
||||
const mod = await moderateText([data.title, data.topic, data.audience].filter(Boolean).join("\n"));
|
||||
if (mod.flagged) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "This topic may violate our content policy and can't be generated. Please revise it and try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve quota atomically up front (a full generation consumes script,
|
||||
// audio and art). The worker won't re-meter; we refund below if create/enqueue
|
||||
// fails. See the metering invariant in lib/usage/meter.ts.
|
||||
const reserved: UsageMetric[] = [];
|
||||
const refundReserved = async () => {
|
||||
for (const m of reserved) await refundUsage(subjectId, subjectType, m);
|
||||
};
|
||||
try {
|
||||
await enforceLimit(session.user.id, "script", activeOrgId);
|
||||
await enforceLimit(session.user.id, "audio", activeOrgId);
|
||||
await reserveLimit(session.user.id, "script", activeOrgId);
|
||||
reserved.push("script");
|
||||
await reserveLimit(session.user.id, "audio", activeOrgId);
|
||||
reserved.push("audio");
|
||||
await reserveLimit(session.user.id, "art", activeOrgId);
|
||||
reserved.push("art");
|
||||
} catch (err) {
|
||||
await refundReserved();
|
||||
if (err instanceof LimitExceededError) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -72,6 +104,7 @@ export async function createEpisodeAction(input: CreateEpisodeInput): Promise<Cr
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
const episode = await prisma.episode.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
@@ -104,6 +137,10 @@ export async function createEpisodeAction(input: CreateEpisodeInput): Promise<Cr
|
||||
revalidatePath("/episodes");
|
||||
revalidatePath("/dashboard");
|
||||
return { ok: true, episodeId: episode.id };
|
||||
} catch (err) {
|
||||
await refundReserved();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function regenerateAction(
|
||||
@@ -113,6 +150,15 @@ export async function regenerateAction(
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
const rl = await rateLimit("generation", session.user.id, LIMITS.generation);
|
||||
if (!rl.ok) {
|
||||
return { ok: false, error: `Too many requests. Try again in ${rl.retryAfterSec}s.` };
|
||||
}
|
||||
|
||||
if (!(await isFlagEnabled("episode_generation_enabled"))) {
|
||||
return { ok: false, error: "Episode generation is temporarily paused. Please try again shortly." };
|
||||
}
|
||||
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { id: episodeId },
|
||||
select: { userId: true, organizationId: true },
|
||||
@@ -122,24 +168,41 @@ export async function regenerateAction(
|
||||
return { ok: false, error: "Not allowed." };
|
||||
}
|
||||
|
||||
// Gate the metrics this regeneration will consume.
|
||||
const metrics: ("script" | "audio" | "art")[] =
|
||||
const activeOrgId = session.session.activeOrganizationId;
|
||||
const { subjectId, subjectType } = await getEffectivePlan(session.user.id, activeOrgId);
|
||||
|
||||
// Reserve the metrics this regeneration will consume up front. The worker
|
||||
// won't re-meter; refund below if enqueue fails. See meter.ts invariant.
|
||||
const metrics: UsageMetric[] =
|
||||
type === "art" ? ["art"] : type === "audio" ? ["audio"] : ["script", "audio"];
|
||||
const reserved: UsageMetric[] = [];
|
||||
const refundReserved = async () => {
|
||||
for (const m of reserved) await refundUsage(subjectId, subjectType, m);
|
||||
};
|
||||
try {
|
||||
for (const m of metrics) await enforceLimit(session.user.id, m, session.session.activeOrganizationId);
|
||||
for (const m of metrics) {
|
||||
await reserveLimit(session.user.id, m, activeOrgId);
|
||||
reserved.push(m);
|
||||
}
|
||||
} catch (err) {
|
||||
await refundReserved();
|
||||
if (err instanceof LimitExceededError) {
|
||||
return { ok: false, error: `Monthly ${err.check.metric} limit reached on the ${err.check.plan} plan.` };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.episode.update({
|
||||
where: { id: episodeId },
|
||||
data: { status: "QUEUED", stage: "Queued for regeneration", errorMessage: null },
|
||||
});
|
||||
await prisma.generationJob.create({ data: { episodeId, type, status: "queued" } });
|
||||
await enqueueEpisodeGeneration({ episodeId, type });
|
||||
} catch (err) {
|
||||
await refundReserved();
|
||||
throw err;
|
||||
}
|
||||
|
||||
revalidatePath(`/episodes/${episodeId}`);
|
||||
return { ok: true };
|
||||
@@ -198,8 +261,16 @@ export async function regenerateSectionAction(
|
||||
}
|
||||
if (!episode.script) return { ok: false, error: "No script to edit yet." };
|
||||
|
||||
const rl = await rateLimit("generation", session.user.id, LIMITS.generation);
|
||||
if (!rl.ok) {
|
||||
return { ok: false, error: `Too many requests. Try again in ${rl.retryAfterSec}s.` };
|
||||
}
|
||||
|
||||
const activeOrgId = session.session.activeOrganizationId;
|
||||
// Reserve the script unit atomically up front. This synchronous action does
|
||||
// NOT increment afterwards (that would double-count) — it refunds on failure.
|
||||
try {
|
||||
await enforceLimit(session.user.id, "script", session.session.activeOrganizationId);
|
||||
await reserveLimit(session.user.id, "script", activeOrgId);
|
||||
} catch (err) {
|
||||
if (err instanceof LimitExceededError) {
|
||||
return { ok: false, error: `Monthly script limit reached on the ${err.check.plan} plan.` };
|
||||
@@ -207,10 +278,12 @@ export async function regenerateSectionAction(
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ownerId = episode.organizationId ?? episode.userId;
|
||||
const ownerType = episode.organizationId ? "organization" : "user";
|
||||
|
||||
// Imported lazily so the AI SDK never reaches client bundles importing this file.
|
||||
const { scriptProvider } = await import("@/lib/ai/providers");
|
||||
const { recordCost, scriptCostUsd } = await import("@/lib/ai/cost");
|
||||
const { incrementUsage } = await import("@/lib/usage/meter");
|
||||
|
||||
const config = {
|
||||
title: episode.title,
|
||||
@@ -227,7 +300,10 @@ export async function regenerateSectionAction(
|
||||
sections: { id: string; title: string; turns: { speakerKey: string; text: string }[] }[];
|
||||
};
|
||||
|
||||
const { section, usage } = await scriptProvider().regenerateSection(config, current, sectionId);
|
||||
let section: { id: string; title: string; turns: { speakerKey: string; text: string }[] };
|
||||
let usage: { inputTokens: number; outputTokens: number };
|
||||
try {
|
||||
({ section, usage } = await scriptProvider().regenerateSection(config, current, sectionId));
|
||||
const updated = {
|
||||
...current,
|
||||
sections: current.sections.map((s) => (s.id === sectionId ? section : s)),
|
||||
@@ -237,10 +313,13 @@ export async function regenerateSectionAction(
|
||||
where: { episodeId },
|
||||
data: { content: updated as unknown as Prisma.InputJsonValue, version: { increment: 1 } },
|
||||
});
|
||||
} catch (err) {
|
||||
// Generation/save failed — refund the script unit we reserved.
|
||||
await refundUsage(ownerId, ownerType, "script");
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ownerId = episode.organizationId ?? episode.userId;
|
||||
const ownerType = episode.organizationId ? "organization" : "user";
|
||||
await incrementUsage(ownerId, ownerType, "script");
|
||||
// No incrementUsage here: the unit was already reserved above.
|
||||
await recordCost({
|
||||
provider: "openai",
|
||||
operation: "script",
|
||||
@@ -270,8 +349,16 @@ export async function repurposeAction(
|
||||
}
|
||||
if (!episode.script) return { ok: false, error: "Generate the episode first." };
|
||||
|
||||
const rl = await rateLimit("repurpose", session.user.id, LIMITS.repurpose);
|
||||
if (!rl.ok) {
|
||||
return { ok: false, error: `Too many requests. Try again in ${rl.retryAfterSec}s.` };
|
||||
}
|
||||
|
||||
const activeOrgId = session.session.activeOrganizationId;
|
||||
// Reserve the repurpose unit atomically up front; refund on failure. This
|
||||
// synchronous action does NOT increment afterwards (that would double-count).
|
||||
try {
|
||||
await enforceLimit(session.user.id, "repurpose", session.session.activeOrganizationId);
|
||||
await reserveLimit(session.user.id, "repurpose", activeOrgId);
|
||||
} catch (err) {
|
||||
if (err instanceof LimitExceededError) {
|
||||
return { ok: false, error: `Monthly repurpose limit reached on the ${err.check.plan} plan.` };
|
||||
@@ -279,22 +366,30 @@ export async function repurposeAction(
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ownerId = episode.organizationId ?? episode.userId;
|
||||
const ownerType = episode.organizationId ? "organization" : "user";
|
||||
|
||||
const { repurposeScript } = await import("@/lib/ai/pipeline/repurpose");
|
||||
const { recordCost, scriptCostUsd } = await import("@/lib/ai/cost");
|
||||
const { incrementUsage } = await import("@/lib/usage/meter");
|
||||
|
||||
const { content, usage } = await repurposeScript(
|
||||
let content: { title: string; body: string };
|
||||
let usage: { inputTokens: number; outputTokens: number };
|
||||
try {
|
||||
({ content, usage } = await repurposeScript(
|
||||
episode.script.content as unknown as Parameters<typeof repurposeScript>[0],
|
||||
format
|
||||
);
|
||||
));
|
||||
|
||||
await prisma.repurposedContent.create({
|
||||
data: { episodeId, type: format, content: content as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
} catch (err) {
|
||||
// Generation/save failed — refund the repurpose unit we reserved.
|
||||
await refundUsage(ownerId, ownerType, "repurpose");
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ownerId = episode.organizationId ?? episode.userId;
|
||||
const ownerType = episode.organizationId ? "organization" : "user";
|
||||
await incrementUsage(ownerId, ownerType, "repurpose");
|
||||
// No incrementUsage here: the unit was already reserved above.
|
||||
await recordCost({
|
||||
provider: "openai",
|
||||
operation: "repurpose",
|
||||
@@ -323,6 +418,53 @@ export async function deleteEpisodeAction(episodeId: string): Promise<{ ok: bool
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle public sharing for an episode. When enabled, mints a random url-safe
|
||||
* `shareId` (reachable at /p/<shareId> with no auth) and stamps `sharedAt`;
|
||||
* when disabled, clears both so the public page 404s. Ownership-checked.
|
||||
*/
|
||||
export async function setEpisodeShareAction(
|
||||
episodeId: string,
|
||||
enabled: boolean
|
||||
): Promise<{ ok: boolean; error?: string; shareId?: string | null }> {
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { id: episodeId },
|
||||
select: { userId: true, shareId: true, status: true },
|
||||
});
|
||||
if (!episode || (episode.userId !== session.user.id && session.user.role !== "admin")) {
|
||||
return { ok: false, error: "Not allowed." };
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
if (episode.status !== "READY") {
|
||||
return { ok: false, error: "Finish generating the episode before sharing it." };
|
||||
}
|
||||
// Reuse an existing shareId if one was already minted (stable public URL).
|
||||
const shareId = episode.shareId ?? randomShareId();
|
||||
await prisma.episode.update({
|
||||
where: { id: episodeId },
|
||||
data: { shareId, sharedAt: new Date() },
|
||||
});
|
||||
revalidatePath(`/episodes/${episodeId}`);
|
||||
return { ok: true, shareId };
|
||||
}
|
||||
|
||||
await prisma.episode.update({
|
||||
where: { id: episodeId },
|
||||
data: { shareId: null, sharedAt: null },
|
||||
});
|
||||
revalidatePath(`/episodes/${episodeId}`);
|
||||
return { ok: true, shareId: null };
|
||||
}
|
||||
|
||||
/** url-safe base64 token (~22 chars, 128 bits) for public share links. */
|
||||
function randomShareId(): string {
|
||||
return randomBytes(16).toString("base64url");
|
||||
}
|
||||
|
||||
function deriveTitle(topic: string): string {
|
||||
const trimmed = topic.trim().replace(/\s+/g, " ");
|
||||
return trimmed.length <= 60 ? trimmed : trimmed.slice(0, 57) + "…";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { HeaderSkeleton, EpisodeGridSkeleton, Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function EpisodesLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<Skeleton className="h-10 w-full sm:max-w-xs" />
|
||||
<Skeleton className="h-10 w-[150px]" />
|
||||
</div>
|
||||
<EpisodeGridSkeleton count={8} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+55
-11
@@ -1,22 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Mic2, Plus } from "lucide-react";
|
||||
import type { Prisma, EpisodeStatus } from "@prisma/client";
|
||||
import { requireAuth } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { EpisodeCard } from "@/components/app/episode-card";
|
||||
import { SearchInput, FilterSelect } from "@/components/admin/ui/table-controls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
export const metadata: Metadata = { title: "Episodes" };
|
||||
|
||||
export default async function EpisodesPage() {
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "QUEUED", label: "Queued" },
|
||||
{ value: "SCRIPTING", label: "Writing script" },
|
||||
{ value: "SYNTHESIZING", label: "Recording audio" },
|
||||
{ value: "STITCHING", label: "Mixing audio" },
|
||||
{ value: "ART", label: "Designing art" },
|
||||
{ value: "SAVING", label: "Finalizing" },
|
||||
{ value: "READY", label: "Ready" },
|
||||
{ value: "FAILED", label: "Failed" },
|
||||
];
|
||||
|
||||
const VALID_STATUSES = new Set(STATUS_OPTIONS.map((o) => o.value));
|
||||
|
||||
export default async function EpisodesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; status?: string }>;
|
||||
}) {
|
||||
const session = await requireAuth();
|
||||
const { q, status } = await searchParams;
|
||||
const query = q?.trim();
|
||||
const statusFilter = status && VALID_STATUSES.has(status) ? status : undefined;
|
||||
|
||||
const where: Prisma.EpisodeWhereInput = {
|
||||
userId: session.user.id,
|
||||
...(query ? { title: { contains: query, mode: "insensitive" } } : {}),
|
||||
...(statusFilter ? { status: statusFilter as EpisodeStatus } : {}),
|
||||
};
|
||||
|
||||
const episodes = await prisma.episode.findMany({
|
||||
where: { userId: session.user.id },
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { coverArt: { select: { storageKey: true } } },
|
||||
});
|
||||
|
||||
const filtering = Boolean(query || statusFilter);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -31,21 +64,32 @@ export default async function EpisodesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{episodes.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border py-16 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.</p>
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<SearchInput placeholder="Search episodes…" />
|
||||
<FilterSelect param="status" placeholder="Status" options={STATUS_OPTIONS} allLabel="All statuses" />
|
||||
</div>
|
||||
|
||||
{episodes.length === 0 ? (
|
||||
filtering ? (
|
||||
<EmptyState
|
||||
icon={Mic2}
|
||||
title="No matching episodes"
|
||||
description="Try a different search term or status filter."
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Mic2}
|
||||
title="No episodes yet"
|
||||
description="Create your first AI-produced episode."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/episodes/new">
|
||||
<Plus className="h-4 w-4" /> New episode
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{episodes.map((ep) => (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function AppError({ error, reset }: { error: Error; reset: () => void }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-display text-lg font-bold tracking-tight">Something went wrong</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{error.message || "This page failed to load. Please try again."}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={reset}>
|
||||
<RotateCw className="h-4 w-4" /> Try again
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+52
-7
@@ -1,9 +1,15 @@
|
||||
import Link from "next/link";
|
||||
import { Mic, Plus } from "lucide-react";
|
||||
import { Mic, Plus, Wrench } from "lucide-react";
|
||||
import { requireAuth } from "@/lib/auth/guards";
|
||||
import { getEffectivePlan } from "@/lib/billing/subscription";
|
||||
import { getActiveBranding, hexToHslTriplet } from "@/lib/branding";
|
||||
import { isFlagEnabled } from "@/lib/flags";
|
||||
import { SidebarNav } from "@/components/app/sidebar-nav";
|
||||
import { AppMobileNav } from "@/components/app/app-mobile-nav";
|
||||
import { UserMenu } from "@/components/app/user-menu";
|
||||
import { CommandPalette } from "@/components/app/command-palette";
|
||||
import { ImpersonationBanner } from "@/components/app/impersonation-banner";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// Authed, DB-backed dashboard — never statically prerender.
|
||||
@@ -11,21 +17,54 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireAuth();
|
||||
const { key: plan } = await getEffectivePlan(
|
||||
session.user.id,
|
||||
session.session.activeOrganizationId
|
||||
);
|
||||
const activeOrgId = session.session.activeOrganizationId;
|
||||
const [{ key: plan }, branding, maintenance] = await Promise.all([
|
||||
getEffectivePlan(session.user.id, activeOrgId),
|
||||
getActiveBranding(session.user.id, activeOrgId),
|
||||
isFlagEnabled("maintenance_banner"),
|
||||
]);
|
||||
const isAdmin = session.user.role === "admin";
|
||||
|
||||
// White-label: override the brand accent with the org's primary color so the
|
||||
// whole app shell (logo tile, active nav, buttons) adopts it.
|
||||
const brandHsl = hexToHslTriplet(branding?.primaryColor);
|
||||
const brandStyle = brandHsl
|
||||
? ({ "--brand": brandHsl, "--ring": brandHsl } as React.CSSProperties)
|
||||
: undefined;
|
||||
const workspaceName = branding?.brandName ?? "PodcastYes";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<ThemeProvider>
|
||||
<div className="flex min-h-screen flex-col" style={brandStyle}>
|
||||
<ImpersonationBanner />
|
||||
{maintenance && (
|
||||
<div className="flex items-center justify-center gap-2 bg-warning px-4 py-2 text-center text-sm font-medium text-warning-foreground">
|
||||
<Wrench className="h-4 w-4" />
|
||||
We're performing scheduled maintenance — some features may be briefly unavailable.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-border bg-background/85 px-4 backdrop-blur-md md:px-6">
|
||||
<div className="flex items-center gap-1">
|
||||
<AppMobileNav plan={plan} workspaceName={workspaceName} />
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5 font-display font-bold tracking-tight">
|
||||
{branding?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={branding.logoUrl}
|
||||
alt={workspaceName}
|
||||
className="h-8 w-auto max-w-[160px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
|
||||
<Mic className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="hidden sm:inline">PodcastYes</span>
|
||||
<span className="hidden sm:inline">{workspaceName}</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button asChild size="sm">
|
||||
<Link href="/episodes/new">
|
||||
@@ -49,8 +88,14 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||
</aside>
|
||||
<main className="flex-1 bg-secondary/50">
|
||||
<div className="container max-w-6xl py-8 md:py-10">{children}</div>
|
||||
{branding && !branding.removePoweredBy && (
|
||||
<p className="pb-8 text-center text-xs text-muted-foreground">Powered by PodcastYes</p>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<CommandPalette />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { enforceLimit, LimitExceededError } from "@/lib/usage/limits";
|
||||
import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss";
|
||||
import { FORMAT_SPEAKERS } from "@/lib/episodes/options";
|
||||
import { DEFAULT_VOICE_IDS, VOICE_CATALOG } from "@/lib/ai/voices";
|
||||
import { isFlagEnabled } from "@/lib/flags";
|
||||
|
||||
const createSchema = z.object({
|
||||
theme: z.string().min(5).max(500),
|
||||
@@ -27,6 +28,9 @@ export async function createSeriesAction(
|
||||
if (!(await subjectHasFeature(session.user.id, "series_generator", session.session.activeOrganizationId))) {
|
||||
return { ok: false, error: "The series generator requires the Pro plan." };
|
||||
}
|
||||
if (!(await isFlagEnabled("episode_generation_enabled"))) {
|
||||
return { ok: false, error: "Generation is temporarily paused. Please try again shortly." };
|
||||
}
|
||||
const parsed = createSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." };
|
||||
|
||||
@@ -54,6 +58,10 @@ export async function generateFromSeriesAction(
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
if (!(await isFlagEnabled("episode_generation_enabled"))) {
|
||||
return { ok: false, error: "Episode generation is temporarily paused. Please try again shortly." };
|
||||
}
|
||||
|
||||
const series = await prisma.series.findUnique({ where: { id: seriesId } });
|
||||
if (!series || series.userId !== session.user.id) return { ok: false, error: "Not allowed." };
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { HeaderSkeleton, Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function SeriesLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<Skeleton className="h-72 max-w-2xl rounded-2xl" />
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { PageHeader } from "@/components/app/page-header";
|
||||
import { UpgradeGate } from "@/components/app/upgrade-gate";
|
||||
import { SeriesCreateForm } from "@/components/app/series-create-form";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
export const metadata: Metadata = { title: "Series" };
|
||||
|
||||
@@ -47,13 +48,13 @@ export default async function SeriesPage() {
|
||||
|
||||
<SeriesCreateForm />
|
||||
|
||||
{series.length > 0 && (
|
||||
<div className="mt-8 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">Your seasons</h2>
|
||||
{series.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{series.map((s) => (
|
||||
<Link key={s.id} href={`/series/${s.id}`}>
|
||||
<Card className="transition-shadow hover:shadow-md">
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-brand/10 text-brand">
|
||||
<ListMusic className="h-5 w-5" />
|
||||
@@ -69,8 +70,14 @@ export default async function SeriesPage() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="No seasons yet"
|
||||
description="Plan a cohesive season above, then generate each episode in a couple of clicks."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { LANGUAGES } from "@/lib/episodes/options";
|
||||
import { VOICE_CATALOG } from "@/lib/ai/voices";
|
||||
|
||||
const VALID_LANGUAGES = new Set<string>(LANGUAGES.map((l) => l.code));
|
||||
const VALID_VOICES = new Set<string>(VOICE_CATALOG.map((v) => v.id));
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
defaultVoiceId: z.string().nullable().optional(),
|
||||
defaultLanguage: z.string().min(2).max(5).optional(),
|
||||
emailOnEpisodeReady: z.boolean().optional(),
|
||||
productEmails: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type PreferencesInput = z.infer<typeof preferencesSchema>;
|
||||
|
||||
/**
|
||||
* Persist the current user's editor defaults and notification preferences.
|
||||
* Auth-checked; upserts the single per-user preferences row. Only validated,
|
||||
* known voice/language values are stored.
|
||||
*/
|
||||
export async function savePreferencesAction(
|
||||
input: PreferencesInput
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
const parsed = preferencesSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: "Invalid settings." };
|
||||
const data = parsed.data;
|
||||
|
||||
if (data.defaultVoiceId && !VALID_VOICES.has(data.defaultVoiceId)) {
|
||||
return { ok: false, error: "Unknown voice." };
|
||||
}
|
||||
if (data.defaultLanguage && !VALID_LANGUAGES.has(data.defaultLanguage)) {
|
||||
return { ok: false, error: "Unsupported language." };
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
// Normalize "none"/empty voice to null.
|
||||
const defaultVoiceId =
|
||||
data.defaultVoiceId === undefined ? undefined : data.defaultVoiceId || null;
|
||||
|
||||
await prisma.userPreferences.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
defaultVoiceId: defaultVoiceId ?? null,
|
||||
defaultLanguage: data.defaultLanguage ?? "en",
|
||||
emailOnEpisodeReady: data.emailOnEpisodeReady ?? true,
|
||||
productEmails: data.productEmails ?? true,
|
||||
},
|
||||
update: {
|
||||
...(defaultVoiceId !== undefined ? { defaultVoiceId } : {}),
|
||||
...(data.defaultLanguage !== undefined ? { defaultLanguage: data.defaultLanguage } : {}),
|
||||
...(data.emailOnEpisodeReady !== undefined
|
||||
? { emailOnEpisodeReady: data.emailOnEpisodeReady }
|
||||
: {}),
|
||||
...(data.productEmails !== undefined ? { productEmails: data.productEmails } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/settings");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete the current user's account. Auth-checked and gated by a
|
||||
* typed email confirmation that must match the session email. The User delete
|
||||
* cascades to sessions, accounts, episodes, series, usage and preferences. The
|
||||
* client signs out after a successful response.
|
||||
*/
|
||||
export async function deleteAccountAction(
|
||||
confirmEmail: string
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
if (confirmEmail.trim().toLowerCase() !== session.user.email.toLowerCase()) {
|
||||
return { ok: false, error: "The email you typed doesn't match your account." };
|
||||
}
|
||||
|
||||
// Deleting the User row cascades to all owned data via onDelete: Cascade.
|
||||
await prisma.user.delete({ where: { id: session.user.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireAuth } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { SettingsClient } from "@/components/app/settings-client";
|
||||
|
||||
@@ -7,10 +8,24 @@ export const metadata: Metadata = { title: "Settings" };
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await requireAuth();
|
||||
|
||||
const prefs = await prisma.userPreferences.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Settings" description="Manage your account." />
|
||||
<SettingsClient name={session.user.name} email={session.user.email} />
|
||||
<SettingsClient
|
||||
name={session.user.name}
|
||||
email={session.user.email}
|
||||
preferences={{
|
||||
defaultVoiceId: prefs?.defaultVoiceId ?? null,
|
||||
defaultLanguage: prefs?.defaultLanguage ?? "en",
|
||||
emailOnEpisodeReady: prefs?.emailOnEpisodeReady ?? true,
|
||||
productEmails: prefs?.productEmails ?? true,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { headers } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { getEffectivePlan } from "@/lib/billing/subscription";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** http(s)-only URL guard: rejects javascript:/data: and other schemes. */
|
||||
const httpUrl = z
|
||||
.string()
|
||||
.url()
|
||||
.refine(
|
||||
(v) => {
|
||||
try {
|
||||
const proto = new URL(v).protocol;
|
||||
return proto === "http:" || proto === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "Logo URL must be an http(s) URL." }
|
||||
);
|
||||
|
||||
const brandingSchema = z.object({
|
||||
brandName: z.string().max(60).optional(),
|
||||
primaryColor: z
|
||||
@@ -12,10 +31,68 @@ const brandingSchema = z.object({
|
||||
.regex(/^#([0-9a-fA-F]{6})$/, "Use a hex colour like #7c3aed")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
logoUrl: z.string().url().optional().or(z.literal("")),
|
||||
logoUrl: httpUrl.optional().or(z.literal("")),
|
||||
removePoweredBy: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email("Enter a valid email address."),
|
||||
});
|
||||
|
||||
/**
|
||||
* Invite a member — the server is the authority on the seat check.
|
||||
*
|
||||
* Better Auth only enforces `membershipLimit: 5` (see lib/auth/auth.ts), which is
|
||||
* NOT the same as the plan's `seats`. So we enforce the plan seat count here before
|
||||
* delegating to Better Auth's server API (`auth.api.createInvitation`), which itself
|
||||
* re-verifies that the caller is allowed to invite. The client-side check in
|
||||
* team-client.tsx remains only as a fast UX guard.
|
||||
*/
|
||||
export async function inviteMemberAction(
|
||||
organizationId: string,
|
||||
email: string
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
const parsed = inviteSchema.safeParse({ email });
|
||||
if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid email." };
|
||||
|
||||
// Caller must be an owner/admin of this organization.
|
||||
const member = await prisma.member.findFirst({
|
||||
where: { organizationId, userId: session.user.id },
|
||||
select: { role: true },
|
||||
});
|
||||
if (!member || !["owner", "admin"].includes(member.role)) {
|
||||
return { ok: false, error: "Only workspace owners can invite members." };
|
||||
}
|
||||
|
||||
// Seat enforcement against the effective plan, counting members + pending invites.
|
||||
const { plan } = await getEffectivePlan(session.user.id, organizationId);
|
||||
const [memberCount, pendingInvites] = await Promise.all([
|
||||
prisma.member.count({ where: { organizationId } }),
|
||||
prisma.invitation.count({ where: { organizationId, status: "pending" } }),
|
||||
]);
|
||||
if (memberCount + pendingInvites >= plan.limits.seats) {
|
||||
return { ok: false, error: `Your plan includes ${plan.limits.seats} seats.` };
|
||||
}
|
||||
|
||||
// Server-side invite via Better Auth's organization plugin API. Passing the
|
||||
// request headers authenticates the call; Better Auth also re-checks permissions.
|
||||
try {
|
||||
await auth.api.createInvitation({
|
||||
body: { email: parsed.data.email, role: "member", organizationId },
|
||||
headers: await headers(),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Could not send invitation.";
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
revalidatePath("/team");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function saveBrandingAction(
|
||||
organizationId: string,
|
||||
data: z.infer<typeof brandingSchema>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { HeaderSkeleton, Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function TeamLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-64 rounded-2xl" />
|
||||
<Skeleton className="h-80 rounded-2xl" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { HeaderSkeleton, Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function UsageLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<Skeleton className="mb-6 h-6 w-44" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { isFlagEnabled } from "@/lib/flags";
|
||||
import { SignUpForm } from "@/components/auth/sign-up-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
export const metadata: Metadata = { title: "Create account" };
|
||||
|
||||
export default async function SignUpPage() {
|
||||
const session = await getServerSession();
|
||||
if (session) redirect("/dashboard");
|
||||
|
||||
if (!(await isFlagEnabled("signups_enabled"))) {
|
||||
return (
|
||||
<Card className="rounded-3xl shadow-md">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-2xl">Sign-ups are paused</CardTitle>
|
||||
<CardDescription>
|
||||
New account registration is temporarily closed. Please check back soon.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/sign-in">Back to sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const googleEnabled = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
return <SignUpForm googleEnabled={googleEnabled} />;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,107 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||
|
||||
export const metadata: Metadata = { title: "Privacy Policy" };
|
||||
|
||||
const UPDATED = "June 7, 2026";
|
||||
|
||||
const SECTIONS: LegalSection[] = [
|
||||
{
|
||||
heading: "Information we collect",
|
||||
paragraphs: ["We collect the following categories of information to operate PodcastYes:"],
|
||||
bullets: [
|
||||
"Account information you provide — your name, email address, and password (passwords are stored only as salted hashes).",
|
||||
"Content you create — episode topics, prompts, configuration (tone, format, language, voices), generated scripts, audio, cover art, and repurposed content.",
|
||||
"Billing information — your plan, subscription status, and payment-provider customer IDs. Card and bank details are handled by our payment processors and are never stored on our servers.",
|
||||
"Usage and technical data — feature usage, monthly generation counts, API-key activity, IP address, browser/user-agent, and server logs used for security and debugging.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "How we use your information",
|
||||
paragraphs: [
|
||||
"We use your information to provide and improve the service: to authenticate you, generate and store your episodes, enforce plan limits, process payments, send transactional email, prevent abuse, and meet legal obligations. We do not sell your personal data, and we do not use your private content to train our own models.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "AI processing and sub-processors",
|
||||
paragraphs: [
|
||||
"Generating an episode requires sending the content you submit (and content derived from it) to our AI sub-processors so they can produce your script, audio, and artwork:",
|
||||
],
|
||||
bullets: [
|
||||
"OpenAI — script generation, content moderation, and cover-art generation.",
|
||||
"ElevenLabs — text-to-speech and multi-speaker dialogue synthesis.",
|
||||
"These providers process your prompts and generated text under their own terms and security commitments. Only the data needed to perform the requested generation is shared.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Payments",
|
||||
paragraphs: [
|
||||
"Subscriptions are processed by Stripe and PayPal. When you check out you interact with the provider directly; we receive only confirmation of your subscription status and identifiers needed to manage it. Please review the privacy policies of Stripe and PayPal for how they handle your payment data.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Email",
|
||||
paragraphs: [
|
||||
"Transactional email (account verification, password resets, and episode-ready notifications) is delivered through Resend. We send only the information necessary to deliver these messages.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Data retention",
|
||||
paragraphs: [
|
||||
"We retain your account and content for as long as your account is active. Generated assets (audio and artwork) are stored so we can deliver your episodes. When you delete an episode it is removed from our systems; when you delete your account, your personal data and content are deleted or anonymized except where we must retain records to comply with legal, tax, or accounting obligations.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Your rights",
|
||||
paragraphs: [
|
||||
"Depending on where you live, you may have rights to access, correct, export, or delete your personal data, and to object to or restrict certain processing. You can edit your profile and delete your content from within the app, or contact us to exercise any of these rights. We will not discriminate against you for exercising them.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Cookies and sessions",
|
||||
paragraphs: [
|
||||
"We use strictly-necessary cookies to keep you signed in and to secure your session. We do not use third-party advertising cookies.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Security",
|
||||
paragraphs: [
|
||||
"We protect your data with encryption in transit, hashed credentials and API keys, scoped access controls, signed billing webhooks, and least-privilege storage. No method of transmission or storage is perfectly secure, but we work to protect your information and to respond promptly to any incident.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Children",
|
||||
paragraphs: [
|
||||
"PodcastYes is not directed to children under 13 (or the minimum age required in your jurisdiction), and we do not knowingly collect their personal data.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "International transfers",
|
||||
paragraphs: [
|
||||
"We and our sub-processors may process your data in countries other than your own. Where required, we rely on appropriate safeguards for such transfers.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Changes to this policy",
|
||||
paragraphs: [
|
||||
"We may update this policy from time to time. Material changes will be reflected by updating the date above and, where appropriate, by notifying you.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Contact",
|
||||
paragraphs: [
|
||||
"Questions about this policy or your data? Email privacy@podcastyes.app and we'll be glad to help.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="container max-w-3xl py-20 md:py-24">
|
||||
<h1 className="font-display text-4xl font-extrabold tracking-tight md:text-5xl">Privacy Policy</h1>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
We collect the account information you provide (name, email) and the content you create to
|
||||
operate PodcastYes. Episode prompts are sent to our AI providers (OpenAI and ElevenLabs) to
|
||||
generate scripts, audio, and artwork. Generated assets are stored to deliver your episodes.
|
||||
We do not sell your personal data. Payment processing is handled by Stripe and PayPal.
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
This is placeholder copy — replace with your reviewed privacy policy before launch.
|
||||
</p>
|
||||
</div>
|
||||
<LegalDoc
|
||||
title="Privacy Policy"
|
||||
updated={UPDATED}
|
||||
intro="This Privacy Policy explains what information PodcastYes collects, how we use it, who we share it with, and the choices you have. It applies to your use of the PodcastYes website and application."
|
||||
sections={SECTIONS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,106 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||
|
||||
export const metadata: Metadata = { title: "Terms of Service" };
|
||||
|
||||
const UPDATED = "June 7, 2026";
|
||||
|
||||
const SECTIONS: LegalSection[] = [
|
||||
{
|
||||
heading: "Acceptance of these terms",
|
||||
paragraphs: [
|
||||
"By creating an account or using PodcastYes (the \"Service\"), you agree to these Terms of Service. If you are using the Service on behalf of an organization, you represent that you are authorized to bind that organization to these terms.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "The Service",
|
||||
paragraphs: [
|
||||
"PodcastYes is an AI platform that turns a topic into a produced podcast episode — writing the script, synthesizing multi-voice audio, and generating cover art — and helps you repurpose that content. Features and limits vary by plan and may change as we improve the Service.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Accounts and eligibility",
|
||||
paragraphs: [
|
||||
"You must provide accurate account information, keep your credentials and API keys confidential, and are responsible for all activity under your account. You must be old enough to form a binding contract in your jurisdiction. Notify us promptly of any unauthorized use.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Acceptable use",
|
||||
paragraphs: [
|
||||
"You agree not to use the Service to create or distribute content that is illegal, infringing, deceptive, hateful, harassing, sexually exploitative, or that impersonates a real person's voice or identity without authorization. You may not attempt to bypass usage limits, security controls, or rate limits, or use the Service to build a competing model.",
|
||||
],
|
||||
bullets: [
|
||||
"We screen topics and generated scripts with automated moderation and may flag, hold, or remove content that violates these terms.",
|
||||
"We may suspend or terminate accounts that abuse the Service or put it, our providers, or other users at risk.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "AI-generated content and ownership",
|
||||
paragraphs: [
|
||||
"Subject to your compliance with these terms and the terms of our AI providers, you own the scripts, audio, and artwork you generate and are responsible for how you use and publish them. AI output can be inaccurate, biased, or unintentionally similar to existing works — you are responsible for reviewing it for accuracy, rights, and suitability before publishing. The Service and generated output are provided on an \"as is\" basis.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Plans, billing, and cancellation",
|
||||
paragraphs: [
|
||||
"Paid plans are billed in advance through Stripe or PayPal on a recurring basis until cancelled. By subscribing you authorize us and our processors to charge your payment method for each renewal at the then-current price. You can cancel at any time from the billing page or provider portal; cancellation takes effect at the end of the current billing period. Except where required by law, payments are non-refundable.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Usage limits",
|
||||
paragraphs: [
|
||||
"Each plan includes monthly allowances for scripts, audio, artwork, repurposing, seats, and maximum episode length. Allowances reset at the start of each calendar month and are enforced at generation time. Exceeding a limit pauses the relevant feature until the next period or until you upgrade.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "API access",
|
||||
paragraphs: [
|
||||
"Eligible plans may access the PodcastYes API using keys you generate. Keep keys secret; requests are attributed to, and count against, the owning account, and are subject to rate and usage limits. We may revoke keys that are abused.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Third-party services",
|
||||
paragraphs: [
|
||||
"The Service relies on third parties including OpenAI, ElevenLabs, Stripe, PayPal, and Resend. Your use of features powered by these providers is also subject to their terms, and we are not responsible for their acts or omissions.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Disclaimers and limitation of liability",
|
||||
paragraphs: [
|
||||
"To the maximum extent permitted by law, the Service is provided without warranties of any kind, and PodcastYes is not liable for indirect, incidental, or consequential damages, or for any loss of data, profits, or goodwill. Our total liability for any claim relating to the Service is limited to the amount you paid us in the twelve months before the claim.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Termination",
|
||||
paragraphs: [
|
||||
"You may stop using the Service and delete your account at any time. We may suspend or terminate access if you breach these terms or to protect the Service. Provisions that by their nature should survive termination (such as ownership, disclaimers, and limitations of liability) will survive.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Changes to these terms",
|
||||
paragraphs: [
|
||||
"We may update these terms as the Service evolves. Material changes will be reflected by updating the date above, and your continued use after changes take effect constitutes acceptance.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Governing law",
|
||||
paragraphs: [
|
||||
"These terms are governed by the laws of the jurisdiction in which PodcastYes is established, without regard to conflict-of-laws rules. Nothing here limits any non-waivable rights you have under your local law.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Contact",
|
||||
paragraphs: ["Questions about these terms? Email legal@podcastyes.app."],
|
||||
},
|
||||
];
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="container max-w-3xl py-20 md:py-24">
|
||||
<h1 className="font-display text-4xl font-extrabold tracking-tight md:text-5xl">Terms of Service</h1>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
These terms govern your use of PodcastYes. By creating an account you agree to use the
|
||||
service lawfully and to retain responsibility for the content you generate. AI-generated
|
||||
scripts, audio, and artwork are provided as-is; review them before publishing. Subscriptions
|
||||
renew automatically until cancelled, and usage limits reset monthly.
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
This is placeholder copy — replace with your reviewed legal terms before launch.
|
||||
</p>
|
||||
</div>
|
||||
<LegalDoc
|
||||
title="Terms of Service"
|
||||
updated={UPDATED}
|
||||
intro="These Terms of Service govern your access to and use of PodcastYes. Please read them carefully — they include important information about your rights, billing, acceptable use, and the limits of our liability."
|
||||
sections={SECTIONS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Public, unauthenticated route group (e.g. shared episode pages). No app shell,
|
||||
// no sidebar, no session requirement — just a clean centered canvas. The root
|
||||
// layout already provides <html>/<body>, fonts and the toaster.
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="min-h-screen bg-background">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Mic2 } from "lucide-react";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { storage } from "@/lib/storage";
|
||||
import { getActiveBranding, hexToHslTriplet } from "@/lib/branding";
|
||||
import { WaveformPlayer } from "@/components/app/waveform-player";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { StructuredScript } from "@/lib/ai/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function loadShared(shareId: string) {
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { shareId },
|
||||
include: { audioAsset: true, coverArt: true, script: true, speakers: true },
|
||||
});
|
||||
// 404 when no episode, sharing disabled, or not finished.
|
||||
if (!episode || !episode.shareId || episode.status !== "READY") return null;
|
||||
return episode;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ shareId: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { shareId } = await params;
|
||||
const episode = await loadShared(shareId);
|
||||
if (!episode) return { title: "Episode not found" };
|
||||
return {
|
||||
title: episode.title,
|
||||
description: episode.topic.slice(0, 160),
|
||||
robots: { index: false },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PublicSharePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ shareId: string }>;
|
||||
}) {
|
||||
const { shareId } = await params;
|
||||
const episode = await loadShared(shareId);
|
||||
if (!episode) notFound();
|
||||
|
||||
// Resolve the owning org's white-label branding (Agency custom_branding only).
|
||||
const branding = await getActiveBranding(episode.userId, episode.organizationId);
|
||||
const brandHsl = hexToHslTriplet(branding?.primaryColor);
|
||||
const brandStyle = brandHsl
|
||||
? ({ "--brand": brandHsl, "--ring": brandHsl } as React.CSSProperties)
|
||||
: undefined;
|
||||
const brandName = branding?.brandName ?? "PodcastYes";
|
||||
const removePoweredBy = branding?.removePoweredBy ?? false;
|
||||
|
||||
// Prefer a directly-fetchable public URL (e.g. nginx /media); otherwise fall
|
||||
// back to the share-authorized public cover route.
|
||||
const coverUrl = episode.coverArt
|
||||
? storage().publicUrl(episode.coverArt.storageKey) ??
|
||||
`/api/public/episodes/${shareId}/cover`
|
||||
: null;
|
||||
|
||||
const speakerNames: Record<string, string> = {};
|
||||
for (const s of episode.speakers) speakerNames[s.speakerKey] = s.displayName;
|
||||
const script = episode.script?.content as unknown as StructuredScript | undefined;
|
||||
|
||||
return (
|
||||
<div style={brandStyle} className="mx-auto max-w-3xl px-6 py-10 sm:py-16">
|
||||
{/* Header / brand wordmark */}
|
||||
<header className="mb-8 flex items-center gap-2.5">
|
||||
{branding?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={branding.logoUrl} alt={brandName} className="h-7 w-auto" />
|
||||
) : (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
|
||||
<Mic2 className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className="font-display text-base font-extrabold tracking-tight">{brandName}</span>
|
||||
</header>
|
||||
|
||||
<article className="space-y-8">
|
||||
<div className="grid gap-6 sm:grid-cols-[200px_1fr] sm:items-end">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="aspect-square bg-muted">
|
||||
{coverUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={coverUrl} alt={episode.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
<Mic2 className="h-10 w-10" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Badge variant="brand" className="uppercase tracking-[0.04em]">
|
||||
Podcast episode
|
||||
</Badge>
|
||||
<h1 className="font-display text-3xl font-extrabold leading-[1.1] tracking-tight sm:text-4xl">
|
||||
{episode.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{episode.format.replace("_", "-").toLowerCase()} · {episode.language.toUpperCase()} ·{" "}
|
||||
{episode.targetLengthMin} min
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{episode.audioAsset && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<WaveformPlayer
|
||||
src={`/api/public/episodes/${shareId}/audio`}
|
||||
durationSec={episode.audioAsset.durationSec}
|
||||
hideDownloads
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight">About this episode</h2>
|
||||
<p className="whitespace-pre-wrap leading-relaxed text-foreground/90">{episode.topic}</p>
|
||||
</section>
|
||||
|
||||
{script && script.sections?.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight">Show notes</h2>
|
||||
<ul className="space-y-1.5 text-sm text-muted-foreground">
|
||||
{script.sections.map((s) => (
|
||||
<li key={s.id} className="flex gap-2">
|
||||
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
|
||||
{s.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
|
||||
{!removePoweredBy && (
|
||||
<footer className="mt-16 border-t pt-6 text-center text-xs text-muted-foreground">
|
||||
Made with{" "}
|
||||
<a href="/" className="font-semibold text-brand hover:underline">
|
||||
PodcastYes
|
||||
</a>{" "}
|
||||
— turn any topic into a podcast with AI.
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import JSZip from "jszip";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { storage } from "@/lib/storage";
|
||||
import type { StructuredScript } from "@/lib/ai/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Bundle everything for an episode into a single .zip download:
|
||||
* - audio.mp3 (the rendered episode, if present)
|
||||
* - cover.png (the cover art, if present)
|
||||
* - script.txt (the full transcript, speaker-labelled)
|
||||
* - show-notes.md (title + section outline)
|
||||
*
|
||||
* Ownership-gated: only the owning user (or an admin) may export.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const session = await getServerSession();
|
||||
if (!session) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
script: true,
|
||||
audioAsset: true,
|
||||
coverArt: true,
|
||||
speakers: true,
|
||||
},
|
||||
});
|
||||
if (!episode) return new Response("Not found", { status: 404 });
|
||||
if (episode.userId !== session.user.id && session.user.role !== "admin") {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const speakerNames: Record<string, string> = {};
|
||||
for (const s of episode.speakers) speakerNames[s.speakerKey] = s.displayName;
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
// Audio (if rendered).
|
||||
if (episode.audioAsset && (await storage().exists(episode.audioAsset.storageKey))) {
|
||||
const audio = await storage().get(episode.audioAsset.storageKey);
|
||||
const ext = episode.audioAsset.format || "mp3";
|
||||
zip.file(`audio.${ext}`, audio);
|
||||
}
|
||||
|
||||
// Cover art (if present).
|
||||
if (episode.coverArt && (await storage().exists(episode.coverArt.storageKey))) {
|
||||
const art = await storage().get(episode.coverArt.storageKey);
|
||||
const ext = episode.coverArt.storageKey.split(".").pop()?.toLowerCase() ?? "png";
|
||||
zip.file(`cover.${ext}`, art);
|
||||
}
|
||||
|
||||
// Transcript + show notes derived from the structured script.
|
||||
if (episode.script) {
|
||||
const script = episode.script.content as unknown as StructuredScript;
|
||||
zip.file("script.txt", buildTranscript(episode.title, script, speakerNames));
|
||||
zip.file("show-notes.md", buildShowNotes(episode, script));
|
||||
}
|
||||
|
||||
const blob = await zip.generateAsync({ type: "nodebuffer" });
|
||||
const filename = `${slugify(episode.title) || "episode"}.zip`;
|
||||
|
||||
return new Response(blob as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Length": String(blob.byteLength),
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildTranscript(
|
||||
title: string,
|
||||
script: StructuredScript,
|
||||
speakerNames: Record<string, string>
|
||||
): string {
|
||||
const lines: string[] = [title, "=".repeat(title.length), ""];
|
||||
for (const section of script.sections ?? []) {
|
||||
lines.push(`## ${section.title}`, "");
|
||||
for (const turn of section.turns ?? []) {
|
||||
const name = speakerNames[turn.speakerKey] ?? turn.speakerKey;
|
||||
lines.push(`${name}: ${turn.text}`, "");
|
||||
}
|
||||
}
|
||||
return lines.join("\n").trimEnd() + "\n";
|
||||
}
|
||||
|
||||
function buildShowNotes(
|
||||
episode: { title: string; topic: string; format: string; language: string; targetLengthMin: number },
|
||||
script: StructuredScript
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
`# ${episode.title}`,
|
||||
"",
|
||||
episode.topic,
|
||||
"",
|
||||
`- **Format:** ${episode.format.replace("_", "-").toLowerCase()}`,
|
||||
`- **Language:** ${episode.language.toUpperCase()}`,
|
||||
`- **Target length:** ${episode.targetLengthMin} min`,
|
||||
"",
|
||||
"## In this episode",
|
||||
"",
|
||||
];
|
||||
for (const section of script.sections ?? []) {
|
||||
lines.push(`- ${section.title}`);
|
||||
}
|
||||
lines.push("", "---", "Generated with PodcastYes.");
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function slugify(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
}
|
||||
@@ -5,6 +5,28 @@ import { isTerminal } from "@/lib/episodes/status";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Per-user concurrency cap for SSE streams. Each open connection polls the DB
|
||||
* every 1.5s, so unbounded streams per user are a cheap DoS / resource leak.
|
||||
* This is an in-process counter (one web instance); see the rate-limiter note
|
||||
* about scaling to multiple nodes.
|
||||
*/
|
||||
const MAX_STREAMS_PER_USER = 5;
|
||||
const activeStreams = new Map<string, number>();
|
||||
|
||||
function tryAcquireStream(userId: string): boolean {
|
||||
const current = activeStreams.get(userId) ?? 0;
|
||||
if (current >= MAX_STREAMS_PER_USER) return false;
|
||||
activeStreams.set(userId, current + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
function releaseStream(userId: string): void {
|
||||
const current = activeStreams.get(userId) ?? 0;
|
||||
if (current <= 1) activeStreams.delete(userId);
|
||||
else activeStreams.set(userId, current - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-Sent Events stream of an episode's generation status. Polls the row
|
||||
* every 1.5s and emits on change until the episode reaches a terminal state.
|
||||
@@ -22,7 +44,19 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
// Cap concurrent streams per user. Released in EVERY stop path below.
|
||||
const streamUserId = session.user.id;
|
||||
if (!tryAcquireStream(streamUserId)) {
|
||||
return new Response("Too many concurrent streams", {
|
||||
status: 429,
|
||||
headers: { "Retry-After": "5" },
|
||||
});
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
// Exposed so the ReadableStream's `cancel` can also release the slot if the
|
||||
// consumer tears down without an abort signal.
|
||||
let stopRef: () => void = () => releaseStream(streamUserId);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
@@ -38,6 +72,9 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
// Release the per-user stream slot exactly once (terminal status,
|
||||
// abort, not-found, or error all route through here).
|
||||
releaseStream(streamUserId);
|
||||
clearInterval(pollTimer);
|
||||
clearInterval(pingTimer);
|
||||
try {
|
||||
@@ -66,6 +103,8 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
if (isTerminal(e.status)) stop();
|
||||
};
|
||||
|
||||
stopRef = stop;
|
||||
|
||||
send({ type: "open" });
|
||||
void poll();
|
||||
pollTimer = setInterval(poll, 1500);
|
||||
@@ -75,6 +114,10 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
req.signal.addEventListener("abort", stop);
|
||||
},
|
||||
cancel() {
|
||||
// Consumer disconnected/cancelled — ensure the slot is released.
|
||||
stopRef();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { storage } from "@/lib/storage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Stream an episode's MP3 to anonymous visitors, authorized purely by a valid,
|
||||
* still-enabled public `shareId` (NOT a session). Returns 404 when the share is
|
||||
* disabled or the audio is missing so we never disclose private episode state.
|
||||
*
|
||||
* Supports HTTP Range requests so the audio element can seek/scrub.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ shareId: string }> }
|
||||
) {
|
||||
const { shareId } = await params;
|
||||
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { shareId },
|
||||
select: { audioAsset: { select: { storageKey: true } } },
|
||||
});
|
||||
const key = episode?.audioAsset?.storageKey;
|
||||
if (!key) return new Response("Not found", { status: 404 });
|
||||
|
||||
if (!(await storage().exists(key))) return new Response("Not found", { status: 404 });
|
||||
|
||||
const data = await storage().get(key);
|
||||
const total = data.byteLength;
|
||||
const contentType = "audio/mpeg";
|
||||
|
||||
const range = req.headers.get("range");
|
||||
if (range) {
|
||||
const match = /bytes=(\d+)-(\d*)/.exec(range);
|
||||
if (match) {
|
||||
const start = Number(match[1]);
|
||||
const end = match[2] ? Math.min(Number(match[2]), total - 1) : total - 1;
|
||||
if (start <= end && start < total) {
|
||||
const chunk = data.subarray(start, end + 1);
|
||||
return new Response(chunk as BodyInit, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(chunk.byteLength),
|
||||
"Content-Range": `bytes ${start}-${end}/${total}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(data as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(total),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { storage } from "@/lib/storage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
webp: "image/webp",
|
||||
};
|
||||
|
||||
/**
|
||||
* Serve an episode's cover art to anonymous visitors, authorized by a valid,
|
||||
* still-enabled public `shareId`. Used as a fallback when the storage provider
|
||||
* doesn't expose a directly-fetchable public URL for cover art.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ shareId: string }> }
|
||||
) {
|
||||
const { shareId } = await params;
|
||||
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { shareId },
|
||||
select: { coverArt: { select: { storageKey: true } } },
|
||||
});
|
||||
const key = episode?.coverArt?.storageKey;
|
||||
if (!key) return new Response("Not found", { status: 404 });
|
||||
if (!(await storage().exists(key))) return new Response("Not found", { status: 404 });
|
||||
|
||||
const data = await storage().get(key);
|
||||
const ext = key.split(".").pop()?.toLowerCase() ?? "png";
|
||||
return new Response(data as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": CONTENT_TYPES[ext] ?? "image/png",
|
||||
"Content-Length": String(data.byteLength),
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,11 +3,15 @@ import { z } from "zod";
|
||||
import { verifyApiKey, bearerKey } from "@/lib/apikeys";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEffectivePlan } from "@/lib/billing/subscription";
|
||||
import { enforceLimit, LimitExceededError } from "@/lib/usage/limits";
|
||||
import { reserveLimit, LimitExceededError } from "@/lib/usage/limits";
|
||||
import { refundUsage } from "@/lib/usage/meter";
|
||||
import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss";
|
||||
import { FORMAT_SPEAKERS } from "@/lib/episodes/options";
|
||||
import { DEFAULT_VOICE_IDS, VOICE_CATALOG } from "@/lib/ai/voices";
|
||||
import { rateLimit, LIMITS } from "@/lib/ratelimit";
|
||||
import { isFlagEnabled } from "@/lib/flags";
|
||||
import { moderateText } from "@/lib/ai/moderation";
|
||||
import type { UsageMetric } from "@/lib/billing/plans";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -20,6 +24,14 @@ export async function GET(req: NextRequest) {
|
||||
const auth = await authorize(req);
|
||||
if (!auth) return Response.json({ error: "Invalid API key" }, { status: 401 });
|
||||
|
||||
const rl = await rateLimit("read", auth.userId, LIMITS.read);
|
||||
if (!rl.ok) {
|
||||
return Response.json(
|
||||
{ error: "Rate limit exceeded" },
|
||||
{ status: 429, headers: { "Retry-After": String(rl.retryAfterSec ?? 60) } }
|
||||
);
|
||||
}
|
||||
|
||||
const episodes = await prisma.episode.findMany({
|
||||
where: { userId: auth.userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
@@ -52,23 +64,51 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await isFlagEnabled("episode_generation_enabled"))) {
|
||||
return Response.json({ error: "Episode generation is temporarily paused" }, { status: 503 });
|
||||
}
|
||||
|
||||
const parsed = createSchema.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
return Response.json({ error: parsed.error.issues[0]?.message ?? "Invalid body" }, { status: 400 });
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
const { plan } = await getEffectivePlan(auth.userId);
|
||||
const { plan, subjectId, subjectType } = await getEffectivePlan(auth.userId);
|
||||
if (data.targetLengthMin > plan.limits.maxEpisodeMinutes) {
|
||||
return Response.json(
|
||||
{ error: `Plan supports episodes up to ${plan.limits.maxEpisodeMinutes} minutes` },
|
||||
{ status: 402 }
|
||||
);
|
||||
}
|
||||
|
||||
// Screen the requested topic before reserving quota or spending AI budget.
|
||||
if (await isFlagEnabled("ai_moderation_enabled")) {
|
||||
const mod = await moderateText([data.title, data.topic, data.audience].filter(Boolean).join("\n"));
|
||||
if (mod.flagged) {
|
||||
return Response.json(
|
||||
{ error: "Topic violates the content policy and cannot be generated" },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Atomically reserve quota up front (full generation = script+audio+art). The
|
||||
// worker won't re-meter; we refund below if create/enqueue fails. See the
|
||||
// metering invariant in lib/usage/meter.ts.
|
||||
const reserved: UsageMetric[] = [];
|
||||
const refundReserved = async () => {
|
||||
for (const m of reserved) await refundUsage(subjectId, subjectType, m);
|
||||
};
|
||||
try {
|
||||
await enforceLimit(auth.userId, "script");
|
||||
await enforceLimit(auth.userId, "audio");
|
||||
await reserveLimit(auth.userId, "script");
|
||||
reserved.push("script");
|
||||
await reserveLimit(auth.userId, "audio");
|
||||
reserved.push("audio");
|
||||
await reserveLimit(auth.userId, "art");
|
||||
reserved.push("art");
|
||||
} catch (err) {
|
||||
await refundReserved();
|
||||
if (err instanceof LimitExceededError) {
|
||||
return Response.json({ error: `Monthly ${err.check.metric} limit reached` }, { status: 402 });
|
||||
}
|
||||
@@ -81,6 +121,7 @@ export async function POST(req: NextRequest) {
|
||||
elevenVoiceId: DEFAULT_VOICE_IDS[s.speakerKey] ?? VOICE_CATALOG[i % VOICE_CATALOG.length].id,
|
||||
}));
|
||||
|
||||
try {
|
||||
const episode = await prisma.episode.create({
|
||||
data: {
|
||||
userId: auth.userId,
|
||||
@@ -100,4 +141,8 @@ export async function POST(req: NextRequest) {
|
||||
await enqueueEpisodeGeneration({ episodeId: episode.id, type: "full" });
|
||||
|
||||
return Response.json({ id: episode.id, status: episode.status }, { status: 201 });
|
||||
} catch (err) {
|
||||
await refundReserved();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { verifyPaypalWebhook } from "@/lib/billing/paypal";
|
||||
import { handlePaypalEvent } from "@/lib/billing/webhooks/paypal";
|
||||
import { alreadyProcessed, logWebhook } from "@/lib/billing/webhook-log";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -23,10 +24,16 @@ export async function POST(req: NextRequest) {
|
||||
return new Response("Invalid signature", { status: 400 });
|
||||
}
|
||||
|
||||
const event = JSON.parse(body) as { id?: string; event_type?: string };
|
||||
const eventId = event.id ?? `paypal_${Date.now()}`;
|
||||
if (event.id && (await alreadyProcessed(eventId))) return new Response("ok (duplicate)");
|
||||
|
||||
try {
|
||||
await handlePaypalEvent(JSON.parse(body));
|
||||
await handlePaypalEvent(event as Parameters<typeof handlePaypalEvent>[0]);
|
||||
await logWebhook("paypal", eventId, event.event_type ?? "unknown", "processed");
|
||||
} catch (err) {
|
||||
console.error("[paypal webhook] handler error", err);
|
||||
await logWebhook("paypal", eventId, event.event_type ?? "unknown", "failed", err instanceof Error ? err.message : String(err));
|
||||
return new Response("Handler error", { status: 500 });
|
||||
}
|
||||
return new Response("ok");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { stripe } from "@/lib/billing/stripe";
|
||||
import { handleStripeEvent } from "@/lib/billing/webhooks/stripe";
|
||||
import { alreadyProcessed, logWebhook } from "@/lib/billing/webhook-log";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -19,10 +20,15 @@ export async function POST(req: NextRequest) {
|
||||
return new Response("Invalid signature", { status: 400 });
|
||||
}
|
||||
|
||||
// Idempotency: skip events we've already processed (Stripe retries deliveries).
|
||||
if (await alreadyProcessed(event.id)) return new Response("ok (duplicate)");
|
||||
|
||||
try {
|
||||
await handleStripeEvent(event);
|
||||
await logWebhook("stripe", event.id, event.type, "processed");
|
||||
} catch (err) {
|
||||
console.error("[stripe webhook] handler error", err);
|
||||
await logWebhook("stripe", event.id, event.type, "failed", err instanceof Error ? err.message : String(err));
|
||||
return new Response("Handler error", { status: 500 });
|
||||
}
|
||||
return new Response("ok");
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { Menu, X, ShieldCheck } from "lucide-react";
|
||||
import { AdminSidebar } from "./admin-sidebar";
|
||||
|
||||
export function AdminMobileNav() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
<DialogPrimitive.Trigger className="inline-flex h-10 w-10 items-center justify-center rounded-full text-foreground hover:bg-secondary md:hidden">
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</DialogPrimitive.Trigger>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in-0 md:hidden" />
|
||||
<DialogPrimitive.Content className="fixed inset-y-0 left-0 z-50 w-72 overflow-y-auto border-r border-border bg-background shadow-xl duration-200 data-[state=open]:animate-in data-[state=open]:slide-in-from-left md:hidden">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-4">
|
||||
<DialogPrimitive.Title className="flex items-center gap-2 font-display font-bold tracking-tight">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-2xl bg-foreground text-background">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
</span>
|
||||
Admin
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Close className="rounded-full p-1 text-muted-foreground hover:text-foreground">
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<AdminSidebar onNavigate={() => setOpen(false)} />
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
@@ -4,39 +4,77 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
Users,
|
||||
CreditCard,
|
||||
BarChart3,
|
||||
ShieldAlert,
|
||||
ListChecks,
|
||||
Activity,
|
||||
Webhook,
|
||||
ShieldAlert,
|
||||
Flag,
|
||||
ScrollText,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NAV = [
|
||||
interface Item {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
const GROUPS: { label: string; items: Item[] }[] = [
|
||||
{
|
||||
label: "Insights",
|
||||
items: [
|
||||
{ label: "Overview", href: "/admin", icon: LayoutDashboard, exact: true },
|
||||
{ label: "Revenue", href: "/admin/revenue", icon: TrendingUp },
|
||||
{ label: "AI cost", href: "/admin/ai-usage", icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
items: [
|
||||
{ label: "Users", href: "/admin/users", icon: Users },
|
||||
{ label: "Subscriptions", href: "/admin/subscriptions", icon: CreditCard },
|
||||
{ label: "AI usage & cost", href: "/admin/ai-usage", icon: BarChart3 },
|
||||
{ label: "Moderation", href: "/admin/moderation", icon: ShieldAlert },
|
||||
{ label: "Jobs", href: "/admin/jobs", icon: ListChecks },
|
||||
{ label: "System health", href: "/admin/health", icon: Activity },
|
||||
{ label: "Webhooks", href: "/admin/webhooks", icon: Webhook },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Governance",
|
||||
items: [
|
||||
{ label: "Moderation", href: "/admin/moderation", icon: ShieldAlert },
|
||||
{ label: "Feature flags", href: "/admin/flags", icon: Flag },
|
||||
{ label: "Audit log", href: "/admin/audit", icon: ScrollText },
|
||||
{ label: "Settings", href: "/admin/settings", icon: Settings },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminSidebar() {
|
||||
export function AdminSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav className="flex flex-col gap-1 p-3">
|
||||
{NAV.map((item) => {
|
||||
const active = item.exact ? pathname === item.href : pathname.startsWith(item.href);
|
||||
<nav className="flex flex-col gap-5 p-4">
|
||||
{GROUPS.map((group) => (
|
||||
<div key={group.label} className="space-y-1">
|
||||
<p className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground/70">
|
||||
{group.label}
|
||||
</p>
|
||||
{group.items.map((item) => {
|
||||
const active = item.exact
|
||||
? pathname === item.href
|
||||
: pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-full px-4 py-2.5 text-sm font-medium transition-colors",
|
||||
"flex items-center gap-3 rounded-full px-3 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-brand/10 font-semibold text-brand"
|
||||
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
@@ -47,6 +85,8 @@ export function AdminSidebar() {
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { exportAuditCsvAction } from "@/app/(admin)/admin/actions";
|
||||
|
||||
export function AuditExport({
|
||||
filters,
|
||||
}: {
|
||||
filters: { action?: string; actor?: string };
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function exportCsv() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await exportAuditCsvAction(filters);
|
||||
if (!res.ok || !res.csv) {
|
||||
toast.error(res.error ?? "Export failed");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([res.csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("CSV downloaded");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Export failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={exportCsv} disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||
Export CSV
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function AuditMetaViewer({
|
||||
metadata,
|
||||
action,
|
||||
}: {
|
||||
metadata: unknown;
|
||||
action: string;
|
||||
}) {
|
||||
if (metadata == null) return <span className="text-muted-foreground">—</span>;
|
||||
|
||||
const compact = JSON.stringify(metadata);
|
||||
const pretty = JSON.stringify(metadata, null, 2);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="max-w-[220px] truncate text-left font-mono text-xs text-muted-foreground hover:text-brand"
|
||||
title="View details"
|
||||
>
|
||||
{compact.slice(0, 60)}
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Event details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Metadata for <span className="font-mono">{action}</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<pre className="max-h-[60vh] overflow-auto rounded-xl border border-border bg-secondary/50 p-4 font-mono text-xs leading-relaxed">
|
||||
{pretty}
|
||||
</pre>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,29 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Trash2, Save, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { toggleFeatureFlagAction } from "@/app/(admin)/admin/actions";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
|
||||
import {
|
||||
toggleFeatureFlagAction,
|
||||
setRolloutAction,
|
||||
deleteFlagAction,
|
||||
} from "@/app/(admin)/admin/actions";
|
||||
|
||||
interface Flag {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
rolloutPct: number;
|
||||
metadata: unknown;
|
||||
updatedAt: string | null;
|
||||
known: boolean;
|
||||
}
|
||||
|
||||
export function FlagsClient({ flags }: { flags: Flag[] }) {
|
||||
@@ -40,7 +52,7 @@ export function FlagsClient({ flags }: { flags: Flag[] }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<form onSubmit={create} className="flex gap-2">
|
||||
@@ -59,15 +71,150 @@ export function FlagsClient({ flags }: { flags: Flag[] }) {
|
||||
{flags.length === 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground">No feature flags yet.</p>
|
||||
) : (
|
||||
<div className="divide-y rounded-lg border">
|
||||
<div className="space-y-4">
|
||||
{flags.map((f) => (
|
||||
<div key={f.key} className="flex items-center justify-between p-4">
|
||||
<code className="text-sm">{f.key}</code>
|
||||
<Switch checked={f.enabled} onCheckedChange={(v) => toggle(f.key, v)} />
|
||||
</div>
|
||||
<FlagRow key={f.key} flag={f} onToggle={toggle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlagRow({
|
||||
flag,
|
||||
onToggle,
|
||||
}: {
|
||||
flag: Flag;
|
||||
onToggle: (key: string, enabled: boolean) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pct, setPct] = useState(flag.rolloutPct);
|
||||
const [meta, setMeta] = useState(
|
||||
flag.metadata == null ? "" : JSON.stringify(flag.metadata, null, 2)
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const dirty =
|
||||
pct !== flag.rolloutPct ||
|
||||
meta.trim() !== (flag.metadata == null ? "" : JSON.stringify(flag.metadata, null, 2)).trim();
|
||||
|
||||
async function save() {
|
||||
let metadata: unknown = null;
|
||||
const trimmed = meta.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
metadata = JSON.parse(trimmed);
|
||||
} catch {
|
||||
toast.error("Metadata must be valid JSON.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await setRolloutAction(flag.key, pct, flag.enabled, metadata);
|
||||
if (res.ok) {
|
||||
toast.success("Flag saved");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.error ?? "Failed");
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="transition-shadow hover:shadow-md">
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{flag.label}</p>
|
||||
{!flag.known && (
|
||||
<span className="rounded-full bg-warning/15 px-2 py-0.5 text-[11px] font-semibold text-warning">
|
||||
unused
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{flag.description}</p>
|
||||
<code className="text-xs text-muted-foreground/70">{flag.key}</code>
|
||||
</div>
|
||||
<Switch
|
||||
checked={flag.enabled}
|
||||
onCheckedChange={(v) => onToggle(flag.key, v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rollout control */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">Rollout</span>
|
||||
<span className="text-muted-foreground">{pct}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={pct}
|
||||
onChange={(e) => setPct(Number(e.target.value))}
|
||||
className="h-2 flex-1 cursor-pointer appearance-none rounded-full bg-secondary accent-brand"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={pct}
|
||||
onChange={(e) =>
|
||||
setPct(Math.max(0, Math.min(100, Number(e.target.value) || 0)))
|
||||
}
|
||||
className="h-9 w-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Metadata (JSON)</label>
|
||||
<Textarea
|
||||
value={meta}
|
||||
onChange={(e) => setMeta(e.target.value)}
|
||||
placeholder='{ "audience": "beta" }'
|
||||
className="min-h-[72px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{flag.updatedAt
|
||||
? `Updated ${new Date(flag.updatedAt).toLocaleString()}`
|
||||
: "Never persisted"}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" /> Delete
|
||||
</Button>
|
||||
}
|
||||
title={`Delete "${flag.key}"?`}
|
||||
description={
|
||||
flag.known
|
||||
? "This removes the DB override; the flag falls back to its code default."
|
||||
: "This permanently deletes the custom flag."
|
||||
}
|
||||
confirmLabel="Delete flag"
|
||||
successMessage="Flag deleted"
|
||||
onConfirm={() => deleteFlagAction(flag.key)}
|
||||
/>
|
||||
<Button size="sm" onClick={save} disabled={!dirty || saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { MoreHorizontal, RotateCw, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
|
||||
import { retryJobAction, cancelJobAction } from "@/app/(admin)/admin/actions";
|
||||
|
||||
export function JobRowActions({ job }: { job: { id: string; status: string } }) {
|
||||
const canRetry = job.status === "failed";
|
||||
const canCancel = job.status === "queued" || job.status === "running";
|
||||
|
||||
if (!canRetry && !canCancel) {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{canRetry && (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<RotateCw className="h-4 w-4" /> Retry job
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
title="Retry this job?"
|
||||
description="The episode will be re-queued and a fresh generation enqueued."
|
||||
destructive={false}
|
||||
confirmLabel="Retry"
|
||||
successMessage="Job re-queued"
|
||||
onConfirm={() => retryJobAction(job.id)}
|
||||
/>
|
||||
)}
|
||||
{canCancel && (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<XCircle className="h-4 w-4" /> Cancel job
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
title="Cancel this job?"
|
||||
description="The job will be marked failed; if the episode is still in progress it will be marked failed too."
|
||||
confirmLabel="Cancel job"
|
||||
successMessage="Job canceled"
|
||||
onConfirm={() => cancelJobAction(job.id)}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Save, Loader2, RotateCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { updatePlanAction } from "@/app/(admin)/admin/actions";
|
||||
|
||||
export interface PlanLimitsValue {
|
||||
script: number;
|
||||
audio: number;
|
||||
art: number;
|
||||
repurpose: number;
|
||||
seats: number;
|
||||
maxEpisodeMinutes: number;
|
||||
}
|
||||
|
||||
export interface EditablePlan {
|
||||
key: string;
|
||||
name: string;
|
||||
priceMonthly: number; // cents
|
||||
priceYearly: number; // cents
|
||||
limits: PlanLimitsValue;
|
||||
}
|
||||
|
||||
const LIMIT_FIELDS: { key: keyof PlanLimitsValue; label: string }[] = [
|
||||
{ key: "script", label: "Scripts / mo" },
|
||||
{ key: "audio", label: "Audio / mo" },
|
||||
{ key: "art", label: "Cover art / mo" },
|
||||
{ key: "repurpose", label: "Repurpose / mo" },
|
||||
{ key: "seats", label: "Seats" },
|
||||
{ key: "maxEpisodeMinutes", label: "Max minutes" },
|
||||
];
|
||||
|
||||
export function PlanEditor({ plan }: { plan: EditablePlan }) {
|
||||
const router = useRouter();
|
||||
const [priceMonthly, setPriceMonthly] = useState(plan.priceMonthly);
|
||||
const [priceYearly, setPriceYearly] = useState(plan.priceYearly);
|
||||
const [limits, setLimits] = useState<PlanLimitsValue>(plan.limits);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const dirty =
|
||||
priceMonthly !== plan.priceMonthly ||
|
||||
priceYearly !== plan.priceYearly ||
|
||||
LIMIT_FIELDS.some((f) => limits[f.key] !== plan.limits[f.key]);
|
||||
|
||||
function reset() {
|
||||
setPriceMonthly(plan.priceMonthly);
|
||||
setPriceYearly(plan.priceYearly);
|
||||
setLimits(plan.limits);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await updatePlanAction(plan.key, { priceMonthly, priceYearly, limits });
|
||||
if (res.ok) {
|
||||
toast.success(`${plan.name} updated`);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.error ?? "Failed");
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="transition-shadow hover:shadow-md">
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{plan.name}
|
||||
<Badge variant="brand" className="capitalize">
|
||||
{plan.key}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
{dirty && (
|
||||
<span className="text-xs font-semibold text-warning">Unsaved changes</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{/* Prices — stored in cents; entered in dollars. */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${plan.key}-monthly`}>Monthly price ($)</Label>
|
||||
<Input
|
||||
id={`${plan.key}-monthly`}
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={(priceMonthly / 100).toString()}
|
||||
onChange={(e) =>
|
||||
setPriceMonthly(Math.max(0, Math.round((Number(e.target.value) || 0) * 100)))
|
||||
}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${plan.key}-yearly`}>Yearly price ($)</Label>
|
||||
<Input
|
||||
id={`${plan.key}-yearly`}
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={(priceYearly / 100).toString()}
|
||||
onChange={(e) =>
|
||||
setPriceYearly(Math.max(0, Math.round((Number(e.target.value) || 0) * 100)))
|
||||
}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Limits — use -1 for unlimited. */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold">Limits</p>
|
||||
<p className="text-xs text-muted-foreground">Use -1 for unlimited.</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{LIMIT_FIELDS.map((f) => (
|
||||
<div key={f.key} className="space-y-1.5">
|
||||
<Label htmlFor={`${plan.key}-${f.key}`} className="text-xs font-medium">
|
||||
{f.label}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${plan.key}-${f.key}`}
|
||||
type="number"
|
||||
value={limits[f.key].toString()}
|
||||
onChange={(e) =>
|
||||
setLimits((prev) => ({
|
||||
...prev,
|
||||
[f.key]: Math.round(Number(e.target.value) || 0),
|
||||
}))
|
||||
}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" onClick={reset} disabled={!dirty || saving}>
|
||||
<RotateCcw className="h-4 w-4" /> Reset
|
||||
</Button>
|
||||
<Button size="sm" onClick={save} disabled={!dirty || saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { MoreHorizontal, Undo2, XCircle, User } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
|
||||
import {
|
||||
refundLatestAction,
|
||||
cancelSubscriptionAdminAction,
|
||||
} from "@/app/(admin)/admin/actions";
|
||||
|
||||
export function SubscriptionRowActions({
|
||||
sub,
|
||||
}: {
|
||||
sub: {
|
||||
id: string;
|
||||
referenceId: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
cancelAtPeriodEnd: boolean | null;
|
||||
};
|
||||
}) {
|
||||
const isStripe = sub.provider === "stripe";
|
||||
const canCancel = !["canceled"].includes(sub.status) && !sub.cancelAtPeriodEnd;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/users/${sub.referenceId}`}>
|
||||
<User className="h-4 w-4" /> View customer
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{isStripe && (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" /> Refund latest
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
title="Refund the latest charge?"
|
||||
description="This issues a Stripe refund for the most recent invoice's payment. This cannot be undone."
|
||||
confirmLabel="Refund"
|
||||
successMessage="Refund issued"
|
||||
onConfirm={() => refundLatestAction(sub.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canCancel && (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<XCircle className="h-4 w-4" /> Cancel subscription
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
title="Cancel this subscription?"
|
||||
description={
|
||||
isStripe
|
||||
? "Stripe will cancel at the end of the current billing period."
|
||||
: "This subscription will be marked canceled immediately."
|
||||
}
|
||||
confirmLabel="Cancel subscription"
|
||||
successMessage="Subscription canceled"
|
||||
onConfirm={() => cancelSubscriptionAdminAction(sub.id)}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/** Periodically re-fetches the current server component tree (for live pages). */
|
||||
export function AutoRefresh({ seconds = 10 }: { seconds?: number }) {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => router.refresh(), seconds * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [router, seconds]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn(className)}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</div>
|
||||
{action}
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Wix-aligned chart palette. Series 1 = Wix Blue, series 2 = deep purple,
|
||||
// plus success/warning for status-coded series. Kept hex (Recharts needs concrete
|
||||
// colors), mirroring the design tokens in app/globals.css.
|
||||
export const CHART = {
|
||||
brand: "#116DFF",
|
||||
brand2: "#3910ED",
|
||||
success: "#00C271",
|
||||
warning: "#FF5500",
|
||||
ink: "#0B0B0B",
|
||||
muted: "#6A6A6A",
|
||||
grid: "#E4E4E4",
|
||||
} as const;
|
||||
|
||||
// Tier colors for the plan-distribution donut.
|
||||
export const TIER_COLORS: Record<string, string> = {
|
||||
free: "#9AA0A6",
|
||||
creator: "#116DFF",
|
||||
pro: "#3910ED",
|
||||
agency: "#0B0B0B",
|
||||
};
|
||||
|
||||
export const axisTick = { fontSize: 11, fill: CHART.muted } as const;
|
||||
export const tooltipStyle = {
|
||||
fontSize: 12,
|
||||
borderRadius: 12,
|
||||
border: "1px solid #E4E4E4",
|
||||
boxShadow: "0 8px 24px -4px rgba(11,11,11,0.08)",
|
||||
} as const;
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import { CHART, axisTick, tooltipStyle } from "./chart-theme";
|
||||
|
||||
type Row = Record<string, string | number>;
|
||||
|
||||
interface SeriesDef {
|
||||
key: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const COLORS = [CHART.brand, CHART.brand2, CHART.success, CHART.warning];
|
||||
|
||||
/** Smooth area trend (e.g. MRR over time). */
|
||||
export function AreaTrend({
|
||||
data,
|
||||
xKey,
|
||||
series,
|
||||
height = 280,
|
||||
format,
|
||||
}: {
|
||||
data: Row[];
|
||||
xKey: string;
|
||||
series: SeriesDef[];
|
||||
height?: number;
|
||||
format?: (v: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||
<defs>
|
||||
{series.map((s, i) => (
|
||||
<linearGradient key={s.key} id={`grad-${s.key}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={s.color ?? COLORS[i % COLORS.length]} stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor={s.color ?? COLORS[i % COLORS.length]} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={CHART.grid} />
|
||||
<XAxis dataKey={xKey} tick={axisTick} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} tickFormatter={format} width={48} />
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={format ? (v: number) => format(v) : undefined} />
|
||||
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
|
||||
{series.map((s, i) => (
|
||||
<Area
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.name}
|
||||
stroke={s.color ?? COLORS[i % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
fill={`url(#grad-${s.key})`}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stacked or grouped bars (e.g. spend by provider/day, signups). */
|
||||
export function BarSeries({
|
||||
data,
|
||||
xKey,
|
||||
series,
|
||||
stacked = true,
|
||||
height = 280,
|
||||
format,
|
||||
}: {
|
||||
data: Row[];
|
||||
xKey: string;
|
||||
series: SeriesDef[];
|
||||
stacked?: boolean;
|
||||
height?: number;
|
||||
format?: (v: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={CHART.grid} />
|
||||
<XAxis dataKey={xKey} tick={axisTick} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} tickFormatter={format} width={48} />
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={format ? (v: number) => format(v) : undefined} cursor={{ fill: "rgba(17,109,255,0.06)" }} />
|
||||
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
|
||||
{series.map((s, i) => (
|
||||
<Bar
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
name={s.name}
|
||||
stackId={stacked ? "a" : undefined}
|
||||
fill={s.color ?? COLORS[i % COLORS.length]}
|
||||
radius={i === series.length - 1 ? [4, 4, 0, 0] : [0, 0, 0, 0]}
|
||||
maxBarSize={42}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineSeries({
|
||||
data,
|
||||
xKey,
|
||||
series,
|
||||
height = 280,
|
||||
format,
|
||||
}: {
|
||||
data: Row[];
|
||||
xKey: string;
|
||||
series: SeriesDef[];
|
||||
height?: number;
|
||||
format?: (v: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={CHART.grid} />
|
||||
<XAxis dataKey={xKey} tick={axisTick} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} tickFormatter={format} width={48} />
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={format ? (v: number) => format(v) : undefined} />
|
||||
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
|
||||
{series.map((s, i) => (
|
||||
<Line
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.name}
|
||||
stroke={s.color ?? COLORS[i % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** Donut for distributions (e.g. plan tiers). */
|
||||
export function Donut({
|
||||
data,
|
||||
height = 240,
|
||||
colors,
|
||||
}: {
|
||||
data: { name: string; value: number }[];
|
||||
height?: number;
|
||||
colors?: string[];
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey="value" nameKey="name" innerRadius={56} outerRadius={84} paddingAngle={2} strokeWidth={0}>
|
||||
{data.map((entry, i) => (
|
||||
<Cell key={entry.name} fill={(colors ?? COLORS)[i % (colors ?? COLORS).length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tiny inline sparkline for StatCards. */
|
||||
export function Sparkline({ data, color = CHART.brand }: { data: number[]; color?: string }) {
|
||||
const rows = data.map((v, i) => ({ i, v }));
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={40}>
|
||||
<AreaChart data={rows} margin={{ top: 2, right: 0, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={`spark-${color.replace("#", "")}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area type="monotone" dataKey="v" stroke={color} strokeWidth={1.5} fill={`url(#spark-${color.replace("#", "")})`} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ConfirmDialog({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "Confirm",
|
||||
destructive = true,
|
||||
successMessage = "Done",
|
||||
body,
|
||||
onConfirm,
|
||||
}: {
|
||||
trigger: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
confirmLabel?: string;
|
||||
destructive?: boolean;
|
||||
successMessage?: string;
|
||||
body?: React.ReactNode;
|
||||
onConfirm: () => Promise<{ ok: boolean; error?: string } | void>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function run() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await onConfirm();
|
||||
if (res && res.ok === false) {
|
||||
toast.error(res.error ?? "Action failed");
|
||||
return;
|
||||
}
|
||||
toast.success(successMessage);
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Action failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
{body}
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button variant={destructive ? "destructive" : "default"} onClick={run} disabled={busy}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { SortHeader } from "./table-controls";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
/** When set, the header becomes a sort toggle for this key. */
|
||||
sortKey?: string;
|
||||
align?: "left" | "right" | "center";
|
||||
className?: string;
|
||||
cell: (row: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
function alignClass(align?: "left" | "right" | "center") {
|
||||
return align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
|
||||
}
|
||||
|
||||
/** Server-rendered table. Sorting/search/pagination live in the URL via table-controls. */
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
rows,
|
||||
getRowKey,
|
||||
empty = "No results.",
|
||||
}: {
|
||||
columns: Column<T>[];
|
||||
rows: T[];
|
||||
getRowKey: (row: T, i: number) => string;
|
||||
empty?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-border bg-card shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-secondary/60 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th key={c.key} className={cn("px-4 py-3 font-medium", alignClass(c.align), c.className)}>
|
||||
{c.sortKey ? (
|
||||
<span className={cn(c.align === "right" && "flex justify-end")}>
|
||||
<SortHeader label={c.header} sortKey={c.sortKey} />
|
||||
</span>
|
||||
) : (
|
||||
c.header
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-14 text-center text-muted-foreground">
|
||||
{empty}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row, i) => (
|
||||
<tr key={getRowKey(row, i)} className="transition-colors hover:bg-secondary/40">
|
||||
{columns.map((c) => (
|
||||
<td key={c.key} className={cn("px-4 py-3 align-middle", alignClass(c.align), c.className)}>
|
||||
{c.cell(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Toolbar row above a table (search left, filters/actions right). */
|
||||
export function TableToolbar({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ArrowUpRight, ArrowDownRight } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Sparkline } from "./charts";
|
||||
|
||||
export interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
/** Percent change vs the previous period. */
|
||||
delta?: number | null;
|
||||
/** When true, a negative delta is "good" (e.g. churn, error rate). */
|
||||
invertDelta?: boolean;
|
||||
spark?: number[];
|
||||
sparkColor?: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
invertDelta,
|
||||
spark,
|
||||
sparkColor,
|
||||
icon: Icon,
|
||||
hint,
|
||||
}: StatCardProps) {
|
||||
const hasDelta = delta !== undefined && delta !== null && Number.isFinite(delta);
|
||||
const positive = hasDelta ? (invertDelta ? (delta as number) <= 0 : (delta as number) >= 0) : false;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">{label}</span>
|
||||
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
|
||||
{hasDelta && (
|
||||
<span
|
||||
className={cn(
|
||||
"mb-1 inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||
positive ? "bg-success/12 text-success" : "bg-destructive/12 text-destructive"
|
||||
)}
|
||||
>
|
||||
{(delta as number) >= 0 ? (
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-3 w-3" />
|
||||
)}
|
||||
{Math.abs(delta as number).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{spark && spark.length > 1 ? (
|
||||
<Sparkline data={spark} color={sparkColor} />
|
||||
) : hint ? (
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function useUpdateParams() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const sp = useSearchParams();
|
||||
return useCallback(
|
||||
(updates: Record<string, string | null>, opts?: { resetPage?: boolean }) => {
|
||||
const params = new URLSearchParams(sp.toString());
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
if (v === null || v === "") params.delete(k);
|
||||
else params.set(k, v);
|
||||
}
|
||||
if (opts?.resetPage) params.delete("page");
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
},
|
||||
[router, pathname, sp]
|
||||
);
|
||||
}
|
||||
|
||||
/** Debounced search box bound to the `q` param. */
|
||||
export function SearchInput({ placeholder = "Search…" }: { placeholder?: string }) {
|
||||
const sp = useSearchParams();
|
||||
const update = useUpdateParams();
|
||||
const [value, setValue] = useState(sp.get("q") ?? "");
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(timer.current);
|
||||
}, []);
|
||||
|
||||
function onChange(v: string) {
|
||||
setValue(v);
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => update({ q: v || null }, { resetPage: true }), 350);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-10 pl-9"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sortable column header — toggles the `sort` param ("key.dir"). */
|
||||
export function SortHeader({
|
||||
label,
|
||||
sortKey,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const sp = useSearchParams();
|
||||
const update = useUpdateParams();
|
||||
const [k, dir] = (sp.get("sort") ?? "").split(".");
|
||||
const active = k === sortKey;
|
||||
const nextDir = active && dir === "desc" ? "asc" : "desc";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update({ sort: `${sortKey}.${nextDir}` })}
|
||||
className={cn("inline-flex items-center gap-1 font-medium hover:text-foreground", className)}
|
||||
>
|
||||
{label}
|
||||
{active ? (
|
||||
dir === "desc" ? (
|
||||
<ArrowDown className="h-3.5 w-3.5 text-brand" />
|
||||
) : (
|
||||
<ArrowUp className="h-3.5 w-3.5 text-brand" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Filter select bound to an arbitrary param. */
|
||||
export function FilterSelect({
|
||||
param,
|
||||
options,
|
||||
placeholder,
|
||||
allLabel = "All",
|
||||
}: {
|
||||
param: string;
|
||||
options: { value: string; label: string }[];
|
||||
placeholder: string;
|
||||
allLabel?: string;
|
||||
}) {
|
||||
const sp = useSearchParams();
|
||||
const update = useUpdateParams();
|
||||
const value = sp.get(param) ?? "all";
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(v) => update({ [param]: v === "all" ? null : v }, { resetPage: true })}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-[150px]">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{allLabel}</SelectItem>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
/** Prev/next pagination bound to the `page` param. */
|
||||
export function Pagination({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}) {
|
||||
const update = useUpdateParams();
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const to = Math.min(total, page * pageSize);
|
||||
|
||||
if (total <= pageSize) {
|
||||
return <p className="text-xs text-muted-foreground">{total} result{total === 1 ? "" : "s"}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{from}–{to} of {total}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => update({ page: String(page - 1) })}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> Prev
|
||||
</Button>
|
||||
<span className="px-2 text-xs text-muted-foreground">
|
||||
{page} / {pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= pages}
|
||||
onClick={() => update({ page: String(page + 1) })}
|
||||
>
|
||||
Next <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Date-range preset toggle bound to the `range` param. */
|
||||
export function RangePicker({ value }: { value?: string }) {
|
||||
const update = useUpdateParams();
|
||||
const current = value ?? "30d";
|
||||
const options: [string, string][] = [
|
||||
["7d", "7D"],
|
||||
["30d", "30D"],
|
||||
["90d", "90D"],
|
||||
["12m", "12M"],
|
||||
];
|
||||
return (
|
||||
<div className="inline-flex rounded-full border border-border p-0.5">
|
||||
{options.map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => update({ range: val })}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-semibold transition-colors",
|
||||
current === val ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogIn, ShieldCheck, ShieldOff, Ban, UserCheck, Gift } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import {
|
||||
banUserAction,
|
||||
setRoleAction,
|
||||
compPlanAction,
|
||||
} from "@/app/(admin)/admin/actions";
|
||||
|
||||
type CompPlan = "creator" | "pro" | "agency";
|
||||
type CompInterval = "month" | "year";
|
||||
|
||||
export function UserDetailActions({
|
||||
user,
|
||||
}: {
|
||||
user: { id: string; role: string; banned: boolean };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [impersonating, setImpersonating] = useState(false);
|
||||
const [compPlan, setCompPlan] = useState<CompPlan>("pro");
|
||||
const [compInterval, setCompInterval] = useState<CompInterval>("month");
|
||||
|
||||
async function run(action: () => Promise<{ ok: boolean; error?: string }>, msg: string) {
|
||||
const res = await action();
|
||||
if (res.ok) {
|
||||
toast.success(msg);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.error ?? "Failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function impersonate() {
|
||||
setImpersonating(true);
|
||||
try {
|
||||
const res = await authClient.admin.impersonateUser({ userId: user.id });
|
||||
if (res.error) {
|
||||
toast.error(res.error.message ?? "Could not impersonate");
|
||||
return;
|
||||
}
|
||||
window.location.href = "/dashboard";
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Could not impersonate");
|
||||
} finally {
|
||||
setImpersonating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="brand" size="sm" onClick={impersonate} disabled={impersonating}>
|
||||
<LogIn className="h-4 w-4" />
|
||||
{impersonating ? "Starting…" : "Impersonate"}
|
||||
</Button>
|
||||
|
||||
{user.role === "admin" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => run(() => setRoleAction(user.id, "user"), "Role updated")}
|
||||
>
|
||||
<ShieldOff className="h-4 w-4" /> Revoke admin
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => run(() => setRoleAction(user.id, "admin"), "Role updated")}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" /> Make admin
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
<Gift className="h-4 w-4" /> Comp plan
|
||||
</Button>
|
||||
}
|
||||
title="Comp a paid plan"
|
||||
description="Grant this user a complimentary subscription. No payment is collected."
|
||||
destructive={false}
|
||||
confirmLabel="Grant plan"
|
||||
successMessage="Comped plan granted"
|
||||
body={
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="comp-plan">Plan</Label>
|
||||
<Select value={compPlan} onValueChange={(v) => setCompPlan(v as CompPlan)}>
|
||||
<SelectTrigger id="comp-plan">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="creator">Creator</SelectItem>
|
||||
<SelectItem value="pro">Pro</SelectItem>
|
||||
<SelectItem value="agency">Agency</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="comp-interval">Interval</Label>
|
||||
<Select
|
||||
value={compInterval}
|
||||
onValueChange={(v) => setCompInterval(v as CompInterval)}
|
||||
>
|
||||
<SelectTrigger id="comp-interval">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="month">Monthly (30 days)</SelectItem>
|
||||
<SelectItem value="year">Yearly (365 days)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onConfirm={() => compPlanAction(user.id, compPlan, compInterval)}
|
||||
/>
|
||||
|
||||
{user.banned ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
<UserCheck className="h-4 w-4" /> Unban
|
||||
</Button>
|
||||
}
|
||||
title="Unban this user?"
|
||||
description="They will regain access immediately."
|
||||
destructive={false}
|
||||
confirmLabel="Unban"
|
||||
successMessage="User unbanned"
|
||||
onConfirm={() => banUserAction(user.id, false)}
|
||||
/>
|
||||
) : (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="destructive" size="sm">
|
||||
<Ban className="h-4 w-4" /> Ban
|
||||
</Button>
|
||||
}
|
||||
title="Ban this user?"
|
||||
description="Their active sessions will be revoked and they'll lose access immediately."
|
||||
confirmLabel="Ban user"
|
||||
successMessage="User banned"
|
||||
onConfirm={() => banUserAction(user.id, true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MoreHorizontal, Eye, ShieldCheck, ShieldOff, Ban, UserCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { banUserAction, setRoleAction } from "@/app/(admin)/admin/actions";
|
||||
|
||||
export function UserRowActions({ user }: { user: { id: string; role: string; banned: boolean } }) {
|
||||
const router = useRouter();
|
||||
|
||||
async function run(action: () => Promise<{ ok: boolean; error?: string }>, msg: string) {
|
||||
const res = await action();
|
||||
if (res.ok) {
|
||||
toast.success(msg);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.error ?? "Failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/users/${user.id}`}>
|
||||
<Eye className="h-4 w-4" /> View details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{user.role === "admin" ? (
|
||||
<DropdownMenuItem onSelect={() => run(() => setRoleAction(user.id, "user"), "Role updated")}>
|
||||
<ShieldOff className="h-4 w-4" /> Revoke admin
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={() => run(() => setRoleAction(user.id, "admin"), "Role updated")}>
|
||||
<ShieldCheck className="h-4 w-4" /> Make admin
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{user.banned ? (
|
||||
<DropdownMenuItem onSelect={() => run(() => banUserAction(user.id, false), "User unbanned")}>
|
||||
<UserCheck className="h-4 w-4" /> Unban
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() => run(() => banUserAction(user.id, true), "User banned")}
|
||||
>
|
||||
<Ban className="h-4 w-4" /> Ban
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Copy, KeyRound, Trash2, Check } from "lucide-react";
|
||||
import { Loader2, Copy, KeyRound, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { createApiKeyAction, revokeApiKeyAction } from "@/app/(app)/api-keys/actions";
|
||||
|
||||
interface KeyRow {
|
||||
@@ -37,17 +39,6 @@ export function ApiKeysClient({ keys }: { keys: KeyRow[] }) {
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function revoke(id: string) {
|
||||
if (!confirm("Revoke this key? Apps using it will stop working.")) return;
|
||||
const res = await revokeApiKeyAction(id);
|
||||
if (res.ok) {
|
||||
toast.success("Key revoked");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.error ?? "Could not revoke");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{newKey && (
|
||||
@@ -88,9 +79,13 @@ export function ApiKeysClient({ keys }: { keys: KeyRow[] }) {
|
||||
</Card>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground">No API keys yet.</p>
|
||||
<EmptyState
|
||||
icon={KeyRound}
|
||||
title="No API keys yet"
|
||||
description="Create a key above to start generating episodes programmatically."
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y rounded-lg border">
|
||||
<div className="divide-y rounded-2xl border">
|
||||
{keys.map((k) => (
|
||||
<div key={k.id} className="flex items-center justify-between gap-3 p-4">
|
||||
<div className="min-w-0">
|
||||
@@ -100,9 +95,21 @@ export function ApiKeysClient({ keys }: { keys: KeyRow[] }) {
|
||||
{k.lastUsedAt ? ` · last used ${new Date(k.lastUsedAt).toLocaleDateString()}` : " · never used"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => revoke(k.id)}>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="text-destructive">
|
||||
<Trash2 className="h-4 w-4" /> Revoke
|
||||
</Button>
|
||||
}
|
||||
title="Revoke this key?"
|
||||
description={`Apps using "${k.name}" will immediately stop working. This cannot be undone.`}
|
||||
confirmLabel="Revoke key"
|
||||
successMessage="Key revoked"
|
||||
onConfirm={async () => {
|
||||
const res = await revokeApiKeyAction(k.id);
|
||||
return { ok: res.ok, error: res.error };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { Menu, X, Mic } from "lucide-react";
|
||||
import type { PlanKey } from "@/lib/billing/plans";
|
||||
import { SidebarNav } from "./sidebar-nav";
|
||||
|
||||
/**
|
||||
* Left-drawer navigation for phones. Mirrors `AdminMobileNav` (radix dialog),
|
||||
* wrapping the shared `SidebarNav` and closing on every link tap. Only rendered
|
||||
* below the `md` breakpoint.
|
||||
*/
|
||||
export function AppMobileNav({ plan, workspaceName }: { plan: PlanKey; workspaceName: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
<DialogPrimitive.Trigger className="inline-flex h-10 w-10 items-center justify-center rounded-full text-foreground hover:bg-secondary md:hidden">
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</DialogPrimitive.Trigger>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in-0 md:hidden" />
|
||||
<DialogPrimitive.Content className="fixed inset-y-0 left-0 z-50 w-72 overflow-y-auto border-r border-border bg-background shadow-xl duration-200 data-[state=open]:animate-in data-[state=open]:slide-in-from-left md:hidden">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-4">
|
||||
<DialogPrimitive.Title className="flex items-center gap-2 font-display font-bold tracking-tight">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
|
||||
<Mic className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate">{workspaceName}</span>
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Close className="rounded-full p-1 text-muted-foreground hover:text-foreground">
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<SidebarNav plan={plan} onNavigate={() => setOpen(false)} />
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function formatDuration(sec?: number | null): string {
|
||||
if (!sec) return "";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
import { WaveformPlayer } from "./waveform-player";
|
||||
|
||||
/**
|
||||
* Episode audio player — a thin wrapper around the custom WaveformPlayer so the
|
||||
* authed asset route and (optional) ZIP export are wired up for the editor.
|
||||
*/
|
||||
export function AudioPlayer({
|
||||
storageKey,
|
||||
durationSec,
|
||||
episodeId,
|
||||
}: {
|
||||
storageKey: string;
|
||||
durationSec?: number | null;
|
||||
episodeId?: string;
|
||||
}) {
|
||||
const src = `/api/assets/${storageKey}`;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<audio controls src={src} className="w-full" preload="metadata" />
|
||||
<div className="flex items-center justify-between">
|
||||
{durationSec ? (
|
||||
<span className="text-xs text-muted-foreground">Length {formatDuration(durationSec)}</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={`${src}?download=1`} download>
|
||||
<Download className="h-4 w-4" /> Download MP3
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WaveformPlayer
|
||||
src={src}
|
||||
downloadUrl={`${src}?download=1`}
|
||||
durationSec={durationSec}
|
||||
exportUrl={episodeId ? `/api/episodes/${episodeId}/export` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
|
||||
import { cn, formatPrice } from "@/lib/utils";
|
||||
import { PLAN_ORDER, PLANS, type PlanKey } from "@/lib/billing/plans";
|
||||
import type { BillingInterval } from "@/lib/billing/catalog";
|
||||
@@ -83,19 +84,21 @@ export function BillingClient({
|
||||
</Button>
|
||||
)}
|
||||
{!subscription.cancelAtPeriodEnd && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm("Cancel your subscription at the end of the period?")) {
|
||||
go(cancelSubscriptionAction, "cancel");
|
||||
}
|
||||
}}
|
||||
disabled={busy === "cancel"}
|
||||
>
|
||||
{busy === "cancel" && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" className="text-destructive">
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
title="Cancel subscription?"
|
||||
description="Your plan stays active until the end of the current billing period, then reverts to Free. You can resubscribe any time."
|
||||
confirmLabel="Cancel subscription"
|
||||
successMessage="Subscription will cancel at period end"
|
||||
onConfirm={async () => {
|
||||
const res = await cancelSubscriptionAction();
|
||||
return res.ok ? { ok: true } : { ok: false, error: res.error };
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Command } from "cmdk";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Mic2,
|
||||
ListMusic,
|
||||
BarChart3,
|
||||
CreditCard,
|
||||
Users,
|
||||
KeyRound,
|
||||
Settings,
|
||||
Plus,
|
||||
Moon,
|
||||
Sun,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
interface PaletteRoute {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
const ROUTES: PaletteRoute[] = [
|
||||
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard, keywords: ["home"] },
|
||||
{ label: "Episodes", href: "/episodes", icon: Mic2, keywords: ["library", "podcasts"] },
|
||||
{ label: "Series", href: "/series", icon: ListMusic, keywords: ["season"] },
|
||||
{ label: "Usage", href: "/usage", icon: BarChart3, keywords: ["limits", "quota"] },
|
||||
{ label: "Billing", href: "/billing", icon: CreditCard, keywords: ["plan", "upgrade", "subscription"] },
|
||||
{ label: "Team", href: "/team", icon: Users, keywords: ["members", "workspace", "branding"] },
|
||||
{ label: "API keys", href: "/api-keys", icon: KeyRound, keywords: ["developer", "token"] },
|
||||
{ label: "Settings", href: "/settings", icon: Settings, keywords: ["account", "profile"] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Global ⌘K / Ctrl-K command palette. Provides "New episode", jump-to-route
|
||||
* navigation, and a theme toggle. Mounted once in the app layout.
|
||||
*/
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
function run(action: () => void) {
|
||||
setOpen(false);
|
||||
action();
|
||||
}
|
||||
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<Command.Dialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
label="Command palette"
|
||||
shouldFilter
|
||||
contentClassName="fixed left-1/2 top-[20vh] z-[61] w-[92vw] max-w-lg -translate-x-1/2 overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-lg outline-none data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||
overlayClassName="fixed inset-0 z-[60] bg-foreground/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Command.Input
|
||||
placeholder="Search actions and pages…"
|
||||
className="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<Command.List className="max-h-[55vh] overflow-y-auto p-2">
|
||||
<Command.Empty className="py-8 text-center text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</Command.Empty>
|
||||
|
||||
<Command.Group
|
||||
heading="Actions"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-muted-foreground"
|
||||
>
|
||||
<PaletteItem
|
||||
label="New episode"
|
||||
keywords={["create", "generate"]}
|
||||
icon={Plus}
|
||||
onSelect={() => run(() => router.push("/episodes/new"))}
|
||||
/>
|
||||
<PaletteItem
|
||||
label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
keywords={["theme", "dark", "light", "appearance"]}
|
||||
icon={isDark ? Sun : Moon}
|
||||
onSelect={() => run(() => setTheme(isDark ? "light" : "dark"))}
|
||||
/>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group
|
||||
heading="Go to"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-muted-foreground"
|
||||
>
|
||||
{ROUTES.map((r) => (
|
||||
<PaletteItem
|
||||
key={r.href}
|
||||
label={r.label}
|
||||
keywords={r.keywords}
|
||||
icon={r.icon}
|
||||
onSelect={() => run(() => router.push(r.href))}
|
||||
/>
|
||||
))}
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
</Command.Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteItem({
|
||||
label,
|
||||
keywords,
|
||||
icon: Icon,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
keywords?: string[];
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Command.Item
|
||||
value={`${label} ${(keywords ?? []).join(" ")}`}
|
||||
onSelect={onSelect}
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm outline-none data-[selected=true]:bg-secondary data-[selected=true]:text-foreground"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{label}
|
||||
</Command.Item>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,30 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { MoreVertical, ImageIcon, RefreshCw, Trash2, Loader2 } from "lucide-react";
|
||||
import {
|
||||
MoreVertical,
|
||||
ImageIcon,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Share2,
|
||||
FileArchive,
|
||||
Copy,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -12,11 +34,30 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { regenerateAction, deleteEpisodeAction } from "@/app/(app)/episodes/actions";
|
||||
import {
|
||||
regenerateAction,
|
||||
deleteEpisodeAction,
|
||||
setEpisodeShareAction,
|
||||
} from "@/app/(app)/episodes/actions";
|
||||
|
||||
export function EpisodeActions({ episodeId }: { episodeId: string }) {
|
||||
export function EpisodeActions({
|
||||
episodeId,
|
||||
initialShareId = null,
|
||||
}: {
|
||||
episodeId: string;
|
||||
initialShareId?: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [shareId, setShareId] = useState<string | null>(initialShareId);
|
||||
const [shareToggling, setShareToggling] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const shareUrl =
|
||||
shareId && typeof window !== "undefined" ? `${window.location.origin}/p/${shareId}` : "";
|
||||
|
||||
async function regen(type: "art" | "full") {
|
||||
setBusy(true);
|
||||
@@ -30,11 +71,38 @@ export function EpisodeActions({ episodeId }: { episodeId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (!confirm("Delete this episode? This cannot be undone.")) return;
|
||||
async function toggleShare(enabled: boolean) {
|
||||
setShareToggling(true);
|
||||
const res = await setEpisodeShareAction(episodeId, enabled);
|
||||
setShareToggling(false);
|
||||
if (!res.ok) {
|
||||
toast.error(res.error ?? "Could not update sharing");
|
||||
return;
|
||||
}
|
||||
setShareId(res.shareId ?? null);
|
||||
toast.success(enabled ? "Public link enabled" : "Sharing turned off");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
if (!shareUrl) return;
|
||||
navigator.clipboard
|
||||
.writeText(shareUrl)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
toast.success("Link copied");
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
})
|
||||
.catch(() => toast.error("Could not copy"));
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
setDeleting(true);
|
||||
const res = await deleteEpisodeAction(episodeId);
|
||||
setDeleting(false);
|
||||
if (res.ok) {
|
||||
toast.success("Episode deleted");
|
||||
setDeleteOpen(false);
|
||||
router.push("/episodes");
|
||||
router.refresh();
|
||||
} else {
|
||||
@@ -43,24 +111,118 @@ export function EpisodeActions({ episodeId }: { episodeId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShareOpen(true)}>
|
||||
<Share2 className="h-4 w-4" /> Share
|
||||
</Button>
|
||||
|
||||
<Button asChild variant="outline" size="icon" title="Download everything (.zip)">
|
||||
<a href={`/api/episodes/${episodeId}/export`} aria-label="Export ZIP">
|
||||
<FileArchive className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreVertical className="h-4 w-4" />}
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onSelect={() => regen("art")}>
|
||||
<ImageIcon className="h-4 w-4" /> Regenerate cover art
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => regen("full")}>
|
||||
<RefreshCw className="h-4 w-4" /> Regenerate everything
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={`/api/episodes/${episodeId}/export`}>
|
||||
<FileArchive className="h-4 w-4" /> Export ZIP
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={del} className="text-destructive focus:text-destructive">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleteOpen(true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> Delete episode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Share dialog */}
|
||||
<Dialog open={shareOpen} onOpenChange={setShareOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share this episode</DialogTitle>
|
||||
<DialogDescription>
|
||||
Turn on a public link to let anyone listen — no account needed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-xl border bg-secondary/40 px-4 py-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-semibold">Public link</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{shareId ? "Anyone with the link can listen." : "Currently private."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{shareToggling && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
||||
<Switch
|
||||
checked={!!shareId}
|
||||
disabled={shareToggling}
|
||||
onCheckedChange={toggleShare}
|
||||
aria-label="Toggle public link"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shareId && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="share-url">Public URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="share-url" readOnly value={shareUrl} className="font-mono text-xs" />
|
||||
<Button type="button" variant="outline" size="icon" onClick={copyLink}>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete this episode?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This permanently removes the script, audio, cover art and all repurposed content. This
|
||||
cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
|
||||
{deleting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Delete episode
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface EpisodeCardData {
|
||||
export function EpisodeCard({ episode }: { episode: EpisodeCardData }) {
|
||||
return (
|
||||
<Link href={`/episodes/${episode.id}`}>
|
||||
<Card className="group overflow-hidden transition-shadow hover:shadow-md">
|
||||
<Card className="group overflow-hidden transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<div className="relative aspect-square bg-muted">
|
||||
{episode.coverArtKey ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Eye, Loader2 } from "lucide-react";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
|
||||
/**
|
||||
* Sticky banner shown while an admin is impersonating a user. Reads the live
|
||||
* session; renders nothing for normal sessions. "Stop" ends impersonation and
|
||||
* returns to the admin surface.
|
||||
*/
|
||||
export function ImpersonationBanner() {
|
||||
const { data: session } = authClient.useSession();
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// `impersonatedBy` is set on the session row by the better-auth admin plugin.
|
||||
const impersonatedBy = session?.session
|
||||
? (session.session as { impersonatedBy?: string | null }).impersonatedBy
|
||||
: null;
|
||||
if (!impersonatedBy) return null;
|
||||
|
||||
const who = session?.user?.email ?? session?.user?.name ?? "this user";
|
||||
|
||||
async function stop() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await authClient.admin.stopImpersonating();
|
||||
} finally {
|
||||
router.push("/admin");
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-40 flex items-center justify-center gap-3 bg-warning px-4 py-2 text-center text-sm font-medium text-warning-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Eye className="h-4 w-4" />
|
||||
Viewing as {who}
|
||||
</span>
|
||||
<button
|
||||
onClick={stop}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-warning-foreground/15 px-3 py-0.5 font-semibold transition-colors hover:bg-warning-foreground/25 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText, Hash, Mail, Loader2, Copy, Sparkles } from "lucide-react";
|
||||
import { FileText, Hash, Mail, Loader2, Copy, Sparkles, Download } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -16,6 +16,27 @@ const FORMATS: { key: Format; label: string; icon: React.ComponentType<{ classNa
|
||||
{ key: "newsletter", label: "Newsletter", icon: Mail },
|
||||
];
|
||||
|
||||
function wordCount(text: string): number {
|
||||
const t = text.trim();
|
||||
return t ? t.split(/\s+/).length : 0;
|
||||
}
|
||||
|
||||
function slugify(s: string): string {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "content";
|
||||
}
|
||||
|
||||
function downloadMarkdown(filename: string, markdown: string) {
|
||||
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function RepurposeClient({
|
||||
episodeId,
|
||||
initial,
|
||||
@@ -64,13 +85,29 @@ export function RepurposeClient({
|
||||
<CardContent className="flex-1">
|
||||
{c ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-medium">{c.title}</p>
|
||||
<span className="shrink-0 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{wordCount(c.body).toLocaleString()} words · {c.body.length.toLocaleString()} chars
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto whitespace-pre-wrap rounded-md bg-muted/40 p-3 text-sm text-muted-foreground">
|
||||
{c.body}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => copy(`${c.title}\n\n${c.body}`)}>
|
||||
<Copy className="h-4 w-4" /> Copy
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
downloadMarkdown(`${slugify(c.title)}.md`, `# ${c.title}\n\n${c.body}\n`)
|
||||
}
|
||||
>
|
||||
<Download className="h-4 w-4" /> Download .md
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Save, RefreshCw, AudioLines } from "lucide-react";
|
||||
import { Loader2, Save, RefreshCw, AudioLines, Clipboard, Clock } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
updateScriptAction,
|
||||
regenerateAction,
|
||||
@@ -27,6 +28,25 @@ interface Script {
|
||||
sections: Section[];
|
||||
}
|
||||
|
||||
// Average speaking pace used to estimate spoken duration from word count.
|
||||
const WORDS_PER_MINUTE = 150;
|
||||
|
||||
function wordCount(text: string): number {
|
||||
const t = text.trim();
|
||||
return t ? t.split(/\s+/).length : 0;
|
||||
}
|
||||
|
||||
function estimateDuration(words: number): string {
|
||||
const totalSec = Math.round((words / WORDS_PER_MINUTE) * 60);
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||
}
|
||||
|
||||
function sectionWords(section: Section): number {
|
||||
return section.turns.reduce((n, t) => n + wordCount(t.text), 0);
|
||||
}
|
||||
|
||||
export function ScriptEditor({
|
||||
episodeId,
|
||||
script,
|
||||
@@ -43,6 +63,11 @@ export function ScriptEditor({
|
||||
const [busySection, setBusySection] = useState<string | null>(null);
|
||||
const [rerecording, setRerecording] = useState(false);
|
||||
|
||||
const totalWords = useMemo(
|
||||
() => sections.reduce((n, s) => n + sectionWords(s), 0),
|
||||
[sections]
|
||||
);
|
||||
|
||||
function updateTurn(si: number, ti: number, text: string) {
|
||||
setSections((prev) =>
|
||||
prev.map((s, i) =>
|
||||
@@ -98,13 +123,47 @@ export function ScriptEditor({
|
||||
}
|
||||
}
|
||||
|
||||
function copyTranscript() {
|
||||
const lines: string[] = [script.title, ""];
|
||||
for (const section of sections) {
|
||||
lines.push(section.title, "");
|
||||
for (const turn of section.turns) {
|
||||
const name = speakerNames[turn.speakerKey] ?? turn.speakerKey;
|
||||
lines.push(`${name}: ${turn.text}`, "");
|
||||
}
|
||||
}
|
||||
navigator.clipboard
|
||||
.writeText(lines.join("\n").trimEnd())
|
||||
.then(() => toast.success("Transcript copied"))
|
||||
.catch(() => toast.error("Could not copy"));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Script</h2>
|
||||
<div className="flex gap-2">
|
||||
{/* Sticky save bar — dirty-aware. */}
|
||||
<div className="sticky top-2 z-10 flex flex-wrap items-center justify-between gap-3 rounded-2xl border bg-card/95 px-4 py-3 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-card/80">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="font-display text-lg font-extrabold tracking-tight">Script</h2>
|
||||
<span className="hidden items-center gap-1.5 text-xs text-muted-foreground sm:inline-flex">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{totalWords.toLocaleString()} words · ~{estimateDuration(totalWords)}
|
||||
</span>
|
||||
{dirty && (
|
||||
<Badge variant="warning" className="hidden sm:inline-flex">
|
||||
Unsaved changes
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={copyTranscript}>
|
||||
<Clipboard className="h-4 w-4" /> Copy full transcript
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={rerecord} disabled={rerecording}>
|
||||
{rerecording ? <Loader2 className="h-4 w-4 animate-spin" /> : <AudioLines className="h-4 w-4" />}
|
||||
{rerecording ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<AudioLines className="h-4 w-4" />
|
||||
)}
|
||||
Re-record audio
|
||||
</Button>
|
||||
<Button size="sm" onClick={save} disabled={!dirty || saving}>
|
||||
@@ -114,10 +173,17 @@ export function ScriptEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sections.map((section, si) => (
|
||||
{sections.map((section, si) => {
|
||||
const words = sectionWords(section);
|
||||
return (
|
||||
<Card key={section.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<CardTitle className="text-sm">{section.title}</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 py-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-sm">{section.title}</CardTitle>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{words.toLocaleString()} words · ▶ {estimateDuration(words)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -147,7 +213,8 @@ export function ScriptEditor({
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,20 +2,62 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
|
||||
import { authClient, signOut } from "@/lib/auth/auth-client";
|
||||
import { VOICE_CATALOG } from "@/lib/ai/voices";
|
||||
import { LANGUAGES } from "@/lib/episodes/options";
|
||||
import { savePreferencesAction, deleteAccountAction } from "@/app/(app)/settings/actions";
|
||||
|
||||
export function SettingsClient({ name, email }: { name: string; email: string }) {
|
||||
const NO_VOICE = "__none__";
|
||||
|
||||
interface Preferences {
|
||||
defaultVoiceId: string | null;
|
||||
defaultLanguage: string;
|
||||
emailOnEpisodeReady: boolean;
|
||||
productEmails: boolean;
|
||||
}
|
||||
|
||||
export function SettingsClient({
|
||||
name,
|
||||
email,
|
||||
preferences,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
preferences: Preferences;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [displayName, setDisplayName] = useState(name);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [savingPw, setSavingPw] = useState(false);
|
||||
|
||||
// Defaults
|
||||
const [voiceId, setVoiceId] = useState(preferences.defaultVoiceId ?? NO_VOICE);
|
||||
const [language, setLanguage] = useState(preferences.defaultLanguage);
|
||||
const [savingDefaults, setSavingDefaults] = useState(false);
|
||||
|
||||
// Notifications
|
||||
const [emailOnReady, setEmailOnReady] = useState(preferences.emailOnEpisodeReady);
|
||||
const [productEmails, setProductEmails] = useState(preferences.productEmails);
|
||||
const [savingNotif, setSavingNotif] = useState(false);
|
||||
|
||||
// Danger zone
|
||||
const [confirmEmail, setConfirmEmail] = useState("");
|
||||
|
||||
async function saveProfile(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSavingProfile(true);
|
||||
@@ -45,6 +87,38 @@ export function SettingsClient({ name, email }: { name: string; email: string })
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDefaults() {
|
||||
setSavingDefaults(true);
|
||||
const res = await savePreferencesAction({
|
||||
defaultVoiceId: voiceId === NO_VOICE ? null : voiceId,
|
||||
defaultLanguage: language,
|
||||
});
|
||||
setSavingDefaults(false);
|
||||
if (res.ok) toast.success("Defaults saved");
|
||||
else toast.error(res.error ?? "Could not save");
|
||||
}
|
||||
|
||||
async function saveNotifications(next: Partial<Preferences>) {
|
||||
const emailVal = next.emailOnEpisodeReady ?? emailOnReady;
|
||||
const productVal = next.productEmails ?? productEmails;
|
||||
setEmailOnReady(emailVal);
|
||||
setProductEmails(productVal);
|
||||
setSavingNotif(true);
|
||||
const res = await savePreferencesAction({
|
||||
emailOnEpisodeReady: emailVal,
|
||||
productEmails: productVal,
|
||||
});
|
||||
setSavingNotif(false);
|
||||
if (!res.ok) {
|
||||
toast.error(res.error ?? "Could not save");
|
||||
// Revert optimistic state.
|
||||
setEmailOnReady(preferences.emailOnEpisodeReady);
|
||||
setProductEmails(preferences.productEmails);
|
||||
} else {
|
||||
toast.success("Notification preferences saved");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-6">
|
||||
<Card>
|
||||
@@ -92,6 +166,131 @@ export function SettingsClient({ name, email }: { name: string; email: string })
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Defaults</CardTitle>
|
||||
<CardDescription>
|
||||
Pre-select a voice and language when creating new episodes.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-voice">Default host voice</Label>
|
||||
<Select value={voiceId} onValueChange={setVoiceId}>
|
||||
<SelectTrigger id="default-voice">
|
||||
<SelectValue placeholder="Choose a voice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_VOICE}>No default (choose each time)</SelectItem>
|
||||
{VOICE_CATALOG.map((v) => (
|
||||
<SelectItem key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
{v.accent ? ` · ${v.accent}` : ""}
|
||||
{v.description ? ` — ${v.description}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-language">Default language</Label>
|
||||
<Select value={language} onValueChange={setLanguage}>
|
||||
<SelectTrigger id="default-language">
|
||||
<SelectValue placeholder="Choose a language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((l) => (
|
||||
<SelectItem key={l.code} value={l.code}>
|
||||
{l.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={saveDefaults} disabled={savingDefaults}>
|
||||
{savingDefaults ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Save defaults
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
<CardDescription>Choose which emails you want to receive.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-semibold">Episode ready</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Email me when an episode finishes generating.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={emailOnReady}
|
||||
disabled={savingNotif}
|
||||
onCheckedChange={(v) => saveNotifications({ emailOnEpisodeReady: v })}
|
||||
aria-label="Email me when an episode is ready"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t py-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-semibold">Product updates</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Occasional product news, tips and announcements.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={productEmails}
|
||||
disabled={savingNotif}
|
||||
onCheckedChange={(v) => saveNotifications({ productEmails: v })}
|
||||
aria-label="Receive product update emails"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-destructive/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger zone</CardTitle>
|
||||
<CardDescription>
|
||||
Permanently delete your account and all of your episodes. This cannot be undone.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ConfirmDialog
|
||||
trigger={<Button variant="destructive">Delete account</Button>}
|
||||
title="Delete your account?"
|
||||
description="This permanently deletes your account, every episode, series, and all generated content. This action is irreversible."
|
||||
confirmLabel="Delete my account"
|
||||
successMessage="Account deleted"
|
||||
body={
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-email">
|
||||
Type <span className="font-semibold text-foreground">{email}</span> to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-email"
|
||||
value={confirmEmail}
|
||||
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||
placeholder={email}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onConfirm={async () => {
|
||||
const res = await deleteAccountAction(confirmEmail);
|
||||
if (res.ok) {
|
||||
await signOut();
|
||||
router.push("/");
|
||||
}
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ const NAV: NavItem[] = [
|
||||
{ label: "Settings", href: "/settings", icon: Settings },
|
||||
];
|
||||
|
||||
export function SidebarNav({ plan }: { plan: PlanKey }) {
|
||||
export function SidebarNav({ plan, onNavigate }: { plan: PlanKey; onNavigate?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const features = PLANS[plan].features;
|
||||
|
||||
@@ -49,6 +49,7 @@ export function SidebarNav({ plan }: { plan: PlanKey }) {
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-full px-4 py-2.5 text-sm font-medium transition-colors",
|
||||
active
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, UserPlus, Building2, Save } from "lucide-react";
|
||||
import { Loader2, UserPlus, Building2, Save, Mic, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -12,7 +12,42 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { saveBrandingAction } from "@/app/(app)/team/actions";
|
||||
import { inviteMemberAction, saveBrandingAction } from "@/app/(app)/team/actions";
|
||||
|
||||
/**
|
||||
* Pure client-side #rrggbb → "H S% L%" converter for the live branding preview.
|
||||
* Mirrors `hexToHslTriplet` in lib/branding.ts (which is server-only). Returns
|
||||
* null for invalid hex so the preview falls back to the default brand token.
|
||||
*/
|
||||
function hexToHslTriplet(hex: string): string | null {
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
|
||||
if (!m) return null;
|
||||
const int = parseInt(m[1], 16);
|
||||
const r = ((int >> 16) & 255) / 255;
|
||||
const g = ((int >> 8) & 255) / 255;
|
||||
const b = (int & 255) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
default:
|
||||
h = (r - g) / d + 4;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
@@ -101,16 +136,16 @@ function MembersCard({ orgId, members, seats }: { orgId: string; members: Member
|
||||
|
||||
async function invite(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
// Fast UX guard only — the server action is the real seat-limit authority.
|
||||
if (members.length >= seats) {
|
||||
toast.error(`Your plan includes ${seats} seats.`);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
await authClient.organization.setActive({ organizationId: orgId });
|
||||
const { error } = await authClient.organization.inviteMember({ email: email.trim(), role: "member" });
|
||||
const res = await inviteMemberAction(orgId, email.trim());
|
||||
setBusy(false);
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Could not invite");
|
||||
if (!res.ok) {
|
||||
toast.error(res.error ?? "Could not invite");
|
||||
return;
|
||||
}
|
||||
toast.success(`Invitation sent to ${email}`);
|
||||
@@ -129,7 +164,7 @@ function MembersCard({ orgId, members, seats }: { orgId: string; members: Member
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="divide-y rounded-lg border">
|
||||
<div className="divide-y rounded-2xl border">
|
||||
{members.map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-3 p-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
@@ -182,6 +217,9 @@ function BrandingCard({ orgId, branding }: { orgId: string; branding: Branding |
|
||||
}
|
||||
}
|
||||
|
||||
const previewHsl = hexToHslTriplet(primaryColor);
|
||||
const previewName = brandName.trim() || "Your brand";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -197,9 +235,64 @@ function BrandingCard({ orgId, branding }: { orgId: string; branding: Branding |
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">Primary colour (hex)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-9 w-9 shrink-0 rounded-xl border border-border"
|
||||
style={{ backgroundColor: previewHsl ? `hsl(${previewHsl})` : "hsl(var(--brand))" }}
|
||||
/>
|
||||
<Input id="color" placeholder="#7c3aed" value={primaryColor} onChange={(e) => setPrimaryColor(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live white-label preview — applies the chosen colour to a mini app mock. */}
|
||||
<div className="space-y-2">
|
||||
<Label>Live preview</Label>
|
||||
<div
|
||||
className="overflow-hidden rounded-2xl border border-border bg-background"
|
||||
style={previewHsl ? ({ "--brand": previewHsl, "--ring": previewHsl } as React.CSSProperties) : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border bg-background/80 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 font-display text-sm font-bold tracking-tight">
|
||||
{logoUrl.trim() ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={logoUrl} alt={previewName} className="h-6 w-auto max-w-[120px] object-contain" />
|
||||
) : (
|
||||
<>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-brand text-brand-foreground">
|
||||
<Mic className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="truncate">{previewName}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-brand px-2.5 py-1 text-xs font-semibold text-brand-foreground">
|
||||
<Plus className="h-3 w-3" /> New
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 p-3">
|
||||
<div className="hidden w-28 shrink-0 flex-col gap-1 sm:flex">
|
||||
<span className="rounded-full bg-brand/10 px-2.5 py-1.5 text-xs font-semibold text-brand">Dashboard</span>
|
||||
<span className="rounded-full px-2.5 py-1.5 text-xs font-medium text-muted-foreground">Episodes</span>
|
||||
<span className="rounded-full px-2.5 py-1.5 text-xs font-medium text-muted-foreground">Billing</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="font-display text-sm font-bold tracking-tight">Welcome back</p>
|
||||
<div className="rounded-xl border border-border p-2.5">
|
||||
<div className="h-1.5 w-2/3 rounded-full bg-brand" />
|
||||
<div className="mt-1.5 h-1.5 w-1/3 rounded-full bg-secondary" />
|
||||
</div>
|
||||
<span className="inline-block text-xs font-semibold text-brand underline-offset-2">
|
||||
View usage →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{primaryColor.trim() && !previewHsl && (
|
||||
<p className="text-xs text-warning">Enter a 6-digit hex colour (e.g. #116DFF).</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logo">Logo URL</Label>
|
||||
<Input id="logo" placeholder="https://…" value={logoUrl} onChange={(e) => setLogoUrl(e.target.value)} />
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
|
||||
/**
|
||||
* Light/dark toggle rendered inside the user menu. Uses `onSelect` with
|
||||
* `preventDefault` so picking it doesn't close the menu, letting the user see
|
||||
* the theme flip. Guards against hydration mismatch by waiting for mount.
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const isDark = mounted && resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setTheme(isDark ? "light" : "dark");
|
||||
}}
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
{isDark ? "Light mode" : "Dark mode"}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { signOut } from "@/lib/auth/auth-client";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
interface UserMenuProps {
|
||||
name: string;
|
||||
@@ -64,6 +65,7 @@ export function UserMenu({ name, email, image, isAdmin }: UserMenuProps) {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleSignOut} className="text-destructive focus:text-destructive">
|
||||
<LogOut className="h-4 w-4" /> Sign out
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Play, Pause, Download, Volume2, VolumeX, FileArchive } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
if (!Number.isFinite(sec) || sec < 0) return "0:00";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic pseudo-waveform bars derived from the source URL — a lightweight,
|
||||
* decode-free stand-in so the player always renders a waveform without fetching
|
||||
* and decoding the audio. Purely decorative; progress fills it as playback moves.
|
||||
*/
|
||||
function bars(seed: string, count: number): number[] {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
h ^= h << 13;
|
||||
h ^= h >>> 17;
|
||||
h ^= h << 5;
|
||||
const n = (h >>> 0) / 4294967295;
|
||||
out.push(0.22 + n * 0.78);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface WaveformPlayerProps {
|
||||
/** Fully-qualified audio source URL (authed asset route or public route). */
|
||||
src: string;
|
||||
/** Optional direct download URL for the MP3 (defaults to `${src}?download=1`). */
|
||||
downloadUrl?: string;
|
||||
/** Server-known duration (seconds) used until metadata loads. */
|
||||
durationSec?: number | null;
|
||||
/** When set, shows a "Download everything (.zip)" button hitting this URL. */
|
||||
exportUrl?: string;
|
||||
/** Hide the download controls (e.g. on public pages). */
|
||||
hideDownloads?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WaveformPlayer({
|
||||
src,
|
||||
downloadUrl,
|
||||
durationSec,
|
||||
exportUrl,
|
||||
hideDownloads = false,
|
||||
className,
|
||||
}: WaveformPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [duration, setDuration] = useState(durationSec ?? 0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [muted, setMuted] = useState(false);
|
||||
|
||||
const waveform = useRef(bars(src, 96)).current;
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
|
||||
// Paint the canvas waveform with a played/unplayed split.
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const styles = getComputedStyle(canvas);
|
||||
const played = `hsl(${styles.getPropertyValue("--brand").trim() || "217 100% 53%"})`;
|
||||
const unplayed = `hsl(${styles.getPropertyValue("--border").trim() || "0 0% 89%"})`;
|
||||
|
||||
const n = waveform.length;
|
||||
const gap = 2;
|
||||
const barW = Math.max(1, (w - gap * (n - 1)) / n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const bh = waveform[i] * h;
|
||||
const x = i * (barW + gap);
|
||||
const y = (h - bh) / 2;
|
||||
ctx.fillStyle = (i + 0.5) / n <= progress ? played : unplayed;
|
||||
ctx.beginPath();
|
||||
const r = Math.min(barW / 2, 1.5);
|
||||
if (typeof ctx.roundRect === "function") ctx.roundRect(x, y, barW, bh, r);
|
||||
else ctx.rect(x, y, barW, bh);
|
||||
ctx.fill();
|
||||
}
|
||||
}, [waveform, progress]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => draw();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [draw]);
|
||||
|
||||
function togglePlay() {
|
||||
const a = audioRef.current;
|
||||
if (!a) return;
|
||||
if (a.paused) void a.play();
|
||||
else a.pause();
|
||||
}
|
||||
|
||||
function seekToClientX(clientX: number, el: HTMLElement) {
|
||||
const a = audioRef.current;
|
||||
if (!a || !duration) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
||||
a.currentTime = ratio * duration;
|
||||
setCurrent(a.currentTime);
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
const a = audioRef.current;
|
||||
if (!a) return;
|
||||
a.muted = !a.muted;
|
||||
setMuted(a.muted);
|
||||
}
|
||||
|
||||
function onVolume(v: number) {
|
||||
const a = audioRef.current;
|
||||
setVolume(v);
|
||||
if (a) {
|
||||
a.volume = v;
|
||||
a.muted = v === 0;
|
||||
setMuted(v === 0);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={src}
|
||||
preload="metadata"
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}
|
||||
onLoadedMetadata={(e) => {
|
||||
if (Number.isFinite(e.currentTarget.duration)) setDuration(e.currentTarget.duration);
|
||||
}}
|
||||
onEnded={() => setPlaying(false)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={togglePlay}
|
||||
aria-label={playing ? "Pause" : "Play"}
|
||||
className="h-12 w-12 shrink-0"
|
||||
>
|
||||
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 translate-x-0.5" />}
|
||||
</Button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Canvas waveform doubles as the scrubber. */}
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Seek"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.round(duration)}
|
||||
aria-valuenow={Math.round(current)}
|
||||
tabIndex={0}
|
||||
className="relative h-12 w-full cursor-pointer touch-none select-none"
|
||||
onPointerDown={(e) => {
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
seekToClientX(e.clientX, e.currentTarget);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (e.buttons === 1) seekToClientX(e.clientX, e.currentTarget);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
const a = audioRef.current;
|
||||
if (!a || !duration) return;
|
||||
if (e.key === "ArrowRight") a.currentTime = Math.min(duration, a.currentTime + 5);
|
||||
else if (e.key === "ArrowLeft") a.currentTime = Math.max(0, a.currentTime - 5);
|
||||
else if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasRef} className="h-full w-full" />
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
|
||||
<span>{formatTime(current)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMute}
|
||||
aria-label={muted ? "Unmute" : "Mute"}
|
||||
className="rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => onVolume(Number(e.target.value))}
|
||||
aria-label="Volume"
|
||||
className="h-1 w-20 cursor-pointer accent-brand"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hideDownloads && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={downloadUrl ?? `${src}?download=1`} download>
|
||||
<Download className="h-4 w-4" /> Download MP3
|
||||
</a>
|
||||
</Button>
|
||||
{exportUrl && (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<a href={exportUrl}>
|
||||
<FileArchive className="h-4 w-4" /> Download everything (.zip)
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,12 +10,14 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { signIn } from "@/lib/auth/auth-client";
|
||||
import { safeRedirect } from "@/lib/utils";
|
||||
import { GoogleButton } from "./google-button";
|
||||
|
||||
export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const redirectTo = params.get("redirect") || "/dashboard";
|
||||
// Validate the ?redirect param to prevent open-redirect attacks.
|
||||
const redirectTo = safeRedirect(params.get("redirect"));
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
|
||||
@@ -26,6 +26,9 @@ export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
|
||||
password: String(form.get("password")),
|
||||
});
|
||||
if (error) {
|
||||
// Accepted tradeoff (L8): the raw Better Auth message can reveal that an
|
||||
// email is already registered (account enumeration). We keep the specific
|
||||
// message for UX clarity; the signup endpoint is rate-limited server-side.
|
||||
toast.error(error.message ?? "Could not create account");
|
||||
setLoading(false);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export interface LegalSection {
|
||||
heading: string;
|
||||
paragraphs: string[];
|
||||
bullets?: string[];
|
||||
}
|
||||
|
||||
/** Shared layout for long-form legal documents (Privacy, Terms). */
|
||||
export function LegalDoc({
|
||||
title,
|
||||
updated,
|
||||
intro,
|
||||
sections,
|
||||
}: {
|
||||
title: string;
|
||||
updated: string;
|
||||
intro: string;
|
||||
sections: LegalSection[];
|
||||
}) {
|
||||
return (
|
||||
<div className="container max-w-3xl py-20 md:py-24">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-[0.04em] text-brand">Legal</p>
|
||||
<h1 className="mt-3 font-display text-4xl font-extrabold tracking-tight md:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-muted-foreground">Last updated: {updated}</p>
|
||||
<p className="mt-6 text-lg leading-relaxed text-muted-foreground">{intro}</p>
|
||||
|
||||
<div className="mt-12 space-y-10">
|
||||
{sections.map((s, i) => (
|
||||
<section key={s.heading} className="space-y-3">
|
||||
<h2 className="font-display text-xl font-bold tracking-tight">
|
||||
<span className="text-muted-foreground/50">{i + 1}.</span> {s.heading}
|
||||
</h2>
|
||||
{s.paragraphs.map((p, j) => (
|
||||
<p key={j} className="leading-relaxed text-muted-foreground">
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
{s.bullets && (
|
||||
<ul className="ml-1 space-y-2">
|
||||
{s.bullets.map((b) => (
|
||||
<li key={b} className="flex gap-2.5 text-muted-foreground">
|
||||
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
|
||||
<span className="leading-relaxed">{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-12 border-t border-border pt-6 text-sm italic text-muted-foreground">
|
||||
This document is provided for transparency about how PodcastYes operates. It is a general
|
||||
template and is not legal advice; have it reviewed by qualified counsel for your
|
||||
jurisdiction before relying on it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from "next-themes";
|
||||
|
||||
/**
|
||||
* App-wide theme provider. Wraps next-themes with class-based dark mode so the
|
||||
* `.dark` token overrides in globals.css apply. `disableTransitionOnChange`
|
||||
* prevents a flash of color transitions when toggling.
|
||||
*/
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border border-border bg-background p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col gap-1.5 text-left", className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("font-display text-lg font-bold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Shared empty-state panel: a brand-tinted icon tile, a title, muted
|
||||
* description, and an optional call-to-action. Used across the dashboard,
|
||||
* episodes, series, and API-keys surfaces.
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
bordered = true,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
/** Wrap in a dashed border panel (default). Set false when already inside a Card. */
|
||||
bordered?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 px-6 py-16 text-center",
|
||||
bordered && "rounded-2xl border border-dashed border-border",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand/10 text-brand">
|
||||
<Icon className="h-6 w-6" />
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<p className="font-display text-lg font-bold tracking-tight">{title}</p>
|
||||
{description && (
|
||||
<p className="mx-auto max-w-md text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="pt-1">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Base shimmer block. Compose larger layouts from these. */
|
||||
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("animate-pulse rounded-xl bg-secondary", className)} {...props} />;
|
||||
}
|
||||
|
||||
/** A page-header placeholder (title + description). */
|
||||
export function HeaderSkeleton() {
|
||||
return (
|
||||
<div className="mb-8 space-y-2">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-4 w-80 max-w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single card-shaped placeholder. */
|
||||
export function CardSkeleton({ className }: { className?: string }) {
|
||||
return <Skeleton className={cn("h-28 rounded-2xl", className)} />;
|
||||
}
|
||||
|
||||
/** A responsive row of stat-card placeholders. */
|
||||
export function StatRowSkeleton({ count = 3 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<CardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A grid of square episode-card placeholders. */
|
||||
export function EpisodeGridSkeleton({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="overflow-hidden rounded-2xl border border-border bg-card">
|
||||
<Skeleton className="aspect-square rounded-none" />
|
||||
<div className="space-y-2 p-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A stacked list of row placeholders (for lists/tables). */
|
||||
export function ListSkeleton({ rows = 4 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="divide-y rounded-2xl border border-border">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-3 p-4">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-2/5" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rangeWindow, type Range } from "./range";
|
||||
|
||||
export const AUDIT_PAGE_SIZE = 30;
|
||||
|
||||
export async function listAudit(params: {
|
||||
action?: string;
|
||||
actor?: string;
|
||||
range?: Range;
|
||||
page: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const pageSize = params.pageSize ?? AUDIT_PAGE_SIZE;
|
||||
const where: Prisma.AuditLogWhereInput = {};
|
||||
if (params.action) where.action = params.action;
|
||||
if (params.actor) where.actor = { email: { contains: params.actor, mode: "insensitive" } };
|
||||
if (params.range) where.createdAt = { gte: rangeWindow(params.range).since };
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (params.page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { actor: { select: { email: true } } },
|
||||
}),
|
||||
prisma.auditLog.count({ where }),
|
||||
]);
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
/** Distinct action names for the filter dropdown. */
|
||||
export async function getAuditActions(): Promise<string[]> {
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
distinct: ["action"],
|
||||
select: { action: true },
|
||||
orderBy: { action: "asc" },
|
||||
});
|
||||
return rows.map((r) => r.action);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PLANS, type PlanKey } from "@/lib/billing/plans";
|
||||
import { rangeWindow, type Range } from "./range";
|
||||
|
||||
export const SUBS_PAGE_SIZE = 25;
|
||||
|
||||
export interface AdminSubRow {
|
||||
id: string;
|
||||
customer: string;
|
||||
referenceId: string;
|
||||
plan: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
cancelAtPeriodEnd: boolean | null;
|
||||
periodEnd: Date | null;
|
||||
stripeSubscriptionId: string | null;
|
||||
paypalSubscriptionId: string | null;
|
||||
}
|
||||
|
||||
async function resolveNames(refIds: string[]): Promise<Map<string, string>> {
|
||||
const [users, orgs] = await Promise.all([
|
||||
prisma.user.findMany({ where: { id: { in: refIds } }, select: { id: true, email: true } }),
|
||||
prisma.organization.findMany({ where: { id: { in: refIds } }, select: { id: true, name: true } }),
|
||||
]);
|
||||
const map = new Map<string, string>();
|
||||
for (const u of users) map.set(u.id, u.email);
|
||||
for (const o of orgs) map.set(o.id, o.name);
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function listSubscriptions(params: {
|
||||
status?: string;
|
||||
provider?: string;
|
||||
search?: string;
|
||||
page: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ rows: AdminSubRow[]; total: number }> {
|
||||
const pageSize = params.pageSize ?? SUBS_PAGE_SIZE;
|
||||
const where: Prisma.SubscriptionWhereInput = {};
|
||||
if (params.status) where.status = params.status;
|
||||
if (params.provider) where.provider = params.provider;
|
||||
if (params.search) {
|
||||
const [users, orgs] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: { contains: params.search, mode: "insensitive" } },
|
||||
{ name: { contains: params.search, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.organization.findMany({
|
||||
where: { name: { contains: params.search, mode: "insensitive" } },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
where.referenceId = { in: [...users.map((u) => u.id), ...orgs.map((o) => o.id)] };
|
||||
}
|
||||
|
||||
const [subs, total] = await Promise.all([
|
||||
prisma.subscription.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (params.page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.subscription.count({ where }),
|
||||
]);
|
||||
|
||||
const names = await resolveNames(subs.map((s) => s.referenceId));
|
||||
return {
|
||||
total,
|
||||
rows: subs.map((s) => ({
|
||||
id: s.id,
|
||||
customer: names.get(s.referenceId) ?? s.referenceId,
|
||||
referenceId: s.referenceId,
|
||||
plan: s.plan,
|
||||
provider: s.provider,
|
||||
status: s.status,
|
||||
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
|
||||
periodEnd: s.periodEnd,
|
||||
stripeSubscriptionId: s.stripeSubscriptionId,
|
||||
paypalSubscriptionId: s.paypalSubscriptionId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-tier MRR breakdown + recent churn list, for the Revenue page. */
|
||||
export async function getRevenueExtras(range: Range) {
|
||||
const w = rangeWindow(range);
|
||||
const [active, churn] = await Promise.all([
|
||||
prisma.subscription.findMany({
|
||||
where: { status: { in: ["active", "trialing"] }, plan: { not: "free" } },
|
||||
select: { plan: true },
|
||||
}),
|
||||
prisma.subscription.findMany({
|
||||
where: { status: "canceled", updatedAt: { gte: w.since } },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 20,
|
||||
select: { referenceId: true, plan: true, provider: true, updatedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const tierMrr: Record<string, number> = { creator: 0, pro: 0, agency: 0 };
|
||||
for (const s of active) {
|
||||
if (s.plan in tierMrr) tierMrr[s.plan] += PLANS[s.plan as PlanKey]?.priceMonthly ?? 0;
|
||||
}
|
||||
|
||||
const names = await resolveNames(churn.map((c) => c.referenceId));
|
||||
const churnList = churn.map((c) => ({
|
||||
customer: names.get(c.referenceId) ?? c.referenceId,
|
||||
plan: c.plan,
|
||||
provider: c.provider,
|
||||
at: c.updatedAt,
|
||||
}));
|
||||
|
||||
return { tierMrr, churnList };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rangeWindow, type Range } from "./range";
|
||||
|
||||
export interface CostBreakdown {
|
||||
total: number;
|
||||
byProvider: { openai: number; elevenlabs: number };
|
||||
byOperation: Record<string, number>;
|
||||
outliers: { userId: string; email: string; cost: number }[];
|
||||
}
|
||||
|
||||
export async function getCostBreakdown(range: Range): Promise<CostBreakdown> {
|
||||
const w = rangeWindow(range);
|
||||
const [logs, grouped] = await Promise.all([
|
||||
prisma.aiCostLog.findMany({
|
||||
where: { createdAt: { gte: w.since } },
|
||||
select: { provider: true, operation: true, costUsd: true },
|
||||
}),
|
||||
prisma.aiCostLog.groupBy({
|
||||
by: ["userId"],
|
||||
where: { createdAt: { gte: w.since }, userId: { not: null } },
|
||||
_sum: { costUsd: true },
|
||||
orderBy: { _sum: { costUsd: "desc" } },
|
||||
take: 10,
|
||||
}),
|
||||
]);
|
||||
|
||||
let total = 0;
|
||||
const byProvider = { openai: 0, elevenlabs: 0 };
|
||||
const byOperation: Record<string, number> = { script: 0, audio: 0, art: 0, repurpose: 0 };
|
||||
for (const l of logs) {
|
||||
const cost = Number(l.costUsd);
|
||||
total += cost;
|
||||
if (l.provider === "elevenlabs") byProvider.elevenlabs += cost;
|
||||
else byProvider.openai += cost;
|
||||
if (l.operation in byOperation) byOperation[l.operation] += cost;
|
||||
}
|
||||
|
||||
const userIds = grouped.map((g) => g.userId!).filter(Boolean);
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
const emailById = new Map(users.map((u) => [u.id, u.email]));
|
||||
const outliers = grouped.map((g) => ({
|
||||
userId: g.userId!,
|
||||
email: emailById.get(g.userId!) ?? "—",
|
||||
cost: Number(g._sum.costUsd ?? 0),
|
||||
}));
|
||||
|
||||
return { total, byProvider, byOperation, outliers };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { FLAG_REGISTRY } from "@/lib/flags";
|
||||
|
||||
export interface AdminFlagView {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
rolloutPct: number;
|
||||
metadata: unknown;
|
||||
updatedAt: Date | null;
|
||||
known: boolean;
|
||||
}
|
||||
|
||||
const REGISTRY = new Map(FLAG_REGISTRY.map((f) => [f.key, f]));
|
||||
|
||||
/**
|
||||
* Fresh (uncached) admin view of feature flags: registry-known flags merged with
|
||||
* their DB rows (rolloutPct / metadata / updatedAt), plus any custom flags.
|
||||
*/
|
||||
export async function getAdminFlags(): Promise<AdminFlagView[]> {
|
||||
const rows = await prisma.featureFlag.findMany({ orderBy: { key: "asc" } });
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
|
||||
const known: AdminFlagView[] = FLAG_REGISTRY.map((def) => {
|
||||
const row = byKey.get(def.key);
|
||||
return {
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
description: def.description,
|
||||
enabled: row?.enabled ?? def.default,
|
||||
rolloutPct: row?.rolloutPct ?? 0,
|
||||
metadata: row?.metadata ?? null,
|
||||
updatedAt: row?.updatedAt ?? null,
|
||||
known: true,
|
||||
};
|
||||
});
|
||||
|
||||
const custom: AdminFlagView[] = rows
|
||||
.filter((r) => !REGISTRY.has(r.key))
|
||||
.map((r) => ({
|
||||
key: r.key,
|
||||
label: r.key,
|
||||
description: "Custom flag (no registry entry — not read by application code).",
|
||||
enabled: r.enabled,
|
||||
rolloutPct: r.rolloutPct,
|
||||
metadata: r.metadata ?? null,
|
||||
updatedAt: r.updatedAt,
|
||||
known: false,
|
||||
}));
|
||||
|
||||
return [...known, ...custom];
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PLANS, type PlanKey } from "@/lib/billing/plans";
|
||||
import { rangeWindow, pctDelta, type Range } from "./range";
|
||||
|
||||
function mrrOf(subs: { plan: string }[]): number {
|
||||
return subs.reduce((sum, s) => sum + (PLANS[s.plan as PlanKey]?.priceMonthly ?? 0), 0);
|
||||
}
|
||||
|
||||
export interface Overview {
|
||||
mrr: number;
|
||||
arr: number;
|
||||
arpu: number;
|
||||
paying: number;
|
||||
activeSubs: number;
|
||||
totalUsers: number;
|
||||
signups: number;
|
||||
signupsDelta: number | null;
|
||||
episodes: number;
|
||||
episodesDelta: number | null;
|
||||
aiSpend: number;
|
||||
aiSpendDelta: number | null;
|
||||
churned: number;
|
||||
successRate: number;
|
||||
failedEpisodes: number;
|
||||
tierCounts: Record<string, number>;
|
||||
providerSplit: { stripe: number; paypal: number; comp: number };
|
||||
}
|
||||
|
||||
export async function getOverview(range: Range): Promise<Overview> {
|
||||
const w = rangeWindow(range);
|
||||
const [activeSubs, signups, prevSignups, episodes, prevEpisodes, failed, spend, prevSpend, churned, totalUsers] =
|
||||
await Promise.all([
|
||||
prisma.subscription.findMany({
|
||||
where: { status: { in: ["active", "trialing"] } },
|
||||
select: { plan: true, provider: true },
|
||||
}),
|
||||
prisma.user.count({ where: { createdAt: { gte: w.since } } }),
|
||||
prisma.user.count({ where: { createdAt: { gte: w.prevSince, lt: w.prevUntil } } }),
|
||||
prisma.episode.count({ where: { createdAt: { gte: w.since } } }),
|
||||
prisma.episode.count({ where: { createdAt: { gte: w.prevSince, lt: w.prevUntil } } }),
|
||||
prisma.episode.count({ where: { status: "FAILED", createdAt: { gte: w.since } } }),
|
||||
prisma.aiCostLog.aggregate({ _sum: { costUsd: true }, where: { createdAt: { gte: w.since } } }),
|
||||
prisma.aiCostLog.aggregate({
|
||||
_sum: { costUsd: true },
|
||||
where: { createdAt: { gte: w.prevSince, lt: w.prevUntil } },
|
||||
}),
|
||||
prisma.subscription.count({ where: { status: "canceled", updatedAt: { gte: w.since } } }),
|
||||
prisma.user.count(),
|
||||
]);
|
||||
|
||||
const mrr = mrrOf(activeSubs);
|
||||
const paying = activeSubs.filter((s) => s.plan !== "free").length;
|
||||
const aiSpend = Number(spend._sum.costUsd ?? 0);
|
||||
const prevAiSpend = Number(prevSpend._sum.costUsd ?? 0);
|
||||
|
||||
const tierCounts: Record<string, number> = { free: 0, creator: 0, pro: 0, agency: 0 };
|
||||
for (const s of activeSubs) if (s.plan in tierCounts) tierCounts[s.plan]++;
|
||||
|
||||
return {
|
||||
mrr,
|
||||
arr: mrr * 12,
|
||||
arpu: paying > 0 ? Math.round(mrr / paying) : 0,
|
||||
paying,
|
||||
activeSubs: activeSubs.length,
|
||||
totalUsers,
|
||||
signups,
|
||||
signupsDelta: pctDelta(signups, prevSignups),
|
||||
episodes,
|
||||
episodesDelta: pctDelta(episodes, prevEpisodes),
|
||||
aiSpend,
|
||||
aiSpendDelta: pctDelta(aiSpend, prevAiSpend),
|
||||
churned,
|
||||
successRate: episodes > 0 ? Math.round(((episodes - failed) / episodes) * 100) : 100,
|
||||
failedEpisodes: failed,
|
||||
tierCounts,
|
||||
providerSplit: {
|
||||
stripe: activeSubs.filter((s) => s.provider === "stripe").length,
|
||||
paypal: activeSubs.filter((s) => s.provider === "paypal").length,
|
||||
comp: activeSubs.filter((s) => s.provider === "comp").length,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const JOBS_PAGE_SIZE = 25;
|
||||
export const WEBHOOKS_PAGE_SIZE = 30;
|
||||
|
||||
export async function listJobs(params: { status?: string; page: number; pageSize?: number }) {
|
||||
const pageSize = params.pageSize ?? JOBS_PAGE_SIZE;
|
||||
const where: Prisma.GenerationJobWhereInput = {};
|
||||
if (params.status) where.status = params.status;
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.generationJob.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (params.page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { episode: { select: { id: true, title: true } } },
|
||||
}),
|
||||
prisma.generationJob.count({ where }),
|
||||
]);
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
export async function getJobStatusCounts(): Promise<Record<string, number>> {
|
||||
const groups = await prisma.generationJob.groupBy({ by: ["status"], _count: true });
|
||||
const counts: Record<string, number> = { queued: 0, running: 0, completed: 0, failed: 0 };
|
||||
for (const g of groups) counts[g.status] = g._count;
|
||||
return counts;
|
||||
}
|
||||
|
||||
export async function getEpisodeStatusCounts() {
|
||||
const groups = await prisma.episode.groupBy({ by: ["status"], _count: true });
|
||||
return groups.map((g) => ({ status: g.status as string, count: g._count }));
|
||||
}
|
||||
|
||||
export async function getModerationQueue() {
|
||||
return prisma.contentFlag.findMany({
|
||||
where: { status: "open" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { episode: { select: { id: true, title: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listWebhookEvents(params: {
|
||||
provider?: string;
|
||||
status?: string;
|
||||
page: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const pageSize = params.pageSize ?? WEBHOOKS_PAGE_SIZE;
|
||||
const where: Prisma.WebhookEventWhereInput = {};
|
||||
if (params.provider) where.provider = params.provider;
|
||||
if (params.status) where.status = params.status;
|
||||
const dayAgo = new Date(Date.now() - 86_400_000);
|
||||
const [rows, total, recentFailures, recentTotal] = await Promise.all([
|
||||
prisma.webhookEvent.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (params.page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.webhookEvent.count({ where }),
|
||||
prisma.webhookEvent.count({ where: { status: "failed", createdAt: { gte: dayAgo } } }),
|
||||
prisma.webhookEvent.count({ where: { createdAt: { gte: dayAgo } } }),
|
||||
]);
|
||||
return { rows, total, recentFailures, recentTotal };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Shared date-range parsing + bucketing for admin analytics.
|
||||
|
||||
export type Range = "7d" | "30d" | "90d" | "12m";
|
||||
export const RANGES: Range[] = ["7d", "30d", "90d", "12m"];
|
||||
|
||||
export function parseRange(v?: string | null): Range {
|
||||
return RANGES.includes(v as Range) ? (v as Range) : "30d";
|
||||
}
|
||||
|
||||
export interface RangeWindow {
|
||||
range: Range;
|
||||
since: Date;
|
||||
until: Date;
|
||||
/** The equally-sized window immediately before `since` (for deltas). */
|
||||
prevSince: Date;
|
||||
prevUntil: Date;
|
||||
days: number;
|
||||
bucket: "day" | "month";
|
||||
}
|
||||
|
||||
const DAYS: Record<Range, number> = { "7d": 7, "30d": 30, "90d": 90, "12m": 365 };
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export function rangeWindow(range: Range, now = new Date()): RangeWindow {
|
||||
const days = DAYS[range];
|
||||
const until = now;
|
||||
const since = new Date(now.getTime() - days * DAY_MS);
|
||||
return {
|
||||
range,
|
||||
since,
|
||||
until,
|
||||
prevSince: new Date(since.getTime() - days * DAY_MS),
|
||||
prevUntil: since,
|
||||
days,
|
||||
bucket: range === "12m" ? "month" : "day",
|
||||
};
|
||||
}
|
||||
|
||||
const isoDay = (d: Date) => d.toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
|
||||
const ym = (d: Date) => d.toISOString().slice(0, 7); // YYYY-MM
|
||||
const dayLabel = (iso: string) => {
|
||||
const [, m, d] = iso.split("-");
|
||||
return `${Number(m)}/${Number(d)}`;
|
||||
};
|
||||
const monthLabel = (key: string) => {
|
||||
const [y, m] = key.split("-");
|
||||
return new Date(Date.UTC(Number(y), Number(m) - 1, 1)).toLocaleString("en", { month: "short" });
|
||||
};
|
||||
|
||||
export interface Buckets {
|
||||
buckets: { key: string; label: string }[];
|
||||
keyOf: (d: Date) => string;
|
||||
}
|
||||
|
||||
/** Ordered chart buckets across the window + a fn mapping a date to its bucket key. */
|
||||
export function buildBuckets(w: RangeWindow): Buckets {
|
||||
if (w.bucket === "month") {
|
||||
const base = new Date(Date.UTC(w.until.getUTCFullYear(), w.until.getUTCMonth(), 1));
|
||||
const buckets = [];
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const m = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth() - i, 1));
|
||||
const key = ym(m);
|
||||
buckets.push({ key, label: monthLabel(key) });
|
||||
}
|
||||
return { buckets, keyOf: (d) => ym(d) };
|
||||
}
|
||||
const buckets = [];
|
||||
for (let i = w.days - 1; i >= 0; i--) {
|
||||
const day = new Date(w.until.getTime() - i * DAY_MS);
|
||||
const key = isoDay(day);
|
||||
buckets.push({ key, label: dayLabel(key) });
|
||||
}
|
||||
return { buckets, keyOf: (d) => isoDay(d) };
|
||||
}
|
||||
|
||||
/** Percent change a→b, null when there's no meaningful base. */
|
||||
export function pctDelta(current: number, previous: number): number | null {
|
||||
if (previous === 0) return current === 0 ? 0 : null;
|
||||
return ((current - previous) / previous) * 100;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PLANS, type PlanKey } from "@/lib/billing/plans";
|
||||
import { rangeWindow, buildBuckets, type Range } from "./range";
|
||||
|
||||
/** Signups per bucket. */
|
||||
export async function getSignupSeries(range: Range) {
|
||||
const w = rangeWindow(range);
|
||||
const { buckets, keyOf } = buildBuckets(w);
|
||||
const rows = await prisma.user.findMany({
|
||||
where: { createdAt: { gte: w.since } },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
const map = new Map(buckets.map((b) => [b.key, 0]));
|
||||
for (const r of rows) {
|
||||
const k = keyOf(r.createdAt);
|
||||
if (map.has(k)) map.set(k, map.get(k)! + 1);
|
||||
}
|
||||
return buckets.map((b) => ({ date: b.label, signups: map.get(b.key) ?? 0 }));
|
||||
}
|
||||
|
||||
/** Episodes created vs failed per bucket. */
|
||||
export async function getEpisodeSeries(range: Range) {
|
||||
const w = rangeWindow(range);
|
||||
const { buckets, keyOf } = buildBuckets(w);
|
||||
const rows = await prisma.episode.findMany({
|
||||
where: { createdAt: { gte: w.since } },
|
||||
select: { createdAt: true, status: true },
|
||||
});
|
||||
const map = new Map(buckets.map((b) => [b.key, { created: 0, failed: 0 }]));
|
||||
for (const r of rows) {
|
||||
const k = keyOf(r.createdAt);
|
||||
const cell = map.get(k);
|
||||
if (!cell) continue;
|
||||
cell.created++;
|
||||
if (r.status === "FAILED") cell.failed++;
|
||||
}
|
||||
return buckets.map((b) => ({ date: b.label, ...map.get(b.key)! }));
|
||||
}
|
||||
|
||||
/** AI cost by provider per bucket. */
|
||||
export async function getAiCostSeries(range: Range) {
|
||||
const w = rangeWindow(range);
|
||||
const { buckets, keyOf } = buildBuckets(w);
|
||||
const rows = await prisma.aiCostLog.findMany({
|
||||
where: { createdAt: { gte: w.since } },
|
||||
select: { createdAt: true, provider: true, costUsd: true },
|
||||
});
|
||||
const map = new Map(buckets.map((b) => [b.key, { openai: 0, elevenlabs: 0 }]));
|
||||
for (const r of rows) {
|
||||
const cell = map.get(keyOf(r.createdAt));
|
||||
if (!cell) continue;
|
||||
const cost = Number(r.costUsd);
|
||||
if (r.provider === "elevenlabs") cell.elevenlabs += cost;
|
||||
else cell.openai += cost;
|
||||
}
|
||||
return buckets.map((b) => {
|
||||
const c = map.get(b.key)!;
|
||||
return { date: b.label, openai: round2(c.openai), elevenlabs: round2(c.elevenlabs) };
|
||||
});
|
||||
}
|
||||
|
||||
/** New vs churned MRR per bucket (proxy from sub create/cancel timestamps). */
|
||||
export async function getRevenueSeries(range: Range) {
|
||||
const w = rangeWindow(range);
|
||||
const { buckets, keyOf } = buildBuckets(w);
|
||||
const [created, canceled] = await Promise.all([
|
||||
prisma.subscription.findMany({
|
||||
where: { createdAt: { gte: w.since }, plan: { not: "free" } },
|
||||
select: { createdAt: true, plan: true },
|
||||
}),
|
||||
prisma.subscription.findMany({
|
||||
where: { status: "canceled", updatedAt: { gte: w.since }, plan: { not: "free" } },
|
||||
select: { updatedAt: true, plan: true },
|
||||
}),
|
||||
]);
|
||||
const map = new Map(buckets.map((b) => [b.key, { newMrr: 0, churnedMrr: 0 }]));
|
||||
for (const s of created) {
|
||||
const cell = map.get(keyOf(s.createdAt));
|
||||
if (cell) cell.newMrr += (PLANS[s.plan as PlanKey]?.priceMonthly ?? 0) / 100;
|
||||
}
|
||||
for (const s of canceled) {
|
||||
const cell = map.get(keyOf(s.updatedAt));
|
||||
if (cell) cell.churnedMrr -= (PLANS[s.plan as PlanKey]?.priceMonthly ?? 0) / 100;
|
||||
}
|
||||
return buckets.map((b) => {
|
||||
const c = map.get(b.key)!;
|
||||
return { date: b.label, newMrr: round2(c.newMrr), churnedMrr: round2(c.churnedMrr) };
|
||||
});
|
||||
}
|
||||
|
||||
function round2(n: number) {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getUsageSummary } from "@/lib/usage/meter";
|
||||
import { getActiveSubscription } from "@/lib/billing/subscription";
|
||||
import { PLANS, type PlanKey } from "@/lib/billing/plans";
|
||||
|
||||
export const USERS_PAGE_SIZE = 25;
|
||||
|
||||
export interface AdminUserRow {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
banned: boolean;
|
||||
plan: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function orderBy(sort?: string): Prisma.UserOrderByWithRelationInput {
|
||||
const [key, dir] = (sort ?? "createdAt.desc").split(".");
|
||||
const d = dir === "asc" ? "asc" : "desc";
|
||||
if (key === "name") return { name: d };
|
||||
if (key === "email") return { email: d };
|
||||
return { createdAt: d };
|
||||
}
|
||||
|
||||
export async function listUsers(params: {
|
||||
search?: string;
|
||||
plan?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
sort?: string;
|
||||
page: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ rows: AdminUserRow[]; total: number }> {
|
||||
const pageSize = params.pageSize ?? USERS_PAGE_SIZE;
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
|
||||
if (params.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: params.search, mode: "insensitive" } },
|
||||
{ email: { contains: params.search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
if (params.role === "admin" || params.role === "user") where.role = params.role;
|
||||
if (params.status === "banned") where.banned = true;
|
||||
else if (params.status === "active") where.banned = { not: true };
|
||||
|
||||
if (params.plan) {
|
||||
const paid = await prisma.subscription.findMany({
|
||||
where: {
|
||||
status: { in: ["active", "trialing"] },
|
||||
plan: params.plan === "free" ? { not: "free" } : params.plan,
|
||||
},
|
||||
select: { referenceId: true },
|
||||
});
|
||||
const ids = paid.map((p) => p.referenceId);
|
||||
where.id = params.plan === "free" ? { notIn: ids } : { in: ids };
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
orderBy: orderBy(params.sort),
|
||||
skip: (params.page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
const subs = await prisma.subscription.findMany({
|
||||
where: { referenceId: { in: users.map((u) => u.id) }, status: { in: ["active", "trialing"] } },
|
||||
select: { referenceId: true, plan: true },
|
||||
});
|
||||
const planByRef = new Map(subs.map((s) => [s.referenceId, s.plan]));
|
||||
|
||||
return {
|
||||
total,
|
||||
rows: users.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
role: u.role ?? "user",
|
||||
banned: !!u.banned,
|
||||
plan: planByRef.get(u.id) ?? "free",
|
||||
createdAt: u.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserDetail(id: string) {
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
if (!user) return null;
|
||||
|
||||
const [sub, usage, episodes, episodeCount, cost, audit] = await Promise.all([
|
||||
getActiveSubscription(id),
|
||||
getUsageSummary(id),
|
||||
prisma.episode.findMany({
|
||||
where: { userId: id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
select: { id: true, title: true, status: true, format: true, createdAt: true },
|
||||
}),
|
||||
prisma.episode.count({ where: { userId: id } }),
|
||||
prisma.aiCostLog.aggregate({ _sum: { costUsd: true }, where: { userId: id } }),
|
||||
prisma.auditLog.findMany({
|
||||
where: { OR: [{ target: id }, { actorId: id }] },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 15,
|
||||
include: { actor: { select: { email: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const planKey = (sub?.plan as PlanKey) ?? "free";
|
||||
return {
|
||||
user,
|
||||
subscription: sub,
|
||||
plan: PLANS[planKey] ?? PLANS.free,
|
||||
planKey,
|
||||
usage,
|
||||
episodes,
|
||||
episodeCount,
|
||||
lifetimeCost: Number(cost._sum.costUsd ?? 0),
|
||||
audit,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { openai } from "./openai";
|
||||
|
||||
const MODERATION_MODEL = process.env.OPENAI_MODERATION_MODEL ?? "omni-moderation-latest";
|
||||
|
||||
export interface ModerationResult {
|
||||
flagged: boolean;
|
||||
/** OpenAI category keys that tripped, e.g. ["hate", "violence"]. */
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen text with OpenAI's moderation endpoint.
|
||||
*
|
||||
* Fails OPEN: if the moderation call errors (outage, quota), we log and return
|
||||
* `flagged: false` so a moderation hiccup never blocks the product. The result
|
||||
* is advisory — callers decide whether to reject input or flag generated output.
|
||||
*/
|
||||
export async function moderateText(input: string): Promise<ModerationResult> {
|
||||
const text = input.trim();
|
||||
if (!text) return { flagged: false, categories: [] };
|
||||
|
||||
try {
|
||||
const res = await openai().moderations.create({ model: MODERATION_MODEL, input: text });
|
||||
const result = res.results[0];
|
||||
if (!result) return { flagged: false, categories: [] };
|
||||
const categories = Object.entries(result.categories)
|
||||
.filter(([, tripped]) => tripped)
|
||||
.map(([key]) => key);
|
||||
return { flagged: result.flagged, categories };
|
||||
} catch (err) {
|
||||
console.error("[moderation] check failed — failing open", err);
|
||||
return { flagged: false, categories: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Short human-readable reason for a content flag from a moderation result. */
|
||||
export function moderationReason(result: ModerationResult): string {
|
||||
const cats = result.categories.length ? result.categories.join(", ") : "policy violation";
|
||||
return `Automated moderation flagged: ${cats}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user