Skip to content

Fix signup errors, add failed-signup digest, replace scroll modal with end-of-post sign-off - #1345

Open
mbifulco wants to merge 15 commits into
mainfrom
feat/newsletter-signup-conversion
Open

mbifulco wants to merge 15 commits into
mainfrom
feat/newsletter-signup-conversion

Conversation

@mbifulco

@mbifulco mbifulco commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Why

Posts turn about 0.4% of visitors into newsletter subscribers. PostHog and Cloudflare data from the last 30 days point to two causes:

  • The signup form often looked broken. Turnstile ran its check as soon as the page loaded, while the form was still far below the post. When Cloudflare asked for a click, nobody saw the request, so it timed out and retried every ~125s. About 430 people saw "We couldn't confirm you're a person" without touching the form. 18 of them tried to subscribe and only 6 got through.
  • The ask came at the wrong moment. The modal that opened at 50% scroll converted 1 in 476 views. The bottom banner, which sat below "Keep reading", got 2 signup attempts in 30 days.

Design: docs/superpowers/specs/2026-11-03-newsletter-signup-conversion-design.md
Plan: docs/superpowers/plans/2026-11-03-newsletter-signup-conversion.md

What changed

  • Turnstile timing. The check runs in execution: 'execute' mode and starts when the reader focuses the form or submits. Errors show only after a submit. The 8s wait pauses while Cloudflare shows a click challenge. A failure before submit gets one silent re-run on submit, and turnstile_error is logged once per phase.

  • Failed-signup digest. When a submit still fails Turnstile, the form reports it to mailingList.reportBlockedSignup, which checks:

    • rate limit and origin
    • honeypot and form timing (a timestamp is required and must be plausible)
    • junk-name filter and emailIsBad
    • field length limits and format checks on the path and reason
    • a client IP must be present

    Reports that pass are stored in Upstash Redis, one hash per US-Eastern day, with HSETNX, a 100/day cap and a 7-day expiry. A Vercel cron (0 13 * * *, Bearer CRON_SECRET, 120s max duration) emails Mike the previous day's entries, labeled as unverified. Resend lookups are spaced out, and the route returns 502 if the send fails. Nothing is added to the list automatically. The subscribe endpoint itself is unchanged.

  • End-of-post sign-off. PostSignoff sits right after the post body on posts and newsletter issues. It has Mike's headshot, two lines of copy, an email-only form, and a link to the latest issue. "Keep reading" now follows the article directly. The bottom banner is removed, and newsletter/signoff_viewed fires when the sign-off is half visible.

  • No scroll modal. The modal opens only from the 💌 button. The newsletter index and archive forms now pass source.

Before merging

  • Add CRON_SECRET to Vercel Production. It's required there, so without it the production build fails.
  • After deploy, trigger /api/cron/blocked-signups-digest once by hand with the Bearer secret, against a day with 2 or more entries, to see the digest render and confirm the Resend pacing.
  • Mike: approve or rewrite the sign-off text and the Turnstile error message.

Testing

  • pnpm vitest run: 345 tests pass. Six files fail to load on missing env vars (src/lib/markdown-export and the /md route), and they fail the same way without this branch.
  • pnpm tsc --noEmit is clean. pnpm build succeeded, though it ran before the final fix commits, which only touched form, server and email code.
  • pnpm lint reports only an existing parse error in .claude/skills/newsletter-image/scripts/render.mjs.
  • Checked manually on a local dev server: order is post body, sign-off, Keep reading; no modal on scroll; no first-name field in the sign-off; no horizontal overflow at 375px.
  • Not tested against the real Cloudflare widget with the always-fail key. The error path is covered by unit tests with a mocked widget.

Follow-ups (not in this PR)

  • Resend a digest after a failed cron run (keep a "sent" marker and check the last few days).
  • Cache getLatestNewsletter() during the build.
  • Next project: promoting posts and distribution.

🤖 Generated with Claude Code

https://claude.ai/code/session_012JgqHSAa6XeuSC32XJuqLz

mbifulco and others added 14 commits November 3, 2026 10:37
Covers deferring Turnstile until the reader engages, a daily digest of
signups that fail verification, an end-of-post sign-off replacing the
scroll modal, and tracking for the new sign-off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JgqHSAa6XeuSC32XJuqLz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JgqHSAa6XeuSC32XJuqLz
Adds a Vercel cron (0 13 * * *, authenticated with CRON_SECRET) that emails
the previous Eastern day's blocked signups with each address's Resend
subscription status, and notes when the 100-entry daily cap was reached.
recordBlockedSignup now uses HSETNX so the first report for an email wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JgqHSAa6XeuSC32XJuqLz
Lookups now run one at a time, 600ms apart, to stay under Resend's
per-team rate limit. The route answers 502 when entries existed but the
email was not sent, so Vercel Cron surfaces the failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JgqHSAa6XeuSC32XJuqLz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JgqHSAa6XeuSC32XJuqLz
The floating button is now the only trigger. The newsletter index and
archive forms get a source so their signups are attributable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JgqHSAa6XeuSC32XJuqLz
Drop reports without plausible form timing or with a bad email, constrain
path and reason, emit the reported event only for newly stored entries,
keep the real Turnstile failure reason over token_wait_limit, pace the
digest send, and label digest entries as unverified with a source column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JgqHSAa6XeuSC32XJuqLz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JgqHSAa6XeuSC32XJuqLz
Copilot AI balanced review requested due to automatic review settings November 3, 2026 16:47
@vercel

vercel Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
mikebifulco-com-bnbu Ready Ready Preview Nov 3, 2026 9:16pm UTC

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved issues allow concurrent reports to exceed the daily cap, break local configuration through blank environment entries, and pollute article search results with repeated signup content.

3 open issues
What changed in this PR

This PR improves newsletter signup reliability and places the subscription prompt immediately after article content. It also adds a daily digest of blocked signup attempts for manual review, without automatically subscribing anyone.

Changes:

  • Defer Turnstile verification until form engagement and report failed signup attempts.
  • Store filtered reports in Redis and send a protected, scheduled email digest.
  • Replace scroll-triggered prompts and bottom banners with an email-only sign-off, including source and visibility tracking.
File Description
vercel.json Schedule the daily digest cron.
src/​utils/​resend.ts Add subscription-status lookups.
src/​utils/​env.ts Validate the cron authorization secret.
src/​utils/​email/​templates/​BlockedSignupsDigest.tsx Render blocked reports with unverified-signup warnings.
src/​utils/​email/​tags.ts Add the digest email category.
src/​utils/​email/​sendBlockedSignupsDigest.tsx Perform paced lookups and send the digest.
src/​utils/​email/​sendBlockedSignupsDigest.test.ts Cover digest content, pacing, and failures.
src/​server/​routers/​mailingList.ts Validate and record blocked signup reports.
src/​server/​routers/​mailingList.test.ts Cover report filtering and storage outcomes.
src/​server/​redis.ts Provide a shared Redis client.
src/​server/​rateLimit.ts Reuse the shared Redis client.
src/​server/​blockedSignups.ts Store deduplicated daily reports with retention and capacity limits.
src/​server/​blockedSignups.test.ts Cover storage, deduplication, and daily limits.
src/​server/​blocked-signups-digest-route.test.ts Cover cron authorization and responses.
src/​pages/​posts/​[slug].tsx Add the sign-off and reorder related content.
src/​pages/​newsletter/​page/​[page].tsx Identify archive signup sources.
src/​pages/​newsletter/​index.tsx Identify newsletter-index signup sources.
src/​pages/​newsletter/​[slug].tsx Add the sign-off and remove the bottom banner.
src/​pages/​api/​cron/​blocked-signups-digest.ts Authorize and send the previous day's digest.
src/​lib/​newsletters.ts Select the latest published issue.
src/​hooks/​useTurnstileGate.ts Defer verification and manage retries and interactive waits.
src/​hooks/​useNewsletterModalTrigger.ts Restrict modal opening to the floating button.
src/​hooks/​useNewsletterModalTrigger.test.ts Verify manual-only modal behavior.
src/​components/​SubscriptionForm/​TurnstileStatus.tsx Revise verification-failure guidance.
src/​components/​SubscriptionForm/​SubscriptionForm.tsx Report blocked attempts and support email-only forms.
src/​components/​SubscriptionForm/​SubscriptionForm.test.tsx Cover deferred verification and blocked reports.
src/​components/​Post/​Post.tsx Forward the sign-off to the full article.
src/​components/​Post/​FullPost.tsx Render the sign-off after article content.
src/​components/​NewsletterSignup/​PostSignoff.tsx Add the signup block and visibility tracking.
src/​components/​NewsletterSignup/​PostSignoff.test.tsx Cover sign-off content, links, and tracking.
docs/​superpowers/​specs/​2026-11-03-newsletter-signup-conversion-design.md Document goals, behavior, and constraints.
docs/​superpowers/​plans/​2026-11-03-newsletter-signup-conversion.md Outline implementation and verification steps.
.env.example Add Turnstile and cron configuration examples.

Review effort: Balanced

Comment thread .env.example Outdated
Comment on lines +36 to +40
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=

# Authorizes Vercel cron requests to /api/cron routes
CRON_SECRET=
Comment thread src/server/blockedSignups.ts Outdated
Comment on lines +37 to +43
if ((await redis.hlen(key)) >= MAX_BLOCKED_SIGNUPS_PER_DAY) {
if (!(await redis.hexists(key, field))) return 'full';
}

const added = await redis.hsetnx(key, field, JSON.stringify(entry));
await redis.expire(key, RETENTION_SECONDS);
return added ? 'stored' : 'duplicate';
- Enforce the daily blocked-signup cap atomically with a Lua script, so
  concurrent reports can't exceed it.
- Lower the cap to 50 and maxDuration to 60s; Vercel Hobby rejects
  anything over 60s, which failed the preview deploy.
- Keep the post sign-off out of the Pagefind index.
- Comment out optional env entries in .env.example so blank values don't
  override the local test defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JgqHSAa6XeuSC32XJuqLz

This branch was successfully deployed

1 active deployment
Preview — 94391744 Deployed Nov 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants