Skip to content

Instantly share code, notes, and snippets.

@haydenbleasel
Last active June 8, 2022 11:03
Show Gist options
  • Save haydenbleasel/4fe1aa7e0d308cf08cac47d0eca1d9f9 to your computer and use it in GitHub Desktop.
Save haydenbleasel/4fe1aa7e0d308cf08cac47d0eca1d9f9 to your computer and use it in GitHub Desktop.
Contact Form
.form {
grid-column: 6 / 11;
@media (--laptop) {
grid-column: 1;
}
}
.form.loading {
opacity: 0.4;
pointer-events: none;
user-select: none;
}
.fieldset,
.input,
.textarea,
.files {
padding: 0;
margin: 0;
border: none;
appearance: none;
font-family: inherit;
}
.input,
.textarea,
.button {
padding: 1rem 1.25rem;
border-radius: 0.25rem;
font-size: 1rem;
}
.input,
.textarea {
background: var(--ghost);
color: var(--black);
width: 100%;
margin-bottom: 2rem;
line-height: 1.6;
transition: background 0.2s, box-shadow 0.2s;
&::placeholder {
color: var(--black);
opacity: 0.4;
}
&:valid:not(:placeholder-shown):not(:focus) {
box-shadow: 0 0 0 0.125rem var(--green);
}
&:invalid:not(:placeholder-shown):not(:focus) {
box-shadow: 0 0 0 0.125rem var(--red);
}
}
.textarea {
height: 8rem;
resize: vertical;
min-height: 3rem;
max-height: 15rem;
}
.label {
composes: smallSans from global;
display: block;
margin: 0 0 0.25rem;
}
.label span {
opacity: 0.5;
}
.button {
appearance: none;
background: var(--black);
color: var(--white);
border: none;
margin-top: 2rem;
cursor: pointer;
}
.files {
composes: smallSans from global;
margin-top: 0.5rem;
cursor: pointer;
background: var(--ghost);
color: var(--black);
padding: 1rem;
border-radius: 0.25rem;
border: none;
margin: 0 1rem 0 0;
cursor: pointer;
display: inline-flex;
}
.fieldHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.remaining {
composes: smallSans from global;
color: var(--grey);
opacity: 0.5;
}
.dropzone {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: color-mod(var(--blue) a(0.2));
pointer-events: none;
user-select: none;
border: 2px dashed var(--blue);
z-index: 1000;
opacity: 0;
transition: opacity 0.2s;
&.active {
opacity: 1;
}
}
.fileList {
margin: 1.5rem -0.5rem -0.5rem;
}
.file {
composes: smallSans from global;
cursor: pointer;
background: var(--ghost);
color: var(--black);
padding: 0.25rem 0.5rem;
border-radius: 10rem;
border: none;
margin: 0.5rem;
cursor: pointer;
display: inline-flex;
}
.remove {
margin-left: 0.5rem;
}
import type { NextPage } from 'next';
import { useRef, useState } from "react";
import type { ChangeEvent, FormEvent } from "react";
import toast, { Toaster } from 'react-hot-toast';
import { useDrop } from 'react-use';
const Contact: NextPage<IContact> = () => {
const [name, setName] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [message, setMessage] = useState<string>("");
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const fileInput = useRef<HTMLInputElement>(null);
const dropState = useDrop({
onFiles: addFiles,
onUri: () => toast.error('Files only please!'),
onText: () => toast.error('Files only please!'),
});
async function sendEmail(event: FormEvent) {
event.preventDefault();
setLoading(true);
try {
const formData = new FormData();
if (!name.trim()) {
throw new Error("Please provide a valid name.");
}
if (!email.trim()) {
throw new Error("Please provide a valid email address.");
}
if (!message.trim()) {
throw new Error("Please provide a valid message.");
}
formData.append("name", name);
formData.append("email", email);
formData.append("message", message);
files.map((file, index) =>
formData.append(`file${index}`, file)
);
const response = await fetch("/api/nodemailer", {
method: "post",
body: formData,
});
const responseData = await response.json();
if (responseData.error) {
throw new Error(responseData.error);
}
toast.success(data.success_alert);
setName("");
setEmail("");
setMessage("");
setFiles([]);
} catch (error) {
toast.error(error.message || data.error_alert);
} finally {
setLoading(false);
}
}
function addFiles(newFiles: File[]) {
newFiles.forEach((file, index) => {
const fileExists = files.some(({ name, size }) => name === file.name && size === file.size);
if (fileExists) {
toast.error(`You already uploaded ${file.name}`);
newFiles.splice(index);
}
if (file.size > 5000000) {
toast.error(`${file.name} is too chonky (5MB max file size).`);
newFiles.splice(index);
}
});
setFiles([...files, ...newFiles]);
}
function onChangeFiles({ target }: ChangeEvent<HTMLInputElement>) {
if (target.files) {
const newFiles = Array.from(target.files);
addFiles(newFiles);
}
}
function clickFiles() {
fileInput.current?.click();
}
function removeFile(index: number) {
const newFiles = files.filter((_, i) => i !== index);
setFiles(newFiles);
}
return (
<>
<form
className={cx('form', { loading })}
onSubmit={sendEmail}
>
<fieldset className={styles.fieldset}>
<div className={styles.fieldHeader}>
<label className={styles.label} htmlFor="name">
{data.form_name_label}
</label>
<span className={styles.remaining}>{name.length} / 320</span>
</div>
<input
className={styles.input}
id="name"
name="name"
type="text"
placeholder={data.form_name_placeholder}
required
autoComplete="on"
value={name}
maxLength={320}
onChange={({ target }: ChangeEvent) =>
setName((target as HTMLInputElement).value)
}
/>
</fieldset>
<fieldset className={styles.fieldset}>
<div className={styles.fieldHeader}>
<label className={styles.label} htmlFor="email">
{data.form_email_label}
</label>
<span className={styles.remaining}>{email.length} / 320</span>
</div>
<input
className={styles.input}
id="email"
name="email"
type="email"
placeholder={data.form_email_placeholder}
required
autoComplete="on"
value={email}
pattern=".+@.+\..+"
maxLength={320}
onChange={({ target }: ChangeEvent) =>
setEmail((target as HTMLInputElement).value)
}
/>
</fieldset>
<fieldset className={styles.fieldset}>
<div className={styles.fieldHeader}>
<label className={styles.label} htmlFor="message">
{data.form_message_label}
</label>
<span className={styles.remaining}>{message.length} / 1000</span>
</div>
<textarea
className={styles.textarea}
id="message"
name="message"
placeholder={data.form_email_placeholder}
required
autoComplete="off"
value={message}
maxLength={1000}
onChange={({ target }: ChangeEvent) =>
setMessage((target as HTMLInputElement).value)
}
/>
</fieldset>
<fieldset className={styles.fieldset}>
<label className={styles.label} htmlFor="files">
{data.form_files_label}
<span> (Optional)</span>
</label>
<input
value={[]}
ref={fileInput}
hidden
id="files"
name="files"
type="file"
multiple
onChange={onChangeFiles}
/>
<button className={styles.files} onClick={clickFiles}>
Upload files
</button>
<div className={styles.fileList}>
{files.map((file, index) => (
<div className={styles.file} key={file.name}>
<span>{file.name} ({(file.size / 1024).toFixed(2)}kb)</span>
<span className={styles.remove} onClick={() => removeFile(index)}>&times;</span>
</div>
))}
</div>
</fieldset>
<button className={styles.button} type="submit">
{data.form_button_text}
</button>
</form>
<div className={cx('dropzone', { active: dropState.over })} />
<Toaster toastOptions={{
duration: 5000,
position: 'bottom-right',
}} />
</>
);
}
export default Contact;
import type { NextApiHandler } from "next";
import nodemailer from "nodemailer";
import formidable from "formidable";
import { withSentry } from "@sentry/nextjs";
type Fields = {
name: string;
message: string;
email: string;
};
type FormidablePromise = {
fields: Fields;
files?: any;
};
interface NodemailerFile extends File {
path: string;
}
const transporter = nodemailer.createTransport({
service: "FastMail",
auth: {
user: process.env.NEXT_PUBLIC_EMAIL_ADDRESS,
pass: process.env.NEXT_PUBLIC_EMAIL_PASSWORD,
},
});
export const config = {
api: {
bodyParser: false,
},
};
function formidablePromise(req, opts): Promise<FormidablePromise> {
return new Promise((resolve, reject) => {
const form = new formidable.IncomingForm(opts);
form.parse(req, (error: Error, fields: any, files: any) => {
if (error) {
return reject(error);
}
resolve({ fields, files });
});
});
}
const handler: NextApiHandler<APIResponse> = async(req, res) => {
if (req.method !== "POST") {
return res.status(404).send({ error: "Begone." });
}
res.setHeader("Content-Type", "application/json");
try {
const { fields, files } = await formidablePromise(req, {});
const fileArray: NodemailerFile[] = Object.values(files);
const { name, email, message } = fields;
if (!name || !name.trim()) {
throw new Error("Please provide a valid name.");
}
if (!email || !email.trim()) {
throw new Error("Please provide a valid email address.");
}
if (!message || !message.trim()) {
throw new Error("Please provide a valid email message.");
}
await transporter.sendMail({
to: process.env.NEXT_PUBLIC_EMAIL_ADDRESS,
from: process.env.NEXT_PUBLIC_EMAIL_ADDRESS,
replyTo: email,
subject: `Hello from ${name}`,
text: message,
html: `<p>${message.replace(/(?:\r\n|\r|\n)/g, "<br>")}</p>`,
attachments: fileArray.map(({ name, path, type }) => ({
filename: name,
path: path,
contentType: type,
})),
});
res.status(200).json({});
} catch (error) {
res.status(500).json({ error: error.message });
}
};
export default withSentry(handler);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment