From 5b0d8a9ffc71922d20b95665b13c6f1a95061f39 Mon Sep 17 00:00:00 2001 From: Daniel LaForce Date: Sun, 27 Apr 2025 18:07:23 -0600 Subject: [PATCH] feat: Add contact page with Resend API integration --- package-lock.json | 270 ++++++++++++++++++++++++++++++++++++ package.json | 1 + src/components/Header.astro | 2 +- src/pages/api/contact.ts | 137 ++++++++---------- src/pages/contact.astro | 203 +++++++++++++++++++++++++++ 5 files changed, 536 insertions(+), 77 deletions(-) create mode 100644 src/pages/contact.astro diff --git a/package-lock.json b/package-lock.json index 6b20479..9359874 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 768a199..47e470b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@astrojs/tailwind": "latest", "astro": "latest", "nodemailer": "^6.10.1", + "resend": "^4.4.1", "tailwindcss": "^3.0.24" }, "devDependencies": { diff --git a/src/components/Header.astro b/src/components/Header.astro index 11c9cb6..123c1b6 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -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' } diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts index 09d9d5e..f0c8ce7 100644 --- a/src/pages/api/contact.ts +++ b/src/pages/api/contact.ts @@ -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 ', // 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: ` +

New Contact Form Submission

+

Name: ${data.name}

+

Email: ${data.email}

+

Subject: ${data.subject}

+
+

Message:

+

${data.message.replace(/\n/g, '
')}

+ `, + 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: ` -

New Argobox Contact Form Submission

-

Name: ${name}

-

Email: ${email}

-

Subject: ${subject}

-

Message:

-

${message.replace(/\n/g, '
')}

- ` - }; - - // 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' } -// }); -// }; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/pages/contact.astro b/src/pages/contact.astro new file mode 100644 index 0000000..28a2ec5 --- /dev/null +++ b/src/pages/contact.astro @@ -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."; +--- + + +
+ +
+
+

Contact Us

+

Have a question, suggestion, or just want to say hello? Fill out the form below.

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ +