Skip to content

Commit 007e3a3

Browse files
authored
Merge pull request #756 from zackproser/codex/implement-free-tier-with-email-submission-7ybr10
Improve email gate logic
2 parents 7fa09bb + 36c5fdc commit 007e3a3

File tree

8 files changed

+128
-23
lines changed

8 files changed

+128
-23
lines changed

src/app/blog/[slug]/page.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ArticleLayout } from '@/components/ArticleLayout'
1212
import React from 'react'
1313
import { CheckCircle } from 'lucide-react'
1414
import { metadataLogger as logger } from '@/utils/logger'
15+
import { isEmailSubscribed } from '@/lib/newsletter'
1516

1617
// Content type for this handler
1718
const CONTENT_TYPE = 'blog'
@@ -80,13 +81,20 @@ export default async function Page({ params }: PageProps) {
8081
} else {
8182
logger.debug(`Content (${slug}) is not marked as paid.`);
8283
}
84+
85+
let isSubscribed = false;
86+
if (content?.commerce?.requiresEmail) {
87+
isSubscribed = await isEmailSubscribed(session?.user?.email || null);
88+
}
8389

8490
logger.info(`Rendering page for slug: ${slug}, Paid: ${!!content?.commerce?.isPaid}, Purchased: ${hasPurchased}`);
8591

8692
// Always use ArticleLayout for consistency, even for purchased content
93+
const hideNewsletter = !!(content?.commerce?.requiresEmail && !isSubscribed)
94+
8795
return (
8896
<>
89-
<ArticleLayout metadata={content} serverHasPurchased={hasPurchased}>
97+
<ArticleLayout metadata={content} serverHasPurchased={hasPurchased} hideNewsletter={hideNewsletter}>
9098
{hasPurchased ? (
9199
<div className="purchased-content">
92100
<div className="inline-flex items-center gap-2 bg-green-50 border border-green-200 rounded-full px-4 py-2 mb-6">
@@ -96,7 +104,7 @@ export default async function Page({ params }: PageProps) {
96104
{React.createElement(MdxContent)}
97105
</div>
98106
) : (
99-
renderPaywalledContent(MdxContent, content, hasPurchased)
107+
renderPaywalledContent(MdxContent, content, hasPurchased, isSubscribed)
100108
)}
101109
</ArticleLayout>
102110
</>

src/app/learn/courses/[slug]/page.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
renderPaywalledContent,
1212
getDefaultPaywallText
1313
} from '@/lib/content-handlers'
14+
import { isEmailSubscribed } from '@/lib/newsletter'
1415

1516
// Content type for this handler
1617
const CONTENT_TYPE = 'learn/courses'
@@ -48,9 +49,14 @@ export default async function CourseSlugPage({ params }: PageProps) {
4849
// Get user ID from session
4950
const session = await auth()
5051
const userId = session?.user.id
51-
52+
5253
// Check if user has purchased access
5354
const userHasPurchased = await hasUserPurchased(userId, CONTENT_TYPE, slug)
55+
56+
let isSubscribed = false
57+
if (content?.commerce?.requiresEmail) {
58+
isSubscribed = await isEmailSubscribed(session?.user?.email || null)
59+
}
5460

5561
// Check if content requires payment
5662
if (content?.commerce?.isPaid) {
@@ -64,7 +70,7 @@ export default async function CourseSlugPage({ params }: PageProps) {
6470
{/* Note: We pass MdxContent to renderPaywalledContent below,
6571
so no need to render it separately here unless you want a specific preview structure */}
6672
{/* <MdxContent /> */}
67-
{renderPaywalledContent(MdxContent, content, userHasPurchased)}
73+
{renderPaywalledContent(MdxContent, content, userHasPurchased, isSubscribed)}
6874
{/* Pass the entire content object to Paywall */}
6975
<Paywall
7076
content={content}
@@ -81,7 +87,7 @@ export default async function CourseSlugPage({ params }: PageProps) {
8187
return (
8288
<>
8389
{/* Render potentially paywalled content (will render full if purchased or not paid) */}
84-
{renderPaywalledContent(MdxContent, content, userHasPurchased)}
90+
{renderPaywalledContent(MdxContent, content, userHasPurchased, isSubscribed)}
8591
</>
8692
)
8793
}

src/components/ArticleContent.tsx

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import React from 'react'
44
import Paywall from './Paywall'
5+
import NewsletterWrapper from './NewsletterWrapper'
6+
import EmailSignupGate from './EmailSignupGate'
57
import { StaticImageData } from 'next/image'
68
import { Content } from '@/types'
79

@@ -14,24 +16,28 @@ interface ArticleContentProps {
1416
paywallBody?: string
1517
buttonText?: string
1618
content: Content
19+
requiresEmail?: boolean
20+
isSubscribed?: boolean
1721
}
1822

1923
export default function ArticleContent({
20-
children,
24+
children,
2125
showFullContent,
2226
previewLength = 150,
2327
previewElements = 3,
2428
paywallHeader,
2529
paywallBody,
2630
buttonText,
27-
content
31+
content,
32+
requiresEmail = false,
33+
isSubscribed = false
2834
}: ArticleContentProps) {
2935
if (!content.slug) {
3036
console.warn('ArticleContent: content.slug is missing, rendering full content')
3137
return <>{children}</>
3238
}
3339

34-
if (showFullContent) {
40+
if (showFullContent || (requiresEmail && isSubscribed)) {
3541
return <>{children}</>
3642
}
3743

@@ -58,12 +64,19 @@ export default function ArticleContent({
5864
<div className="article-preview">
5965
{preview}
6066
</div>
61-
<Paywall
62-
content={content}
63-
paywallHeader={paywallHeader}
64-
paywallBody={paywallBody}
65-
buttonText={buttonText}
66-
/>
67+
{requiresEmail ? (
68+
<EmailSignupGate
69+
header={paywallHeader}
70+
body={paywallBody}
71+
/>
72+
) : (
73+
<Paywall
74+
content={content}
75+
paywallHeader={paywallHeader}
76+
paywallBody={paywallBody}
77+
buttonText={buttonText}
78+
/>
79+
)}
6780
</>
6881
)
69-
}
82+
}

src/components/ArticleLayout.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@ interface ArticleLayoutProps {
2323
miniPaywallDescription?: string | null
2424
}
2525
serverHasPurchased?: boolean
26+
hideNewsletter?: boolean
2627
}
2728

2829
export function ArticleLayout({
2930
children,
3031
metadata,
3132
serverHasPurchased = false,
33+
hideNewsletter = false,
3234
}: ArticleLayoutProps) {
3335
const router = useRouter()
3436
const { data: session } = useSession()
@@ -192,10 +194,12 @@ export function ArticleLayout({
192194
{children}
193195
</Prose>
194196
</article>
195-
<NewsletterWrapper
196-
title={'If you made it this far, you can do anything!'}
197-
body={'I publish technical content for developers who want to skill up, and break down AI, vector databases and tools for investors'}
198-
/>
197+
{!hideNewsletter && (
198+
<NewsletterWrapper
199+
title={'If you made it this far, you can do anything!'}
200+
body={'I publish technical content for developers who want to skill up, and break down AI, vector databases and tools for investors'}
201+
/>
202+
)}
199203
<Suspense fallback={<div>Loading...</div>}>
200204
<GiscusWrapper />
201205
</Suspense>

src/components/EmailSignupGate.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use client'
2+
3+
import NewsletterWrapper from './NewsletterWrapper'
4+
5+
interface EmailSignupGateProps {
6+
header?: string
7+
body?: string
8+
}
9+
10+
export default function EmailSignupGate({
11+
header,
12+
body
13+
}: EmailSignupGateProps) {
14+
return (
15+
<div className="my-8 p-8 bg-gradient-to-br from-zinc-100 to-zinc-200 dark:from-zinc-800 dark:to-zinc-900 rounded-xl shadow-xl border border-zinc-300 dark:border-zinc-700">
16+
<NewsletterWrapper
17+
title={header || 'Sign in & subscribe to read for free'}
18+
body={body || 'Sign in to zackproser.com and subscribe to unlock this article.'}
19+
successMessage="Thanks for subscribing! Refresh to view the full article."
20+
position="paywall"
21+
/>
22+
</div>
23+
)
24+
}

src/lib/content-handlers.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -447,14 +447,29 @@ export function getDefaultPaywallText(contentType: string): {
447447
export function renderPaywalledContent(
448448
MdxContent: React.ComponentType,
449449
content: Content, // Use the processed Content type
450-
hasPurchased: boolean
450+
hasPurchased: boolean,
451+
isSubscribed: boolean
451452
) {
452453
// Determine if we should show the full content
453-
const showFullContent = !content.commerce?.isPaid || hasPurchased;
454+
const showFullContent =
455+
(!content.commerce?.isPaid && !content.commerce?.requiresEmail) ||
456+
hasPurchased ||
457+
(content.commerce?.requiresEmail && isSubscribed);
454458

455459
// Get default paywall text based on content type
456460
const defaultText = getDefaultPaywallText(content.type);
457461

462+
let paywallHeader = defaultText.header;
463+
let paywallBody = defaultText.body;
464+
465+
if (content.commerce?.requiresEmail) {
466+
paywallHeader = content.commerce.paywallHeader || 'Sign in & subscribe to read for free';
467+
paywallBody = content.commerce.paywallBody || 'Sign in to zackproser.com and subscribe to unlock this content.';
468+
} else {
469+
paywallHeader = content.commerce?.paywallHeader || defaultText.header;
470+
paywallBody = content.commerce?.paywallBody || defaultText.body;
471+
}
472+
458473
// Dynamically import ArticleContent to avoid circular dependencies if this file is imported in components
459474
// Note: This file is marked 'server-only', but ArticleContent likely exists client-side,
460475
// so dynamic import with `ssr: false` might be needed depending on ArticleContent's usage.
@@ -473,9 +488,11 @@ export function renderPaywalledContent(
473488
title: content.title, // Use the processed title
474489
previewLength: content.commerce?.previewLength,
475490
previewElements: content.commerce?.previewElements,
476-
paywallHeader: content.commerce?.paywallHeader || defaultText.header,
477-
paywallBody: content.commerce?.paywallBody || defaultText.body,
491+
paywallHeader: paywallHeader,
492+
paywallBody: paywallBody,
478493
buttonText: content.commerce?.buttonText || defaultText.buttonText,
494+
requiresEmail: content.commerce?.requiresEmail,
495+
isSubscribed: isSubscribed,
479496
// Pass content object itself if ArticleContent needs more data
480497
content: content,
481498
}

src/lib/newsletter.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { logger } from '@/utils/logger'
2+
3+
/**
4+
* Check if an email address is subscribed to the main EmailOctopus list.
5+
* Returns true when the contact exists and has status 'SUBSCRIBED'.
6+
*/
7+
export async function isEmailSubscribed(email: string | null | undefined): Promise<boolean> {
8+
if (!email) return false
9+
10+
try {
11+
const apiKey = process.env.EMAIL_OCTOPUS_API_KEY
12+
const listId = process.env.EMAIL_OCTOPUS_LIST_ID
13+
if (!apiKey || !listId) return false
14+
15+
const endpoint = `https://emailoctopus.com/api/1.6/lists/${listId}/contacts/${encodeURIComponent(email)}?api_key=${apiKey}`
16+
const res = await fetch(endpoint)
17+
if (!res.ok) {
18+
logger.warn(`EmailOctopus lookup failed for ${email}: ${res.status}`)
19+
return false
20+
}
21+
22+
const data = await res.json()
23+
return data.status === 'SUBSCRIBED'
24+
} catch (err) {
25+
logger.error('Failed to check email subscription', err)
26+
return false
27+
}
28+
}

src/types/commerce.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import { StaticImageData } from 'next/image'
33
// Commerce-related types
44
export interface CommerceConfig {
55
isPaid: boolean
6+
/**
7+
* When true, the content is free but requires the user to submit
8+
* their email and be subscribed to the newsletter.
9+
*/
10+
requiresEmail?: boolean
611
price: number
712
stripe_price_id?: string
813
previewLength?: number

0 commit comments

Comments
 (0)