25
loading...
This website collects cookies to deliver better user experience
react-hot-toast
: a properly good solution for simple toasts. Lightweight, gorgeous and promise-based, what more could you ask for?react-use
: a collection of essential React Hooks. Specifically, we'll be using useDrop
. This library is quickly becoming my favourite package on NPM.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> = () => {
return <div />;
};
export default Contact;
import type
. Microsoft sums it up pretty well:import type
only imports declarations to be used for type annotations and declarations. It always gets fully erased, so there's no remnant of it at runtime.
<Toaster />
in my layout.tsx
so I could call toast()
everywhere but I've added it here so it makes sense out of context.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!'),
});
File[]
. This opens the door to a couple of questions, mostly around how the DOM deals with "file lists"... we'll get onto this later. I like using the built-in File
type though over a custom object as it's immediately and reliably typed.dropState
will be used for the dropzone obviously, but the handy thing here is that it supports handling uris and text, meaning I'm able to throw nice errors when folks try to upload things that aren't files.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("Thanks, I’ll be in touch!");
setName("");
setEmail("");
setMessage("");
setFiles([]);
} catch (error) {
toast.error(error.message);
} finally {
setLoading(false);
}
}
The FormData interface provides a way to easily construct a set of key/value pairs representing form fields and their values, which can then be easily sent using the XMLHttpRequest.send() method. It uses the same format a form would use if the encoding type were set to "multipart/form-data".
multipart/form-data
encoding type on the data. More on this later.return (
<>
<form
className={`${styles.form} ${loading ? styles.loading : ''}`}
onSubmit={sendEmail}
>
<fieldset className={styles.fieldset}>
<div className={styles.fieldHeader}>
<label className={styles.label} htmlFor="name">
Full name
</label>
<span className={styles.remaining}>{name.length} / 320</span>
</div>
<input
className={styles.input}
id="name"
name="name"
type="text"
placeholder="Jane Smith"
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">
Email address
</label>
<span className={styles.remaining}>{email.length} / 320</span>
</div>
<input
className={styles.input}
id="email"
name="email"
type="email"
placeholder="[email protected]"
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">
Message
</label>
<span className={styles.remaining}>{message.length} / 1000</span>
</div>
<textarea
className={styles.textarea}
id="message"
name="message"
placeholder="What's on your mind?"
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">
Upload files (Optional)
</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)}>×</span>
</div>
))}
</div>
</fieldset>
<button className={styles.button} type="submit">
Send me a message
</button>
</form>
<div className={`${styles.dropzone} ${dropState.over ? styles.active : ''}`} />
<Toaster toastOptions={{
duration: 5000,
position: 'bottom-right',
}} />
</>
);
a@a
are totally fine. You can bump this up a notch by adding a simple pattern attribute like pattern=".+@.+\..+"
to change the pattern to roughly [email protected]
without needing any JS checks.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);
}
onChangeFiles
, why are you turning what I assume is an array of files into an array of files?FileList
which is like an array, but more annoying because we can't map over it. Since we need a common type for "dropped" and "clicked" uploaded files, we're turning them into an array of files before updating the state.<input type="file" />
to handle your click-to-upload files?nodemailer
: a library to send emails from Node.js.formidable
: a Node.js module for parsing form data, especially file uploads. This library isn't typed or anything so I really need to find a replacement at some point.import type { NextApiHandler } from "next";
import nodemailer from "nodemailer";
import formidable from "formidable";
const handler: NextApiHandler<APIResponse> = async(req, res) => {
res.status(200).json({ message: 'It works... for now.' });
};
export default handler;
NextApiHandler
that types the entire function, including the request (req
) and response (res
) params. The best part though is you can give it a type parameter (which I've called APIResponse
) that types your API's response object... how great is that!NextApiRequest
and NextApiResponse
interfaces if you want to type the params manually.type Fields = {
name: string;
message: string;
email: string;
};
interface NodemailerFile extends File {
path: string;
}
Fields
is the interface we'll be using for the data that we receive in the FormData object (Formidable calls these "fields") and NodemailerFile
exists because Nodemailer actually extends the native File type and adds a path
property to the temporary location on disk.type FormidablePromise = {
fields: Fields;
files?: any;
};
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 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,
},
};
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 });
}
};
nodemailer.createTransport
.replyTo
field so you can immediately hit reply on the email in your inbox and be chatting to the right person.