Skip to content

Commit 3ccd2c3

Browse files
authored
Newsletter form card (#6688)
1 parent 4f5f29f commit 3ccd2c3

File tree

8 files changed

+228
-31
lines changed

8 files changed

+228
-31
lines changed

packages/web/docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"@radix-ui/react-icons": "1.3.2",
1717
"@radix-ui/react-tabs": "1.1.2",
1818
"@radix-ui/react-tooltip": "1.1.6",
19-
"@theguild/components": "9.5.0",
19+
"@theguild/components": "9.7.0",
2020
"date-fns": "4.1.0",
2121
"next": "15.2.4",
2222
"react": "19.0.0",

packages/web/docs/src/app/blog/components/blog-tag-chip.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function BlogTagChip({ tag, colorScheme, inert }: BlogTagChipProps) {
1111
const className = cn(
1212
'rounded-full px-3 py-1 text-white text-sm',
1313
colorScheme === 'featured'
14-
? 'dark:bg-primary/80 dark:text-neutral-900 bg-green-800'
14+
? 'dark:bg-primary/90 dark:text-neutral-900 bg-green-800'
1515
: 'bg-beige-800 dark:bg-beige-800/40',
1616
!inert &&
1717
(colorScheme === 'featured'
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
'use client';
2+
3+
import { useRef, useState } from 'react';
4+
import { CallToAction, cn, Heading, Input } from '@theguild/components';
5+
6+
export function NewsletterFormCard(props: React.HTMLAttributes<HTMLElement>) {
7+
type Idle = undefined;
8+
type Pending = { status: 'pending'; message?: never };
9+
type Success = { status: 'success'; message: string };
10+
type Error = { status: 'error'; message: string };
11+
type State = Idle | Pending | Success | Error;
12+
const [state, setState] = useState<State>();
13+
14+
// we don't want to blink a message on retries when request is pending
15+
const lastErrorMessage = useRef<string>();
16+
lastErrorMessage.current = state?.message || lastErrorMessage.current;
17+
18+
return (
19+
<article
20+
{...props}
21+
className={cn(
22+
props.className,
23+
'bg-primary dark:bg-primary/95 light @container/card text-green-1000 relative rounded-2xl',
24+
)}
25+
>
26+
<div className="p-6 pb-0">
27+
<Heading
28+
as="h3"
29+
size="xs"
30+
className="@[354px]/card:text-5xl/[56px] @[354px]/card:tracking-[-0.48px]"
31+
>
32+
Stay in the loop
33+
</Heading>
34+
<p className="relative mt-4">
35+
Get the latest insights and best practices on GraphQL API management delivered straight to
36+
your inbox.
37+
</p>
38+
</div>
39+
<form
40+
className="relative z-10 p-6"
41+
onSubmit={async event => {
42+
event.preventDefault();
43+
const email = event.currentTarget.email.value;
44+
45+
if (!email?.includes('@')) {
46+
setState({ status: 'error', message: 'Please enter a valid email address.' });
47+
return;
48+
}
49+
50+
setState({ status: 'pending' });
51+
52+
try {
53+
const response = await fetch('https://utils.the-guild.dev/api/newsletter-subscribe', {
54+
body: JSON.stringify({ email }),
55+
method: 'POST',
56+
});
57+
58+
const json = await response.json();
59+
if (json.status === 'success') {
60+
lastErrorMessage.current = undefined;
61+
setState({ status: 'success', message: json.message });
62+
} else {
63+
setState({ status: 'error', message: json.message });
64+
}
65+
} catch (e: unknown) {
66+
if (!navigator.onLine) {
67+
setState({
68+
status: 'error',
69+
message: 'Please check your internet connection and try again.',
70+
});
71+
}
72+
73+
if (e instanceof Error && e.message !== 'Failed to fetch') {
74+
setState({ status: 'error', message: e.message });
75+
return;
76+
}
77+
78+
setState({ status: 'error', message: 'Something went wrong. Please let us know.' });
79+
}
80+
}}
81+
>
82+
<Input
83+
name="email"
84+
placeholder="E-mail"
85+
severity={lastErrorMessage.current ? 'critical' : undefined}
86+
message={lastErrorMessage.current}
87+
/>
88+
{!state || state.status === 'error' ? (
89+
<CallToAction type="submit" variant="secondary-inverted" className="mt-2 !w-full">
90+
Subscribe
91+
</CallToAction>
92+
) : state.status === 'pending' ? (
93+
<CallToAction
94+
type="submit"
95+
variant="secondary-inverted"
96+
className="mt-2 !w-full"
97+
disabled
98+
>
99+
Subscribing...
100+
</CallToAction>
101+
) : state.status === 'success' ? (
102+
<CallToAction
103+
type="reset"
104+
variant="secondary-inverted"
105+
className="group/button mt-2 !w-full before:absolute"
106+
onClick={() => {
107+
// the default behavior of <button type="reset"> doesn't work here
108+
// because it gets unmounted too fast
109+
setTimeout(() => {
110+
setState(undefined);
111+
}, 0);
112+
}}
113+
>
114+
<span className="group-hover/button:hidden group-focus/button:hidden">Subscribed</span>
115+
<span aria-hidden className="hidden group-hover/button:block group-focus/button:block">
116+
Another email?
117+
</span>
118+
</CallToAction>
119+
) : null}
120+
</form>
121+
<DecorationArch color="#A2C1C4" className="absolute bottom-0 right-0" />
122+
</article>
123+
);
124+
}
125+
126+
function DecorationArch({ className, color }: { className?: string; color: string }) {
127+
return (
128+
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" className={className}>
129+
<path
130+
d="M6.72485 73.754C2.74132 77.7375 0.499999 83.1445 0.499999 88.7742L0.499998 199.5L41.2396 199.5L41.2396 74.3572C41.2396 56.0653 56.0652 41.2396 74.3571 41.2396L199.5 41.2396L199.5 0.500033L88.7741 0.500032C83.1444 0.500032 77.7374 2.74135 73.7539 6.72488L42.0931 38.3857L38.3856 42.0932L6.72485 73.754Z"
131+
stroke="url(#paint0_linear_2735_2359)"
132+
/>
133+
<defs>
134+
<linearGradient
135+
id="paint0_linear_2735_2359"
136+
x1="100"
137+
y1="104.605"
138+
x2="6.24999"
139+
y2="3.28952"
140+
gradientUnits="userSpaceOnUse"
141+
>
142+
<stop stopColor={color} stopOpacity="0" />
143+
<stop offset="1" stopColor={color} stopOpacity="0.8" />
144+
</linearGradient>
145+
</defs>
146+
</svg>
147+
);
148+
}

packages/web/docs/src/app/blog/components/posts-by-tag/index.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ const TOP_10_TAGS = [
1717
'graphql-tools',
1818
];
1919

20-
export function PostsByTag(props: { posts: BlogPostFile[]; tag?: string; className?: string }) {
20+
export function PostsByTag(props: {
21+
posts: BlogPostFile[];
22+
tag?: string;
23+
className?: string;
24+
children?: React.ReactNode;
25+
}) {
2126
const tag = props.tag ?? null;
2227

2328
const posts = [...props.posts].sort(
@@ -33,7 +38,9 @@ export function PostsByTag(props: { posts: BlogPostFile[]; tag?: string; classNa
3338
<section className={cn('px-4 sm:px-6', props.className)}>
3439
<CategorySelect tag={tag} categories={categories} />
3540
<FeaturedPosts posts={posts} className="sm:mb-12 md:mt-16" tag={tag} />
36-
<LatestPosts posts={posts} tag={tag} />
41+
<LatestPosts posts={posts} tag={tag}>
42+
{props.children}
43+
</LatestPosts>
3744
</section>
3845
);
3946
}

packages/web/docs/src/app/blog/components/posts-by-tag/latest-posts.tsx

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,55 @@
1+
'use client';
2+
3+
// ^ todo: this "use client" is temporary until we test the newsletter card on prod
4+
import { useEffect, useState } from 'react';
15
import { Heading } from '@theguild/components';
26
import { BlogPostFile } from '../../blog-types';
37
import { BlogCard } from '../blog-card';
48
import { prettyPrintTag } from '../pretty-print-tag';
59

6-
export function LatestPosts({ posts, tag }: { posts: BlogPostFile[]; tag: string | null }) {
7-
const firstTwelve = posts.slice(0, 12); // it needs to be 12, because we have 2/3/4 column layouts
8-
const rest = posts.slice(12);
10+
// There's a CORS error from deploy previews and I'd rather test it before giving it to users.
11+
function useTemporaryFeatureFlag() {
12+
const [visible, setVisible] = useState(false);
13+
14+
useEffect(() => {
15+
const searchParams = new URLSearchParams(window.location.search);
16+
const flag = searchParams.get('newsletter-form-card');
17+
if (flag === '1') {
18+
setVisible(true);
19+
}
20+
}, []);
21+
22+
return visible;
23+
}
24+
25+
export function LatestPosts({
26+
posts,
27+
tag,
28+
children,
29+
}: {
30+
posts: BlogPostFile[];
31+
tag: string | null;
32+
children?: React.ReactNode;
33+
}) {
34+
// TODO: remove this once we test the newsletter card on prod
35+
if (!useTemporaryFeatureFlag()) {
36+
children = undefined;
37+
}
38+
39+
// it needs to be 12, because we have 2/3/4 column layouts
40+
const itemsInFirstSection = children ? 11 : 12;
41+
const firstTwelve = posts.slice(0, itemsInFirstSection);
42+
const rest = posts.slice(itemsInFirstSection);
43+
44+
const firstSection = firstTwelve.map(post => (
45+
<li key={post.route} className="*:h-full">
46+
<BlogCard post={post} tag={tag} />
47+
</li>
48+
));
49+
50+
if (children) {
51+
firstSection.splice(7, 0, <li key="extra">{children}</li>);
52+
}
953

1054
return (
1155
<section className="pt-6 sm:pt-12">
@@ -21,13 +65,7 @@ export function LatestPosts({ posts, tag }: { posts: BlogPostFile[]; tag: string
2165
)}
2266
</Heading>
2367
<ul className="mt-6 grid grid-cols-1 gap-4 sm:grid sm:grid-cols-2 sm:gap-6 md:mt-16 lg:grid-cols-3 xl:grid-cols-4">
24-
{firstTwelve.map(post => {
25-
return (
26-
<li key={post.route} className="*:h-full">
27-
<BlogCard post={post} tag={tag} />
28-
</li>
29-
);
30-
})}
68+
{firstSection}
3169
</ul>
3270
<details className="mt-8 sm:mt-12">
3371
<summary className="bg-beige-200 text-green-1000 border-beige-300 hover:bg-beige-300 hive-focus mx-auto w-fit cursor-pointer select-none list-none rounded-lg border px-4 py-2 hover:border-current dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700 [&::marker]:hidden [[open]>&]:mb-8 [[open]>&]:sm:mb-12">

packages/web/docs/src/app/blog/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getPageMap } from '@theguild/components/server';
22
import { isBlogPost } from './blog-types';
3+
import { NewsletterFormCard } from './components/newsletter-form-card';
34
import { PostsByTag } from './components/posts-by-tag';
45
// We can't move this page to `(index)` dir together with `tag` page because Nextra crashes for
56
// some reason. It will cause an extra rerender on first navigation to a tag page, which isn't
@@ -16,7 +17,9 @@ export default async function BlogPage() {
1617

1718
return (
1819
<BlogPageLayout>
19-
<PostsByTag posts={allPosts} />
20+
<PostsByTag posts={allPosts}>
21+
<NewsletterFormCard />
22+
</PostsByTag>
2023
</BlogPageLayout>
2124
);
2225
}

packages/web/docs/tailwind.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ function blockquotesPlugin() {
9191
});
9292
}
9393

94+
// TODO: This should probably go to a shared Tailwind config
9495
function firefoxVariantPlugin() {
9596
return plugin((api: PluginAPI) => {
9697
const { addVariant, e, postcss } = api as PluginAPI & { postcss: any };

0 commit comments

Comments
 (0)