Skip to content

Commit 48d0453

Browse files
ralyodioclaude
andcommitted
feat: CrawlProof autoblogger webhook, /blog, /blog/rss.xml
- blog_posts table in migration (slug, title, excerpt, content, cover_image, author, status, published_at, updated_at) - POST /api/webhooks/crawlproof — Bearer-token auth, handles blog.publish / blog.update / blog.delete / blog.unpublish - /blog listing page with cover images and date - /blog/[slug] detail page with HTML content rendering + OG meta - /blog/rss.xml route — RSS 2.0 feed, Cache-Control 1h - Sitemap updated: /blog + dynamic blog post URLs - Header + Footer: Blog link + RSS feed link added - RSS autodiscovery <link> in layout <head> - CRAWLPROOF_WEBHOOK_SECRET added to all env files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3e458aa commit 48d0453

11 files changed

Lines changed: 342 additions & 1 deletion

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ COINPAY_MERCHANT_ID=your-merchant-uuid
55
COINPAY_CLIENT_SECRET=your-coinpay-client-secret
66
COINPAY_WEBHOOK_SECRET=whsecret_your_webhook_secret
77
SESSION_SECRET=your-session-secret
8+
CRAWLPROOF_WEBHOOK_SECRET=your-crawlproof-webhook-secret

apps/web/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ COINPAY_MERCHANT_ID=your-merchant-uuid
55
COINPAY_CLIENT_SECRET=your-coinpay-client-secret
66
COINPAY_WEBHOOK_SECRET=whsecret_your_webhook_secret
77
SESSION_SECRET=your-session-secret
8+
CRAWLPROOF_WEBHOOK_SECRET=your-crawlproof-webhook-secret
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { getDb } from '@/lib/db';
3+
4+
const SECRET = process.env.CRAWLPROOF_WEBHOOK_SECRET!;
5+
6+
function verify(req: NextRequest): boolean {
7+
const auth = req.headers.get('authorization') ?? '';
8+
const token = auth.startsWith('Bearer ') ? auth.slice(7) : auth;
9+
return token === SECRET;
10+
}
11+
12+
export async function POST(req: NextRequest) {
13+
if (!verify(req)) {
14+
console.error('CrawlProof webhook: unauthorized');
15+
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
16+
}
17+
18+
const payload = await req.json();
19+
const { type, data } = payload;
20+
21+
console.log('CrawlProof webhook:', type);
22+
23+
const db = getDb();
24+
25+
if (type === 'blog.publish' || type === 'blog.update') {
26+
const { slug, title, excerpt = null, content = '', cover_image = null, author = null, published_at = null } = data;
27+
28+
if (!slug || !title) {
29+
return NextResponse.json({ error: 'slug and title are required' }, { status: 400 });
30+
}
31+
32+
await db.sql`
33+
INSERT INTO blog_posts (slug, title, excerpt, content, cover_image, author, published_at, updated_at, status)
34+
VALUES (
35+
${slug}, ${title}, ${excerpt}, ${content}, ${cover_image}, ${author},
36+
${published_at ?? new Date().toISOString()},
37+
${new Date().toISOString()},
38+
'published'
39+
)
40+
ON CONFLICT(slug) DO UPDATE SET
41+
title = excluded.title,
42+
excerpt = excluded.excerpt,
43+
content = excluded.content,
44+
cover_image = excluded.cover_image,
45+
author = excluded.author,
46+
updated_at = excluded.updated_at,
47+
status = 'published'
48+
`;
49+
50+
return NextResponse.json({ received: true, slug });
51+
}
52+
53+
if (type === 'blog.delete') {
54+
const { slug } = data;
55+
if (!slug) return NextResponse.json({ error: 'slug required' }, { status: 400 });
56+
await db.sql`DELETE FROM blog_posts WHERE slug = ${slug}`;
57+
return NextResponse.json({ received: true, deleted: slug });
58+
}
59+
60+
if (type === 'blog.unpublish') {
61+
const { slug } = data;
62+
if (!slug) return NextResponse.json({ error: 'slug required' }, { status: 400 });
63+
await db.sql`UPDATE blog_posts SET status = 'draft', updated_at = ${new Date().toISOString()} WHERE slug = ${slug}`;
64+
return NextResponse.json({ received: true, slug });
65+
}
66+
67+
return NextResponse.json({ received: true, type });
68+
}

apps/web/app/blog/[slug]/page.tsx

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
export const dynamic = 'force-dynamic';
2+
3+
import type { Metadata } from 'next';
4+
import { notFound } from 'next/navigation';
5+
import Image from 'next/image';
6+
import Link from 'next/link';
7+
import { getDb } from '@/lib/db';
8+
9+
interface Post {
10+
id: number;
11+
slug: string;
12+
title: string;
13+
excerpt: string | null;
14+
content: string;
15+
cover_image: string | null;
16+
author: string | null;
17+
published_at: string;
18+
updated_at: string;
19+
}
20+
21+
async function getPost(slug: string): Promise<Post | null> {
22+
try {
23+
const db = getDb();
24+
const rows = await db.sql`
25+
SELECT * FROM blog_posts
26+
WHERE slug = ${slug} AND status = 'published'
27+
`;
28+
return rows.length ? rows[0] : null;
29+
} catch {
30+
return null;
31+
}
32+
}
33+
34+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
35+
const { slug } = await params;
36+
const post = await getPost(slug);
37+
if (!post) return { title: 'Post not found' };
38+
39+
return {
40+
title: post.title,
41+
description: post.excerpt ?? undefined,
42+
alternates: { canonical: `https://c0upons.com/blog/${slug}` },
43+
openGraph: {
44+
title: post.title,
45+
description: post.excerpt ?? undefined,
46+
url: `https://c0upons.com/blog/${slug}`,
47+
type: 'article',
48+
publishedTime: post.published_at,
49+
modifiedTime: post.updated_at,
50+
images: post.cover_image ? [{ url: post.cover_image }] : [],
51+
},
52+
};
53+
}
54+
55+
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
56+
const { slug } = await params;
57+
const post = await getPost(slug);
58+
if (!post) notFound();
59+
60+
return (
61+
<div className="max-w-2xl mx-auto flex flex-col gap-8">
62+
<Link href="/blog" className="text-sm text-orange-500 hover:underline">← All posts</Link>
63+
64+
<article className="flex flex-col gap-6">
65+
{post.cover_image && (
66+
<div className="relative h-64 rounded-2xl overflow-hidden bg-gray-100">
67+
<Image src={post.cover_image} alt={post.title} fill className="object-cover" />
68+
</div>
69+
)}
70+
71+
<header className="flex flex-col gap-3">
72+
<h1 className="text-3xl font-black text-gray-900 leading-tight">{post.title}</h1>
73+
<div className="flex items-center gap-2 text-sm text-gray-400">
74+
{post.author && <span className="font-medium text-gray-600">{post.author}</span>}
75+
{post.author && <span>·</span>}
76+
<time dateTime={post.published_at}>
77+
{new Date(post.published_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
78+
</time>
79+
</div>
80+
</header>
81+
82+
<div
83+
className="prose prose-gray max-w-none text-gray-700 leading-relaxed [&_h2]:text-xl [&_h2]:font-bold [&_h2]:text-gray-900 [&_h2]:mt-8 [&_h2]:mb-3 [&_h3]:text-base [&_h3]:font-bold [&_h3]:text-gray-800 [&_h3]:mt-6 [&_h3]:mb-2 [&_p]:mb-4 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:mb-4 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:mb-4 [&_li]:mb-1 [&_a]:text-orange-500 [&_a]:underline [&_code]:bg-gray-100 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm [&_pre]:bg-gray-900 [&_pre]:text-gray-100 [&_pre]:p-4 [&_pre]:rounded-xl [&_pre]:overflow-x-auto [&_blockquote]:border-l-4 [&_blockquote]:border-orange-300 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-gray-500"
84+
dangerouslySetInnerHTML={{ __html: post.content }}
85+
/>
86+
</article>
87+
88+
<div className="border-t border-gray-200 pt-8">
89+
<Link
90+
href="/submit"
91+
className="inline-flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white font-semibold px-5 py-2.5 rounded-xl text-sm transition-colors"
92+
>
93+
Found a deal? Submit a coupon →
94+
</Link>
95+
</div>
96+
</div>
97+
);
98+
}

apps/web/app/blog/page.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
export const dynamic = 'force-dynamic';
2+
3+
import type { Metadata } from 'next';
4+
import Link from 'next/link';
5+
import Image from 'next/image';
6+
import { getDb } from '@/lib/db';
7+
8+
export const metadata: Metadata = {
9+
title: 'Blog',
10+
description: 'Tips, deal roundups, and savings guides from the c0upons community.',
11+
alternates: { canonical: 'https://c0upons.com/blog' },
12+
};
13+
14+
interface Post {
15+
id: number;
16+
slug: string;
17+
title: string;
18+
excerpt: string | null;
19+
cover_image: string | null;
20+
author: string | null;
21+
published_at: string;
22+
}
23+
24+
async function getPosts(): Promise<Post[]> {
25+
try {
26+
const db = getDb();
27+
return await db.sql`
28+
SELECT id, slug, title, excerpt, cover_image, author, published_at
29+
FROM blog_posts
30+
WHERE status = 'published'
31+
ORDER BY published_at DESC
32+
`;
33+
} catch {
34+
return [];
35+
}
36+
}
37+
38+
export default async function BlogPage() {
39+
const posts = await getPosts();
40+
41+
return (
42+
<div className="max-w-4xl mx-auto flex flex-col gap-10">
43+
<div>
44+
<h1 className="text-3xl font-black text-gray-900">Blog</h1>
45+
<p className="text-gray-500 mt-2">Deal tips, savings guides, and community updates.</p>
46+
</div>
47+
48+
{posts.length === 0 ? (
49+
<p className="text-gray-400">No posts yet — check back soon.</p>
50+
) : (
51+
<div className="grid sm:grid-cols-2 gap-6">
52+
{posts.map((post) => (
53+
<Link
54+
key={post.id}
55+
href={`/blog/${post.slug}`}
56+
className="group border border-gray-200 rounded-xl overflow-hidden hover:border-orange-300 hover:shadow-lg hover:shadow-orange-50 transition-all"
57+
>
58+
{post.cover_image && (
59+
<div className="relative h-48 bg-gray-100">
60+
<Image
61+
src={post.cover_image}
62+
alt={post.title}
63+
fill
64+
className="object-cover"
65+
/>
66+
</div>
67+
)}
68+
<div className="p-5 flex flex-col gap-2">
69+
<h2 className="font-bold text-gray-900 group-hover:text-orange-500 transition-colors leading-snug">
70+
{post.title}
71+
</h2>
72+
{post.excerpt && (
73+
<p className="text-sm text-gray-500 line-clamp-2">{post.excerpt}</p>
74+
)}
75+
<div className="flex items-center gap-2 mt-auto pt-2 text-xs text-gray-400">
76+
{post.author && <span>{post.author}</span>}
77+
{post.author && <span>·</span>}
78+
<time dateTime={post.published_at}>
79+
{new Date(post.published_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
80+
</time>
81+
</div>
82+
</div>
83+
</Link>
84+
))}
85+
</div>
86+
)}
87+
</div>
88+
);
89+
}

apps/web/app/blog/rss.xml/route.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
export const dynamic = 'force-dynamic';
2+
3+
import { getDb } from '@/lib/db';
4+
5+
const BASE = 'https://c0upons.com';
6+
7+
export async function GET() {
8+
let posts: Array<{ slug: string; title: string; excerpt: string | null; published_at: string; author: string | null }> = [];
9+
10+
try {
11+
const db = getDb();
12+
posts = await db.sql`
13+
SELECT slug, title, excerpt, published_at, author
14+
FROM blog_posts
15+
WHERE status = 'published'
16+
ORDER BY published_at DESC
17+
LIMIT 50
18+
`;
19+
} catch { /* return empty feed on DB error */ }
20+
21+
const items = posts.map((p) => `
22+
<item>
23+
<title><![CDATA[${p.title}]]></title>
24+
<link>${BASE}/blog/${p.slug}</link>
25+
<guid isPermaLink="true">${BASE}/blog/${p.slug}</guid>
26+
${p.excerpt ? `<description><![CDATA[${p.excerpt}]]></description>` : ''}
27+
${p.author ? `<author>${p.author}</author>` : ''}
28+
<pubDate>${new Date(p.published_at).toUTCString()}</pubDate>
29+
</item>`).join('');
30+
31+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
32+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
33+
<channel>
34+
<title>c0upons Blog</title>
35+
<link>${BASE}/blog</link>
36+
<description>Deal tips, savings guides, and community updates from c0upons.</description>
37+
<language>en-US</language>
38+
<atom:link href="${BASE}/blog/rss.xml" rel="self" type="application/rss+xml"/>
39+
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
40+
${items}
41+
</channel>
42+
</rss>`;
43+
44+
return new Response(xml, {
45+
headers: {
46+
'Content-Type': 'application/rss+xml; charset=utf-8',
47+
'Cache-Control': 'public, max-age=3600',
48+
},
49+
});
50+
}

apps/web/app/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export default function RootLayout({
7171
return (
7272
<html lang="en">
7373
<head>
74+
<link rel="alternate" type="application/rss+xml" title="c0upons Blog" href="https://c0upons.com/blog/rss.xml" />
7475
<script
7576
type="application/ld+json"
7677
dangerouslySetInnerHTML={{ __html: JSON.stringify([orgSchema, siteSchema]) }}

apps/web/app/sitemap.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
1111
{ url: `${BASE}/docs`, changeFrequency: 'monthly', priority: 0.6 },
1212
{ url: `${BASE}/search`, changeFrequency: 'monthly', priority: 0.5 },
1313
{ url: `${BASE}/about`, changeFrequency: 'monthly', priority: 0.5 },
14+
{ url: `${BASE}/blog`, changeFrequency: 'daily', priority: 0.8 },
1415
{ url: `${BASE}/privacy`, changeFrequency: 'yearly', priority: 0.3 },
1516
{ url: `${BASE}/terms`, changeFrequency: 'yearly', priority: 0.3 },
1617
];
@@ -28,14 +29,22 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
2829
priority: 0.8,
2930
}));
3031

32+
const posts = await db.sql`SELECT slug, updated_at FROM blog_posts WHERE status = 'published' ORDER BY published_at DESC`;
33+
const postRoutes: MetadataRoute.Sitemap = posts.map((p: { slug: string; updated_at: string }) => ({
34+
url: `${BASE}/blog/${p.slug}`,
35+
lastModified: p.updated_at ? new Date(p.updated_at) : undefined,
36+
changeFrequency: 'weekly' as const,
37+
priority: 0.7,
38+
}));
39+
3140
const couponRoutes: MetadataRoute.Sitemap = coupons.map((c: { id: number; created_at: string }) => ({
3241
url: `${BASE}/coupons/${c.id}`,
3342
lastModified: c.created_at ? new Date(c.created_at) : undefined,
3443
changeFrequency: 'weekly',
3544
priority: 0.6,
3645
}));
3746

38-
return [...staticRoutes, ...storeRoutes, ...couponRoutes];
47+
return [...staticRoutes, ...postRoutes, ...storeRoutes, ...couponRoutes];
3948
} catch {
4049
return staticRoutes;
4150
}

apps/web/components/Footer.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export default function Footer() {
4040

4141
<div className="flex flex-col gap-3 text-sm">
4242
<span className="font-semibold text-gray-200 text-xs uppercase tracking-widest">Developers</span>
43+
<Link href="/blog" className="hover:text-white transition-colors">Blog</Link>
44+
<Link href="/blog/rss.xml" className="hover:text-white transition-colors">RSS Feed</Link>
4345
<Link href="/docs" className="hover:text-white transition-colors">Documentation</Link>
4446
<Link href="/docs#cli" className="hover:text-white transition-colors">CLI</Link>
4547
<Link href="/docs#api" className="hover:text-white transition-colors">REST API</Link>

apps/web/components/Header.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export default function Header() {
2626
<Link href="/stores" className="px-3 py-1.5 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors">
2727
Stores
2828
</Link>
29+
<Link href="/blog" className="px-3 py-1.5 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors">
30+
Blog
31+
</Link>
2932
<Link href="/docs" className="px-3 py-1.5 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors">
3033
Docs
3134
</Link>
@@ -69,6 +72,9 @@ export default function Header() {
6972
<Link href="/stores" className="px-3 py-2 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" onClick={() => setMenuOpen(false)}>
7073
Stores
7174
</Link>
75+
<Link href="/blog" className="px-3 py-2 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" onClick={() => setMenuOpen(false)}>
76+
Blog
77+
</Link>
7278
<Link href="/docs" className="px-3 py-2 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" onClick={() => setMenuOpen(false)}>
7379
Docs
7480
</Link>

0 commit comments

Comments
 (0)