Skip to content

Commit 65272fe

Browse files
khaliqgantclaude
andcommitted
Add Buttondown newsletter integration with signup forms and agent API
Adds a full newsletter system powered by Buttondown: - Reusable signup component (card + inline variants) matching the site aesthetic - Cloudflare Pages Functions for subscribe, email management, and subscriber management - Agent-managed endpoints (auth'd with CRON_WEBHOOK_SECRET) for drafting and sending - Newsletter CTA on homepage, post pages (post-read), and footer - Deploy workflow syncs BUTTONDOWN_API_KEY from GitHub secrets to Cloudflare Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f22e7ff commit 65272fe

11 files changed

Lines changed: 590 additions & 1 deletion

File tree

.github/workflows/deploy.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ jobs:
3131
- name: Build static site
3232
run: npm run build
3333

34+
- name: Sync secrets to Cloudflare Pages
35+
if: ${{ secrets.BUTTONDOWN_API_KEY != '' }}
36+
env:
37+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
38+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
39+
run: echo "${{ secrets.BUTTONDOWN_API_KEY }}" | npx wrangler pages secret put BUTTONDOWN_API_KEY --project-name=proactive-agents
40+
3441
- name: Publish to Cloudflare Pages
3542
uses: cloudflare/wrangler-action@v3
3643
with:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
.env
23

34
# dependencies
45
/node_modules

agents/shared/runtime/cloudflare-context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type CfEnv = GithubEnv & {
1717
NANGO_HOST?: string;
1818
NANGO_NOTION_CONNECTION_ID?: string;
1919
NOTION_DATABASE_ID?: string;
20+
BUTTONDOWN_API_KEY?: string;
2021
};
2122

2223
const REPO_OWNER = "AgentWorkforce";

app/page.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { BackgroundOrbs } from "@/components/background-orbs";
44
import { ScrollReveal } from "@/components/scroll-reveal";
55
import { Sparkle } from "@/components/decorations";
66
import { CardArt, ClockListenerInbox } from "@/components/card-illustrations";
7+
import { NewsletterSignup } from "@/components/newsletter-signup";
78
import {
89
jsonLd,
910
websiteSchema,
@@ -488,6 +489,13 @@ export default async function Home() {
488489
</div>
489490
</div>
490491
</section>
492+
493+
{/* NEWSLETTER */}
494+
<section className="relative mt-24 sm:mt-40">
495+
<div className="reveal mx-auto max-w-3xl px-5 sm:px-10">
496+
<NewsletterSignup />
497+
</div>
498+
</section>
491499
</>
492500
);
493501
}

app/posts/[slug]/page.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ScrollToTop } from "@/components/scroll-to-top";
1010
import { mdxComponents } from "@/components/mdx/mdx-components";
1111
import { Asterism } from "@/components/decorations";
1212
import { AgentActions } from "@/components/agent-actions";
13+
import { NewsletterSignup } from "@/components/newsletter-signup";
1314
import {
1415
jsonLd,
1516
articleSchema,
@@ -124,6 +125,13 @@ export default async function PostPage({
124125
<Asterism className="h-4 opacity-70" />
125126
</div>
126127

128+
<div className="mt-12">
129+
<NewsletterSignup
130+
heading="Liked this essay?"
131+
description="Get the next one in your inbox. One email per essay, no spam."
132+
/>
133+
</div>
134+
127135
<div className="mt-12 border-t border-rule pt-8">
128136
<p className="text-xs uppercase tracking-[0.22em] text-ink-faint">
129137
Posted {formatDate(post.date)}

components/newsletter-signup.tsx

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"use client";
2+
3+
import { useState, type FormEvent } from "react";
4+
5+
type Status = "idle" | "loading" | "success" | "error";
6+
7+
export function NewsletterSignup({
8+
variant = "card",
9+
heading,
10+
description,
11+
}: {
12+
variant?: "inline" | "card";
13+
heading?: string;
14+
description?: string;
15+
}) {
16+
const [email, setEmail] = useState("");
17+
const [status, setStatus] = useState<Status>("idle");
18+
const [message, setMessage] = useState("");
19+
20+
async function handleSubmit(e: FormEvent) {
21+
e.preventDefault();
22+
if (!email) return;
23+
setStatus("loading");
24+
25+
try {
26+
const res = await fetch("/api/newsletter/subscribe", {
27+
method: "POST",
28+
headers: { "Content-Type": "application/json" },
29+
body: JSON.stringify({ email }),
30+
});
31+
const data = (await res.json()) as {
32+
ok: boolean;
33+
error?: string;
34+
message?: string;
35+
};
36+
37+
if (data.ok) {
38+
setStatus("success");
39+
setMessage(data.message ?? "Check your inbox to confirm.");
40+
setEmail("");
41+
} else {
42+
setStatus("error");
43+
setMessage(data.error ?? "Something went wrong.");
44+
}
45+
} catch {
46+
setStatus("error");
47+
setMessage("Something went wrong. Try again.");
48+
}
49+
}
50+
51+
if (variant === "inline") return <InlineForm email={email} setEmail={setEmail} status={status} setStatus={setStatus} message={message} onSubmit={handleSubmit} />;
52+
return <CardForm email={email} setEmail={setEmail} status={status} setStatus={setStatus} message={message} heading={heading} description={description} onSubmit={handleSubmit} />;
53+
}
54+
55+
function InlineForm({
56+
email,
57+
setEmail,
58+
status,
59+
setStatus,
60+
message,
61+
onSubmit,
62+
}: {
63+
email: string;
64+
setEmail: (v: string) => void;
65+
status: Status;
66+
setStatus: (v: Status) => void;
67+
message: string;
68+
onSubmit: (e: FormEvent) => void;
69+
}) {
70+
if (status === "success") {
71+
return (
72+
<p className="mt-4 font-serif text-[0.85rem] leading-relaxed text-moss">
73+
{message}
74+
</p>
75+
);
76+
}
77+
78+
return (
79+
<form onSubmit={onSubmit} className="mt-4 flex gap-2">
80+
<label htmlFor="footer-email" className="sr-only">Email address</label>
81+
<input
82+
id="footer-email"
83+
type="email"
84+
required
85+
placeholder="you@example.com"
86+
value={email}
87+
onChange={(e) => {
88+
setEmail(e.target.value);
89+
if (status === "error") setStatus("idle");
90+
}}
91+
className="w-full max-w-56 rounded-full border border-rule bg-paper px-4 py-2 font-mono text-xs text-ink placeholder:text-ink-faint/50 transition-colors focus:border-terracotta/50 focus:outline-none"
92+
/>
93+
<input type="text" name="hp" aria-hidden="true" tabIndex={-1} className="absolute -left-[9999px]" autoComplete="off" />
94+
<button
95+
type="submit"
96+
disabled={status === "loading"}
97+
className="shrink-0 rounded-full bg-ink px-4 py-2 text-xs font-medium tracking-wide text-paper transition-all hover:-translate-y-0.5 hover:bg-ink/85 disabled:opacity-50"
98+
>
99+
{status === "loading" ? "…" : "Subscribe"}
100+
</button>
101+
{status === "error" && (
102+
<p className="self-center font-serif text-xs text-terracotta">{message}</p>
103+
)}
104+
</form>
105+
);
106+
}
107+
108+
function CardForm({
109+
email,
110+
setEmail,
111+
status,
112+
setStatus,
113+
message,
114+
heading,
115+
description,
116+
onSubmit,
117+
}: {
118+
email: string;
119+
setEmail: (v: string) => void;
120+
status: Status;
121+
setStatus: (v: Status) => void;
122+
message: string;
123+
heading?: string;
124+
description?: string;
125+
onSubmit: (e: FormEvent) => void;
126+
}) {
127+
return (
128+
<div className="relative overflow-hidden rounded-[1.5rem] border border-rule bg-paper-deep/50 px-6 py-10 sm:rounded-[2rem] sm:px-14 sm:py-14">
129+
<div className="absolute -left-20 -top-20 h-64 w-64 rounded-full bg-peach/50 blur-3xl" />
130+
<div className="absolute -bottom-24 -right-16 h-72 w-72 rounded-full bg-lavender/40 blur-3xl" />
131+
132+
<div className="relative">
133+
<p className="font-display text-sm uppercase tracking-[0.28em] text-terracotta">
134+
✦ Newsletter
135+
</p>
136+
<h3 className="mt-3 font-display text-[clamp(1.6rem,3.2vw,2.2rem)] leading-[1.1] tracking-tight text-ink">
137+
{heading ?? "New essays, straight to your inbox."}
138+
</h3>
139+
<p className="mt-4 max-w-lg font-serif text-[1.02rem] leading-relaxed text-ink-soft">
140+
{description ??
141+
"One email when we publish. No spam, no sales pitches, just the next piece on proactive agents."}
142+
</p>
143+
144+
{status === "success" ? (
145+
<div className="mt-8 flex items-center gap-3 rounded-xl border border-sage/60 bg-sage/20 px-5 py-4">
146+
<span className="text-lg" aria-hidden></span>
147+
<p className="font-serif text-[0.95rem] text-ink">{message}</p>
148+
</div>
149+
) : (
150+
<form
151+
onSubmit={onSubmit}
152+
className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center"
153+
>
154+
<label htmlFor="card-email" className="sr-only">Email address</label>
155+
<input
156+
id="card-email"
157+
type="email"
158+
required
159+
placeholder="you@example.com"
160+
value={email}
161+
onChange={(e) => {
162+
setEmail(e.target.value);
163+
if (status === "error") setStatus("idle");
164+
}}
165+
className="w-full rounded-full border border-rule bg-paper px-5 py-3 font-mono text-sm text-ink placeholder:text-ink-faint/50 transition-colors focus:border-terracotta/50 focus:outline-none sm:max-w-72"
166+
/>
167+
<input type="text" name="hp" aria-hidden="true" tabIndex={-1} className="absolute -left-[9999px]" autoComplete="off" />
168+
<button
169+
type="submit"
170+
disabled={status === "loading"}
171+
className="group inline-flex shrink-0 items-center justify-center gap-3 rounded-full bg-ink px-6 py-3 text-sm font-medium tracking-wide text-paper transition-all hover:-translate-y-0.5 hover:bg-ink/85 disabled:opacity-50"
172+
>
173+
{status === "loading" ? "Subscribing…" : "Subscribe"}
174+
<span
175+
aria-hidden
176+
className="transition-transform group-hover:translate-x-1"
177+
>
178+
179+
</span>
180+
</button>
181+
</form>
182+
)}
183+
{status === "error" && (
184+
<p className="mt-3 font-serif text-sm text-terracotta">{message}</p>
185+
)}
186+
</div>
187+
</div>
188+
);
189+
}

components/site-footer.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import Link from "next/link";
22
import { SiteLogo } from "@/components/site-logo";
3+
import { NewsletterSignup } from "@/components/newsletter-signup";
34

45
export function SiteFooter() {
56
return (
67
<footer className="relative z-20 mt-24 border-t border-rule/70 sm:mt-32">
78
<div className="mx-auto max-w-6xl px-5 py-12 sm:px-10 sm:py-14">
89
<div className="grid gap-10 sm:grid-cols-12 sm:gap-8">
9-
{/* Brand */}
10+
{/* Brand + Newsletter */}
1011
<div className="sm:col-span-7">
1112
<div className="flex items-center gap-3">
1213
<SiteLogo className="h-7 w-auto" />
@@ -15,6 +16,10 @@ export function SiteFooter() {
1516
<p className="mt-3 max-w-md font-serif text-[0.95rem] leading-relaxed text-ink-soft">
1617
A working manual on the agents that don&rsquo;t wait to be asked.
1718
</p>
19+
<p className="mt-5 font-serif text-xs text-ink-faint">
20+
Get new essays delivered to your inbox.
21+
</p>
22+
<NewsletterSignup variant="inline" />
1823
</div>
1924

2025
{/* Links */}

0 commit comments

Comments
 (0)