Add SSR contact form
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 18m1s
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 18m1s
This commit is contained in:
@@ -14,6 +14,8 @@ import robotsTxt from "astro-robots-txt";
|
|||||||
|
|
||||||
import { transformerMetaHighlight } from "@shikijs/transformers";
|
import { transformerMetaHighlight } from "@shikijs/transformers";
|
||||||
|
|
||||||
|
import node from "@astrojs/node";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
vite: {
|
vite: {
|
||||||
@@ -51,12 +53,15 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
site: "https://www.nathancummins.com.au",
|
site: "https://www.nathancummins.com.au",
|
||||||
|
|
||||||
trailingSlash: "always",
|
trailingSlash: "always",
|
||||||
|
|
||||||
image: {
|
image: {
|
||||||
responsiveStyles: false
|
responsiveStyles: false
|
||||||
},
|
},
|
||||||
|
|
||||||
output: "static"
|
output: "static",
|
||||||
});
|
|
||||||
|
adapter: node({
|
||||||
|
mode: "standalone"
|
||||||
|
})
|
||||||
|
});
|
1650
package-lock.json
generated
1650
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^4.3.1",
|
"@astrojs/mdx": "^4.3.1",
|
||||||
|
"@astrojs/node": "^9.4.3",
|
||||||
"@astrojs/sitemap": "^3.4.2",
|
"@astrojs/sitemap": "^3.4.2",
|
||||||
"@iconify-json/fa": "^1.2.1",
|
"@iconify-json/fa": "^1.2.1",
|
||||||
"@iconify-json/fa7-brands": "^1.2.0",
|
"@iconify-json/fa7-brands": "^1.2.0",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"astro-robots-txt": "^1.0.0",
|
"astro-robots-txt": "^1.0.0",
|
||||||
"astro-seo-schema": "^5.1.0",
|
"astro-seo-schema": "^5.1.0",
|
||||||
"get-audio-duration": "^4.0.1",
|
"get-audio-duration": "^4.0.1",
|
||||||
|
"nodemailer": "^7.0.5",
|
||||||
"schema-dts": "^1.1.5",
|
"schema-dts": "^1.1.5",
|
||||||
"tailwindcss": "^4.1.11"
|
"tailwindcss": "^4.1.11"
|
||||||
},
|
},
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"@cspell/dict-en-au": "^1.1.4",
|
"@cspell/dict-en-au": "^1.1.4",
|
||||||
"@shikijs/transformers": "^3.9.2",
|
"@shikijs/transformers": "^3.9.2",
|
||||||
"@types/howler": "^2.2.12",
|
"@types/howler": "^2.2.12",
|
||||||
|
"@types/nodemailer": "^7.0.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||||
"@typescript-eslint/parser": "^8.38.0",
|
"@typescript-eslint/parser": "^8.38.0",
|
||||||
"eslint": "^9.32.0",
|
"eslint": "^9.32.0",
|
||||||
|
102
src/components/ContactForm.astro
Normal file
102
src/components/ContactForm.astro
Normal 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
13
src/env.d.ts
vendored
@@ -12,3 +12,16 @@ interface Window {
|
|||||||
player: any;
|
player: any;
|
||||||
playerIsInitialised: boolean;
|
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
46
src/lib/email.ts
Normal 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;
|
||||||
|
}
|
52
src/pages/api/email/contact.ts
Normal file
52
src/pages/api/email/contact.ts
Normal 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"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
@@ -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 { 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">
|
<MainLayout title="Contact">
|
||||||
<div class="w-full">
|
<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">
|
<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="grid grid-cols-1 text-lg md:grid-cols-2">
|
||||||
<div class="col-span-1 flex flex-col items-center justify-center p-4">
|
<div class="col-span-1 flex flex-col items-center justify-center p-4">
|
||||||
<Icon name="fa7-solid:at" class="text-4xl" />
|
<Icon name="fa7-solid:at" class="text-4xl" />
|
||||||
|
Reference in New Issue
Block a user