Add SSR contact form
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 18m1s

This commit is contained in:
2025-08-29 11:36:58 +09:30
parent 1c3b37a70c
commit e46a4a560a
8 changed files with 1881 additions and 17 deletions

View File

@@ -14,6 +14,8 @@ import robotsTxt from "astro-robots-txt";
import { transformerMetaHighlight } from "@shikijs/transformers";
import node from "@astrojs/node";
// https://astro.build/config
export default defineConfig({
vite: {
@@ -51,12 +53,15 @@ export default defineConfig({
},
site: "https://www.nathancummins.com.au",
trailingSlash: "always",
image: {
responsiveStyles: false
},
output: "static"
});
output: "static",
adapter: node({
mode: "standalone"
})
});

1650
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@astrojs/mdx": "^4.3.1",
"@astrojs/node": "^9.4.3",
"@astrojs/sitemap": "^3.4.2",
"@iconify-json/fa": "^1.2.1",
"@iconify-json/fa7-brands": "^1.2.0",
@@ -22,6 +23,7 @@
"astro-robots-txt": "^1.0.0",
"astro-seo-schema": "^5.1.0",
"get-audio-duration": "^4.0.1",
"nodemailer": "^7.0.5",
"schema-dts": "^1.1.5",
"tailwindcss": "^4.1.11"
},
@@ -29,6 +31,7 @@
"@cspell/dict-en-au": "^1.1.4",
"@shikijs/transformers": "^3.9.2",
"@types/howler": "^2.2.12",
"@types/nodemailer": "^7.0.1",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"eslint": "^9.32.0",

View File

@@ -0,0 +1,102 @@
---
import { Icon } from "astro-icon/components";
const ID = `contact-form-${Math.random().toString(36)}`;
---
<form
class="mx-auto mt-8 max-w-xl space-y-6 rounded bg-white p-8 shadow-lg dark:bg-gray-900"
id={ID}
>
<div>
<label for="name" class="block font-bold">Name</label>
<input
type="text"
id="name"
name="name"
required
class="mt-2 block w-full rounded p-2 shadow dark:bg-gray-600"
/>
</div>
<div>
<label for="email" class="block font-bold">Your email</label>
<input
type="email"
id="email"
name="email"
required
class="mt-2 block w-full rounded p-2 shadow dark:bg-gray-600"
/>
</div>
<div>
<label for="subject" class="block font-bold">Subject</label>
<input
type="text"
id="subject"
name="subject"
class="mt-2 block w-full rounded p-2 shadow dark:bg-gray-600"
/>
</div>
<div>
<label for="message" class="block font-bold">Message</label>
<textarea
id="message"
name="message"
rows="4"
class="mt-2 block w-full rounded p-2 shadow dark:bg-gray-600"></textarea>
</div>
<button
id="submit"
type="submit"
class="bg-primary text-md font-header hover:text-primary repeat hover:ring-primary mx-auto inline-block rounded px-6 py-3 font-light text-white uppercase drop-shadow transition hover:bg-white hover:ring-2"
><Icon
name="mdi:loading"
id="loading-icon"
class="mr-4 hidden animate-spin"
/><span id="response-message" class="inline">Send message</span></button
>
</form>
<script define:vars={{ ID }}>
document.addEventListener("astro:page-load", () => {
const form = document.getElementById(ID);
const responseMsg = form.querySelector("#response-message");
const button = form.querySelector("#submit");
const loadingIcon = form.querySelector("#loading-icon");
form.addEventListener("submit", async function (event) {
event.preventDefault();
loadingIcon.classList.replace("hidden", "inline");
responseMsg.textContent = "Sending...";
button.disabled = true;
try {
const formData = new FormData(form);
const response = await fetch("/api/email/contact/", {
method: "POST",
body: formData
});
const data = await response.json();
if (response.ok) {
responseMsg.textContent = data.message;
form.reset();
} else {
responseMsg.textContent = data.message;
}
} catch (err) {
responseMsg.textContent =
"An error occurred. Please refresh the page and try again.";
}
loadingIcon.classList.replace("inline", "hidden");
setTimeout(() => {
responseMsg.textContent = "Send message";
button.disabled = false;
}, 10000);
});
});
</script>

13
src/env.d.ts vendored
View File

@@ -12,3 +12,16 @@ interface Window {
player: any;
playerIsInitialised: boolean;
}
interface ImportMetaEnv {
readonly EMAIL_USER: string;
readonly EMAIL_PASS: string;
readonly EMAIL_PORT: number;
readonly EMAIL_SECURE: "true" | "false";
readonly EMAIL_HOST: string;
readonly EMAIL_FROM: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

46
src/lib/email.ts Normal file
View File

@@ -0,0 +1,46 @@
import { createTransport } from "nodemailer";
import SMTPTransport from "nodemailer/lib/smtp-transport";
import type { Options } from "nodemailer/lib/mailer";
interface Email {
email: string;
message: string;
html?: string;
subject: string;
name: string;
}
export function constructEmailHtml(props: Email) {
return `<div>${props.message}</div>`;
}
export default async function (props: Email) {
const transporter = createTransport({
host: process.env.EMAIL_HOST || import.meta.env.EMAIL_HOST,
port: process.env.EMAIL_PORT || import.meta.env.EMAIL_PORT,
secure:
process.env.EMAIL_SECURE === "true" ||
import.meta.env.EMAIL_SECURE === "true",
auth: {
user: process.env.EMAIL_USER || import.meta.env.EMAIL_USER,
pass: process.env.EMAIL_PASS || import.meta.env.EMAIL_PASS
}
} as SMTPTransport.Options);
if (!props.html) {
props.html = constructEmailHtml(props);
}
const message: Options = {
from: `${props.name} (Website) <${process.env.EMAIL_FROM || import.meta.env.EMAIL_FROM}>`,
to: "nathan@nathancummins.com.au",
replyTo: props.email,
subject: props.subject,
text: props.message,
html: props.html
};
const info = await transporter.sendMail(message);
return info;
}

View File

@@ -0,0 +1,52 @@
import sendEmail from "@lib/email";
import type { APIRoute } from "astro";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const data = await request.formData();
const email = data.get("email") as string | null;
const subject = data.get("subject") as string | null;
const message = data.get("message") as string | null;
const name = data.get("name") as string | null;
if (!email || !subject || !message || !name) {
return new Response(
JSON.stringify({ message: "Required field missing." }),
{
status: 400,
statusText: "Missing required fields."
}
);
}
const html = `<h1>Message Received via Contact Form</h1><br>
<b>Name</b>: ${name}<br>
<b>Email</b>: ${email}<br>
<b>Message</b>: ${message}`;
try {
await sendEmail({ email, subject, message, name, html });
} catch (error) {
return new Response(
JSON.stringify({
message: `Message failed to send with an internal server error. Please contact me directly via a method below.`
}),
{
status: 500,
statusText: `Message failed to send with an internal server error. Please contact me directly via a method below.`
}
);
}
return new Response(
JSON.stringify({
message:
"Thanks for getting in touch! I will try to get back to you as soon as I can."
}),
{
status: 200,
statusText: "OK"
}
);
};

View File

@@ -1,16 +1,25 @@
---
import ContactForm from "@components/ContactForm.astro";
import Paragraph from "@components/Paragraph.astro";
import SectionTitle from "@components/SectionTitle.astro";
import TextLink from "@components/TextLink.astro";
import MainLayout from "@layouts/MainLayout.astro";
import { Icon } from "astro-icon/components";
import Paragraph from "../components/Paragraph.astro";
import SectionTitle from "../components/SectionTitle.astro";
import TextLink from "../components/TextLink.astro";
import MainLayout from "../layouts/MainLayout.astro";
---
<MainLayout title="Contact">
<div class="w-full">
<section id="contact" class="bg-white dark:bg-gray-950">
<section id="contact" class="bg-primary dark:bg-gray-800">
<div class="mx-auto max-w-4xl px-8 py-16 text-center">
<SectionTitle>Let's get in touch!</SectionTitle>
<SectionTitle class="text-white" lineColour="text-white"
>Let's get in touch!</SectionTitle
>
<ContactForm />
</div>
</section>
<section id="details" class="bg-white dark:bg-gray-950">
<div class="mx-auto max-w-4xl px-8 py-16 text-center">
<SectionTitle>Other ways to reach me</SectionTitle>
<div class="grid grid-cols-1 text-lg md:grid-cols-2">
<div class="col-span-1 flex flex-col items-center justify-center p-4">
<Icon name="fa7-solid:at" class="text-4xl" />