further fixes

This commit is contained in:
2026-06-13 15:00:40 -05:00
parent dfd5e744a4
commit 45422753a3
10 changed files with 180 additions and 20 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
# Amazon SES SMTP credentials (NOT IAM access keys — use dedicated SMTP credentials)
SES_SMTP_USER=
SES_SMTP_PASS=
# Verified sender email in Amazon SES (must be a verified identity)
SES_FROM_EMAIL=support@bayarea-cc.com

View File

@@ -0,0 +1,92 @@
import { NextResponse } from "next/server";
import nodemailer from "nodemailer";
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export async function POST(request: Request) {
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const name = String(body.name ?? "").trim();
const email = String(body.email ?? "").trim();
const mailboxes = String(body.mailboxes ?? "").trim();
const provider = String(body.provider ?? "").trim();
const message = String(body.message ?? "").trim();
const errors: Record<string, string> = {};
if (!name) {
errors.name = "Please enter your name.";
}
if (!email) {
errors.email = "Please enter your business email.";
} else if (!EMAIL_REGEX.test(email)) {
errors.email = "Email address needs to include an @ symbol.";
}
if (Object.keys(errors).length > 0) {
return NextResponse.json({ error: "Validation failed.", fields: errors }, { status: 400 });
}
const sesUser = process.env.SES_SMTP_USER;
const sesPass = process.env.SES_SMTP_PASS;
const fromEmail = process.env.SES_FROM_EMAIL;
if (!sesUser || !sesPass || !fromEmail) {
return NextResponse.json({ error: "Server configuration error." }, { status: 500 });
}
const transporter = nodemailer.createTransport({
host: "email-smtp.us-east-2.amazonaws.com",
port: 587,
secure: false,
auth: {
user: sesUser,
pass: sesPass,
},
});
const textBody = [
`Name: ${name}`,
`Business Email: ${email}`,
`Mailboxes: ${mailboxes || "(not provided)"}`,
`Current Provider: ${provider || "(not selected)"}`,
`Message: ${message || "(none)"}`,
].join("\n");
const htmlBody = [
"<table style='font-family:sans-serif;border-collapse:collapse;'>",
`<tr><td style='padding:6px 12px 6px 0;font-weight:700;white-space:nowrap;'>Name</td><td style='padding:6px 0;'>${escapeHtml(name)}</td></tr>`,
`<tr><td style='padding:6px 12px 6px 0;font-weight:700;white-space:nowrap;'>Business Email</td><td style='padding:6px 0;'>${escapeHtml(email)}</td></tr>`,
`<tr><td style='padding:6px 12px 6px 0;font-weight:700;white-space:nowrap;'>Mailboxes</td><td style='padding:6px 0;'>${escapeHtml(mailboxes || "(not provided)")}</td></tr>`,
`<tr><td style='padding:6px 12px 6px 0;font-weight:700;white-space:nowrap;'>Current Provider</td><td style='padding:6px 0;'>${escapeHtml(provider || "(not selected)")}</td></tr>`,
`<tr><td style='padding:6px 12px 6px 0;font-weight:700;white-space:nowrap;'>Message</td><td style='padding:6px 0;'>${escapeHtml(message || "(none)")}</td></tr>`,
"</table>",
].join("\n");
try {
await transporter.sendMail({
from: fromEmail,
to: "support@bayarea-cc.com",
replyTo: email,
subject: `New email assessment request — ${name}`,
text: textBody,
html: htmlBody,
});
} catch {
return NextResponse.json({ error: "Unable to send your request. Please try again or call us." }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

View File

@@ -1478,6 +1478,7 @@ h3 {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-3);
max-width: 56rem; max-width: 56rem;
margin-inline: auto;
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
@@ -1499,6 +1500,8 @@ h3 {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--space-4); gap: var(--space-4);
max-width: 68rem;
margin-inline: auto;
} }
@media (min-width: 760px) { @media (min-width: 760px) {
@@ -2635,7 +2638,7 @@ h3 {
.pricing-builder { .pricing-builder {
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);
max-width: var(--page-max); max-width: 68rem;
margin-inline: auto; margin-inline: auto;
padding: 0; padding: 0;
} }

View File

@@ -18,6 +18,7 @@ export default function Page() {
const [mailboxes, setMailboxes] = useState(10); const [mailboxes, setMailboxes] = useState(10);
const [formErrors, setFormErrors] = useState({ name: "", email: "" }); const [formErrors, setFormErrors] = useState({ name: "", email: "" });
const [formStatus, setFormStatus] = useState(""); const [formStatus, setFormStatus] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
const storedTheme = window.localStorage.getItem("bes-theme"); const storedTheme = window.localStorage.getItem("bes-theme");
@@ -112,7 +113,7 @@ export default function Page() {
} }
}; };
const handleAssessmentSubmit = (event: FormEvent<HTMLFormElement>) => { const handleAssessmentSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
const form = event.currentTarget; const form = event.currentTarget;
const formData = new FormData(form); const formData = new FormData(form);
@@ -139,8 +140,40 @@ export default function Page() {
return; return;
} }
setFormStatus("Thanks. We'll review your mailbox count and current provider before we reply."); setIsSubmitting(true);
form.reset();
try {
const response = await fetch("/api/assessment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
email,
mailboxes: formData.get("mailboxes") ?? "",
provider: formData.get("provider") ?? "",
message: formData.get("message") ?? "",
}),
});
if (response.ok) {
setFormStatus("Thanks. We'll review your mailbox count and current provider before we reply.");
form.reset();
} else {
const data = await response.json().catch(() => null);
if (data?.fields) {
setFormErrors({
name: data.fields.name ?? "",
email: data.fields.email ?? "",
});
} else {
setFormStatus(data?.error ?? "Something went wrong. Please try again or call us.");
}
}
} catch {
setFormStatus("Network error. Please check your connection and try again.");
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
@@ -167,6 +200,7 @@ export default function Page() {
<AssessmentSection <AssessmentSection
formErrors={formErrors} formErrors={formErrors}
formStatus={formStatus} formStatus={formStatus}
isSubmitting={isSubmitting}
onAssessmentSubmit={handleAssessmentSubmit} onAssessmentSubmit={handleAssessmentSubmit}
/> />
</main> </main>

View File

@@ -3,10 +3,11 @@ import type { FormEventHandler } from "react";
type AssessmentSectionProps = { type AssessmentSectionProps = {
formErrors: { name: string; email: string }; formErrors: { name: string; email: string };
formStatus: string; formStatus: string;
isSubmitting: boolean;
onAssessmentSubmit: FormEventHandler<HTMLFormElement>; onAssessmentSubmit: FormEventHandler<HTMLFormElement>;
}; };
export default function AssessmentSection({ formErrors, formStatus, onAssessmentSubmit }: AssessmentSectionProps) { export default function AssessmentSection({ formErrors, formStatus, isSubmitting, onAssessmentSubmit }: AssessmentSectionProps) {
return ( return (
<section className="content-section assessment-section" id="assessment" aria-labelledby="assessment-title"> <section className="content-section assessment-section" id="assessment" aria-labelledby="assessment-title">
<div className="assessment-bg" aria-hidden="true"></div> <div className="assessment-bg" aria-hidden="true"></div>
@@ -56,9 +57,14 @@ export default function AssessmentSection({ formErrors, formStatus, onAssessment
<label htmlFor="message">What should we review?</label> <label htmlFor="message">What should we review?</label>
<textarea id="message" name="message" rows={4} placeholder="Domain, mailbox count, migration timing, device setup..."></textarea> <textarea id="message" name="message" rows={4} placeholder="Domain, mailbox count, migration timing, device setup..."></textarea>
</div> </div>
<button className="button button-primary button-large" type="submit"> <button
Request email assessment className="button button-primary button-large"
<span aria-hidden="true"></span> type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
>
{isSubmitting ? "Sending…" : "Request email assessment"}
<span aria-hidden="true">{isSubmitting ? "" : "→"}</span>
</button> </button>
<p className="form-trust-line">We reply within one business day. We don&rsquo;t share your details.</p> <p className="form-trust-line">We reply within one business day. We don&rsquo;t share your details.</p>
<p className="form-status" role="status" aria-live="polite">{formStatus}</p> <p className="form-status" role="status" aria-live="polite">{formStatus}</p>
@@ -67,7 +73,7 @@ export default function AssessmentSection({ formErrors, formStatus, onAssessment
<div className="assessment-copy"> <div className="assessment-copy">
<p className="eyebrow">Start here</p> <p className="eyebrow">Start here</p>
<h2 id="assessment-title">Lets review your email setup.</h2> <h2 id="assessment-title">Let&rsquo;s review your email setup.</h2>
<p> <p>
Tell us about your domain, mailbox count, current provider, and support needs. We will reply with clear next steps for hosting, DNS, migration, and device setup. Tell us about your domain, mailbox count, current provider, and support needs. We will reply with clear next steps for hosting, DNS, migration, and device setup.
</p> </p>

View File

@@ -1,6 +1,6 @@
export default function ProblemSection() { export default function ProblemSection() {
return ( return (
<section className="content-section problem-section" aria-labelledby="problem-title"> <section className="content-section problem-section" id="why" aria-labelledby="problem-title">
<div className="section-heading"> <div className="section-heading">
<p className="eyebrow">Why it matters</p> <p className="eyebrow">Why it matters</p>
<h2 id="problem-title">Email problems rarely look technical at first.</h2> <h2 id="problem-title">Email problems rarely look technical at first.</h2>

View File

@@ -26,11 +26,10 @@ export default function SiteFooter() {
<nav className="footer-links" aria-label="Footer navigation"> <nav className="footer-links" aria-label="Footer navigation">
<h2>Explore</h2> <h2>Explore</h2>
<ul> <ul>
<li><a href="#services">Services</a></li> <li><a href="#why">Why email matters</a></li>
<li><a href="#infrastructure">Infrastructure</a></li>
<li><a href="#pricing-detail">Pricing</a></li> <li><a href="#pricing-detail">Pricing</a></li>
<li><a href="#migration-detail">Migration</a></li>
<li><a href="#faq">FAQ</a></li> <li><a href="#faq">FAQ</a></li>
<li><a href="#assessment">Get assessment</a></li>
</ul> </ul>
</nav> </nav>
@@ -47,7 +46,6 @@ export default function SiteFooter() {
<div className="footer-bottom"> <div className="footer-bottom">
<p>Bay Area Email Services, part of Bay Area IT. All rights reserved.</p> <p>Bay Area Email Services, part of Bay Area IT. All rights reserved.</p>
<div> <div>
<a href="#assessment">Support</a>
<a href="#top" className="back-to-top"> <a href="#top" className="back-to-top">
Back to top Back to top
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">

View File

@@ -43,12 +43,10 @@ export default function SiteHeader({ menuOpen, theme, onMenuToggle, onThemeToggl
</button> </button>
<nav id="nav-menu" className={`nav-menu${menuOpen ? " is-open" : ""}`} onClick={onNavClick}> <nav id="nav-menu" className={`nav-menu${menuOpen ? " is-open" : ""}`} onClick={onNavClick}>
<a href="#services">Services</a> <a href="#why">Why email matters</a>
<a href="#infrastructure">Infrastructure</a> <a href="#pricing-detail">Pricing</a>
<a href="#pricing">Pricing</a> <a href="#faq">FAQ</a>
<a href="#migration">Migration</a> <a href="#assessment">Get assessment</a>
<a href="#faq">Resources</a>
<a href="#assessment">Support</a>
</nav> </nav>
<div className="header-actions"> <div className="header-actions">

21
package-lock.json generated
View File

@@ -9,11 +9,13 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"next": "^15.3.3", "next": "^15.3.3",
"nodemailer": "^6.9.16",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.15.24", "@types/node": "^22.15.24",
"@types/nodemailer": "^6.4.17",
"@types/react": "^19.0.12", "@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"typescript": "^5.8.3" "typescript": "^5.8.3"
@@ -648,6 +650,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "6.4.24",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz",
"integrity": "sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.15", "version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
@@ -781,6 +793,15 @@
} }
} }
}, },
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",

View File

@@ -10,11 +10,13 @@
}, },
"dependencies": { "dependencies": {
"next": "^15.3.3", "next": "^15.3.3",
"nodemailer": "^6.9.16",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.15.24", "@types/node": "^22.15.24",
"@types/nodemailer": "^6.4.17",
"@types/react": "^19.0.12", "@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"typescript": "^5.8.3" "typescript": "^5.8.3"