-
Notifications
You must be signed in to change notification settings - Fork 111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Newsletter form card (don't merge) #6688
base: main
Are you sure you want to change the base?
Changes from 7 commits
4c8f3d6
8c38751
f59988b
b15ac03
b873185
32259ab
11b8c95
4029a3b
5e3bc31
f1c2819
c12f527
b95d9cd
1605d34
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
'use client'; | ||
|
||
import { useRef, useState } from 'react'; | ||
import { CallToAction, cn, Heading } from '@theguild/components'; | ||
|
||
export function NewsletterFormCard(props: React.HTMLAttributes<HTMLElement>) { | ||
type Idle = undefined; | ||
type Pending = { status: 'pending'; message?: never }; | ||
type Success = { status: 'success'; message: string }; | ||
type Error = { status: 'error'; message: string }; | ||
type State = Idle | Pending | Success | Error; | ||
const [state, setState] = useState<State>(); | ||
|
||
// we don't want to blink a message on retries when request is pending | ||
const lastMessage = useRef<string>(); | ||
lastMessage.current = state?.message || lastMessage.current; | ||
|
||
return ( | ||
<article | ||
{...props} | ||
className={cn( | ||
props.className, | ||
'bg-primary dark:bg-primary/95 light @container/card text-green-1000 relative rounded-2xl', | ||
)} | ||
> | ||
<div className="p-6 pb-0"> | ||
<Heading | ||
as="h3" | ||
size="xs" | ||
className="@[354px]/card:text-5xl/[56px] @[354px]/card:tracking-[-0.48px]" | ||
> | ||
Stay in the loop | ||
</Heading> | ||
<div className="relative mt-4"> | ||
<p style={{ opacity: lastMessage.current ? 0 : 1 }}> | ||
Get the latest insights and best practices on GraphQL API management delivered straight | ||
to your inbox. | ||
</p> | ||
{lastMessage.current && <p className="absolute inset-0">{lastMessage.current}</p>} | ||
</div> | ||
</div> | ||
<form | ||
className="relative z-10 p-6" | ||
onSubmit={async event => { | ||
event.preventDefault(); | ||
const email = event.currentTarget.email.value; | ||
|
||
if (!email?.includes('@')) { | ||
setState({ status: 'error', message: 'Please enter a valid email address.' }); | ||
return; | ||
} | ||
|
||
setState({ status: 'pending' }); | ||
|
||
try { | ||
const response = await fetch('https://utils.the-guild.dev/api/newsletter-subscribe', { | ||
body: JSON.stringify({ email }), | ||
method: 'POST', | ||
}); | ||
|
||
setState((await response.json()) as { status: 'success' | 'error'; message: string }); | ||
} catch (e: unknown) { | ||
if (!navigator.onLine) { | ||
setState({ | ||
status: 'error', | ||
message: 'Please check your internet connection and try again.', | ||
}); | ||
} | ||
|
||
if (e instanceof Error && e.message !== 'Failed to fetch') { | ||
setState({ status: 'error', message: e.message }); | ||
} | ||
|
||
setState({ status: 'error', message: 'Something went wrong. Please let us know.' }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The unconditional call to setState in the catch block may override previous error messages set in earlier conditionals. Consider restructuring the error handling with if/else statements or early returns to preserve more specific error messages. Copilot is powered by AI, so mistakes are possible. Review output carefully before use. Positive FeedbackNegative Feedback There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good bot. I missed an early return. |
||
} | ||
}} | ||
> | ||
<Input name="email" placeholder="E-mail" error={state?.status === 'error'} /> | ||
{!state || state.status === 'error' ? ( | ||
<CallToAction type="submit" variant="secondary-inverted" className="mt-2 !w-full"> | ||
Subscribe | ||
</CallToAction> | ||
) : state.status === 'pending' ? ( | ||
<CallToAction | ||
type="submit" | ||
variant="secondary-inverted" | ||
className="mt-2 !w-full" | ||
disabled | ||
> | ||
Subscribing... | ||
</CallToAction> | ||
) : state.status === 'success' ? ( | ||
<CallToAction | ||
type="reset" | ||
variant="secondary-inverted" | ||
className="group/button mt-2 !w-full before:absolute" | ||
onClick={() => { | ||
// the default behavior of <button type="reset"> doesn't work here | ||
// because it gets unmounted too fast | ||
setTimeout(() => { | ||
setState(undefined); | ||
}, 0); | ||
}} | ||
> | ||
<span className="group-hover/button:hidden group-focus/button:hidden">Subscribed</span> | ||
<span aria-hidden className="hidden group-hover/button:block group-focus/button:block"> | ||
Another email? | ||
</span> | ||
</CallToAction> | ||
) : null} | ||
</form> | ||
<DecorationArch color="#A2C1C4" className="absolute bottom-0 right-0" /> | ||
</article> | ||
); | ||
} | ||
|
||
function DecorationArch({ className, color }: { className?: string; color: string }) { | ||
return ( | ||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" className={className}> | ||
<path | ||
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" | ||
stroke="url(#paint0_linear_2735_2359)" | ||
/> | ||
<defs> | ||
<linearGradient | ||
id="paint0_linear_2735_2359" | ||
x1="100" | ||
y1="104.605" | ||
x2="6.24999" | ||
y2="3.28952" | ||
gradientUnits="userSpaceOnUse" | ||
> | ||
<stop stopColor={color} stopOpacity="0" /> | ||
<stop offset="1" stopColor={color} stopOpacity="0.8" /> | ||
</linearGradient> | ||
</defs> | ||
</svg> | ||
); | ||
} | ||
|
||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { | ||
error?: boolean; | ||
} | ||
|
||
function Input({ error, ...props }: InputProps) { | ||
/* todo: add this to theguild/components */ | ||
return ( | ||
<input | ||
{...props} | ||
className={cn( | ||
'hover:placeholder:text-green-1000/60 w-full rounded-lg border border-blue-400 bg-white py-3 indent-4 font-medium placeholder:text-green-800 autofill:shadow-[inset_0_0_0px_1000px_rgb(255,255,255)] autofill:first-line:font-sans focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-green-800/40 focus-visible:ring-0', | ||
props.className, | ||
error && 'border-critical-dark/20 !outline-critical-dark', | ||
)} | ||
/> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,9 +3,29 @@ import { BlogPostFile } from '../../blog-types'; | |
import { BlogCard } from '../blog-card'; | ||
import { prettyPrintTag } from '../pretty-print-tag'; | ||
|
||
export function LatestPosts({ posts, tag }: { posts: BlogPostFile[]; tag: string | null }) { | ||
const firstTwelve = posts.slice(0, 12); // it needs to be 12, because we have 2/3/4 column layouts | ||
const rest = posts.slice(12); | ||
export function LatestPosts({ | ||
posts, | ||
tag, | ||
children, | ||
}: { | ||
posts: BlogPostFile[]; | ||
tag: string | null; | ||
children?: React.ReactNode; | ||
}) { | ||
// it needs to be 12, because we have 2/3/4 column layouts | ||
const itemsInFirstSection = children ? 11 : 12; | ||
const firstTwelve = posts.slice(0, itemsInFirstSection); | ||
const rest = posts.slice(itemsInFirstSection); | ||
|
||
const firstSection = firstTwelve.map(post => ( | ||
<li key={post.route} className="*:h-full"> | ||
<BlogCard post={post} tag={tag} /> | ||
</li> | ||
)); | ||
|
||
if (children) { | ||
firstSection.splice(7, 0, <li key="extra">{children}</li>); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} | ||
|
||
return ( | ||
<section className="pt-6 sm:pt-12"> | ||
|
@@ -21,13 +41,7 @@ export function LatestPosts({ posts, tag }: { posts: BlogPostFile[]; tag: string | |
)} | ||
</Heading> | ||
<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"> | ||
{firstTwelve.map(post => { | ||
return ( | ||
<li key={post.route} className="*:h-full"> | ||
<BlogCard post={post} tag={tag} /> | ||
</li> | ||
); | ||
})} | ||
{firstSection} | ||
</ul> | ||
<details className="mt-8 sm:mt-12"> | ||
<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"> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -42,6 +42,7 @@ const config: Config = { | |
}, | ||
}, | ||
plugins: [ | ||
...baseConfig.plugins, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The base config includes the container queries plugin. It's already built-in in Tailwind 4, but I don't want to do that migration yet (ever? before AI can e2e test it looking for visual regressions?) |
||
tailwindcssRadix({ variantPrefix: 'rdx' }), | ||
tailwindcssAnimate, | ||
blockquotesPlugin(), | ||
|
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is unrelated to the PR topic, but the chips looked kinda bad, like... brownish. I don't know why I wrote
/80
before.