Skip to content
Merged
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
29 changes: 16 additions & 13 deletions app/(marketing)/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { stripDeadLinks } from "@/lib/dead-links";
import { stripTrailingAd } from "@/lib/blog/split-html";
import { AdUnit } from "@/components/ads/ad-unit";
import { env } from "@/lib/env";
import { createSeoAeoMetadata, serializeJsonLd } from "@/lib/seo/seo-aeo-meta";

// The article body's typography.
const PROSE =
Expand All @@ -25,21 +26,23 @@ export async function generateMetadata({
}) {
const { slug } = await params;
const post = await findAnyPost(slug);
const url = `${env.siteUrl.replace(/\/$/, "")}/blog/${slug}`;
if (!post) {
return { title: "Post not found", alternates: { canonical: `/blog/${slug}` } };
return createSeoAeoMetadata({
title: "Post not found",
description: "This CrawlProof post is no longer available.",
url,
robots: { index: false, follow: false },
});
}
return {
return createSeoAeoMetadata({
title: post.title,
description: post.excerpt ?? undefined,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
type: "article",
url: `/blog/${slug}`,
title: post.title,
description: post.excerpt ?? undefined,
images: post.image_url ? [{ url: post.image_url }] : undefined,
},
};
description: post.excerpt || post.title,
url,
type: "article",
socialCard: post.image_url ? "summary_large_image" : "summary",
...(post.image_url ? { image: post.image_url, imageAlt: `${post.title} featured image` } : {}),
});
}

export default async function BlogPost({
Expand Down Expand Up @@ -69,7 +72,7 @@ export default async function BlogPost({
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
__html: serializeJsonLd({
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
Expand Down
27 changes: 9 additions & 18 deletions app/(marketing)/recent/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Link from "next/link";
import { serviceClient } from "@/lib/supabase/service";
import { createClient } from "@/lib/supabase/server";
import { env } from "@/lib/env";
import { createSeoAeoMetadata } from "@/lib/seo/seo-aeo-meta";
import { listUserOrgs } from "@/lib/orgs";
import { RecentOutreachForm, type OutreachHistoryItem } from "./outreach-form";
import { deriveOutreachStatus, type OutreachSocialPost } from "@/lib/sp/outreachStatus";
Expand Down Expand Up @@ -89,25 +90,15 @@ export async function generateMetadata({
const sp = await searchParams;
const page = Math.max(1, Math.min(MAX_PAGES, Number(sp.page) || 1));
const url = `${env.siteUrl.replace(/\/$/, "")}/recent${page > 1 ? `?page=${page}` : ""}`;
return {
title:
page === 1
? "Recent AEO audits"
: `Recent AEO audits — page ${page}`,
description:
"Browse opted-in free AEO audits run on CrawlProof. See what AI crawlers (GPTBot, ClaudeBot, PerplexityBot, Google-Extended) can find on real sites — score, findings, and a priority to-do list.",
alternates: { canonical: url },
openGraph: {
type: "website",
url,
title: "Recent AEO audits — CrawlProof",
description:
"The latest opted-in AEO audits run on CrawlProof. Shareable scans of real sites.",
siteName: "CrawlProof",
},
twitter: { card: "summary_large_image", title: "Recent AEO audits — CrawlProof" },
const title = page === 1 ? "Recent AEO audits" : `Recent AEO audits — page ${page}`;
const description = "Browse opted-in free AEO audits run on CrawlProof. See what AI crawlers (GPTBot, ClaudeBot, PerplexityBot, Google-Extended) can find on real sites — score, findings, and a priority to-do list.";
return createSeoAeoMetadata({
title,
description,
url,
robots: { index: true, follow: true },
};
socialCard: "summary_large_image",
});
}

export default async function RecentPage({
Expand Down
15 changes: 7 additions & 8 deletions app/c/[project]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { env } from "@/lib/env";
import { createSeoAeoMetadata, serializeJsonLd } from "@/lib/seo/seo-aeo-meta";
import { jobPostingJsonLd, workplaceSummary } from "@/lib/careers/jobs";
import { displayHost, loadBoard } from "@/lib/careers/board";

Expand All @@ -28,14 +29,12 @@
}): Promise<Metadata> {
const { project: projectId, slug } = await params;
const found = await load(projectId, slug);
if (!found) return { title: "Role not found" };
return {
if (!found) return createSeoAeoMetadata({ title: "Role not found", description: "This job posting is no longer available." });
return createSeoAeoMetadata({
title: `${found.job.title} — ${found.project.name}`,
description:
found.job.overview?.slice(0, 200) ??
`${found.job.title} at ${found.project.name}.`,
alternates: { canonical: `${env.siteUrl}/c/${projectId}/${slug}` },
};
description: found.job.overview ?? `${found.job.title} at ${found.project.name}.`,
url: `${env.siteUrl.replace(/\/$/, "")}/c/${projectId}/${slug}`,
});
}

export default async function HostedJobPage({
Expand Down Expand Up @@ -74,7 +73,7 @@
<main className="mx-auto max-w-3xl px-4 py-10 space-y-6">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>

<nav className="text-sm">
Expand Down
11 changes: 6 additions & 5 deletions app/c/[project]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { env } from "@/lib/env";
import { createSeoAeoMetadata, serializeJsonLd } from "@/lib/seo/seo-aeo-meta";
import { jobPostingJsonLd, workplaceSummary } from "@/lib/careers/jobs";
import { displayHost, loadBoard } from "@/lib/careers/board";

Expand All @@ -21,12 +22,12 @@ export async function generateMetadata({
}): Promise<Metadata> {
const { project: projectId } = await params;
const board = await loadBoard(projectId);
if (!board) return { title: "Careers" };
return {
if (!board) return createSeoAeoMetadata({ title: "Careers", description: "Open roles on CrawlProof-hosted career pages." });
return createSeoAeoMetadata({
title: `Careers at ${board.project.name}`,
description: `${board.jobs.length} open role${board.jobs.length === 1 ? "" : "s"} at ${board.project.name}.`,
alternates: { canonical: `${env.siteUrl}/c/${projectId}` },
};
url: `${env.siteUrl.replace(/\/$/, "")}/c/${projectId}`,
});
}

export default async function HostedBoardPage({
Expand Down Expand Up @@ -55,7 +56,7 @@ export default async function HostedBoardPage({
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({ "@context": "https://schema.org", "@graph": graph }),
__html: serializeJsonLd({ "@context": "https://schema.org", "@graph": graph }),
}}
/>
)}
Expand Down
28 changes: 8 additions & 20 deletions app/r/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import { loadConsolidatedOrSoloMarkdown } from "@/lib/audit/summary-markdown";
import { buildShareCard } from "@/lib/audit/share-card";
import { env } from "@/lib/env";
import { createSeoAeoMetadata, serializeJsonLd } from "@/lib/seo/seo-aeo-meta";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -104,29 +105,16 @@
: `AEO audit for ${host} from CrawlProof. See what AI crawlers can find on the site.`;

return {
title,
description,
alternates: { canonical: url },
robots,
openGraph: {
type: "article",
url,
...createSeoAeoMetadata({
title,
description,
siteName: "CrawlProof",
url,
type: "article",
robots,
socialCard: "summary_large_image",
publishedTime: audit.created_at,
modifiedTime: audit.completed_at ?? audit.created_at,
// No `images` here on purpose. Declaring one would override the
// generated per-report card in ./opengraph-image.tsx and put every
// report back on the identical static banner. The file convention
// supplies og:image, its dimensions, and the alt text.
},
twitter: {
card: "summary_large_image",
title,
description,
// Same reason — the card file feeds twitter:image too.
},
}),
other: {
"article:section": "AEO Audit",
"article:tag": "AEO, LLM, GPTBot, ClaudeBot, PerplexityBot",
Expand Down Expand Up @@ -173,7 +161,7 @@
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
/>
);
}
Expand Down
42 changes: 42 additions & 0 deletions app/surveys/[slug]/invite/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ImageResponse } from "next/og";
import { serviceClient } from "@/lib/supabase/service";

export const runtime = "nodejs";
export const revalidate = 300;
export const alt = "CrawlProof survey invitation";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const svc = serviceClient();
const { data: survey } = await svc.from("surveys")
.select("title, question, project_id")
.eq("slug", slug)
.maybeSingle();
const { data: project } = survey
? await svc.from("projects").select("name").eq("id", survey.project_id).maybeSingle()
: { data: null };
const title = survey?.title ?? "One question. Better feedback.";
const question = survey?.question ?? "Share what you think.";
const site = project?.name?.trim() || "CrawlProof Survey";
const titleSize = title.length > 48 ? 54 : title.length > 28 ? 68 : 82;

return new ImageResponse(
(
<div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "space-between", background: "#0b0d10", padding: "64px 72px", color: "#e7e9ee", fontFamily: "sans-serif" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ display: "flex", color: "#6ee7b7", fontSize: 26, fontWeight: 700, letterSpacing: 6 }}>CRAWLPROOF</div>
<div style={{ display: "flex", color: "#9aa3b2", fontSize: 24 }}>{site}</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 26 }}>
<div style={{ display: "flex", fontSize: 24, color: "#6ee7b7", textTransform: "uppercase", letterSpacing: 4 }}>Feedback survey</div>
<div style={{ display: "flex", fontSize: titleSize, lineHeight: 1.08, fontWeight: 800, maxWidth: 1030 }}>{title}</div>
<div style={{ display: "flex", color: "#9aa3b2", fontSize: 32, lineHeight: 1.25, maxWidth: 1000 }}>{question}</div>
</div>
<div style={{ display: "flex", borderTop: "1px solid #1f2630", paddingTop: 24, color: "#9aa3b2", fontSize: 22 }}>Share your feedback · crawlproof.com</div>
</div>
),
size,
);
}
68 changes: 57 additions & 11 deletions app/surveys/[slug]/invite/page.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,76 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { cache } from "react";
import { env } from "@/lib/env";
import { createSeoAeoMetadata, serializeJsonLd } from "@/lib/seo/seo-aeo-meta";
import { serviceClient } from "@/lib/supabase/service";
import { PublicInviteForm } from "./form";

export const metadata = {
title: "Get a survey invitation · CrawlProof",
robots: { index: false, follow: false },
};

export default async function SurveyInvitePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const loadSurveyInvite = cache(async (slug: string) => {
const svc = serviceClient();
const { data: survey } = await svc.from("surveys")
.select("title, question, answer_type, status, project_id")
.select("title, question, status, project_id")
.eq("slug", slug)
.maybeSingle();
if (!survey) notFound();
const { data: project } = await svc.from("projects").select("name").eq("id", survey.project_id).maybeSingle();
if (!survey) return null;
const { data: project } = await svc.from("projects")
.select("name")
.eq("id", survey.project_id)
.maybeSingle();
return { survey, projectName: project?.name?.trim() || "this site" };
});

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const context = await loadSurveyInvite(slug);
if (!context) {
return createSeoAeoMetadata({
title: "Survey invitation not found · CrawlProof",
description: "This survey invitation is no longer available.",
robots: { index: false, follow: false },
});
}

const { survey, projectName } = context;
const title = `${survey.title} · ${projectName}`;
const description = survey.status === "live"
? `${projectName} is asking for feedback: ${survey.question}`
: `This survey from ${projectName} is paused.`;

return createSeoAeoMetadata({
title,
description,
url: `${env.siteUrl.replace(/\/$/, "")}/surveys/${slug}/invite`,
robots: { index: false, follow: false },
socialCard: "summary_large_image",
});
}

export default async function SurveyInvitePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const context = await loadSurveyInvite(slug);
if (!context) notFound();
const { survey, projectName } = context;
const active = survey.status === "live";
const pageUrl = `${env.siteUrl.replace(/\/$/, "")}/surveys/${slug}/invite`;
const structuredData = {
"@context": "https://schema.org",
"@type": "WebPage",
name: survey.title,
description: survey.question,
url: pageUrl,
isPartOf: { "@type": "WebSite", name: "CrawlProof", url: env.siteUrl },
mainEntity: { "@type": "Question", text: survey.question },
};

return (
<main className="mx-auto max-w-xl px-4 py-14 sm:px-6">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: serializeJsonLd(structuredData) }} />
<div className="card space-y-5 p-6 sm:p-8">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--color-accent)]">CrawlProof Survey</p>
<h1 className="text-3xl font-bold">{survey.title}</h1>
<p className="text-sm text-[var(--color-muted)]">{survey.question}</p>
{active ? <PublicInviteForm slug={slug} siteName={project?.name ?? "this site"} /> : <p className="rounded border border-[var(--color-border)] p-4 text-sm text-[var(--color-muted)]">This survey is paused and is not accepting new invitations.</p>}
{active ? <PublicInviteForm slug={slug} siteName={projectName} /> : <p className="rounded border border-[var(--color-border)] p-4 text-sm text-[var(--color-muted)]">This survey is paused and is not accepting new invitations.</p>}
<p className="border-t border-[var(--color-border)] pt-4 text-xs leading-relaxed text-[var(--color-muted)]">Your email or phone is used only for the survey invitation channel you select. SMS is sent only with your explicit permission. Email is used as a fallback only when you separately allow it.</p>
</div>
</main>
Expand Down
9 changes: 6 additions & 3 deletions app/surveys/r/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { notFound } from "next/navigation";
import { createSeoAeoMetadata } from "@/lib/seo/seo-aeo-meta";
import { serviceClient } from "@/lib/supabase/service";
import { SurveyResponseForm } from "./form";

export const metadata = {
title: "Survey response · CrawlProof",
export const metadata = createSeoAeoMetadata({
title: "Your survey response · CrawlProof",
description: "Answer the survey question you were invited to complete.",
robots: { index: false, follow: false },
};
socialCard: "summary",
});

export default async function SurveyResponsePage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
Expand Down
Loading