Skip to content

Commit

Permalink
Buttondown newsletter subscription, setup with server actions 😎
Browse files Browse the repository at this point in the history
  • Loading branch information
simonswiss committed Aug 15, 2024
1 parent 830d46d commit 5ae09c8
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 61 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
dist/
node_modules/
*.db
.env

# ts-gql
__generated__
Expand Down
1 change: 1 addition & 0 deletions docs/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
BUTTONDOWN_API_KEY=
43 changes: 43 additions & 0 deletions docs/app/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use server'

// ------------------------------
// Buttondown subscription
// ------------------------------
export async function subscribeToButtondown (pathname: string, formData: FormData) {
try {
const data = {
email: formData.get('email'),
tags: [
...formData.getAll('tags'),
`keystone website${pathname !== '/' ? `: ${pathname}` : ' homepage'}`,
],
}

const buttondownResponse = await fetch('https://api.buttondown.email/v1/subscribers', {
method: 'POST',
headers: {
Authorization: `Token ${process.env.BUTTONDOWN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email_address: data.email,
tags: data.tags,
}),
})

if (!buttondownResponse.ok) {
const error = await buttondownResponse.json()
return {
// 409 status Conflict has no detail message
error: error?.detail || 'Sorry, an error has occurred — please try again later.',
}
}

return { success: true }
} catch (error) {
console.error('An error occurred: ', error)
return {
error: 'Sorry, an error has occurred — please try again later.',
}
}
}
141 changes: 80 additions & 61 deletions docs/components/SubscribeForm.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
/** @jsxImportSource @emotion/react */

import { Fragment, useState, type ReactNode, type SyntheticEvent, type HTMLAttributes } from 'react'
import { Fragment, useState, type ReactNode, type HTMLAttributes, useTransition } from 'react'

import { subscribeToButtondown } from '../app/actions'

import { useMediaQuery } from '../lib/media'
import { Button } from './primitives/Button'
import { Field } from './primitives/Field'
import { Stack } from './primitives/Stack'

const validEmail = (email: string) =>
/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(
email
)

const signupURL = 'https://signup.keystonejs.cloud/api/newsletter-signup'
import { usePathname } from 'next/navigation'

type SubscriptFormProps = {
autoFocus?: boolean
Expand All @@ -21,89 +17,112 @@ type SubscriptFormProps = {
} & HTMLAttributes<HTMLFormElement>

export function SubscribeForm ({ autoFocus, stacked, children, ...props }: SubscriptFormProps) {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const pathname = usePathname()
const mq = useMediaQuery()
const [isPending, startTransition] = useTransition()

const [error, setError] = useState<string | null>(null)
const [formSubmitted, setFormSubmitted] = useState(false)
const mq = useMediaQuery()

const onSubmit = (event: SyntheticEvent) => {
event.preventDefault()
setError(null)
// Check if user wants to subscribe.
// and there's a valid email address.
// Basic validation check on the email?
setLoading(true)
if (validEmail(email)) {
// if good add email to mailing list
// and redirect to dashboard.
return fetch(signupURL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
source: '@keystone-6/website',
}),
})
.then(res => {
if (res.status !== 200) {
// We explicitly set the status in our endpoint
// any status that isn't 200 we assume is a failure
// which we want to surface to the user
res.json().then(({ error }) => {
setError(error)
setLoading(false)
})
} else {
setFormSubmitted(true)
}
})
.catch(err => {
// network errors or failed parse
setError(err.toString())
setLoading(false)
})
} else {
setLoading(false)
// if email fails validation set error message
setError('Please enter a valid email')
return
}
// Augment the server action with the pathname
const subscribeToButtondownWithPathname = subscribeToButtondown.bind(null, pathname)

async function submitAction (formData: FormData) {
startTransition(async () => {
const response = await subscribeToButtondownWithPathname(formData)
if (response.error) return setError(response.error)
if (response.success) return setFormSubmitted(true)
})
}

return !formSubmitted ? (
<Fragment>
{children}
<form onSubmit={onSubmit} {...props}>
<form action={submitAction} {...props}>
<Stack
orientation={stacked ? 'vertical' : 'horizontal'}
block={stacked}
gap={5}
css={{
justifyItems: stacked ? 'baseline' : undefined,
}}
>
<label
htmlFor="email"
css={{
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
borderWidth: '0',
}}
>
Email address
</label>
<Field
type="email"
autoComplete="off"
autoFocus={autoFocus}
placeholder="Your email address"
value={email}
onChange={e => setEmail(e.target.value)}
css={mq({
maxWidth: '25rem',
margin: ['0 auto', 0],
})}
name="email"
id="email"
required
/>
<Button look="secondary" size="small" loading={loading} type={'submit'}>

<div css={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem 0.75rem' }}>
<div css={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<input
type="checkbox"
name="tags"
id="mailing-list-keystone"
css={{ height: '1rem', width: '1rem' }}
value="keystone_list"
defaultChecked
/>
<label css={{ fontSize: '0.9rem' }} htmlFor="mailing-list-keystone">
Keystone news
</label>
</div>
<div css={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<input
type="checkbox"
name="tags"
id="mailing-list-thinkmill"
css={{ height: '1rem', width: '1rem' }}
value="thinkmill_list"
/>
<label css={{ fontSize: '0.9rem' }} htmlFor="mailing-list-thinkmill">
Thinkmill news (
<a
href="https://www.thinkmill.com.au/newsletter"
target="_blank"
aria-label="Thinkmill (Opens in new tab)"
>
example
</a>
)
</label>
</div>
</div>

<Button look="secondary" size="small" loading={isPending} type="submit">
{error ? 'Try again' : 'Subscribe'}
</Button>
</Stack>
{error ? <p css={{ margin: '0.5rem, 0', color: 'red' }}>{error}</p> : null}
{error ? (
<p css={{ marginTop: '0.5rem', color: 'red', fontSize: '0.85rem' }}>{error}</p>
) : null}
</form>
</Fragment>
) : (
<p>❤️ Thank you for subscribing!</p>
<p>❤️ Thank you! Please check your email to confirm your subscription.</p>
)
}

0 comments on commit 5ae09c8

Please sign in to comment.