Created
April 23, 2023 11:10
-
-
Save chooie/8e5db6f1fca0d8a96cf9ae10f69dd7d8 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import styles from "#styles/routes/contact.css"; | |
import React from "react"; | |
import { Form, useActionData, useLoaderData } from "@remix-run/react"; | |
import type { ActionFunction } from "@remix-run/server-runtime"; | |
import { json } from "@remix-run/server-runtime"; | |
import invariant from "tiny-invariant"; | |
import { sendEmail } from "~/server/mail.server"; | |
import Button from "~/components/Button"; | |
import PageWrapper from "~/components/PageWrapper"; | |
import clsx from "clsx"; | |
export function links() { | |
return [ | |
...PageWrapper.links, | |
...Button.links, | |
{ rel: "stylesheet", href: styles }, | |
]; | |
} | |
interface ActionResponseData { | |
recaptchaError?: boolean; | |
fieldErrors: | |
| boolean | |
| { | |
name?: string; | |
email?: string; | |
message?: string; | |
}; | |
fields: { | |
name: string; | |
email: string; | |
message: string; | |
}; | |
} | |
export const loader = async () => { | |
return { | |
googleRecaptchaSiteKey: process.env.GOOGLE_RECAPTCHA_SITE_KEY, | |
}; | |
}; | |
const INPUT_NAMES = { | |
name: "name", | |
email: "email", | |
message: "message", | |
token: "recaptchaToken", | |
} as const; | |
type ActionData = ActionResponseData | undefined; | |
export const action: ActionFunction = async ({ request }) => { | |
const form = await request.formData(); | |
const name = form.get(INPUT_NAMES.name); | |
const email = form.get(INPUT_NAMES.email); | |
const message = form.get(INPUT_NAMES.message); | |
const recaptchaToken = form.get(INPUT_NAMES.token); | |
let fieldErrors: ActionResponseData["fieldErrors"] = false; | |
if (name === "" || email === "" || message === "") { | |
fieldErrors = {}; | |
invariant(typeof fieldErrors === "object"); | |
if (name === "") { | |
fieldErrors.name = "Name must not be empty"; | |
} | |
if (email === "") { | |
fieldErrors.email = "Email must not be empty"; | |
} | |
if (message === "") { | |
fieldErrors.message = "Message must not be empty"; | |
} | |
} | |
const formData = { | |
fieldErrors, | |
fields: { | |
name, | |
email, | |
message, | |
}, | |
}; | |
if (fieldErrors) { | |
return json(formData, { status: 400 }); | |
} | |
invariant(typeof name === "string"); | |
invariant(typeof email === "string"); | |
invariant(typeof message === "string"); | |
const recaptchaResponse = await fetch( | |
`https://www.google.com/recaptcha/api/siteverify?secret=${process.env.GOOGLE_RECAPTCHA_SECRET_KEY}&response=${recaptchaToken}`, | |
{ | |
method: "POST", | |
headers: { | |
"Content-Type": "application/json", | |
}, | |
} | |
); | |
const recaptchaResult = await recaptchaResponse.json(); | |
if (!recaptchaResult.success || recaptchaResult.score < 0.4) { | |
return json({ ...formData, recaptchaError: true }, { status: 400 }); | |
} | |
await sendEmail({ | |
name, | |
email, | |
message, | |
}); | |
return formData; | |
}; | |
export default function Contact() { | |
const formData = useActionData<ActionData>(); | |
let content; | |
if (formData && formData.recaptchaError) { | |
content = ( | |
<div className="rounded-lg bg-slate-200 px-8 py-8 dark:bg-slate-800"> | |
<p className="center text-red-500"> | |
Sorry. Our system determined that your request may be spam and so it | |
was ignored. If this was a mistake, please try again later. | |
</p> | |
</div> | |
); | |
} else if (formData && !formData.fieldErrors) { | |
content = ( | |
<div className="rounded-lg bg-slate-200 px-8 py-8 dark:bg-slate-800"> | |
<p className="center"> | |
Thank you for your message,{" "} | |
<span className="font-bold">{formData.fields.name}</span>. We'll try | |
to get back to you as soon as possible. | |
</p> | |
</div> | |
); | |
} else { | |
content = <ContactForm formData={formData} />; | |
} | |
return ( | |
<PageWrapper className="IIT-contact-page" maxWidth="40ch"> | |
<h1>Contact</h1> | |
<div className="inner">{content}</div> | |
</PageWrapper> | |
); | |
} | |
const VALIDATION_DELAY_MS = 200; | |
const CAPTCHA_BADGE_CLASS = ".grecaptcha-badge"; | |
const captchaStyles = ` | |
${CAPTCHA_BADGE_CLASS} { | |
visibility: hidden; | |
} | |
`; | |
interface ContactFormProps { | |
formData: ActionData; | |
} | |
function ContactForm({ formData }: ContactFormProps) { | |
const { googleRecaptchaSiteKey } = useLoaderData(); | |
React.useEffect(() => { | |
const script = document.createElement("script"); | |
script.src = `https://www.google.com/recaptcha/api.js?render=${googleRecaptchaSiteKey}`; | |
script.async = true; | |
document.body.appendChild(script); | |
return () => { | |
document.body.removeChild(script); | |
const recaptchaBadge = document.querySelector(CAPTCHA_BADGE_CLASS); | |
recaptchaBadge?.remove(); | |
}; | |
}, [googleRecaptchaSiteKey]); | |
let nameError, emailError, messageError; | |
let startingState = { | |
nameInput: false, | |
emailInput: false, | |
messageInput: false, | |
}; | |
if (typeof formData?.fieldErrors === "object") { | |
const errors = formData.fieldErrors; | |
startingState = { | |
nameInput: errors.name !== "", | |
emailInput: errors.email !== "", | |
messageInput: errors.message !== "", | |
}; | |
} | |
const [hasBeenTouched, setHasBeenTouched] = React.useState(startingState); | |
if (formData?.fieldErrors) { | |
invariant(typeof formData.fieldErrors === "object"); | |
const errors = formData.fieldErrors; | |
if (errors.name) { | |
nameError = <p className="error">{errors.name}</p>; | |
} | |
if (errors.email) { | |
emailError = <p className="error">{errors.email}</p>; | |
} | |
if (errors.message) { | |
messageError = <p className="error">{errors.message}</p>; | |
} | |
} | |
const formRef = React.useRef<HTMLFormElement>(null); | |
return ( | |
<Form | |
ref={formRef} | |
className="contact-form" | |
action="." | |
method="post" | |
onSubmit={async (event) => { | |
event.preventDefault(); | |
// @ts-ignore | |
grecaptcha.ready(async function () { | |
// @ts-ignore | |
const token = await grecaptcha.execute(googleRecaptchaSiteKey, { | |
action: "submit", | |
}); | |
const form = formRef.current; | |
invariant(form instanceof HTMLFormElement); | |
const tokenInput = document.createElement("input"); | |
tokenInput.setAttribute("name", INPUT_NAMES.token); | |
tokenInput.setAttribute("type", "text"); | |
tokenInput.setAttribute("value", token); | |
tokenInput.setAttribute("readonly", "true"); | |
tokenInput.setAttribute("hidden", "true"); | |
form.appendChild(tokenInput); | |
form.submit(); | |
}); | |
}} | |
> | |
<style>{captchaStyles}</style> | |
<div className="g-recaptcha"></div> | |
<label className="text-lg md:text-xl"> | |
Name: | |
<input | |
required | |
className={clsx(inputStyles, { | |
[str(touchedInputStyles)]: hasBeenTouched.nameInput, | |
})} | |
onChange={debounce(() => { | |
setHasBeenTouched({ | |
...hasBeenTouched, | |
nameInput: true, | |
}); | |
}, VALIDATION_DELAY_MS)} | |
defaultValue={formData?.fields.name} | |
name={INPUT_NAMES.name} | |
type="text" | |
placeholder="Your Name" | |
/> | |
{nameError} | |
</label> | |
<label className="text-lg md:text-xl"> | |
Email address: | |
<input | |
required | |
className={clsx(inputStyles, { | |
[str(touchedInputStyles)]: hasBeenTouched.emailInput, | |
})} | |
onChange={debounce(() => { | |
setHasBeenTouched({ | |
...hasBeenTouched, | |
emailInput: true, | |
}); | |
}, VALIDATION_DELAY_MS)} | |
defaultValue={formData?.fields.email} | |
name={INPUT_NAMES.email} | |
type="email" | |
placeholder="[email protected]" | |
/> | |
{emailError} | |
</label> | |
<label className="text-lg md:text-xl"> | |
Message: | |
<textarea | |
required | |
className={clsx(inputStyles, { | |
[str(touchedInputStyles)]: hasBeenTouched.messageInput, | |
})} | |
onChange={debounce(() => { | |
setHasBeenTouched({ | |
...hasBeenTouched, | |
messageInput: true, | |
}); | |
}, VALIDATION_DELAY_MS)} | |
defaultValue={formData?.fields.message} | |
name={INPUT_NAMES.message} | |
rows={10} | |
placeholder="Say hi..." | |
/> | |
{messageError} | |
</label> | |
<p> | |
This site is protected by reCAPTCHA and the Google | |
<a | |
className="text-[--color-link]" | |
href="https://policies.google.com/privacy" | |
target="_blank" | |
rel="noreferrer" | |
> | |
Privacy Policy | |
</a>{" "} | |
and | |
<a | |
className="text-[--color-link]" | |
href="https://policies.google.com/terms" | |
target="_blank" | |
rel="noreferrer" | |
> | |
Terms of Service | |
</a>{" "} | |
apply. | |
</p> | |
<Button text="Submit" /> | |
</Form> | |
); | |
} | |
// REMEMBER: we can't dynamically generate classes with template literals | |
// (Tailwind doesn't support it) | |
const inputStyles = [ | |
// Box model stuff | |
"block", | |
"w-full", | |
"rounded-md", | |
"border", | |
[ | |
// Border color | |
"border-slate-300", | |
"focus:border-sky-500", | |
"disabled:border-slate-200", | |
"px-3", | |
"py-2", | |
], | |
// Background | |
"bg-white", | |
"disabled:bg-slate-50", | |
"dark:bg-black", | |
// Shadow | |
"shadow-sm", | |
"disabled:shadow-none", | |
// No-outline: controlled by ring | |
"outline-none", | |
"focus:ring-2", | |
"focus:ring-sky-500", | |
"focus:valid:border-green-500", | |
"focus:valid:ring-green-500", | |
// Text | |
"text-md", | |
"md:text-base", | |
"text-slate-800", | |
"dark:text-white", | |
"disabled:text-slate-300", | |
// Placeholder text | |
"placeholder-slate-400", | |
]; | |
const touchedInputStyles = [ | |
"valid:border-green-500", | |
"invalid:border-red-500", | |
"invalid:placeholder-shown:border-slate-500", | |
"invalid:focus:border-red-500", | |
"invalid:focus:placeholder-shown:border-sky-500", | |
"invalid:focus:ring-red-500", | |
"invalid:focus:placeholder-shown:ring-sky-500", | |
"invalid:text-red-600", | |
"invalid:focus:placeholder-shown:text-slate-800", | |
"dark:invalid:focus:placeholder-shown:text-slate-800", | |
]; | |
function str(styles: string[]): string { | |
return styles.join(" "); | |
} | |
function debounce(func: Function, timeout: number) { | |
let timer: null | NodeJS.Timer = null; | |
return (...args: any[]) => { | |
if (timer) { | |
clearTimeout(timer); | |
} | |
timer = setTimeout(() => { | |
func.apply(null, args); | |
timer = null; | |
}, timeout); | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment