feat: Add contact page with Resend API integration

This commit is contained in:
Daniel LaForce 2025-04-27 18:07:23 -06:00
parent 0390a370c2
commit 5b0d8a9ffc
5 changed files with 536 additions and 77 deletions

270
package-lock.json generated
View File

@ -15,6 +15,7 @@
"@astrojs/tailwind": "latest",
"astro": "latest",
"nodemailer": "^6.10.1",
"resend": "^4.4.1",
"tailwindcss": "^3.0.24"
},
"devDependencies": {
@ -1358,6 +1359,24 @@
"node": ">=14"
}
},
"node_modules/@react-email/render": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.6.tgz",
"integrity": "sha512-zNueW5Wn/4jNC1c5LFgXzbUdv5Lhms+FWjOvWAhal7gx5YVf0q6dPJ0dnR70+ifo59gcMLwCZEaTS9EEuUhKvQ==",
"license": "MIT",
"dependencies": {
"html-to-text": "9.0.5",
"prettier": "3.5.3",
"react-promise-suspense": "0.3.4"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
@ -1646,6 +1665,19 @@
"win32"
]
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"selderee": "^0.11.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@shikijs/core": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.3.0.tgz",
@ -2677,6 +2709,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
@ -2766,6 +2807,73 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom-serializer/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dset": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz",
@ -3548,6 +3656,22 @@
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
"license": "MIT"
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
"license": "MIT",
"dependencies": {
"@selderee/plugin-htmlparser2": "^0.11.0",
"deepmerge": "^4.3.1",
"dom-serializer": "^2.0.0",
"htmlparser2": "^8.0.2",
"selderee": "^0.11.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
@ -3558,6 +3682,37 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-cache-semantics": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
@ -3817,6 +3972,15 @@
"node": ">=6"
}
},
"node_modules/leac": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@ -5364,6 +5528,19 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
"license": "MIT",
"dependencies": {
"leac": "^0.6.0",
"peberminta": "^0.9.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -5407,6 +5584,15 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -5600,6 +5786,21 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/prettier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/printable-characters": {
"version": "1.0.42",
"resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz",
@ -5673,6 +5874,44 @@
"integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==",
"license": "MIT"
},
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.0"
}
},
"node_modules/react-promise-suspense": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
"integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^2.0.1"
}
},
"node_modules/react-promise-suspense/node_modules/fast-deep-equal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
"integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==",
"license": "MIT"
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -5965,6 +6204,18 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/resend": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/resend/-/resend-4.4.1.tgz",
"integrity": "sha512-FR22bzMW3VfoyZSBc8ScGo8ShrMWHmWB0G3FrispzWCnYSEEK5M7pyRvZtInKmM/09lsJETKc2q66mX+dXPSmg==",
"license": "MIT",
"dependencies": {
"@react-email/render": "1.0.6"
},
"engines": {
"node": ">=18"
}
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@ -6130,6 +6381,25 @@
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
"license": "ISC"
},
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT",
"peer": true
},
"node_modules/selderee": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
"license": "MIT",
"dependencies": {
"parseley": "^0.12.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",

View File

@ -16,6 +16,7 @@
"@astrojs/tailwind": "latest",
"astro": "latest",
"nodemailer": "^6.10.1",
"resend": "^4.4.1",
"tailwindcss": "^3.0.24"
},
"devDependencies": {

View File

@ -8,7 +8,7 @@ const navItems = [
{ name: 'Blog', url: '/blog' },
{ name: 'Projects', url: '/projects' },
{ name: 'Tech Stack', url: '/tech-stack' },
{ name: 'Home Lab', url: 'https://argobox.com' },
{ name: 'Home Lab', url: '/homelab' }, // Updated URL
{ name: 'Resources', url: '/resources' },
{ name: 'About', url: 'https://ArgoBox.com' },
{ name: 'Contact', url: 'https://ArgoBox.com/index.html#contact' }

View File

@ -1,98 +1,83 @@
import type { APIRoute } from 'astro';
import nodemailer from 'nodemailer';
import { Resend } from 'resend';
// Validate environment variables
const emailUser = import.meta.env.EMAIL_USER;
const emailPass = import.meta.env.EMAIL_PASS;
const emailTo = import.meta.env.EMAIL_TO || 'daniel.laforce@argobox.com'; // Default recipient
// Initialize Resend with API key from environment variables
// IMPORTANT: Set RESEND_API_KEY in your deployment environment (e.g., Cloudflare Pages)
const resend = new Resend(import.meta.env.RESEND_API_KEY);
if (!emailUser || !emailPass) {
console.error("Email credentials (EMAIL_USER, EMAIL_PASS) are not set in environment variables.");
// Optionally, you could throw an error here during build or startup in development
// Define the expected structure of the form data
interface FormData {
name: string;
email: string;
subject: string;
message: string;
}
// Configure Nodemailer transporter
// Ensure you have configured "less secure app access" in Gmail or use an App Password
// if using 2-Factor Authentication. Consider using a more robust email service
// like SendGrid, Mailgun, or Resend for production.
const transporter = nodemailer.createTransport({
service: 'gmail', // Or your preferred service
auth: {
user: emailUser,
pass: emailPass,
},
});
export const POST: APIRoute = async ({ request }) => {
if (!emailUser || !emailPass) {
return new Response(JSON.stringify({ message: 'Server email configuration error.' }), {
// Check if API key is configured
if (!import.meta.env.RESEND_API_KEY) {
console.error("Resend API key is not configured.");
return new Response(JSON.stringify({ message: 'Server configuration error.' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
headers: { 'Content-Type': 'application/json' },
});
}
let data: FormData;
try {
data = await request.json();
} catch (error) {
return new Response(JSON.stringify({ message: 'Invalid request body.' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Basic validation
if (!data.name || !data.email || !data.subject || !data.message) {
return new Response(JSON.stringify({ message: 'Missing required fields.' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const data = await request.json();
const { name, email, subject, message } = data;
// Send email using Resend
const { data: emailData, error: emailError } = await resend.emails.send({
from: 'Contact Form <noreply@argobox.com>', // Replace with your desired "from" address (must be a verified domain in Resend)
to: ['daniel.laforce@gmail.com'], // Replace with the email address where you want to receive messages
subject: `New Contact Form Submission: ${data.subject}`,
html: `
<h1>New Contact Form Submission</h1>
<p><strong>Name:</strong> ${data.name}</p>
<p><strong>Email:</strong> ${data.email}</p>
<p><strong>Subject:</strong> ${data.subject}</p>
<hr>
<p><strong>Message:</strong></p>
<p>${data.message.replace(/\n/g, '<br>')}</p>
`,
reply_to: data.email, // Set the reply-to header to the sender's email
});
// Basic validation
if (!name || !email || !subject || !message) {
return new Response(JSON.stringify({ message: 'Missing required fields.' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
if (emailError) {
console.error('Resend error:', emailError);
return new Response(JSON.stringify({ message: 'Failed to send email.' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// Email options
const mailOptions = {
from: `"${name}" <${emailUser}>`, // Display name and authorized sender
replyTo: email, // Set reply-to for easy response
to: emailTo, // Recipient address from env or default
subject: `Argobox Contact Form: ${subject}`,
text: `
Name: ${name}
Email: ${email}
Subject: ${subject}
Message:
${message}
`,
html: `
<h3>New Argobox Contact Form Submission</h3>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> <a href="mailto:${email}">${email}</a></p>
<p><strong>Subject:</strong> ${subject}</p>
<h4>Message:</h4>
<p>${message.replace(/\n/g, '<br>')}</p>
`
};
// Send email
await transporter.sendMail(mailOptions);
return new Response(JSON.stringify({ message: 'Message sent successfully!' }), {
// Email sent successfully
return new Response(JSON.stringify({ message: 'Email sent successfully!' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Error processing contact form:', error);
let errorMessage = 'Failed to send message. Please try again later.';
if (error instanceof Error) {
// Check for specific Nodemailer errors if needed
// e.g., if (error.code === 'EAUTH') errorMessage = 'Server authentication error.';
}
return new Response(JSON.stringify({ message: errorMessage }), {
console.error('Unexpected error sending email:', error);
return new Response(JSON.stringify({ message: 'An unexpected server error occurred.' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
headers: { 'Content-Type': 'application/json' },
});
}
};
// Optional: Add a GET handler if you want to test the endpoint directly
// export const GET: APIRoute = () => {
// return new Response(JSON.stringify({ message: 'Contact API endpoint is active. Use POST to submit.' }), {
// status: 200,
// headers: { 'Content-Type': 'application/json' }
// });
// };
};

203
src/pages/contact.astro Normal file
View File

@ -0,0 +1,203 @@
---
// src/pages/contact.astro
import BaseLayout from '../layouts/BaseLayout.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
const title = "Contact | ArgoBox";
const description = "Get in touch with ArgoBox. Send a message using the contact form.";
---
<BaseLayout {title} {description}>
<Header slot="header" />
<main class="contact-page container">
<section class="contact-form-section">
<h1>Contact Us</h1>
<p>Have a question, suggestion, or just want to say hello? Fill out the form below.</p>
<form id="contact-form" class="contact-form">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" required />
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
</div>
<div class="form-group">
<label for="subject">Subject</label>
<input type="text" id="subject" name="subject" required />
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required></textarea>
</div>
<button type="submit" class="btn btn-primary" id="submit-button">
Send Message
</button>
<div id="form-status" class="form-status" aria-live="polite"></div>
</form>
</section>
</main>
<Footer />
</BaseLayout>
<style>
.contact-page {
padding-top: 2rem;
padding-bottom: 4rem;
max-width: 700px; /* Limit width for better readability */
margin: 0 auto;
}
.contact-form-section h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
text-align: center;
color: var(--text-primary);
}
.contact-form-section p {
text-align: center;
margin-bottom: 2.5rem;
color: var(--text-secondary);
font-size: 1.1rem;
}
.contact-form {
background: var(--bg-secondary);
padding: 2.5rem;
border-radius: 8px;
border: 1px solid var(--border-primary);
box-shadow: var(--card-shadow-lg);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input[type="text"],
.form-group input[type="email"],
.form-group textarea {
width: 100%;
padding: 0.8rem 1rem;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 1rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--glow-primary);
}
.form-group textarea {
resize: vertical;
min-height: 120px;
}
.btn-primary {
display: inline-block;
padding: 0.8rem 1.5rem;
background: var(--accent-primary);
color: var(--bg-primary);
border: none;
border-radius: 6px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.1s ease;
}
.btn-primary:hover {
background: var(--accent-secondary);
}
.btn-primary:active {
transform: scale(0.98);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-status {
margin-top: 1.5rem;
padding: 1rem;
border-radius: 6px;
text-align: center;
font-weight: 500;
}
.form-status.success {
background-color: rgba(16, 185, 129, 0.1); /* Tailwind green-100 */
color: #059669; /* Tailwind green-700 */
border: 1px solid rgba(16, 185, 129, 0.2);
}
.form-status.error {
background-color: rgba(239, 68, 68, 0.1); /* Tailwind red-100 */
color: #dc2626; /* Tailwind red-600 */
border: 1px solid rgba(239, 68, 68, 0.2);
}
</style>
<script>
const form = document.getElementById('contact-form') as HTMLFormElement;
const formStatus = document.getElementById('form-status');
const submitButton = document.getElementById('submit-button') as HTMLButtonElement;
form?.addEventListener('submit', async (event) => {
event.preventDefault();
if (!formStatus || !submitButton) return;
submitButton.disabled = true;
submitButton.textContent = 'Sending...';
formStatus.textContent = '';
formStatus.className = 'form-status'; // Reset classes
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
const result = await response.json();
if (response.ok) {
formStatus.textContent = 'Message sent successfully! Thank you.';
formStatus.classList.add('success');
form.reset(); // Clear the form
} else {
formStatus.textContent = `Error: ${result.message || 'Could not send message.'}`;
formStatus.classList.add('error');
}
} catch (error) {
console.error('Form submission error:', error);
formStatus.textContent = 'An unexpected error occurred. Please try again later.';
formStatus.classList.add('error');
} finally {
submitButton.disabled = false;
submitButton.textContent = 'Send Message';
}
});
</script>