mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2025-09-18 15:05:32 -04:00
server all the actions!
This commit is contained in:
60
app/contact/actions.ts
Normal file
60
app/contact/actions.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
"use server";
|
||||
|
||||
import { Resend } from "resend";
|
||||
import config from "../../lib/config";
|
||||
|
||||
export async function sendMessage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prevState: any,
|
||||
formData: FormData
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
payload: FormData;
|
||||
}> {
|
||||
try {
|
||||
// these are both backups to client-side validations just in case someone squeezes through without them. the codes
|
||||
// are identical so they're caught in the same fashion.
|
||||
if (!formData.get("name") || !formData.get("email") || !formData.get("message")) {
|
||||
return { success: false, message: "Please make sure that all fields are properly filled in.", payload: formData };
|
||||
}
|
||||
|
||||
// validate captcha
|
||||
const turnstileResponse = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
secret: process.env.TURNSTILE_SECRET_KEY || "1x0000000000000000000000000000000AA",
|
||||
response: formData.get("cf-turnstile-response"),
|
||||
}),
|
||||
});
|
||||
const turnstileData = await turnstileResponse.json();
|
||||
|
||||
if (!turnstileData.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Did you complete the CAPTCHA? (If you're human, that is...)",
|
||||
payload: formData,
|
||||
};
|
||||
}
|
||||
|
||||
// send email
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
await resend.emails.send({
|
||||
from: `${formData.get("name")} <${process.env.RESEND_DOMAIN ? `noreply@${process.env.RESEND_DOMAIN}` : "onboarding@resend.dev"}>`,
|
||||
replyTo: `${formData.get("name")} <${formData.get("email")}>`,
|
||||
to: [config.authorEmail],
|
||||
subject: `[${config.siteDomain}] Contact Form Submission`,
|
||||
text: formData.get("message") as string,
|
||||
});
|
||||
|
||||
return { success: true, message: "Thanks! You should hear from me soon.", payload: formData };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Internal server error... Try again later or shoot me an old-fashioned email?",
|
||||
payload: formData,
|
||||
};
|
||||
}
|
||||
}
|
@@ -25,23 +25,6 @@
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.markdownTip {
|
||||
font-size: 0.825em;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.markdownIcon {
|
||||
display: inline;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
vertical-align: -0.25em;
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.captcha {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@@ -1,199 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Formik, Form, Field } from "formik";
|
||||
import { useState, useActionState } from "react";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import Turnstile from "react-turnstile";
|
||||
import clsx from "clsx";
|
||||
import Link from "../../components/Link";
|
||||
import useTheme from "../../hooks/useTheme";
|
||||
import { sendMessage } from "./actions";
|
||||
import { GoCheck, GoX } from "react-icons/go";
|
||||
import { SiMarkdown } from "react-icons/si";
|
||||
import type { FormikHelpers, FormikProps, FieldInputProps, FieldMetaProps } from "formik";
|
||||
|
||||
import styles from "./form.module.css";
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
"cf-turnstile-response": string;
|
||||
};
|
||||
|
||||
export type ContactFormProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ContactForm = ({ className }: ContactFormProps) => {
|
||||
const ContactForm = () => {
|
||||
const { activeTheme } = useTheme();
|
||||
|
||||
// status/feedback:
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
|
||||
const handleSubmit = (values: FormValues, { setSubmitting }: FormikHelpers<FormValues>) => {
|
||||
// once a user attempts a submission, this is true and stays true whether or not the next attempt(s) are successful
|
||||
setSubmitted(true);
|
||||
|
||||
// https://stackoverflow.com/a/68372184
|
||||
const formData = new FormData();
|
||||
for (const key in values) {
|
||||
formData.append(key, values[key as keyof FormValues]);
|
||||
}
|
||||
|
||||
// if we've gotten here then all data is (or should be) valid and ready to post to API
|
||||
fetch("/api/contact/", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.success === true) {
|
||||
// handle successful submission
|
||||
// disable submissions, hide the send button, and let user know we were successful
|
||||
setSuccess(true);
|
||||
setFeedback("Thanks! You should hear from me soon.");
|
||||
} else {
|
||||
// pass on any error sent by the server to the catch block below
|
||||
throw new Error(data.message);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setSuccess(false);
|
||||
|
||||
if (error.message === "missing_data") {
|
||||
// this should be validated client-side but it's also checked server-side just in case someone slipped past
|
||||
setFeedback("Please make sure that all fields are properly filled in.");
|
||||
} else if (error.message === "invalid_captcha") {
|
||||
// missing/invalid captcha
|
||||
setFeedback("Did you complete the CAPTCHA? (If you're human, that is...)");
|
||||
} else {
|
||||
// something else went wrong, and it's probably my fault...
|
||||
setFeedback("Internal server error... Try again later or shoot me an old-fashioned email?");
|
||||
}
|
||||
})
|
||||
.finally(() => setSubmitting(false));
|
||||
};
|
||||
const [formState, formAction, pending] = useActionState<
|
||||
Partial<{ success: boolean; message: string; payload: FormData }>,
|
||||
FormData
|
||||
>(sendMessage, {});
|
||||
const [turnstileToken, setTurnstileToken] = useState<string>("");
|
||||
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={handleSubmit}
|
||||
initialValues={{
|
||||
name: "",
|
||||
email: "",
|
||||
message: "",
|
||||
"cf-turnstile-response": "",
|
||||
}}
|
||||
validate={(values: FormValues) => {
|
||||
const errors: Partial<Record<keyof FormValues, boolean>> = {};
|
||||
<form action={formAction}>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Name"
|
||||
required
|
||||
className={styles.input}
|
||||
defaultValue={(formState.payload?.get("name") || "") as string}
|
||||
disabled={formState.success}
|
||||
/>
|
||||
|
||||
errors.name = !values.name;
|
||||
errors.email = !values.email; // also loosely validated that it's email-like via browser (not foolproof)
|
||||
errors.message = !values.message;
|
||||
errors["cf-turnstile-response"] = !values["cf-turnstile-response"];
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
required
|
||||
inputMode="email"
|
||||
className={styles.input}
|
||||
defaultValue={(formState.payload?.get("email") || "") as string}
|
||||
disabled={formState.success}
|
||||
/>
|
||||
|
||||
if (!errors.name && !errors.email && !errors.message && !errors["cf-turnstile-response"]) {
|
||||
setFeedback("");
|
||||
return {};
|
||||
} else {
|
||||
setSuccess(false);
|
||||
setFeedback("Please make sure that all fields are properly filled in.");
|
||||
}
|
||||
<TextareaAutosize
|
||||
name="message"
|
||||
placeholder="Write something..."
|
||||
minRows={5}
|
||||
required
|
||||
className={styles.input}
|
||||
defaultValue={(formState.payload?.get("message") || "") as string}
|
||||
disabled={formState.success}
|
||||
/>
|
||||
|
||||
return errors;
|
||||
}}
|
||||
>
|
||||
{({ setFieldValue, isSubmitting }: FormikProps<FormValues>) => (
|
||||
<Form className={className} name="contact">
|
||||
<Field name="name">
|
||||
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
disabled={success}
|
||||
className={clsx(styles.input, { [styles.missing]: !!(meta.error && meta.touched) })}
|
||||
{...field}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.825em",
|
||||
lineHeight: 1.75,
|
||||
}}
|
||||
>
|
||||
<SiMarkdown
|
||||
style={{
|
||||
display: "inline",
|
||||
width: "1.25em",
|
||||
height: "1.25em",
|
||||
verticalAlign: "-0.25em",
|
||||
marginRight: "0.25em",
|
||||
}}
|
||||
/>{" "}
|
||||
Basic{" "}
|
||||
<Link href="https://commonmark.org/help/" title="Markdown reference sheet" style={{ fontWeight: 600 }}>
|
||||
Markdown syntax
|
||||
</Link>{" "}
|
||||
is allowed here, e.g.: <strong>**bold**</strong>, <em>_italics_</em>, [
|
||||
<Link href="https://jarv.is" underline={false} openInNewTab>
|
||||
links
|
||||
</Link>
|
||||
](https://jarv.is), and <code>`code`</code>.
|
||||
</div>
|
||||
|
||||
<Turnstile
|
||||
sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"}
|
||||
onVerify={(token) => setTurnstileToken(token)}
|
||||
style={{ margin: "1em 0" }}
|
||||
theme={activeTheme === "dark" ? activeTheme : "light"}
|
||||
/>
|
||||
|
||||
<input type="hidden" name="cf-turnstile-response" value={turnstileToken} />
|
||||
|
||||
<div className={styles.actionRow}>
|
||||
{!formState.success && (
|
||||
<button type="submit" disabled={pending} className={styles.submitButton}>
|
||||
{pending ? (
|
||||
<span>Sending...</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.submitIcon}>📤</span> <span>Send</span>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Field name="email">
|
||||
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
|
||||
<input
|
||||
type="email"
|
||||
inputMode="email"
|
||||
placeholder="Email"
|
||||
disabled={success}
|
||||
className={clsx(styles.input, { [styles.missing]: !!(meta.error && meta.touched) })}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="message">
|
||||
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
|
||||
<TextareaAutosize
|
||||
placeholder="Write something..."
|
||||
minRows={5}
|
||||
disabled={success}
|
||||
className={clsx(styles.input, { [styles.missing]: !!(meta.error && meta.touched) })}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div className={styles.markdownTip}>
|
||||
<SiMarkdown className={styles.markdownIcon} /> Basic{" "}
|
||||
<Link href="https://commonmark.org/help/" title="Markdown reference sheet" style={{ fontWeight: 600 }}>
|
||||
Markdown syntax
|
||||
</Link>{" "}
|
||||
is allowed here, e.g.: <strong>**bold**</strong>, <em>_italics_</em>, [
|
||||
<Link href="https://jarv.is" underline={false} openInNewTab>
|
||||
links
|
||||
</Link>
|
||||
](https://jarv.is), and <code>`code`</code>.
|
||||
{formState.message && (
|
||||
<div className={clsx(styles.result, formState.success ? styles.success : styles.error)}>
|
||||
{formState.success ? <GoCheck className={styles.resultIcon} /> : <GoX className={styles.resultIcon} />}{" "}
|
||||
{formState.message}
|
||||
</div>
|
||||
|
||||
<Turnstile
|
||||
sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"}
|
||||
onVerify={(token) => setFieldValue("cf-turnstile-response", token)}
|
||||
className={styles.captcha}
|
||||
theme={activeTheme === "dark" ? activeTheme : "light"}
|
||||
/>
|
||||
|
||||
<div className={styles.actionRow}>
|
||||
<button
|
||||
type="submit"
|
||||
title="Send Message"
|
||||
aria-label="Send Message"
|
||||
onClick={() => setSubmitted(true)}
|
||||
disabled={isSubmitting}
|
||||
className={styles.submitButton}
|
||||
style={{ display: success ? "none" : "inline-flex" }}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span>Sending...</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.submitIcon}>📤</span> <span>Send</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={clsx(styles.result, success ? styles.success : styles.error)}
|
||||
style={{ display: submitted && feedback && !isSubmitting ? "block" : "none" }}
|
||||
>
|
||||
{success ? <GoCheck className={styles.resultIcon} /> : <GoX className={styles.resultIcon} />} {feedback}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
Reference in New Issue
Block a user