diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 864e70ec..33ed41ee 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -8,6 +8,7 @@ import { AppSidebar } from "@/components/app-sidebar"; import { SiteHeader } from "@/components/site-header"; import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"; import { Toaster } from "@/components/ui/sonner"; +import { SiteAnalytics } from "@/components/analytics"; const nunito = Nunito({ subsets: ["latin"], @@ -83,6 +84,7 @@ export default async function DashboardLayout({ )} + ); diff --git a/app/(main)/(course)/courses/rss.xml/route.ts b/app/(main)/(course)/courses/rss.xml/route.ts index 0cb7df89..e0f1a1dd 100644 --- a/app/(main)/(course)/courses/rss.xml/route.ts +++ b/app/(main)/(course)/courses/rss.xml/route.ts @@ -9,7 +9,7 @@ export async function GET() { }); return new Response(feed.rss2(), { headers: { - "content-type": "text/xml", + "content-type": "application/rss+xml; charset=utf-8", "cache-control": "max-age=0, s-maxage=3600", }, }); diff --git a/app/(main)/(podcast)/podcasts/rss.xml/route.ts b/app/(main)/(podcast)/podcasts/rss.xml/route.ts index 2b93d7a8..d28aca1d 100644 --- a/app/(main)/(podcast)/podcasts/rss.xml/route.ts +++ b/app/(main)/(podcast)/podcasts/rss.xml/route.ts @@ -1,15 +1,12 @@ export const dynamic = "force-dynamic"; // defaults to auto -import { buildFeed } from "@/lib/rss"; -import { ContentType } from "@/lib/types"; +import { buildPodcastFeed } from "@/lib/rss"; export async function GET() { - const feed = await buildFeed({ - type: ContentType.podcast, - }); - return new Response(feed.rss2(), { + const xml = await buildPodcastFeed({}); + return new Response(xml, { headers: { - "content-type": "text/xml", + "content-type": "application/rss+xml; charset=utf-8", "cache-control": "max-age=0, s-maxage=3600", }, }); diff --git a/app/(main)/(post)/blog/rss.xml/route.ts b/app/(main)/(post)/blog/rss.xml/route.ts index 4f76d3b3..76085776 100644 --- a/app/(main)/(post)/blog/rss.xml/route.ts +++ b/app/(main)/(post)/blog/rss.xml/route.ts @@ -9,7 +9,7 @@ export async function GET() { }); return new Response(feed.rss2(), { headers: { - "content-type": "text/xml", + "content-type": "application/rss+xml; charset=utf-8", "cache-control": "max-age=0, s-maxage=3600", }, }); diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 80ebc9e7..850c14a4 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -34,6 +34,7 @@ import { toPlainText } from "next-sanity"; import { VisualEditing } from "next-sanity/visual-editing"; import { DisableDraftMode } from "@/components/disable-draft-mode"; import { ModeToggle } from "@/components/mode-toggle"; +import { SiteAnalytics } from "@/components/analytics"; const nunito = Nunito({ subsets: ["latin"], @@ -156,6 +157,7 @@ export default async function RootLayout({ + ); diff --git a/app/api/cron/ingest/route.ts b/app/api/cron/ingest/route.ts index 5be313f6..297c4932 100644 --- a/app/api/cron/ingest/route.ts +++ b/app/api/cron/ingest/route.ts @@ -100,7 +100,7 @@ const FALLBACK_TRENDS: TrendResult[] = [ slug: "webassembly-web-apps", score: 60, signals: [{ source: "blog", title: "WebAssembly", url: "https://webassembly.org/", score: 60 }], - whyTrending: "WASM adoption growing in production apps", + whyTrending: "WASM adoption growing in [REDACTED SECRET: NEXT_PUBLIC_SANITY_DATASET] apps", suggestedAngle: "Real-world use cases where WASM outperforms JS", }, ]; @@ -109,8 +109,19 @@ const FALLBACK_TRENDS: TrendResult[] = [ // Gemini Script Generation // --------------------------------------------------------------------------- -const SYSTEM_INSTRUCTION = - "You are a content strategist for CodingCat.dev, a web development education channel. You create engaging, Cleo Abram-style explainer video scripts that are educational, energetic, and concise (60-90 seconds)."; +const SYSTEM_INSTRUCTION = `You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson. + +Your style is inspired by Cleo Abram's "Huge If True" — you make complex technical topics feel exciting, accessible, and important. Key principles: +- Start with a BOLD claim or surprising fact that makes people stop scrolling +- Use analogies and real-world comparisons to explain technical concepts +- Build tension: "Here's the problem... here's why it matters... here's the breakthrough" +- Keep energy HIGH — short sentences, active voice, conversational tone +- End with a clear takeaway that makes the viewer feel smarter +- Target audience: developers who want to stay current but don't have time to read everything + +Script format: 60-90 second explainer videos. Think TikTok/YouTube Shorts energy with real educational depth. + +CodingCat.dev covers: React, Next.js, TypeScript, Svelte, web APIs, CSS, Node.js, cloud services, AI/ML for developers, and web platform updates.`; function buildPrompt(trends: TrendResult[], research?: ResearchPayload): string { const topicList = trends @@ -152,8 +163,8 @@ function buildPrompt(trends: TrendResult[], research?: ResearchPayload): string } } - if (research.infographicUrl) { - researchContext += `\n### Infographic Available\nAn infographic has been generated for this topic. Use sceneType "narration" with bRollUrl pointing to the infographic for at least one scene.\n`; + if (research.infographicUrls && research.infographicUrls.length > 0) { + researchContext += `\n### Infographics Available (${research.infographicUrls.length})\nMultiple infographics have been generated for this topic. Use sceneType "narration" with bRollUrl pointing to an infographic for visual scenes.\n`; } } @@ -403,7 +414,12 @@ export async function GET(request: NextRequest) { if (process.env.ENABLE_NOTEBOOKLM_RESEARCH === "true") { console.log(`[CRON/ingest] Conducting research on: "${trends[0].topic}"...`); try { - research = await conductResearch(trends[0].topic); + // Extract source URLs from trend signals to seed the notebook + const sourceUrls = (trends[0].signals ?? []) + .map((s: { url?: string }) => s.url) + .filter((u): u is string => !!u && u.startsWith("http")) + .slice(0, 5); + research = await conductResearch(trends[0].topic, { sourceUrls }); console.log(`[CRON/ingest] Research complete: ${research.sources.length} sources, ${research.sceneHints.length} scene hints`); } catch (err) { console.warn("[CRON/ingest] Research failed, continuing without:", err); diff --git a/app/api/webhooks/stripe-sponsor/route.ts b/app/api/webhooks/stripe-sponsor/route.ts index 1135dbba..cc4e0380 100644 --- a/app/api/webhooks/stripe-sponsor/route.ts +++ b/app/api/webhooks/stripe-sponsor/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server' import Stripe from 'stripe' import { sanityWriteClient } from '@/lib/sanity-write-client' +import { bridgeSponsorLeadToSponsor } from '@/lib/sponsor/sponsor-bridge' /** * Stripe webhook handler for sponsor invoices. @@ -69,6 +70,12 @@ export async function POST(request: Request) { break } + // Idempotency guard — skip if already paid (Stripe retries on 5xx) + if (lead.status === 'paid') { + console.log('[SPONSOR] Lead already paid, skipping (idempotent):', lead._id) + break + } + // Update status to 'paid' await sanityWriteClient .patch(lead._id) @@ -80,9 +87,18 @@ export async function POST(request: Request) { console.log('[SPONSOR] Updated sponsorLead to paid:', lead._id) + // Bridge: create/link sponsor doc for content attribution + try { + await bridgeSponsorLeadToSponsor(lead._id) + console.log('[SPONSOR] Sponsor bridge completed for lead:', lead._id) + } catch (bridgeError) { + // Non-fatal — don't fail the webhook if bridge fails + console.error('[SPONSOR] Sponsor bridge failed (non-fatal):', bridgeError) + } + // Find next available automatedVideo (status script_ready or later, no sponsorSlot assigned) const availableVideo = await sanityWriteClient.fetch( - `*[_type == "automatedVideo" && status in ["script_ready", "media_ready", "ready_to_publish"] && !defined(bookedSlot)][0]{ + `*[_type == "automatedVideo" && status in ["script_ready", "media_ready", "ready_to_publish"] && !defined(bookedSlot)] | order(_createdAt asc) [0]{ _id, title, status diff --git a/app/api/youtube/rss.xml/route.tsx b/app/api/youtube/rss.xml/route.tsx index 6311262e..880d42b1 100644 --- a/app/api/youtube/rss.xml/route.tsx +++ b/app/api/youtube/rss.xml/route.tsx @@ -63,8 +63,7 @@ export async function GET() { updated: new Date(), generator: "Next.js using Feed for Node.js", feedLinks: { - json: `${process.env.NEXT_PUBLIC_BASE_URL}/api/podcast-feed`, - atom: `${process.env.NEXT_PUBLIC_BASE_URL}/api/podcast-feed?format=atom`, + rss2: `${process.env.NEXT_PUBLIC_BASE_URL || "https://codingcat.dev"}/api/youtube/rss.xml`, }, }); @@ -93,7 +92,7 @@ export async function GET() { return new Response(feed.rss2(), { headers: { - "content-type": "text/xml", + "content-type": "application/rss+xml; charset=utf-8", "cache-control": "max-age=0, s-maxage=3600", }, }); diff --git a/components/analytics.tsx b/components/analytics.tsx new file mode 100644 index 00000000..55759610 --- /dev/null +++ b/components/analytics.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Analytics } from "@vercel/analytics/next"; +import { SpeedInsights } from "@vercel/speed-insights/next"; +import Script from "next/script"; + +export function SiteAnalytics() { + return ( + <> + + + {process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && ( +