Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions app/compare/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Check, X, Minus, ArrowRight } from "lucide-react";
import { compareCopy, type SupportLevel } from "@/lib/copy/compare";
import { buildPageMetadata } from "@/lib/seo";
import { siteConfig } from "@/lib/config";

export const metadata: Metadata = buildPageMetadata({
title: `Why Recoup? — Compare AI Music Agents vs Agencies & Generic AI | ${siteConfig.name}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The metadata title will render with "Recoup" appearing twice in the browser tab. The string already includes | Recoup at the end, and the root layout's title.template: "%s | Recoup" applies another suffix on top. The fix: remove | \${siteConfig.name} from the title string and let the root layout template handle the suffix, or switch to title: { absolute: "..." } if you want full control. Since every page in the codebase follows this same pattern, you may want to fix all at once to keep titles consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/compare/page.tsx, line 9:

<comment>The metadata title will render with "Recoup" appearing twice in the browser tab. The string already includes `| Recoup` at the end, and the root layout's `title.template: "%s | Recoup"` applies another suffix on top. The fix: remove `| \${siteConfig.name}` from the title string and let the root layout template handle the suffix, or switch to `title: { absolute: "..." }` if you want full control. Since every page in the codebase follows this same pattern, you may want to fix all at once to keep titles consistent.</comment>

<file context>
@@ -0,0 +1,194 @@
+import { siteConfig } from "@/lib/config";
+
+export const metadata: Metadata = buildPageMetadata({
+  title: `Why Recoup? — Compare AI Music Agents vs Agencies & Generic AI | ${siteConfig.name}`,
+  description:
+    "See how Recoup's purpose-built music agents compare to hiring staff, creative agencies, and generic AI like ChatGPT. Real cost savings from real labels.",
</file context>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Title resolves to ~73 characters, exceeding the recommended 50-60 char range from the SEO guidelines. Truncated titles get cut off in SERP snippets. Shorten the title to stay within 60 chars — e.g. drop "& Agencies" or shorten "Generic AI" since the description already names each alternative.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/compare/page.tsx, line 9:

<comment>Title resolves to ~73 characters, exceeding the recommended 50-60 char range from the SEO guidelines. Truncated titles get cut off in SERP snippets. Shorten the title to stay within 60 chars — e.g. drop "& Agencies" or shorten "Generic AI" since the description already names each alternative.</comment>

<file context>
@@ -0,0 +1,194 @@
+import { siteConfig } from "@/lib/config";
+
+export const metadata: Metadata = buildPageMetadata({
+  title: `Why Recoup? — Compare AI Music Agents vs Agencies & Generic AI | ${siteConfig.name}`,
+  description:
+    "See how Recoup's purpose-built music agents compare to hiring staff, creative agencies, and generic AI like ChatGPT. Real cost savings from real labels.",
</file context>

description:
"See how Recoup's purpose-built music agents compare to hiring staff, creative agencies, and generic AI like ChatGPT. Real cost savings from real labels.",
path: "/compare",
});

/* ── support-level icon ── */
function SupportIcon({ level }: { level: SupportLevel }) {
if (level === "full")
return (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-emerald-500/15 text-emerald-400">
<Check className="w-3.5 h-3.5" />
</span>
);
if (level === "partial")
return (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-amber-500/15 text-amber-400">
<Minus className="w-3.5 h-3.5" />
</span>
);
return (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-red-500/10 text-red-400/70">
<X className="w-3.5 h-3.5" />
</span>
);
}

export default function ComparePage() {
const c = compareCopy;

return (
<div className="max-w-5xl mx-auto px-4 py-16 sm:py-24">
{/* ── Hero ── */}
<header className="text-center mb-20">
<h1 className="text-4xl sm:text-5xl font-bold tracking-tight text-[var(--foreground)] mb-6">
{c.title}
</h1>
<p className="text-lg sm:text-xl text-[var(--muted-foreground)] max-w-2xl mx-auto leading-relaxed">
{c.description}
</p>
</header>

{/* ── Comparison Grid ── */}
<section className="mb-24">
{/* column headers — desktop */}
<div className="hidden md:grid grid-cols-5 gap-3 mb-4 text-xs font-medium uppercase tracking-wider text-neutral-500 px-4">
<div>Capability</div>
{c.columns.map((col) => (
<div key={col} className="text-center">
{col}
</div>
))}
</div>

<div className="space-y-3">
{c.rows.map((row) => (
<div
key={row.capability}
className="rounded-xl border border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12] transition-colors"
>
{/* Desktop row */}
<div className="hidden md:grid grid-cols-5 gap-3 items-center px-6 py-5">
<div className="font-medium text-[var(--foreground)]">
{row.capability}
</div>
{(["recoup", "humans", "agency", "genericAI"] as const).map(
(key) => (
<div key={key} className="flex flex-col items-center gap-1.5 text-center">
<SupportIcon level={row[key].level} />
<span className="text-xs text-neutral-400 leading-tight max-w-[180px]">
{row[key].note}
</span>
</div>
)
)}
</div>

{/* Mobile card */}
<div className="md:hidden p-5">
<h3 className="font-medium text-[var(--foreground)] mb-4">
{row.capability}
</h3>
<div className="grid grid-cols-2 gap-3">
{(["recoup", "humans", "agency", "genericAI"] as const).map(
(key, i) => (
<div key={key} className="flex items-start gap-2">
<SupportIcon level={row[key].level} />
<div>
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-0.5">
{c.columns[i]}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Column labels in the mobile view are resolved by positional index (c.columns[i]), creating a fragile coupling between the copy's column array order and the hardcoded tuple order. If someone reorders the columns in the copy file, the mobile card labels will silently show the wrong company names. Consider building a key→label map instead so the mobile view can look up by key name rather than index.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/compare/page.tsx, line 98:

<comment>Column labels in the mobile view are resolved by positional index (`c.columns[i]`), creating a fragile coupling between the copy's column array order and the hardcoded tuple order. If someone reorders the columns in the copy file, the mobile card labels will silently show the wrong company names. Consider building a key→label map instead so the mobile view can look up by key name rather than index.</comment>

<file context>
@@ -0,0 +1,194 @@
+                        <SupportIcon level={row[key].level} />
+                        <div>
+                          <div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-0.5">
+                            {c.columns[i]}
+                          </div>
+                          <div className="text-xs text-neutral-400 leading-tight">
</file context>

</div>
<div className="text-xs text-neutral-400 leading-tight">
{row[key].note}
</div>
</div>
</div>
)
)}
</div>
</div>
</div>
))}
</div>
</section>

{/* ── Cost Comparison ── */}
<section className="mb-24">
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight text-[var(--foreground)] mb-3 text-center">
{c.costTitle}
</h2>
<p className="text-[var(--muted-foreground)] text-center mb-12 max-w-xl mx-auto">
{c.costDescription}
</p>

<div className="grid sm:grid-cols-3 gap-4">
{c.costs.map((cost) => (
<div
key={cost.label}
className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 hover:border-white/[0.12] transition-colors"
>
<span className="text-xs font-medium uppercase tracking-wider text-neutral-500">
{cost.source}
</span>
<h3 className="text-xl font-semibold text-[var(--foreground)] mt-3 mb-2">
{cost.label}
</h3>
<div className="text-3xl font-bold text-red-400/80 line-through mb-1">
{cost.traditional}
</div>
<div className="text-emerald-400 font-medium mb-4">
{cost.recoup}
</div>
<p className="text-sm text-neutral-400">{cost.savings}</p>
</div>
))}
</div>
</section>

{/* ── ChatGPT Objection ── */}
<section className="mb-24">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 sm:p-12">
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-[var(--foreground)] mb-4">
{c.chatgptTitle}
</h2>
<p className="text-[var(--muted-foreground)] leading-relaxed mb-8 max-w-2xl">
{c.chatgptDescription}
</p>

<div className="grid sm:grid-cols-2 gap-x-8 gap-y-3 mb-8">
{c.chatgptPoints.map((point) => (
<div key={point} className="flex items-start gap-3">
<span className="mt-1 inline-flex items-center justify-center w-5 h-5 rounded-full bg-red-500/10 text-red-400/70 flex-shrink-0">
<X className="w-3 h-3" />
</span>
<span className="text-sm text-neutral-300">{point}</span>
</div>
))}
</div>

<p className="text-[var(--foreground)] font-medium text-lg">
{c.chatgptPunchline}
</p>
</div>
</section>

{/* ── CTA ── */}
<section className="text-center">
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link
href={c.ctaPrimary.href}
className="inline-flex items-center gap-2 px-8 py-3.5 rounded-full bg-white text-black font-medium text-sm hover:bg-neutral-200 transition-colors"
>
{c.ctaPrimary.label}
<ArrowRight className="w-4 h-4" />
</Link>
<Link
href={c.ctaSecondary.href}
className="inline-flex items-center gap-2 px-8 py-3.5 rounded-full border border-white/[0.12] text-[var(--foreground)] font-medium text-sm hover:border-white/[0.25] transition-colors"
>
{c.ctaSecondary.label}
</Link>
</div>
</section>
</div>
);
}
129 changes: 129 additions & 0 deletions lib/copy/compare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Compare page copy — single source of truth.
* Competitive comparison: Recoup vs alternatives.
*/

export type SupportLevel = "full" | "partial" | "none";

export interface ComparisonRow {
capability: string;
recoup: { level: SupportLevel; note: string };
humans: { level: SupportLevel; note: string };
agency: { level: SupportLevel; note: string };
genericAI: { level: SupportLevel; note: string };
}

export interface CostRow {
label: string;
traditional: string;
recoup: string;
savings: string;
source: string;
}

export const compareCopy = {
title: "Why Recoup?",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The brand name "Recoup" is hardcoded throughout this copy object (title, description, chatgptDescription, chatgptPunchline, costDescription) rather than being derived from siteConfig.name. This is inconsistent with app/compare/page.tsx in this same PR which imports siteConfig and uses siteConfig.name in the metadata. If the brand name ever changes, these references would need manual updates. Consider exporting a function that accepts the site name, or interpolating siteConfig.name where applicable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/copy/compare.ts, line 25:

<comment>The brand name "Recoup" is hardcoded throughout this copy object (title, description, chatgptDescription, chatgptPunchline, costDescription) rather than being derived from `siteConfig.name`. This is inconsistent with `app/compare/page.tsx` in this same PR which imports `siteConfig` and uses `siteConfig.name` in the metadata. If the brand name ever changes, these references would need manual updates. Consider exporting a function that accepts the site name, or interpolating `siteConfig.name` where applicable.</comment>

<file context>
@@ -0,0 +1,129 @@
+}
+
+export const compareCopy = {
+  title: "Why Recoup?",
+  description:
+    "Generic AI doesn't understand music. Agencies are slow and expensive. Recoup is purpose-built music intelligence — agents that know your artists, your data, and your industry.",
</file context>

description:
"Generic AI doesn't understand music. Agencies are slow and expensive. Recoup is purpose-built music intelligence — agents that know your artists, your data, and your industry.",

columns: ["Recoup", "In-House Team", "Creative Agency", "ChatGPT / Claude"] as const,
Comment on lines +24 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Avoid hardcoding brand values and ensure SEO title limits.

As per coding guidelines, brand values should never be hardcoded; they must be imported from lib/config.ts (using siteConfig.name). Additionally, every page must have a unique SEO title between 50-60 characters, but the current title in app/compare/page.tsx exceeds this limit.

  • lib/copy/compare.ts#L24-L29: Replace instances of the hardcoded "Recoup" brand name with ${siteConfig.name} throughout the exported compareCopy object.
  • app/compare/page.tsx#L8-L13: Replace the hardcoded "Recoup" mentions in the metadata with ${siteConfig.name}, and shorten the SEO title to fit within 50-60 characters (e.g., Why ${siteConfig.name}? — Compare AI Music Agents vs Agencies).
📍 Affects 2 files
  • lib/copy/compare.ts#L24-L29 (this comment)
  • app/compare/page.tsx#L8-L13
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/copy/compare.ts` around lines 24 - 29, The hardcoded brand and overlong
SEO title must use siteConfig.name and meet the 50–60 character limit. In
lib/copy/compare.ts lines 24-29, import siteConfig from lib/config.ts and
replace every “Recoup” brand reference in compareCopy with siteConfig.name; in
app/compare/page.tsx lines 8-13, replace metadata brand references likewise and
shorten the title to a unique 50–60 character value.

Source: Coding guidelines


rows: [
{
capability: "Content Creation",
recoup: { level: "full", note: "30 days of branded content in one session" },
humans: { level: "full", note: "High quality, slow turnaround" },
agency: { level: "full", note: "$5K+/mo, days per batch" },
genericAI: { level: "partial", note: "Can write, but no artist context" },
},
{
capability: "Artist Research",
recoup: { level: "full", note: "Streaming, social, audience, playlists — live data" },
humans: { level: "partial", note: "Manual data pulls, hours per report" },
agency: { level: "none", note: "Not typically offered" },
genericAI: { level: "partial", note: "No access to streaming APIs" },
},
{
capability: "Release Strategy",
recoup: { level: "full", note: "Data-informed, auto-generated per release" },
humans: { level: "full", note: "Experienced, but expensive" },
agency: { level: "partial", note: "Template-based, not data-driven" },
genericAI: { level: "partial", note: "Generic advice, no real data" },
},
{
capability: "Social Analytics",
recoup: { level: "full", note: "Cross-platform scraping + insights" },
humans: { level: "partial", note: "Manual exports from each platform" },
agency: { level: "partial", note: "Monthly reports, often delayed" },
genericAI: { level: "none", note: "No platform access" },
},
{
capability: "Catalog Valuation",
recoup: { level: "full", note: "Real-time from streaming data" },
humans: { level: "partial", note: "Requires specialist + weeks" },
agency: { level: "none", note: "Not offered" },
genericAI: { level: "none", note: "No valuation models" },
},
{
capability: "Automated Reporting",
recoup: { level: "full", note: "Weekly reports, zero human effort" },
humans: { level: "partial", note: "40+ hours/month for a team of 5" },
agency: { level: "partial", note: "Monthly cadence, manual process" },
genericAI: { level: "none", note: "No scheduling or automation" },
},
{
capability: "Agent Plugin Ecosystem",
recoup: { level: "full", note: "Install plugins in Claude, Cursor, Codex" },
humans: { level: "none", note: "N/A" },
agency: { level: "none", note: "N/A" },
genericAI: { level: "partial", note: "General tools, no music plugins" },
},
] satisfies ComparisonRow[],

costTitle: "The real cost of alternatives",
costDescription:
"These aren't hypothetical. They're from real labels who switched to Recoup.",

costs: [
{
label: "Creative Director",
traditional: "$10,000/mo",
recoup: "72-hour campaign delivery",
savings: "$10K saved on a single campaign",
source: "Fat Beats",
},
{
label: "Content Agency",
traditional: "$5,000/mo",
recoup: "Same price, 10× the output",
savings: "Agency eliminated + new AI artist co-created",
source: "Rostrum Records",
},
{
label: "Manual Reporting",
traditional: "40+ staff hours/mo",
recoup: "Automated, $0 marginal cost",
savings: "$3K–$5K/mo in staff time",
source: "Parlophone (Warner)",
},
] satisfies CostRow[],

chatgptTitle: "But can't I just use ChatGPT?",
chatgptDescription:
"ChatGPT can write a marketing plan. Recoup writes one informed by your artist's actual streaming data, audience demographics, playlist history, and competitive landscape.",

chatgptPoints: [
"No access to streaming analytics (Spotify, Apple Music, Chartmetric)",
"No playlist intelligence or placement tracking",
"No artist context — brand voice, catalog history, audience segments",
"No automated workflows — can't schedule reports or content pipelines",
"No music industry data models — catalog valuation, royalty estimation",
"No plugin ecosystem — can't extend with music-specific tools",
],

chatgptPunchline:
"Generic AI is a blank canvas. Recoup is a music business that already knows your artists.",

ctaPrimary: { label: "See it in action", href: "/valuation" },
ctaSecondary: { label: "View pricing", href: "/pricing" },
};
2 changes: 2 additions & 0 deletions lib/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { siteConfig } from "@/lib/config";

export const nav: readonly { label: string; href: string; external?: boolean }[] = [
{ label: "Results", href: "/results" },
{ label: "Compare", href: "/compare" },
{ label: "Pricing", href: "/pricing" },
{ label: "Docs", href: siteConfig.docsUrl, external: true },
{ label: "Blog", href: "/blog" },
Expand All @@ -24,6 +25,7 @@ export const footerNav = {
label: "Resources",
items: [
{ label: "Results", href: "/results" },
{ label: "Compare", href: "/compare" },
{ label: "Blog", href: "/resources" },
{ label: "AI Playbook", href: "/playbook" },
{ label: "ROI Calculator", href: "/calculator" },
Expand Down