Skip to content

Instantly share code, notes, and snippets.

@chooie
Created April 23, 2023 11:10
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save chooie/8e5db6f1fca0d8a96cf9ae10f69dd7d8 to your computer and use it in GitHub Desktop.
Save chooie/8e5db6f1fca0d8a96cf9ae10f69dd7d8 to your computer and use it in GitHub Desktop.
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="hello@email.com"
/>
{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&nbsp;
<a
className="text-[--color-link]"
href="https://policies.google.com/privacy"
target="_blank"
rel="noreferrer"
>
Privacy Policy
</a>{" "}
and&nbsp;
<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