import { PLANS, PLAN_ORDER } from "@/lib/billing/plans"; import { SITE_DESCRIPTION, SITE_NAME, SITE_URL, absoluteUrl } from "@/lib/seo"; /** * schema.org JSON-LD builders. * * Everything is emitted into a single `@graph` per page with stable `@id`s, so * nodes can reference each other (e.g. the software product is `publisher`-ed by * the organization) instead of being repeated on every route. */ const ORG_ID = `${SITE_URL}/#organization`; const SITE_ID = `${SITE_URL}/#website`; const APP_ID = `${SITE_URL}/#software`; export function organizationSchema() { return { "@type": "Organization", "@id": ORG_ID, name: SITE_NAME, url: absoluteUrl("/"), description: SITE_DESCRIPTION, logo: { "@type": "ImageObject", url: absoluteUrl("/logo-dark.png"), contentUrl: absoluteUrl("/logo-dark.png"), }, contactPoint: [ { "@type": "ContactPoint", contactType: "customer support", email: "support@podcastdistributionai.com", availableLanguage: ["English"], }, ], }; } export function websiteSchema() { return { "@type": "WebSite", "@id": SITE_ID, url: absoluteUrl("/"), name: SITE_NAME, description: SITE_DESCRIPTION, publisher: { "@id": ORG_ID }, inLanguage: "en", }; } /** * The product itself, with one Offer per plan. Prices come from the plan catalog * so the markup can never drift from what the pricing page actually charges. */ export function softwareApplicationSchema() { return { "@type": "SoftwareApplication", "@id": APP_ID, name: SITE_NAME, url: absoluteUrl("/"), description: SITE_DESCRIPTION, applicationCategory: "MultimediaApplication", applicationSubCategory: "Podcast production", operatingSystem: "Web browser", publisher: { "@id": ORG_ID }, featureList: [ "AI podcast script generation", "Realistic multi-voice text-to-speech", "AI-generated episode cover art", "Content repurposing to blog and social posts", "Series and season generator", "13+ languages", "Team workspace and white-label branding", "REST API access", ], offers: PLAN_ORDER.map((key) => { const plan = PLANS[key]; return { "@type": "Offer", name: `${plan.name} plan`, description: plan.tagline, price: (plan.priceMonthly / 100).toFixed(2), priceCurrency: "USD", category: plan.priceMonthly === 0 ? "Free" : "Subscription", url: absoluteUrl("/pricing"), availability: "https://schema.org/InStock", }; }), }; } /** A single page node, linked to the site — gives each URL an explicit identity. */ export function webPageSchema({ path, name, description, }: { path: string; name: string; description: string; }) { return { "@type": "WebPage", "@id": `${absoluteUrl(path)}#webpage`, url: absoluteUrl(path), name, description, isPartOf: { "@id": SITE_ID }, about: { "@id": ORG_ID }, inLanguage: "en", }; } /** Breadcrumbs from Home to the current page. Pass the trail without Home. */ export function breadcrumbSchema(trail: { name: string; path: string }[]) { return { "@type": "BreadcrumbList", "@id": `${absoluteUrl(trail[trail.length - 1]?.path ?? "/")}#breadcrumb`, itemListElement: [{ name: "Home", path: "/" }, ...trail].map((item, i) => ({ "@type": "ListItem", position: i + 1, name: item.name, item: absoluteUrl(item.path), })), }; } export function faqPageSchema(items: { q: string; a: string }[]) { return { "@type": "FAQPage", "@id": `${absoluteUrl("/faq")}#faq`, mainEntity: items.map(({ q, a }) => ({ "@type": "Question", name: q, acceptedAnswer: { "@type": "Answer", text: a }, })), }; } /** Wrap nodes into the `@graph` envelope every page emits. */ export function graph(...nodes: Record[]) { return { "@context": "https://schema.org", "@graph": nodes }; }