Add subscription feature
All checks were successful
Build And Publish / BuildAndPublish (push) Successful in 3m11s

This commit is contained in:
Ryan Freeman 2025-04-22 16:48:06 +01:00
parent 272a5cecab
commit 36532bc1f1
8 changed files with 123 additions and 4 deletions

View File

@ -13,4 +13,8 @@ AUTH_SECRET=
AUTH_TRUST_HOST= AUTH_TRUST_HOST=
AUTH_REDIRECT_PROXY_URL= AUTH_REDIRECT_PROXY_URL=
NTFY_URL= NTFY_URL=
NTFY_TOKEN= NTFY_TOKEN=
LISTMONK_URL=
LISTMONK_LIST_ID=
LISTMONK_USERNAME=
LISTMONK_TOKEN=

BIN
.env.gpg

Binary file not shown.

2
.gitignore vendored
View File

@ -3,6 +3,8 @@ prisma/**/*
!prisma/schema.prisma !prisma/schema.prisma
.env .env
encrypt_secrets.sh
supabase/ supabase/
!/lib/supabase !/lib/supabase

32
app/actions/subscribe.ts Normal file
View File

@ -0,0 +1,32 @@
'use server'
import {z} from 'zod'
import {addSubscriber} from '@/lib/listmonk'
export async function subscribe(prevState: { message: string }, formData: FormData) {
const schema = z.object({
email: z.string().email()
})
const {email} = {
email: formData.get('email')
}
const parse = schema.safeParse({email})
if (!parse.success) {
return {message: 'Error, your email address is invalid.'}
}
try {
await addSubscriber(email)
} catch (error) {
let errorMessage
if (error instanceof Error) errorMessage = error.message
else errorMessage = String(error)
console.error(error)
return {message: errorMessage}
}
return {message: 'Subscribed'}
}

View File

@ -6,6 +6,7 @@ import {Views} from '@/components/ui/Views'
import {ArrowDownIcon} from '@/components/icons/ArrowDownIcon' import {ArrowDownIcon} from '@/components/icons/ArrowDownIcon'
import ArticleNav from '@/components/ui/ArticleNav' import ArticleNav from '@/components/ui/ArticleNav'
import {Comments} from '@/components/ui/Comments' import {Comments} from '@/components/ui/Comments'
import {Subscribe} from '@/components/ui/Subscribe'
import {getAllArticles} from '@/lib/getAllArticles' import {getAllArticles} from '@/lib/getAllArticles'
import {getComments} from '@/lib/getComments' import {getComments} from '@/lib/getComments'
import {format} from 'date-fns' import {format} from 'date-fns'
@ -83,6 +84,7 @@ export async function ArticleLayout({
</header> </header>
<Prose className="mt-8" data-mdx-content>{children}</Prose> <Prose className="mt-8" data-mdx-content>{children}</Prose>
</article> </article>
<Subscribe/>
<Comments slug={slug} comments={comments}/> <Comments slug={slug} comments={comments}/>
<ArticleNav prev={prev} next={next}/> <ArticleNav prev={prev} next={next}/>
</div> </div>

View File

@ -6,11 +6,12 @@ import {CrossIcon} from '@/components/icons/CrossIcon'
type StatusMessageProps = { type StatusMessageProps = {
children: ReactNode children: ReactNode
className?: string className?: string
errorConditions?: string[]
} }
export function StatusMessage({children, className}: StatusMessageProps) { export function StatusMessage({children, className, errorConditions}: StatusMessageProps) {
const errorConditions = ['error', 'problem'] const _errorConditions = ['error', 'problem', ...errorConditions ?? []]
const isError = errorConditions.some(condition => children?.toString().toLowerCase().includes(condition)) const isError = _errorConditions.some(condition => children?.toString().toLowerCase().includes(condition))
return ( return (
<> <>

View File

@ -0,0 +1,50 @@
'use client'
import React, {useActionState} from 'react'
import {Button} from '@/components/ui/Button'
import {InboxIcon} from '@/components/icons/InboxIcon'
import {subscribe} from '@/app/actions/subscribe'
import {StatusMessage} from '@/components/ui/StatusMessage'
type InitialState = {
message: string
}
const initialState: InitialState = {
message: ''
}
export function Subscribe() {
const [state, formAction, pending] = useActionState(subscribe, initialState)
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'Enter' || e.key === 'NumpadEnter')) {
e.preventDefault()
e.currentTarget.form?.requestSubmit()
}
}
return (
<div className="mt-24 rounded-2xl p-px dark:bg-gradient-to-r dark:from-pink-500 dark:via-red-500 dark:to-yellow-500">
<div className="rounded-2xl border border-zinc-100 p-6 dark:border-zinc-700/40 bg-white dark:bg-gray-950">
<h2 className="flex items-center text-base font-semibold text-zinc-900 dark:text-zinc-100">
<InboxIcon className="size-6"/>
<span className="ml-3">Stay in the loop</span>
</h2>
<p className="mt-2 text-base text-zinc-600 dark:text-gray-400">
Subscribe to my newsletter and get notified when I publish more content like this.
</p>
<form className="mt-6 flex flex-col sm:flex-row gap-x-2"
action={formAction}>
<input type="email" name="email" id="email" required onKeyDown={handleKeyDown}
placeholder="Your email address"
className="rounded-md px-3 py-1.5 text-base text-zinc-600 dark:text-zinc-400 bg-[#fafafa] dark:bg-[#121212] border-[1px] dark:border-zinc-700/40 -outline-offset-1 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 focus:dark:outline-indigo-600"/>
<Button variant="secondary" className="mt-2 sm:mt-0" disabled={pending}>
Subscribe
</Button>
<StatusMessage className="mt-2 sm:mt-0 sm:ml-2" errorConditions={['already']}>{state?.message}</StatusMessage>
</form>
</div>
</div>
)
}

28
lib/listmonk.ts Normal file
View File

@ -0,0 +1,28 @@
const LISTMONK_URL = process.env.LISTMONK_URL ?? ''
const LISTMONK_LIST_ID = process.env.LISTMONK_LIST_ID ?? ''
const LISTMONK_USERNAME = process.env.LISTMONK_USERNAME ?? ''
const LISTMONK_TOKEN = process.env.LISTMONK_TOKEN ?? ''
export async function addSubscriber(email: FormDataEntryValue | null) {
if (email !== null && LISTMONK_URL !== '') {
const headers = new Headers()
headers.append('Content-Type', 'application/json')
headers.append('Authorization', 'Basic ' + Buffer.from(LISTMONK_USERNAME + ':' + LISTMONK_TOKEN).toString('base64'))
const response = await fetch(LISTMONK_URL, {
method: 'POST',
headers: headers,
body: JSON.stringify({
email: email,
lists: [parseInt(LISTMONK_LIST_ID)],
preconfirm_subscriptions: true
})
})
if (response.status === 409) {
throw new Error('Already subscribed')
} else if (response.status !== 200) {
throw new Error('Server error')
}
}
}