Initial commit: PodcastYes — AI podcast platform

This commit is contained in:
Leon Serfaty
2026-06-07 03:58:32 -04:00
commit 155507f21a
151 changed files with 19826 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import type { Prisma } from "@prisma/client";
import { getServerSession } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
async function adminSession() {
const s = await getServerSession();
if (!s || s.user.role !== "admin") return null;
return s;
}
async function audit(
actorId: string,
action: string,
target?: string,
metadata?: Prisma.InputJsonValue
) {
await prisma.auditLog.create({
data: { actorId, actorType: "admin", action, target, metadata },
});
}
export async function banUserAction(userId: string, ban: boolean): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
if (userId === s.user.id) return { ok: false, error: "You can't ban yourself." };
await prisma.user.update({ where: { id: userId }, data: { banned: ban } });
// Revoke sessions on ban so access stops immediately.
if (ban) await prisma.session.deleteMany({ where: { userId } });
await audit(s.user.id, ban ? "user.ban" : "user.unban", userId);
revalidatePath("/admin/users");
return { ok: true };
}
export async function setRoleAction(userId: string, role: "admin" | "user"): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
await prisma.user.update({ where: { id: userId }, data: { role } });
await audit(s.user.id, "user.role", userId, { role });
revalidatePath("/admin/users");
return { ok: true };
}
export async function toggleFeatureFlagAction(
key: string,
enabled: boolean
): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
await prisma.featureFlag.upsert({ where: { key }, create: { key, enabled }, update: { enabled } });
await audit(s.user.id, "flag.toggle", key, { enabled });
revalidatePath("/admin/flags");
return { ok: true };
}
export async function reviewContentFlagAction(
flagId: string,
status: "reviewed" | "removed"
): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
await prisma.contentFlag.update({ where: { id: flagId }, data: { status, reviewedBy: s.user.id } });
await audit(s.user.id, "content.review", flagId, { status });
revalidatePath("/admin/moderation");
return { ok: true };
}
+94
View File
@@ -0,0 +1,94 @@
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 { CostChart, type CostPoint } from "@/components/admin/cost-chart";
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 },
});
// 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,
}));
return (
<>
<PageHeader title="AI usage & cost" description="Spend across providers over the last 14 days." />
<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>
<Card>
<CardHeader>
<CardTitle>Daily AI spend</CardTitle>
</CardHeader>
<CardContent>
<CostChart data={data} />
</CardContent>
</Card>
<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">
<p className="text-xs capitalize text-muted-foreground">{op}</p>
<p className="mt-1 text-lg font-semibold">${(byOperation[op] ?? 0).toFixed(2)}</p>
</div>
))}
</div>
</CardContent>
</Card>
</>
);
}
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>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
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 } } },
});
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>
</div>
)}
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { FlagsClient } from "@/components/admin/flags-client";
export const metadata: Metadata = { title: "Admin · Feature flags" };
export default async function AdminFlagsPage() {
const flags = await prisma.featureFlag.findMany({ orderBy: { key: "asc" } });
return (
<>
<PageHeader title="Feature flags" description="Toggle features without a deploy." />
<FlagsClient flags={flags.map((f) => ({ key: f.key, enabled: f.enabled }))} />
</>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import { Activity, CheckCircle2, 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";
export const metadata: Metadata = { title: "Admin · 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 jobCounts = Object.fromEntries(jobGroups.map((g) => [g.status, g._count]));
const queueHealthy = recentFailures < 5;
return (
<>
<PageHeader title="System health" description="Generation pipeline and queue 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>
<div className="grid gap-6 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Generation jobs</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>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Episodes by status</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{episodeGroups.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>
</div>
))}
</CardContent>
</Card>
</div>
</>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { ShieldCheck } from "lucide-react";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ModerationActions } from "@/components/admin/moderation-actions";
export const metadata: Metadata = { title: "Admin · Moderation" };
export default async function AdminModerationPage() {
const flags = await prisma.contentFlag.findMany({
where: { status: "open" },
orderBy: { createdAt: "desc" },
include: { episode: { select: { id: true, title: true } } },
});
return (
<>
<PageHeader title="Content moderation" description="Review flagged episodes." />
{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>
<p className="text-sm text-muted-foreground">There are no open content flags.</p>
</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>
<p className="text-sm text-muted-foreground">{f.reason}</p>
</div>
<ModerationActions flagId={f.id} />
</CardContent>
</Card>
))}
</div>
)}
</>
);
}
+96
View File
@@ -0,0 +1,96 @@
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 { formatPrice } from "@/lib/utils";
export const metadata: Metadata = { title: "Admin" };
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 } } }),
]);
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;
return (
<>
<PageHeader title="Overview" description="Platform health at a glance." />
<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>
<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>
<p className="mt-1 text-xs text-muted-foreground">{formatPrice(PLANS[key].priceMonthly)}/mo</p>
</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>
);
}
+91
View File
@@ -0,0 +1,91 @@
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 { formatPrice } from "@/lib/utils";
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 } }),
]);
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;
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>
<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>
</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>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { UsersTable } from "@/components/admin/users-table";
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]));
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(),
}))}
/>
</>
);
}
+45
View File
@@ -0,0 +1,45 @@
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 { UserMenu } from "@/components/app/user-menu";
import { Button } from "@/components/ui/button";
// Authed, DB-backed admin surface — never statically prerender.
export const dynamic = "force-dynamic";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await requireAdmin();
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">
<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 className="flex items-center gap-3">
<Button asChild variant="ghost" size="sm">
<Link href="/dashboard">
<ArrowLeft className="h-4 w-4" /> Back to app
</Link>
</Button>
<UserMenu name={session.user.name} email={session.user.email} image={session.user.image} isAdmin />
</div>
</header>
<div className="flex flex-1">
<aside className="hidden w-64 shrink-0 border-r border-border bg-background md:block">
<div className="sticky top-16">
<AdminSidebar />
</div>
</aside>
<main className="flex-1 bg-secondary/50">
<div className="container max-w-6xl py-8 md:py-10">{children}</div>
</main>
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use server";
import { revalidatePath } from "next/cache";
import { getServerSession } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
import { subjectHasFeature } from "@/lib/billing/subscription";
import { generateRawKey, hashKey, keyPreview } from "@/lib/apikeys";
export async function createApiKeyAction(
name: string
): Promise<{ ok: boolean; error?: string; key?: string }> {
const session = await getServerSession();
if (!session) return { ok: false, error: "You must be signed in." };
const allowed = await subjectHasFeature(
session.user.id,
"api_access",
session.session.activeOrganizationId
);
if (!allowed) return { ok: false, error: "API access requires the Pro plan or higher." };
const trimmed = name.trim() || "Untitled key";
const raw = generateRawKey();
await prisma.apiKey.create({
data: {
userId: session.user.id,
name: trimmed,
hashedKey: hashKey(raw),
prefix: keyPreview(raw),
},
});
revalidatePath("/api-keys");
// Return the raw key once — it is never stored in plaintext.
return { ok: true, key: raw };
}
export async function revokeApiKeyAction(id: string): Promise<{ ok: boolean; error?: string }> {
const session = await getServerSession();
if (!session) return { ok: false, error: "You must be signed in." };
const key = await prisma.apiKey.findUnique({ where: { id }, select: { userId: true } });
if (!key || key.userId !== session.user.id) return { ok: false, error: "Not allowed." };
await prisma.apiKey.update({ where: { id }, data: { revokedAt: new Date() } });
revalidatePath("/api-keys");
return { ok: true };
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { requireAuth } from "@/lib/auth/guards";
import { subjectHasFeature } from "@/lib/billing/subscription";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { UpgradeGate } from "@/components/app/upgrade-gate";
import { ApiKeysClient } from "@/components/app/api-keys-client";
export const metadata: Metadata = { title: "API keys" };
export default async function ApiKeysPage() {
const session = await requireAuth();
const allowed = await subjectHasFeature(
session.user.id,
"api_access",
session.session.activeOrganizationId
);
return (
<>
<PageHeader title="API keys" description="Programmatic access to the PodcastYes API." />
{!allowed ? (
<UpgradeGate
title="API access is a Pro feature"
description="Upgrade to Pro to create API keys and generate episodes programmatically."
requiredPlan="Pro"
/>
) : (
<ApiKeysClient
keys={(
await prisma.apiKey.findMany({
where: { userId: session.user.id, revokedAt: null },
orderBy: { createdAt: "desc" },
})
).map((k) => ({
id: k.id,
name: k.name,
prefix: k.prefix,
lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
createdAt: k.createdAt.toISOString(),
}))}
/>
)}
</>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use server";
import { revalidatePath } from "next/cache";
import { getServerSession } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
import {
stripe,
createStripeCheckout,
createStripePortal,
isStripeConfigured,
} from "@/lib/billing/stripe";
import {
createPaypalSubscription,
cancelPaypalSubscription,
isPaypalConfigured,
} from "@/lib/billing/paypal";
import { paypalPlanId, type BillingInterval } from "@/lib/billing/catalog";
import { getActiveSubscription } from "@/lib/billing/subscription";
import type { PlanKey } from "@/lib/billing/plans";
type ActionResult = { ok: true; url?: string } | { ok: false; error: string };
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : "Something went wrong";
}
export async function startStripeCheckoutAction(
plan: PlanKey,
interval: BillingInterval
): Promise<ActionResult> {
const session = await getServerSession();
if (!session) return { ok: false, error: "Please sign in." };
if (!isStripeConfigured()) return { ok: false, error: "Card payments aren't configured yet." };
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true, email: true, name: true, stripeCustomerId: true },
});
if (!user) return { ok: false, error: "Account not found." };
try {
const url = await createStripeCheckout({
user,
plan,
interval,
subjectId: user.id,
subjectType: "user",
});
return { ok: true, url };
} catch (e) {
return { ok: false, error: errMsg(e) };
}
}
export async function startPaypalCheckoutAction(plan: PlanKey): Promise<ActionResult> {
const session = await getServerSession();
if (!session) return { ok: false, error: "Please sign in." };
if (!isPaypalConfigured()) return { ok: false, error: "PayPal isn't configured yet." };
const planId = paypalPlanId(plan);
if (!planId) return { ok: false, error: "PayPal plan isn't configured for this tier." };
try {
const { approveUrl } = await createPaypalSubscription({
planId,
custom: { subjectId: session.user.id, subjectType: "user", plan },
});
return { ok: true, url: approveUrl };
} catch (e) {
return { ok: false, error: errMsg(e) };
}
}
export async function openStripePortalAction(): Promise<ActionResult> {
const session = await getServerSession();
if (!session) return { ok: false, error: "Please sign in." };
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
});
if (!user?.stripeCustomerId) return { ok: false, error: "No billing account yet." };
try {
const url = await createStripePortal(user.stripeCustomerId);
return { ok: true, url };
} catch (e) {
return { ok: false, error: errMsg(e) };
}
}
export async function cancelSubscriptionAction(): Promise<ActionResult> {
const session = await getServerSession();
if (!session) return { ok: false, error: "Please sign in." };
const sub = await getActiveSubscription(session.user.id);
if (!sub) return { ok: false, error: "No active subscription." };
try {
if (sub.provider === "paypal" && sub.paypalSubscriptionId) {
await cancelPaypalSubscription(sub.paypalSubscriptionId);
await prisma.subscription.update({
where: { id: sub.id },
data: { status: "canceled", cancelAtPeriodEnd: true },
});
} else if (sub.provider === "stripe" && sub.stripeSubscriptionId) {
await stripe().subscriptions.update(sub.stripeSubscriptionId, { cancel_at_period_end: true });
await prisma.subscription.update({
where: { id: sub.id },
data: { cancelAtPeriodEnd: true },
});
}
revalidatePath("/billing");
return { ok: true };
} catch (e) {
return { ok: false, error: errMsg(e) };
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import { requireAuth } from "@/lib/auth/guards";
import { getEffectivePlan, getActiveSubscription } from "@/lib/billing/subscription";
import { isStripeConfigured } from "@/lib/billing/stripe";
import { isPaypalConfigured } from "@/lib/billing/paypal";
import { PageHeader } from "@/components/app/page-header";
import { BillingClient } from "@/components/app/billing-client";
export const metadata: Metadata = { title: "Billing" };
export default async function BillingPage({
searchParams,
}: {
searchParams: Promise<{ status?: string }>;
}) {
const session = await requireAuth();
const { status } = await searchParams;
const { key: currentPlan } = await getEffectivePlan(
session.user.id,
session.session.activeOrganizationId
);
const sub = await getActiveSubscription(session.user.id);
return (
<>
<PageHeader title="Billing" description="Manage your plan and payment method." />
{status === "success" && (
<div className="mb-6 rounded-2xl border border-success/30 bg-success/10 px-4 py-3 text-sm font-medium text-success">
Payment received your plan will update momentarily once the provider confirms.
</div>
)}
{status === "cancel" && (
<div className="mb-6 rounded-2xl border border-warning/30 bg-warning/10 px-4 py-3 text-sm font-medium text-warning">
Checkout canceled. No changes were made.
</div>
)}
<BillingClient
currentPlan={currentPlan}
subscription={
sub
? {
provider: sub.provider,
status: sub.status,
cancelAtPeriodEnd: sub.cancelAtPeriodEnd,
periodEnd: sub.periodEnd ? sub.periodEnd.toISOString() : null,
}
: null
}
stripeConfigured={isStripeConfigured()}
paypalConfigured={isPaypalConfigured()}
/>
</>
);
}
+131
View File
@@ -0,0 +1,131 @@
import Link from "next/link";
import { Mic2, Plus, Sparkles, ArrowRight } from "lucide-react";
import { requireAuth } from "@/lib/auth/guards";
import { getEffectivePlan } from "@/lib/billing/subscription";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
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";
export default async function DashboardPage() {
const session = await requireAuth();
const { plan, key } = await getEffectivePlan(
session.user.id,
session.session.activeOrganizationId
);
const [episodeCount, recent] = await Promise.all([
prisma.episode.count({ where: { userId: session.user.id } }),
prisma.episode.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
take: 5,
select: { id: true, title: true, status: true, format: true, createdAt: true },
}),
]);
const firstName = session.user.name.split(" ")[0];
return (
<>
<PageHeader
title={`Welcome back, ${firstName}`}
description="Spin up a fully produced episode in a couple of minutes."
action={
<Button asChild>
<Link href="/episodes/new">
<Plus className="h-4 w-4" /> New episode
</Link>
</Button>
}
/>
<div className="grid gap-4 sm:grid-cols-3">
<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>
</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">
<Link href="/usage">
View usage <ArrowRight className="h-4 w-4" />
</Link>
</Button>
</CardContent>
</Card>
</div>
<Card className="mt-6">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent episodes</CardTitle>
<Button asChild variant="ghost" size="sm">
<Link href="/episodes">View all</Link>
</Button>
</CardHeader>
<CardContent>
{recent.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand/10 text-brand">
<Mic2 className="h-6 w-6" />
</span>
<div>
<p className="font-medium">No episodes yet</p>
<p className="text-sm text-muted-foreground">
Create your first AI-produced episode to get started.
</p>
</div>
<Button asChild>
<Link href="/episodes/new">
<Sparkles className="h-4 w-4" /> Create your first episode
</Link>
</Button>
</div>
) : (
<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"
>
<div className="min-w-0">
<p className="truncate font-medium">{ep.title}</p>
<p className="text-xs text-muted-foreground">
{ep.format.replace("_", "-").toLowerCase()} ·{" "}
{ep.createdAt.toLocaleDateString()}
</p>
</div>
<EpisodeStatusBadge status={ep.status} />
</Link>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { Mic2, Repeat } from "lucide-react";
import { requireAuth } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { GenerationProgress } from "@/components/app/generation-progress";
import { ScriptEditor } from "@/components/app/script-editor";
import { AudioPlayer } from "@/components/app/audio-player";
import { EpisodeActions } from "@/components/app/episode-actions";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import type { StructuredScript } from "@/lib/ai/types";
export default async function EpisodePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await requireAuth();
const episode = await prisma.episode.findUnique({
where: { id },
include: { script: true, audioAsset: true, coverArt: true, speakers: true },
});
if (!episode) notFound();
if (episode.userId !== session.user.id && session.user.role !== "admin") notFound();
const inProgress = !["READY", "FAILED"].includes(episode.status);
const speakerNames: Record<string, string> = {};
for (const s of episode.speakers) speakerNames[s.speakerKey] = s.displayName;
return (
<>
<PageHeader
title={episode.title}
description={`${episode.format.replace("_", "-").toLowerCase()} · ${episode.language.toUpperCase()} · ${episode.targetLengthMin} min`}
action={!inProgress ? <EpisodeActions episodeId={episode.id} /> : undefined}
/>
{episode.status === "FAILED" || inProgress ? (
<GenerationProgress
episodeId={episode.id}
initialStatus={episode.status}
initialStage={episode.stage}
initialError={episode.errorMessage}
/>
) : (
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-1">
<Card className="overflow-hidden">
<div className="aspect-square bg-muted">
{episode.coverArt ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/assets/${episode.coverArt.storageKey}`}
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>
{episode.audioAsset && (
<Card>
<CardContent className="pt-6">
<AudioPlayer
storageKey={episode.audioAsset.storageKey}
durationSec={episode.audioAsset.durationSec}
/>
</CardContent>
</Card>
)}
<Button asChild variant="outline" className="w-full">
<Link href={`/episodes/${episode.id}/repurpose`}>
<Repeat className="h-4 w-4" /> Repurpose content
</Link>
</Button>
</div>
<div className="lg:col-span-2">
{episode.script ? (
<ScriptEditor
key={episode.script.version}
episodeId={episode.id}
script={episode.script.content as unknown as StructuredScript}
speakerNames={speakerNames}
/>
) : (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
No script available.
</CardContent>
</Card>
)}
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,50 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { requireAuth } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { RepurposeClient } from "@/components/app/repurpose-client";
import { Button } from "@/components/ui/button";
type Format = "blog" | "social_thread" | "newsletter";
type Content = { title: string; body: string } | null;
export default async function RepurposePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await requireAuth();
const episode = await prisma.episode.findUnique({
where: { id },
select: {
userId: true,
title: true,
script: { select: { id: true } },
repurposed: { orderBy: { createdAt: "desc" } },
},
});
if (!episode) notFound();
if (episode.userId !== session.user.id && session.user.role !== "admin") notFound();
// Latest content per format.
const initial: Record<Format, Content> = { blog: null, social_thread: null, newsletter: null };
for (const r of episode.repurposed) {
const key = r.type as Format;
if (key in initial && !initial[key]) initial[key] = r.content as unknown as Content;
}
return (
<>
<Button asChild variant="ghost" size="sm" className="mb-2">
<Link href={`/episodes/${id}`}>
<ArrowLeft className="h-4 w-4" /> Back to episode
</Link>
</Button>
<PageHeader
title="Repurpose content"
description={`Turn "${episode.title}" into a blog post, social thread, or newsletter.`}
/>
<RepurposeClient episodeId={id} initial={initial} />
</>
);
}
+329
View File
@@ -0,0 +1,329 @@
"use server";
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 { enqueueEpisodeGeneration } from "@/lib/queue/pgboss";
import { rateLimit, LIMITS } from "@/lib/ratelimit";
import type { GenerationType } from "@/lib/queue/jobs";
import type { Prisma } from "@prisma/client";
const speakerSchema = z.object({
speakerKey: z.string().min(1).max(40),
displayName: z.string().min(1).max(60),
elevenVoiceId: z.string().min(1).max(60),
});
const createSchema = z.object({
title: z.string().max(120).optional(),
topic: z.string().min(10, "Describe your topic in a bit more detail").max(2000),
tone: z.string().min(1),
format: z.enum(["SOLO", "INTERVIEW", "MULTI_HOST"]),
language: z.string().min(2).max(5),
targetLengthMin: z.number().int().min(1).max(180),
audience: z.string().max(200).optional(),
speakers: z.array(speakerSchema).min(1).max(6),
});
export type CreateEpisodeInput = z.infer<typeof createSchema>;
export type CreateEpisodeResult =
| { ok: true; episodeId: string }
| { ok: false; error: string; limited?: boolean };
export async function createEpisodeAction(input: CreateEpisodeInput): Promise<CreateEpisodeResult> {
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.` };
}
const parsed = createSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." };
}
const data = parsed.data;
const activeOrgId = session.session.activeOrganizationId;
const { plan } = await getEffectivePlan(session.user.id, activeOrgId);
if (data.targetLengthMin > plan.limits.maxEpisodeMinutes) {
return {
ok: false,
error: `The ${plan.name} plan supports episodes up to ${plan.limits.maxEpisodeMinutes} minutes.`,
limited: true,
};
}
try {
await enforceLimit(session.user.id, "script", activeOrgId);
await enforceLimit(session.user.id, "audio", activeOrgId);
} catch (err) {
if (err instanceof LimitExceededError) {
return {
ok: false,
error: `You've reached your monthly ${err.check.metric} limit on the ${err.check.plan} plan. Upgrade to keep creating.`,
limited: true,
};
}
throw err;
}
const episode = await prisma.episode.create({
data: {
userId: session.user.id,
organizationId: activeOrgId ?? undefined,
title: data.title?.trim() || deriveTitle(data.topic),
topic: data.topic,
tone: data.tone,
format: data.format,
language: data.language,
targetLengthMin: data.targetLengthMin,
audience: data.audience,
status: "QUEUED",
stage: "Queued for generation",
speakers: {
create: data.speakers.map((s) => ({
speakerKey: s.speakerKey,
displayName: s.displayName,
elevenVoiceId: s.elevenVoiceId,
})),
},
jobs: { create: { type: "full", status: "queued" } },
},
});
await enqueueEpisodeGeneration(
{ episodeId: episode.id, type: "full" },
{ priority: plan.features.includes("priority_generation") ? 10 : 0 }
);
revalidatePath("/episodes");
revalidatePath("/dashboard");
return { ok: true, episodeId: episode.id };
}
export async function regenerateAction(
episodeId: string,
type: GenerationType
): Promise<{ ok: boolean; error?: string }> {
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, organizationId: true },
});
if (!episode) return { ok: false, error: "Episode not found." };
if (episode.userId !== session.user.id && session.user.role !== "admin") {
return { ok: false, error: "Not allowed." };
}
// Gate the metrics this regeneration will consume.
const metrics: ("script" | "audio" | "art")[] =
type === "art" ? ["art"] : type === "audio" ? ["audio"] : ["script", "audio"];
try {
for (const m of metrics) await enforceLimit(session.user.id, m, session.session.activeOrganizationId);
} catch (err) {
if (err instanceof LimitExceededError) {
return { ok: false, error: `Monthly ${err.check.metric} limit reached on the ${err.check.plan} plan.` };
}
throw err;
}
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 });
revalidatePath(`/episodes/${episodeId}`);
return { ok: true };
}
const scriptContentSchema = z.object({
title: z.string().min(1),
sections: z
.array(
z.object({
id: z.string().min(1),
title: z.string().min(1),
turns: z.array(z.object({ speakerKey: z.string(), text: z.string() })).min(1),
})
)
.min(1),
});
export async function updateScriptAction(
episodeId: string,
content: unknown
): Promise<{ ok: boolean; error?: string }> {
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 },
});
if (!episode || episode.userId !== session.user.id) return { ok: false, error: "Not allowed." };
const parsed = scriptContentSchema.safeParse(content);
if (!parsed.success) return { ok: false, error: "Invalid script format." };
await prisma.script.update({
where: { episodeId },
data: { content: parsed.data as unknown as Prisma.InputJsonValue, version: { increment: 1 } },
});
revalidatePath(`/episodes/${episodeId}`);
return { ok: true };
}
export async function regenerateSectionAction(
episodeId: string,
sectionId: string
): Promise<{ ok: boolean; error?: string; section?: { id: string; title: string; turns: { speakerKey: string; text: string }[] } }> {
const session = await getServerSession();
if (!session) return { ok: false, error: "You must be signed in." };
const episode = await prisma.episode.findUnique({
where: { id: episodeId },
include: { speakers: true, script: true },
});
if (!episode || (episode.userId !== session.user.id && session.user.role !== "admin")) {
return { ok: false, error: "Not allowed." };
}
if (!episode.script) return { ok: false, error: "No script to edit yet." };
try {
await enforceLimit(session.user.id, "script", session.session.activeOrganizationId);
} catch (err) {
if (err instanceof LimitExceededError) {
return { ok: false, error: `Monthly script limit reached on the ${err.check.plan} plan.` };
}
throw err;
}
// 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,
topic: episode.topic,
tone: episode.tone,
format: episode.format,
language: episode.language,
targetLengthMin: episode.targetLengthMin,
audience: episode.audience ?? undefined,
speakers: episode.speakers.map((s) => ({ speakerKey: s.speakerKey, displayName: s.displayName })),
};
const current = episode.script.content as unknown as {
title: string;
sections: { id: string; title: string; turns: { speakerKey: string; text: string }[] }[];
};
const { section, usage } = await scriptProvider().regenerateSection(config, current, sectionId);
const updated = {
...current,
sections: current.sections.map((s) => (s.id === sectionId ? section : s)),
};
await prisma.script.update({
where: { episodeId },
data: { content: updated as unknown as Prisma.InputJsonValue, version: { increment: 1 } },
});
const ownerId = episode.organizationId ?? episode.userId;
const ownerType = episode.organizationId ? "organization" : "user";
await incrementUsage(ownerId, ownerType, "script");
await recordCost({
provider: "openai",
operation: "script",
units: usage.inputTokens + usage.outputTokens,
costUsd: scriptCostUsd(usage),
episodeId,
userId: episode.userId,
});
revalidatePath(`/episodes/${episodeId}`);
return { ok: true, section };
}
export async function repurposeAction(
episodeId: string,
format: "blog" | "social_thread" | "newsletter"
): Promise<{ ok: boolean; error?: string; content?: { title: string; body: string } }> {
const session = await getServerSession();
if (!session) return { ok: false, error: "You must be signed in." };
const episode = await prisma.episode.findUnique({
where: { id: episodeId },
include: { script: true },
});
if (!episode || (episode.userId !== session.user.id && session.user.role !== "admin")) {
return { ok: false, error: "Not allowed." };
}
if (!episode.script) return { ok: false, error: "Generate the episode first." };
try {
await enforceLimit(session.user.id, "repurpose", session.session.activeOrganizationId);
} catch (err) {
if (err instanceof LimitExceededError) {
return { ok: false, error: `Monthly repurpose limit reached on the ${err.check.plan} plan.` };
}
throw err;
}
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(
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 },
});
const ownerId = episode.organizationId ?? episode.userId;
const ownerType = episode.organizationId ? "organization" : "user";
await incrementUsage(ownerId, ownerType, "repurpose");
await recordCost({
provider: "openai",
operation: "repurpose",
units: usage.inputTokens + usage.outputTokens,
costUsd: scriptCostUsd(usage),
episodeId,
userId: episode.userId,
});
revalidatePath(`/episodes/${episodeId}/repurpose`);
return { ok: true, content };
}
export async function deleteEpisodeAction(episodeId: string): Promise<{ ok: boolean; error?: string }> {
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 },
});
if (!episode || (episode.userId !== session.user.id && session.user.role !== "admin")) {
return { ok: false, error: "Not allowed." };
}
await prisma.episode.delete({ where: { id: episodeId } });
revalidatePath("/episodes");
return { ok: true };
}
function deriveTitle(topic: string): string {
const trimmed = topic.trim().replace(/\s+/g, " ");
return trimmed.length <= 60 ? trimmed : trimmed.slice(0, 57) + "…";
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { requireAuth } from "@/lib/auth/guards";
import { getEffectivePlan } from "@/lib/billing/subscription";
import { PageHeader } from "@/components/app/page-header";
import { EpisodeWizard } from "@/components/app/episode-wizard";
export const metadata: Metadata = { title: "Create an episode" };
export default async function NewEpisodePage() {
const session = await requireAuth();
const { plan } = await getEffectivePlan(session.user.id, session.session.activeOrganizationId);
return (
<>
<PageHeader
title="Create an episode"
description="Configure your episode and let the AI write, record, and design it."
/>
<EpisodeWizard maxMinutes={plan.limits.maxEpisodeMinutes} />
</>
);
}
+69
View File
@@ -0,0 +1,69 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Mic2, Plus } from "lucide-react";
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 { Button } from "@/components/ui/button";
export const metadata: Metadata = { title: "Episodes" };
export default async function EpisodesPage() {
const session = await requireAuth();
const episodes = await prisma.episode.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
include: { coverArt: { select: { storageKey: true } } },
});
return (
<>
<PageHeader
title="Episodes"
description="Your AI-produced podcast library."
action={
<Button asChild>
<Link href="/episodes/new">
<Plus className="h-4 w-4" /> New episode
</Link>
</Button>
}
/>
{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>
<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) => (
<EpisodeCard
key={ep.id}
episode={{
id: ep.id,
title: ep.title,
status: ep.status,
format: ep.format,
language: ep.language,
createdAt: ep.createdAt,
coverArtKey: ep.coverArt?.storageKey,
}}
/>
))}
</div>
)}
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
import Link from "next/link";
import { Mic, Plus } from "lucide-react";
import { requireAuth } from "@/lib/auth/guards";
import { getEffectivePlan } from "@/lib/billing/subscription";
import { SidebarNav } from "@/components/app/sidebar-nav";
import { UserMenu } from "@/components/app/user-menu";
import { Button } from "@/components/ui/button";
// Authed, DB-backed dashboard — never statically prerender.
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 isAdmin = session.user.role === "admin";
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">
<Link href="/dashboard" 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-brand text-brand-foreground">
<Mic className="h-5 w-5" />
</span>
<span className="hidden sm:inline">PodcastYes</span>
</Link>
<div className="flex items-center gap-3">
<Button asChild size="sm">
<Link href="/episodes/new">
<Plus className="h-4 w-4" /> New episode
</Link>
</Button>
<UserMenu
name={session.user.name}
email={session.user.email}
image={session.user.image}
isAdmin={isAdmin}
/>
</div>
</header>
<div className="flex flex-1">
<aside className="hidden w-64 shrink-0 border-r border-border bg-background md:block">
<div className="sticky top-16">
<SidebarNav plan={plan} />
</div>
</aside>
<main className="flex-1 bg-secondary/50">
<div className="container max-w-6xl py-8 md:py-10">{children}</div>
</main>
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { requireAuth } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { SeriesDetailClient } from "@/components/app/series-detail-client";
import { EpisodeStatusBadge } from "@/components/app/episode-status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export default async function SeriesDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await requireAuth();
const series = await prisma.series.findUnique({
where: { id },
include: { episodes: { orderBy: { createdAt: "desc" } } },
});
if (!series) notFound();
if (series.userId !== session.user.id && session.user.role !== "admin") notFound();
const planned = (series.plan as unknown as { title: string; topic: string; summary: string }[]) ?? [];
return (
<>
<Button asChild variant="ghost" size="sm" className="mb-2">
<Link href="/series">
<ArrowLeft className="h-4 w-4" /> Back to series
</Link>
</Button>
<PageHeader title={series.title} description={series.description ?? undefined} />
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">Planned episodes</h2>
<SeriesDetailClient seriesId={series.id} episodes={planned} />
{series.episodes.length > 0 && (
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">Generated</h2>
<div className="space-y-2">
{series.episodes.map((ep) => (
<Link key={ep.id} href={`/episodes/${ep.id}`}>
<Card className="transition-shadow hover:shadow-md">
<CardContent className="flex items-center justify-between py-3">
<span className="truncate font-medium">{ep.title}</span>
<EpisodeStatusBadge status={ep.status} />
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)}
</>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import type { Prisma } from "@prisma/client";
import { getServerSession } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
import { subjectHasFeature } from "@/lib/billing/subscription";
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";
const createSchema = z.object({
theme: z.string().min(5).max(500),
count: z.number().int().min(2).max(12),
tone: z.string().min(1),
audience: z.string().max(200).optional(),
language: z.string().min(2).max(5),
});
export async function createSeriesAction(
input: z.infer<typeof createSchema>
): Promise<{ ok: boolean; error?: string; seriesId?: string }> {
const session = await getServerSession();
if (!session) return { ok: false, error: "You must be signed in." };
if (!(await subjectHasFeature(session.user.id, "series_generator", session.session.activeOrganizationId))) {
return { ok: false, error: "The series generator requires the Pro plan." };
}
const parsed = createSchema.safeParse(input);
if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." };
const { planSeason } = await import("@/lib/ai/series");
const { plan } = await planSeason(parsed.data);
const series = await prisma.series.create({
data: {
userId: session.user.id,
organizationId: session.session.activeOrganizationId ?? undefined,
title: plan.title,
description: plan.description,
plannedCount: plan.episodes.length,
plan: plan.episodes as unknown as Prisma.InputJsonValue,
},
});
revalidatePath("/series");
return { ok: true, seriesId: series.id };
}
export async function generateFromSeriesAction(
seriesId: string,
index: number
): Promise<{ ok: boolean; error?: string; episodeId?: string }> {
const session = await getServerSession();
if (!session) return { ok: false, error: "You must be signed in." };
const series = await prisma.series.findUnique({ where: { id: seriesId } });
if (!series || series.userId !== session.user.id) return { ok: false, error: "Not allowed." };
const episodes = (series.plan as unknown as { title: string; topic: string; summary: string }[]) ?? [];
const item = episodes[index];
if (!item) return { ok: false, error: "Episode not found in plan." };
try {
await enforceLimit(session.user.id, "script", session.session.activeOrganizationId);
await enforceLimit(session.user.id, "audio", session.session.activeOrganizationId);
} catch (err) {
if (err instanceof LimitExceededError) {
return { ok: false, error: `Monthly ${err.check.metric} limit reached.` };
}
throw err;
}
const speakers = FORMAT_SPEAKERS.SOLO.map((s, i) => ({
speakerKey: s.speakerKey,
displayName: s.defaultName,
elevenVoiceId: DEFAULT_VOICE_IDS[s.speakerKey] ?? VOICE_CATALOG[i % VOICE_CATALOG.length].id,
}));
const episode = await prisma.episode.create({
data: {
userId: session.user.id,
organizationId: series.organizationId ?? undefined,
seriesId: series.id,
title: item.title,
topic: item.topic,
tone: "Conversational",
format: "SOLO",
language: "en",
targetLengthMin: 10,
status: "QUEUED",
stage: "Queued for generation",
speakers: { create: speakers },
jobs: { create: { type: "full", status: "queued" } },
},
});
await enqueueEpisodeGeneration({ episodeId: episode.id, type: "full" });
revalidatePath(`/series/${seriesId}`);
return { ok: true, episodeId: episode.id };
}
+76
View File
@@ -0,0 +1,76 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ListMusic } from "lucide-react";
import { requireAuth } from "@/lib/auth/guards";
import { subjectHasFeature } from "@/lib/billing/subscription";
import { prisma } from "@/lib/db";
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";
export const metadata: Metadata = { title: "Series" };
export default async function SeriesPage() {
const session = await requireAuth();
const allowed = await subjectHasFeature(
session.user.id,
"series_generator",
session.session.activeOrganizationId
);
if (!allowed) {
return (
<>
<PageHeader title="Series generator" description="Plan a whole season at once." />
<UpgradeGate
title="Series generator is a Pro feature"
description="Upgrade to Pro to plan entire seasons and batch-generate episodes."
requiredPlan="Pro"
/>
</>
);
}
const series = await prisma.series.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
include: { _count: { select: { episodes: true } } },
});
return (
<>
<PageHeader
title="Series generator"
description="Plan a cohesive season, then generate each episode."
/>
<SeriesCreateForm />
{series.length > 0 && (
<div className="mt-8 space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground">Your seasons</h2>
<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">
<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" />
</span>
<div className="min-w-0">
<p className="truncate font-medium">{s.title}</p>
<p className="text-xs text-muted-foreground">
{s.plannedCount} planned · {s._count.episodes} generated
</p>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)}
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { requireAuth } from "@/lib/auth/guards";
import { PageHeader } from "@/components/app/page-header";
import { SettingsClient } from "@/components/app/settings-client";
export const metadata: Metadata = { title: "Settings" };
export default async function SettingsPage() {
const session = await requireAuth();
return (
<>
<PageHeader title="Settings" description="Manage your account." />
<SettingsClient name={session.user.name} email={session.user.email} />
</>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getServerSession } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
const brandingSchema = z.object({
brandName: z.string().max(60).optional(),
primaryColor: z
.string()
.regex(/^#([0-9a-fA-F]{6})$/, "Use a hex colour like #7c3aed")
.optional()
.or(z.literal("")),
logoUrl: z.string().url().optional().or(z.literal("")),
removePoweredBy: z.boolean().optional(),
});
export async function saveBrandingAction(
organizationId: string,
data: z.infer<typeof brandingSchema>
): Promise<{ ok: boolean; error?: string }> {
const session = await getServerSession();
if (!session) return { ok: false, error: "You must be signed in." };
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 edit branding." };
}
const parsed = brandingSchema.safeParse(data);
if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." };
const payload = {
brandName: parsed.data.brandName || null,
primaryColor: parsed.data.primaryColor || null,
logoUrl: parsed.data.logoUrl || null,
removePoweredBy: parsed.data.removePoweredBy ?? false,
};
await prisma.orgBranding.upsert({
where: { organizationId },
create: { organizationId, ...payload },
update: payload,
});
revalidatePath("/team");
return { ok: true };
}
+68
View File
@@ -0,0 +1,68 @@
import type { Metadata } from "next";
import { requireAuth } from "@/lib/auth/guards";
import { getEffectivePlan, subjectHasFeature } from "@/lib/billing/subscription";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { UpgradeGate } from "@/components/app/upgrade-gate";
import { TeamClient } from "@/components/app/team-client";
export const metadata: Metadata = { title: "Team" };
export default async function TeamPage() {
const session = await requireAuth();
const allowed = await subjectHasFeature(
session.user.id,
"team_workspace",
session.session.activeOrganizationId
);
if (!allowed) {
return (
<>
<PageHeader title="Team workspace" description="Collaborate with your team." />
<UpgradeGate
title="Team workspaces are an Agency feature"
description="Upgrade to Agency for a 5-seat workspace, white-label mode, and custom branding."
requiredPlan="Agency"
/>
</>
);
}
const { plan } = await getEffectivePlan(session.user.id, session.session.activeOrganizationId);
const membership = await prisma.member.findFirst({
where: { userId: session.user.id },
include: {
organization: {
include: {
branding: true,
members: { include: { user: { select: { name: true, email: true } } } },
},
},
},
});
const org = membership?.organization ?? null;
const members =
org?.members.map((m) => ({ id: m.id, name: m.user.name, email: m.user.email, role: m.role })) ?? [];
return (
<>
<PageHeader title="Team workspace" description="Members, seats, and white-label branding." />
<TeamClient
org={org ? { id: org.id, name: org.name } : null}
members={members}
branding={
org?.branding
? {
brandName: org.branding.brandName,
primaryColor: org.branding.primaryColor,
logoUrl: org.branding.logoUrl,
removePoweredBy: org.branding.removePoweredBy,
}
: null
}
seats={plan.limits.seats}
/>
</>
);
}
+97
View File
@@ -0,0 +1,97 @@
import type { Metadata } from "next";
import Link from "next/link";
import { FileText, AudioLines, ImageIcon, Repeat, 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 { PageHeader } from "@/components/app/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { UNLIMITED, type UsageMetric } from "@/lib/billing/plans";
export const metadata: Metadata = { title: "Usage" };
const METRICS: { key: UsageMetric; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ key: "script", label: "Scripts", icon: FileText },
{ key: "audio", label: "Audio generations", icon: AudioLines },
{ key: "art", label: "Cover art", icon: ImageIcon },
{ key: "repurpose", label: "Repurposed content", icon: Repeat },
];
function nextResetLabel(): string {
const now = new Date();
const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
return next.toLocaleDateString(undefined, { month: "long", day: "numeric" });
}
export default async function UsagePage() {
const session = await requireAuth();
const { plan, key, subjectId } = await getEffectivePlan(
session.user.id,
session.session.activeOrganizationId
);
const usage = await getUsageSummary(subjectId);
return (
<>
<PageHeader
title="Usage & limits"
description={`Resets on ${nextResetLabel()}.`}
action={
key !== "agency" ? (
<Button asChild>
<Link href="/billing">Upgrade plan</Link>
</Button>
) : undefined
}
/>
<div className="mb-6 flex items-center gap-2">
<span className="text-sm text-muted-foreground">Current plan</span>
<Badge className="capitalize">{plan.name}</Badge>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{METRICS.map((m) => {
const used = usage[m.key];
const limit = plan.limits[m.key];
const unlimited = limit === UNLIMITED;
const pct = unlimited ? 0 : Math.min(100, Math.round((used / Math.max(1, limit)) * 100));
const atLimit = !unlimited && used >= limit;
return (
<Card key={m.key}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<m.icon className="h-4 w-4" /> {m.label}
</CardTitle>
{atLimit && <Badge variant="warning">Limit reached</Badge>}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-baseline justify-between">
<span className="font-display text-3xl font-extrabold tracking-tight">{used}</span>
<span className="flex items-center gap-1 text-sm text-muted-foreground">
{unlimited ? (
<>
<InfinityIcon className="h-4 w-4" /> Unlimited
</>
) : (
<>of {limit}</>
)}
</span>
</div>
{!unlimited && (
<Progress
value={pct}
indicatorClassName={atLimit ? "bg-warning" : undefined}
/>
)}
</CardContent>
</Card>
);
})}
</div>
</>
);
}
+8
View File
@@ -0,0 +1,8 @@
import type { Metadata } from "next";
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form";
export const metadata: Metadata = { title: "Forgot password" };
export default function ForgotPasswordPage() {
return <ForgotPasswordForm />;
}
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { Mic } from "lucide-react";
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="relative flex min-h-screen flex-col items-center justify-center bg-secondary px-4 py-12">
<div className="pointer-events-none absolute inset-0 bg-hero-wash" />
<Link
href="/"
className="relative mb-8 flex items-center gap-2.5 font-display text-xl font-bold tracking-tight"
>
<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>
PodcastYes
</Link>
<div className="relative w-full max-w-md">{children}</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { ResetPasswordForm } from "@/components/auth/reset-password-form";
export const metadata: Metadata = { title: "Reset password" };
export default function ResetPasswordPage() {
return (
<Suspense>
<ResetPasswordForm />
</Suspense>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "@/lib/auth/guards";
import { SignInForm } from "@/components/auth/sign-in-form";
export const metadata: Metadata = { title: "Sign in" };
export default async function SignInPage() {
const session = await getServerSession();
if (session) redirect("/dashboard");
const googleEnabled = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
return (
<Suspense>
<SignInForm googleEnabled={googleEnabled} />
</Suspense>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "@/lib/auth/guards";
import { SignUpForm } from "@/components/auth/sign-up-form";
export const metadata: Metadata = { title: "Create account" };
export default async function SignUpPage() {
const session = await getServerSession();
if (session) redirect("/dashboard");
const googleEnabled = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
return <SignUpForm googleEnabled={googleEnabled} />;
}
+12
View File
@@ -0,0 +1,12 @@
import { SiteHeader } from "@/components/marketing/site-header";
import { SiteFooter } from "@/components/marketing/site-footer";
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="flex-1">{children}</main>
<SiteFooter />
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
import Link from "next/link";
import {
ArrowRight,
FileText,
AudioLines,
ImageIcon,
Sparkles,
Languages,
Users,
Repeat,
ListChecks,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { PLAN_ORDER, PLANS } from "@/lib/billing/plans";
import { formatPrice } from "@/lib/utils";
export default function LandingPage() {
return (
<>
{/* Hero */}
<section className="relative overflow-hidden bg-hero-wash">
<div className="container flex flex-col items-center gap-7 py-24 text-center md:py-36">
<Badge variant="secondary" className="py-1.5">
<Sparkles className="h-3.5 w-3.5 text-brand" />
GPT-4 · ElevenLabs · DALL·E in one workflow
</Badge>
<h1 className="max-w-4xl text-balance font-display text-5xl font-extrabold leading-[1.05] tracking-tight sm:text-6xl md:text-7xl">
From a topic idea to a{" "}
<span className="text-brand">finished podcast</span> in minutes
</h1>
<p className="max-w-2xl text-lg text-muted-foreground sm:text-xl">
PodcastYes writes the script, records realistic multi-voice audio, and designs the cover
art automatically. No microphone, no editing, no design skills required.
</p>
<div className="mt-2 flex flex-col gap-3 sm:flex-row">
<Button asChild size="lg">
<Link href="/sign-up">
Start free <ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link href="/#how-it-works">See how it works</Link>
</Button>
</div>
<p className="text-sm text-muted-foreground">
Free plan includes 3 scripts &amp; 1 audio generation / month. No card required.
</p>
</div>
</section>
{/* How it works */}
<section id="how-it-works" className="border-t border-border bg-secondary py-24 md:py-28">
<div className="container">
<SectionHeading
eyebrow="How it works"
title="Three steps to a published episode"
subtitle="Configure once. The AI generates everything. You fine-tune and publish."
/>
<div className="mt-16 grid gap-6 md:grid-cols-3">
{STEPS.map((step, i) => (
<Card key={step.title} className="relative transition-shadow hover:shadow-md">
<CardContent className="space-y-4 p-8">
<div className="flex items-center justify-between">
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand/10 text-brand">
<step.icon className="h-6 w-6" />
</span>
<span className="font-display text-5xl font-extrabold text-foreground/[0.08]">
{String(i + 1).padStart(2, "0")}
</span>
</div>
<h3 className="font-display text-xl font-bold tracking-tight">{step.title}</h3>
<p className="text-muted-foreground">{step.body}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
{/* Features */}
<section id="features" className="py-24 md:py-28">
<div className="container">
<SectionHeading
eyebrow="Everything in one place"
title="The whole podcast toolkit"
subtitle="Replace a writer, a voice actor, an editor, and a designer with one workflow."
/>
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{FEATURES.map((f) => (
<div
key={f.title}
className="flex gap-4 rounded-2xl border border-border bg-card p-6 transition-all hover:-translate-y-0.5 hover:shadow-md"
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-brand/10 text-brand">
<f.icon className="h-5 w-5" />
</span>
<div className="space-y-1">
<h3 className="font-display font-bold tracking-tight">{f.title}</h3>
<p className="text-sm text-muted-foreground">{f.body}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* Pricing preview */}
<section id="pricing" className="border-t border-border bg-secondary py-24 md:py-28">
<div className="container">
<SectionHeading
eyebrow="Pricing"
title="Start free, upgrade as you grow"
subtitle="Simple monthly plans. Cancel anytime."
/>
<div className="mt-16 grid gap-6 lg:grid-cols-4">
{PLAN_ORDER.map((key) => {
const plan = PLANS[key];
return (
<Card
key={key}
className={
plan.highlight
? "relative ring-2 ring-brand shadow-lg"
: "relative"
}
>
{plan.highlight && (
<Badge variant="brand" className="absolute -top-3 left-1/2 -translate-x-1/2 bg-brand text-brand-foreground shadow-sm">
Most popular
</Badge>
)}
<CardContent className="space-y-6 p-7">
<div>
<h3 className="font-display text-lg font-bold tracking-tight">{plan.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">{plan.tagline}</p>
</div>
<div className="flex items-baseline gap-1">
<span className="font-display text-4xl font-extrabold tracking-tight">{formatPrice(plan.priceMonthly)}</span>
<span className="text-sm text-muted-foreground">/mo</span>
</div>
<Button
asChild
className="w-full"
variant={plan.highlight ? "default" : "outline"}
>
<Link href="/sign-up">{key === "free" ? "Start free" : `Choose ${plan.name}`}</Link>
</Button>
<ul className="space-y-2.5 text-sm">
{plan.bullets.slice(0, 5).map((b) => (
<li key={b} className="flex gap-2.5">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-brand" />
<span className="text-muted-foreground">{b}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
})}
</div>
<p className="mt-10 text-center text-sm text-muted-foreground">
Full comparison on the{" "}
<Link href="/pricing" className="font-semibold text-brand hover:underline">
pricing page
</Link>
.
</p>
</div>
</section>
{/* CTA */}
<section className="py-24 md:py-28">
<div className="container">
<div className="relative overflow-hidden rounded-3xl bg-primary px-8 py-20 text-center text-primary-foreground">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(60%_80%_at_50%_0%,hsl(var(--brand)/0.35),transparent_70%)]" />
<div className="relative">
<h2 className="font-display text-4xl font-extrabold tracking-tight sm:text-5xl">
Make your first episode today
</h2>
<p className="mx-auto mt-4 max-w-xl text-lg text-primary-foreground/75">
Spin up a fully produced episode on the free plan in a couple of minutes then decide.
</p>
<Button asChild size="lg" variant="brand" className="mt-9">
<Link href="/sign-up">
Get started free <ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
</section>
</>
);
}
function SectionHeading({
eyebrow,
title,
subtitle,
}: {
eyebrow: string;
title: string;
subtitle: string;
}) {
return (
<div className="mx-auto max-w-2xl text-center">
<p className="text-[13px] font-semibold uppercase tracking-[0.04em] text-brand">{eyebrow}</p>
<h2 className="mt-3 font-display text-3xl font-extrabold tracking-tight sm:text-4xl md:text-5xl">{title}</h2>
<p className="mt-4 text-lg text-muted-foreground">{subtitle}</p>
</div>
);
}
const STEPS = [
{
icon: FileText,
title: "Configure your episode",
body: "Set the topic, tone, length, audience, and language. Pick a format: solo, interview, or multi-host.",
},
{
icon: AudioLines,
title: "AI generates everything",
body: "GPT-4 writes the script, ElevenLabs records it with realistic voices, and DALL·E designs the cover art.",
},
{
icon: ImageIcon,
title: "Fine-tune & publish",
body: "Edit the script, regenerate sections, download the MP3 and assets, and repurpose into blogs and social posts.",
},
];
const FEATURES = [
{ icon: FileText, title: "AI script generation", body: "Structured, on-brand scripts tailored to your tone, format, and audience." },
{ icon: AudioLines, title: "Multi-voice audio", body: "14+ realistic voices and multi-speaker dialogue for interviews and panels." },
{ icon: ImageIcon, title: "AI cover art", body: "Eye-catching episode artwork generated to match your topic in one click." },
{ icon: Repeat, title: "Content repurposing", body: "Turn any episode into blog posts, social threads, and newsletters instantly." },
{ icon: ListChecks, title: "Series generator", body: "Plan an entire season — titles, topics, and episodes — from a single prompt." },
{ icon: Languages, title: "13+ languages", body: "Produce episodes for a global audience without re-recording anything." },
];
+76
View File
@@ -0,0 +1,76 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { PLAN_ORDER, PLANS } from "@/lib/billing/plans";
import { formatPrice } from "@/lib/utils";
export const metadata: Metadata = {
title: "Pricing",
description: "Simple plans for every podcaster — start free and upgrade as you grow.",
};
export default function PricingPage() {
return (
<div className="bg-hero-wash">
<div className="container py-24 md:py-28">
<div className="mx-auto max-w-2xl text-center">
<p className="text-[13px] font-semibold uppercase tracking-[0.04em] text-brand">Pricing</p>
<h1 className="mt-3 font-display text-5xl font-extrabold tracking-tight md:text-6xl">
Start free, scale anytime
</h1>
<p className="mt-4 text-lg text-muted-foreground">
Upgrade for higher limits and more features. Cancel anytime. Pay with Stripe
or PayPal.
</p>
</div>
<div className="mt-16 grid gap-6 lg:grid-cols-4">
{PLAN_ORDER.map((key) => {
const plan = PLANS[key];
return (
<Card
key={key}
className={plan.highlight ? "relative ring-2 ring-brand shadow-lg" : "relative"}
>
{plan.highlight && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-brand text-brand-foreground shadow-sm">
Most popular
</Badge>
)}
<CardContent className="flex h-full flex-col gap-6 p-7">
<div>
<h3 className="font-display text-lg font-bold tracking-tight">{plan.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">{plan.tagline}</p>
</div>
<div className="flex items-baseline gap-1">
<span className="font-display text-5xl font-extrabold tracking-tight">{formatPrice(plan.priceMonthly)}</span>
<span className="text-sm text-muted-foreground">/mo</span>
</div>
<Button asChild className="w-full" variant={plan.highlight ? "default" : "outline"}>
<Link href="/sign-up">{key === "free" ? "Start free" : `Choose ${plan.name}`}</Link>
</Button>
<ul className="space-y-2.5 text-sm">
{plan.bullets.map((b) => (
<li key={b} className="flex gap-2.5">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-brand" />
<span className="text-muted-foreground">{b}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
})}
</div>
<p className="mx-auto mt-12 max-w-2xl text-center text-xs text-muted-foreground">
Prices in USD. Annual billing saves roughly two months. Usage limits reset on the first of
each month.
</p>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Privacy Policy" };
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>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Terms of Service" };
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>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest } from "next/server";
import { getServerSession } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
import { storage } from "@/lib/storage";
export const dynamic = "force-dynamic";
const CONTENT_TYPES: Record<string, string> = {
mp3: "audio/mpeg",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
zip: "application/zip",
txt: "text/plain; charset=utf-8",
};
/**
* Serve a stored asset by key after verifying the requester owns the episode
* (or is an admin). Private MP3 downloads flow through here; public cover art is
* served directly by nginx from /media.
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ key: string[] }> }
) {
const { key: segments } = await params;
const key = segments.join("/");
const session = await getServerSession();
if (!session) return new Response("Unauthorized", { status: 401 });
// Resolve the owning episode from the asset record so we can authorize.
const [audio, art] = await Promise.all([
prisma.audioAsset.findFirst({ where: { storageKey: key }, select: { episode: { select: { userId: true } } } }),
prisma.coverArt.findFirst({ where: { storageKey: key }, select: { episode: { select: { userId: true } } } }),
]);
const ownerId = audio?.episode.userId ?? art?.episode.userId;
if (!ownerId) return new Response("Not found", { status: 404 });
const isOwner = ownerId === session.user.id;
const isAdmin = session.user.role === "admin";
if (!isOwner && !isAdmin) return new Response("Forbidden", { status: 403 });
const exists = await storage().exists(key);
if (!exists) return new Response("Not found", { status: 404 });
const data = await storage().get(key);
const ext = key.split(".").pop()?.toLowerCase() ?? "";
const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
const download = req.nextUrl.searchParams.get("download");
const filename = key.split("/").pop() ?? "asset";
return new Response(data as BodyInit, {
headers: {
"Content-Type": contentType,
"Content-Length": String(data.byteLength),
"Cache-Control": "private, max-age=3600",
...(download
? { "Content-Disposition": `attachment; filename="${filename}"` }
: {}),
},
});
}
+4
View File
@@ -0,0 +1,4 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth/auth";
export const { GET, POST } = toNextJsHandler(auth.handler);
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest } from "next/server";
import { getServerSession } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
import { isTerminal } from "@/lib/episodes/status";
export const dynamic = "force-dynamic";
/**
* 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.
* (LISTEN/NOTIFY is a future optimization; polling is simpler and robust.)
*/
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 ep = await prisma.episode.findUnique({ where: { id }, select: { userId: true } });
if (!ep) return new Response("Not found", { status: 404 });
if (ep.userId !== session.user.id && session.user.role !== "admin") {
return new Response("Forbidden", { status: 403 });
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
let lastSig = "";
let stopped = false;
let pollTimer: ReturnType<typeof setInterval>;
let pingTimer: ReturnType<typeof setInterval>;
const send = (data: unknown) => {
if (!stopped) controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
const stop = () => {
if (stopped) return;
stopped = true;
clearInterval(pollTimer);
clearInterval(pingTimer);
try {
controller.close();
} catch {
/* already closed */
}
};
const poll = async () => {
if (stopped) return;
const e = await prisma.episode.findUnique({
where: { id },
select: { status: true, stage: true, errorMessage: true },
});
if (!e) {
send({ status: "FAILED", error: "Episode not found" });
stop();
return;
}
const sig = `${e.status}:${e.stage ?? ""}`;
if (sig !== lastSig) {
lastSig = sig;
send({ status: e.status, stage: e.stage, error: e.errorMessage });
}
if (isTerminal(e.status)) stop();
};
send({ type: "open" });
void poll();
pollTimer = setInterval(poll, 1500);
pingTimer = setInterval(() => {
if (!stopped) controller.enqueue(encoder.encode(": ping\n\n"));
}, 15000);
req.signal.addEventListener("abort", stop);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
+103
View File
@@ -0,0 +1,103 @@
import { NextRequest } from "next/server";
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 { 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";
export const dynamic = "force-dynamic";
async function authorize(req: NextRequest) {
return verifyApiKey(bearerKey(req.headers.get("authorization")));
}
/** GET /api/v1/episodes — list the caller's episodes. */
export async function GET(req: NextRequest) {
const auth = await authorize(req);
if (!auth) return Response.json({ error: "Invalid API key" }, { status: 401 });
const episodes = await prisma.episode.findMany({
where: { userId: auth.userId },
orderBy: { createdAt: "desc" },
take: 50,
select: { id: true, title: true, status: true, format: true, language: true, createdAt: true },
});
return Response.json({ episodes });
}
const createSchema = z.object({
topic: z.string().min(10).max(2000),
title: z.string().max(120).optional(),
tone: z.string().default("Conversational"),
format: z.enum(["SOLO", "INTERVIEW", "MULTI_HOST"]).default("SOLO"),
language: z.string().min(2).max(5).default("en"),
targetLengthMin: z.number().int().min(1).max(180).default(5),
audience: z.string().max(200).optional(),
});
/** POST /api/v1/episodes — create + generate an episode. */
export async function POST(req: NextRequest) {
const auth = await authorize(req);
if (!auth) return Response.json({ error: "Invalid API key" }, { status: 401 });
const rl = await rateLimit("api", auth.userId, LIMITS.api);
if (!rl.ok) {
return Response.json(
{ error: "Rate limit exceeded" },
{ status: 429, headers: { "Retry-After": String(rl.retryAfterSec ?? 60) } }
);
}
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);
if (data.targetLengthMin > plan.limits.maxEpisodeMinutes) {
return Response.json(
{ error: `Plan supports episodes up to ${plan.limits.maxEpisodeMinutes} minutes` },
{ status: 402 }
);
}
try {
await enforceLimit(auth.userId, "script");
await enforceLimit(auth.userId, "audio");
} catch (err) {
if (err instanceof LimitExceededError) {
return Response.json({ error: `Monthly ${err.check.metric} limit reached` }, { status: 402 });
}
throw err;
}
const speakers = FORMAT_SPEAKERS[data.format].map((s, i) => ({
speakerKey: s.speakerKey,
displayName: s.defaultName,
elevenVoiceId: DEFAULT_VOICE_IDS[s.speakerKey] ?? VOICE_CATALOG[i % VOICE_CATALOG.length].id,
}));
const episode = await prisma.episode.create({
data: {
userId: auth.userId,
title: data.title?.trim() || data.topic.slice(0, 60),
topic: data.topic,
tone: data.tone,
format: data.format,
language: data.language,
targetLengthMin: data.targetLengthMin,
audience: data.audience,
status: "QUEUED",
stage: "Queued for generation",
speakers: { create: speakers },
jobs: { create: { type: "full", status: "queued" } },
},
});
await enqueueEpisodeGeneration({ episodeId: episode.id, type: "full" });
return Response.json({ id: episode.id, status: episode.status }, { status: 201 });
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest } from "next/server";
import { verifyPaypalWebhook } from "@/lib/billing/paypal";
import { handlePaypalEvent } from "@/lib/billing/webhooks/paypal";
export const dynamic = "force-dynamic";
const SIG_HEADERS = [
"paypal-auth-algo",
"paypal-cert-url",
"paypal-transmission-id",
"paypal-transmission-sig",
"paypal-transmission-time",
];
export async function POST(req: NextRequest) {
const body = await req.text();
const headers: Record<string, string | undefined> = {};
for (const h of SIG_HEADERS) headers[h] = req.headers.get(h) ?? undefined;
const verified = await verifyPaypalWebhook(headers, body).catch(() => false);
if (!verified) {
console.error("[paypal webhook] verification failed");
return new Response("Invalid signature", { status: 400 });
}
try {
await handlePaypalEvent(JSON.parse(body));
} catch (err) {
console.error("[paypal webhook] handler error", err);
return new Response("Handler error", { status: 500 });
}
return new Response("ok");
}
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest } from "next/server";
import type Stripe from "stripe";
import { stripe } from "@/lib/billing/stripe";
import { handleStripeEvent } from "@/lib/billing/webhooks/stripe";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
const signature = req.headers.get("stripe-signature");
if (!secret || !signature) return new Response("Webhook not configured", { status: 400 });
const body = await req.text();
let event: Stripe.Event;
try {
event = stripe().webhooks.constructEvent(body, signature, secret);
} catch (err) {
console.error("[stripe webhook] signature verification failed", err);
return new Response("Invalid signature", { status: 400 });
}
try {
await handleStripeEvent(event);
} catch (err) {
console.error("[stripe webhook] handler error", err);
return new Response("Handler error", { status: 500 });
}
return new Response("ok");
}
+104
View File
@@ -0,0 +1,104 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Wix-inspired: white base, near-black ink, single Wix-Blue accent */
--background: 0 0% 100%;
--foreground: 0 0% 7%;
--card: 0 0% 100%;
--card-foreground: 0 0% 7%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 7%;
/* INK — primary action is black */
--primary: 0 0% 5%;
--primary-foreground: 0 0% 100%;
/* WIX BLUE — links, focus, active states, accents */
--brand: 217 100% 53%;
--brand-foreground: 0 0% 100%;
--brand-hover: 219 90% 46%;
--secondary: 0 0% 96%;
--secondary-foreground: 0 0% 7%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 42%;
--accent: 0 0% 96%;
--accent-foreground: 0 0% 7%;
--destructive: 4 86% 58%;
--destructive-foreground: 0 0% 100%;
--success: 157 100% 30%;
--warning: 24 100% 50%;
--border: 0 0% 89%;
--input: 0 0% 89%;
--ring: 217 100% 53%;
--radius: 0.875rem;
}
.dark {
--background: 0 0% 5%;
--foreground: 0 0% 98%;
--card: 0 0% 8%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 8%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 100%;
--primary-foreground: 0 0% 5%;
--brand: 217 100% 60%;
--brand-foreground: 0 0% 100%;
--brand-hover: 217 100% 66%;
--secondary: 0 0% 13%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 13%;
--muted-foreground: 0 0% 64%;
--accent: 0 0% 13%;
--accent-foreground: 0 0% 98%;
--destructive: 4 80% 58%;
--destructive-foreground: 0 0% 100%;
--success: 157 70% 45%;
--warning: 24 100% 55%;
--border: 0 0% 16%;
--input: 0 0% 16%;
--ring: 217 100% 60%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1, "ss01" 1;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Headings use the Wix Madefor Display face by default */
h1, h2, h3, .font-display {
font-family: var(--font-display), var(--font-sans), ui-sans-serif, system-ui, sans-serif;
letter-spacing: -0.02em;
}
}
@layer utilities {
/* Subtle Wix-style radial wash for hero sections */
.bg-hero-wash {
background-image: radial-gradient(
60% 60% at 50% 0%,
hsl(var(--brand) / 0.1),
transparent 70%
);
}
.text-balance {
text-wrap: balance;
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { Wix_Madefor_Text, Wix_Madefor_Display } from "next/font/google";
import { Toaster } from "sonner";
import "./globals.css";
// Wix Madefor — the platform typeface (body + UI)
const madeforText = Wix_Madefor_Text({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
// Wix Madefor Display — headlines (variable font, full weight range)
const madeforDisplay = Wix_Madefor_Display({
subsets: ["latin"],
variable: "--font-display",
display: "swap",
});
export const metadata: Metadata = {
title: {
default: "PodcastYes — From topic idea to published podcast in minutes",
template: "%s · PodcastYes",
},
description:
"PodcastYes is an all-in-one AI platform that writes your script, records realistic multi-voice audio, and designs cover art — turning a topic into a finished episode in minutes.",
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"),
openGraph: {
title: "PodcastYes",
description: "Create scripted, narrated, illustrated podcasts with AI — no recording gear required.",
type: "website",
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${madeforText.variable} ${madeforDisplay.variable} font-sans antialiased`}
>
{children}
<Toaster richColors position="top-center" />
</body>
</html>
);
}