mirror of
				https://github.com/r-freeman/portfolio.git
				synced 2025-11-04 15:51:11 +00:00 
			
		
		
		
	Add subscription feature
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
		
			
				
	
				Build And Publish / BuildAndPublish (push) Successful in 3m23s
				
			
		
		
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	Build And Publish / BuildAndPublish (push) Successful in 3m23s
				
			This commit is contained in:
		
							parent
							
								
									272a5cecab
								
							
						
					
					
						commit
						823e95df34
					
				@ -13,4 +13,8 @@ AUTH_SECRET=
 | 
			
		||||
AUTH_TRUST_HOST=
 | 
			
		||||
AUTH_REDIRECT_PROXY_URL=
 | 
			
		||||
NTFY_URL=
 | 
			
		||||
NTFY_TOKEN=
 | 
			
		||||
NTFY_TOKEN=
 | 
			
		||||
LISTMONK_URL=
 | 
			
		||||
LISTMONK_LIST_ID=
 | 
			
		||||
LISTMONK_USERNAME=
 | 
			
		||||
LISTMONK_TOKEN=
 | 
			
		||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -3,6 +3,8 @@ prisma/**/*
 | 
			
		||||
!prisma/schema.prisma
 | 
			
		||||
.env
 | 
			
		||||
 | 
			
		||||
encrypt_secrets.sh
 | 
			
		||||
 | 
			
		||||
supabase/
 | 
			
		||||
!/lib/supabase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										32
									
								
								app/actions/subscribe.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								app/actions/subscribe.ts
									
									
									
									
									
										Normal 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'}
 | 
			
		||||
}
 | 
			
		||||
@ -6,6 +6,7 @@ import {Views} from '@/components/ui/Views'
 | 
			
		||||
import {ArrowDownIcon} from '@/components/icons/ArrowDownIcon'
 | 
			
		||||
import ArticleNav from '@/components/ui/ArticleNav'
 | 
			
		||||
import {Comments} from '@/components/ui/Comments'
 | 
			
		||||
import {Subscribe} from '@/components/ui/Subscribe'
 | 
			
		||||
import {getAllArticles} from '@/lib/getAllArticles'
 | 
			
		||||
import {getComments} from '@/lib/getComments'
 | 
			
		||||
import {format} from 'date-fns'
 | 
			
		||||
@ -83,6 +84,7 @@ export async function ArticleLayout({
 | 
			
		||||
                        </header>
 | 
			
		||||
                        <Prose className="mt-8" data-mdx-content>{children}</Prose>
 | 
			
		||||
                    </article>
 | 
			
		||||
                    <Subscribe/>
 | 
			
		||||
                    <Comments slug={slug} comments={comments}/>
 | 
			
		||||
                    <ArticleNav prev={prev} next={next}/>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
@ -6,11 +6,12 @@ import {CrossIcon} from '@/components/icons/CrossIcon'
 | 
			
		||||
type StatusMessageProps = {
 | 
			
		||||
    children: ReactNode
 | 
			
		||||
    className?: string
 | 
			
		||||
    errorConditions?: string[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function StatusMessage({children, className}: StatusMessageProps) {
 | 
			
		||||
    const errorConditions = ['error', 'problem']
 | 
			
		||||
    const isError = errorConditions.some(condition => children?.toString().toLowerCase().includes(condition))
 | 
			
		||||
export function StatusMessage({children, className, errorConditions}: StatusMessageProps) {
 | 
			
		||||
    const _errorConditions = ['error', 'problem', ...errorConditions ?? []]
 | 
			
		||||
    const isError = _errorConditions.some(condition => children?.toString().toLowerCase().includes(condition))
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										50
									
								
								components/ui/Subscribe.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								components/ui/Subscribe.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								lib/listmonk.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								lib/listmonk.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,25 @@
 | 
			
		||||
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')
 | 
			
		||||
        if (response.status >= 500) throw new Error('Server error')
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user