Skip to content

Commit 6e5b50d

Browse files
authored
Add buttondown for the /docs newsletter subscription using server actions 😎 (#9293)
1 parent dc3802a commit 6e5b50d

File tree

4 files changed

+125
-61
lines changed

4 files changed

+125
-61
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
dist/
44
node_modules/
55
*.db
6+
.env
67

78
# ts-gql
89
__generated__

docs/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
BUTTONDOWN_API_KEY=

docs/app/actions.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
'use server'
2+
3+
// ------------------------------
4+
// Buttondown subscription
5+
// ------------------------------
6+
export async function subscribeToButtondown (pathname: string, formData: FormData) {
7+
try {
8+
const data = {
9+
email: formData.get('email'),
10+
tags: [
11+
...formData.getAll('tags'),
12+
`keystone website${pathname !== '/' ? `: ${pathname}` : ' homepage'}`,
13+
],
14+
}
15+
16+
const buttondownResponse = await fetch('https://api.buttondown.email/v1/subscribers', {
17+
method: 'POST',
18+
headers: {
19+
Authorization: `Token ${process.env.BUTTONDOWN_API_KEY}`,
20+
'Content-Type': 'application/json',
21+
},
22+
body: JSON.stringify({
23+
email_address: data.email,
24+
tags: data.tags,
25+
}),
26+
})
27+
28+
if (!buttondownResponse.ok) {
29+
const error = await buttondownResponse.json()
30+
return {
31+
// 409 status Conflict has no detail message
32+
error: error?.detail || 'Sorry, an error has occurred — please try again later.',
33+
}
34+
}
35+
36+
return { success: true }
37+
} catch (error) {
38+
console.error('An error occurred: ', error)
39+
return {
40+
error: 'Sorry, an error has occurred — please try again later.',
41+
}
42+
}
43+
}

docs/components/SubscribeForm.tsx

Lines changed: 80 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
/** @jsxImportSource @emotion/react */
22

3-
import { Fragment, useState, type ReactNode, type SyntheticEvent, type HTMLAttributes } from 'react'
3+
import { Fragment, useState, type ReactNode, type HTMLAttributes, useTransition } from 'react'
4+
5+
import { subscribeToButtondown } from '../app/actions'
46

57
import { useMediaQuery } from '../lib/media'
68
import { Button } from './primitives/Button'
79
import { Field } from './primitives/Field'
810
import { Stack } from './primitives/Stack'
9-
10-
const validEmail = (email: string) =>
11-
/^[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(
12-
email
13-
)
14-
15-
const signupURL = 'https://signup.keystonejs.cloud/api/newsletter-signup'
11+
import { usePathname } from 'next/navigation'
1612

1713
type SubscriptFormProps = {
1814
autoFocus?: boolean
@@ -21,89 +17,112 @@ type SubscriptFormProps = {
2117
} & HTMLAttributes<HTMLFormElement>
2218

2319
export function SubscribeForm ({ autoFocus, stacked, children, ...props }: SubscriptFormProps) {
24-
const [email, setEmail] = useState('')
25-
const [loading, setLoading] = useState(false)
20+
const pathname = usePathname()
21+
const mq = useMediaQuery()
22+
const [isPending, startTransition] = useTransition()
23+
2624
const [error, setError] = useState<string | null>(null)
2725
const [formSubmitted, setFormSubmitted] = useState(false)
28-
const mq = useMediaQuery()
2926

30-
const onSubmit = (event: SyntheticEvent) => {
31-
event.preventDefault()
32-
setError(null)
33-
// Check if user wants to subscribe.
34-
// and there's a valid email address.
35-
// Basic validation check on the email?
36-
setLoading(true)
37-
if (validEmail(email)) {
38-
// if good add email to mailing list
39-
// and redirect to dashboard.
40-
return fetch(signupURL, {
41-
method: 'POST',
42-
headers: {
43-
'Content-Type': 'application/json',
44-
},
45-
body: JSON.stringify({
46-
email,
47-
source: '@keystone-6/website',
48-
}),
49-
})
50-
.then(res => {
51-
if (res.status !== 200) {
52-
// We explicitly set the status in our endpoint
53-
// any status that isn't 200 we assume is a failure
54-
// which we want to surface to the user
55-
res.json().then(({ error }) => {
56-
setError(error)
57-
setLoading(false)
58-
})
59-
} else {
60-
setFormSubmitted(true)
61-
}
62-
})
63-
.catch(err => {
64-
// network errors or failed parse
65-
setError(err.toString())
66-
setLoading(false)
67-
})
68-
} else {
69-
setLoading(false)
70-
// if email fails validation set error message
71-
setError('Please enter a valid email')
72-
return
73-
}
27+
// Augment the server action with the pathname
28+
const subscribeToButtondownWithPathname = subscribeToButtondown.bind(null, pathname)
29+
30+
async function submitAction (formData: FormData) {
31+
startTransition(async () => {
32+
const response = await subscribeToButtondownWithPathname(formData)
33+
if (response.error) return setError(response.error)
34+
if (response.success) return setFormSubmitted(true)
35+
})
7436
}
7537

7638
return !formSubmitted ? (
7739
<Fragment>
7840
{children}
79-
<form onSubmit={onSubmit} {...props}>
41+
<form action={submitAction} {...props}>
8042
<Stack
8143
orientation={stacked ? 'vertical' : 'horizontal'}
8244
block={stacked}
45+
gap={5}
8346
css={{
8447
justifyItems: stacked ? 'baseline' : undefined,
8548
}}
8649
>
50+
<label
51+
htmlFor="email"
52+
css={{
53+
position: 'absolute',
54+
width: '1px',
55+
height: '1px',
56+
padding: 0,
57+
margin: '-1px',
58+
overflow: 'hidden',
59+
clip: 'rect(0, 0, 0, 0)',
60+
whiteSpace: 'nowrap',
61+
borderWidth: '0',
62+
}}
63+
>
64+
Email address
65+
</label>
8766
<Field
8867
type="email"
8968
autoComplete="off"
9069
autoFocus={autoFocus}
9170
placeholder="Your email address"
92-
value={email}
93-
onChange={e => setEmail(e.target.value)}
9471
css={mq({
9572
maxWidth: '25rem',
9673
margin: ['0 auto', 0],
9774
})}
75+
name="email"
76+
id="email"
77+
required
9878
/>
99-
<Button look="secondary" size="small" loading={loading} type={'submit'}>
79+
80+
<div css={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem 0.75rem' }}>
81+
<div css={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
82+
<input
83+
type="checkbox"
84+
name="tags"
85+
id="mailing-list-keystone"
86+
css={{ height: '1rem', width: '1rem' }}
87+
value="keystone_list"
88+
defaultChecked
89+
/>
90+
<label css={{ fontSize: '0.9rem' }} htmlFor="mailing-list-keystone">
91+
Keystone news
92+
</label>
93+
</div>
94+
<div css={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
95+
<input
96+
type="checkbox"
97+
name="tags"
98+
id="mailing-list-thinkmill"
99+
css={{ height: '1rem', width: '1rem' }}
100+
value="thinkmill_list"
101+
/>
102+
<label css={{ fontSize: '0.9rem' }} htmlFor="mailing-list-thinkmill">
103+
Thinkmill news (
104+
<a
105+
href="https://www.thinkmill.com.au/newsletter/tailwind-for-designers-multi-brand-design-systems-and-a-search-tool-for-public-domain-content"
106+
target="_blank"
107+
aria-label="Thinkmill (Opens in new tab)"
108+
>
109+
example
110+
</a>
111+
)
112+
</label>
113+
</div>
114+
</div>
115+
116+
<Button look="secondary" size="small" loading={isPending} type="submit">
100117
{error ? 'Try again' : 'Subscribe'}
101118
</Button>
102119
</Stack>
103-
{error ? <p css={{ margin: '0.5rem, 0', color: 'red' }}>{error}</p> : null}
120+
{error ? (
121+
<p css={{ marginTop: '0.5rem', color: 'red', fontSize: '0.85rem' }}>{error}</p>
122+
) : null}
104123
</form>
105124
</Fragment>
106125
) : (
107-
<p>❤️ Thank you for subscribing!</p>
126+
<p>❤️ Thank you! Please check your email to confirm your subscription.</p>
108127
)
109128
}

0 commit comments

Comments
 (0)