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

View File

@@ -18,6 +18,7 @@ export default function Page() {
const [mailboxes, setMailboxes] = useState(10);
const [formErrors, setFormErrors] = useState({ name: "", email: "" });
const [formStatus, setFormStatus] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
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();
const form = event.currentTarget;
const formData = new FormData(form);
@@ -139,8 +140,40 @@ export default function Page() {
return;
}
setFormStatus("Thanks. We'll review your mailbox count and current provider before we reply.");
form.reset();
setIsSubmitting(true);
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 (
@@ -167,6 +200,7 @@ export default function Page() {
<AssessmentSection
formErrors={formErrors}
formStatus={formStatus}
isSubmitting={isSubmitting}
onAssessmentSubmit={handleAssessmentSubmit}
/>
</main>

View File

@@ -3,10 +3,11 @@ import type { FormEventHandler } from "react";
type AssessmentSectionProps = {
formErrors: { name: string; email: string };
formStatus: string;
isSubmitting: boolean;
onAssessmentSubmit: FormEventHandler<HTMLFormElement>;
};
export default function AssessmentSection({ formErrors, formStatus, onAssessmentSubmit }: AssessmentSectionProps) {
export default function AssessmentSection({ formErrors, formStatus, isSubmitting, onAssessmentSubmit }: AssessmentSectionProps) {
return (
<section className="content-section assessment-section" id="assessment" aria-labelledby="assessment-title">
<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>
<textarea id="message" name="message" rows={4} placeholder="Domain, mailbox count, migration timing, device setup..."></textarea>
</div>
<button className="button button-primary button-large" type="submit">
Request email assessment
<span aria-hidden="true"></span>
<button
className="button button-primary button-large"
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
>
{isSubmitting ? "Sending…" : "Request email assessment"}
<span aria-hidden="true">{isSubmitting ? "" : "→"}</span>
</button>
<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>
@@ -67,7 +73,7 @@ export default function AssessmentSection({ formErrors, formStatus, onAssessment
<div className="assessment-copy">
<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>
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>

View File

@@ -1,6 +1,6 @@
export default function ProblemSection() {
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">
<p className="eyebrow">Why it matters</p>
<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">
<h2>Explore</h2>
<ul>
<li><a href="#services">Services</a></li>
<li><a href="#infrastructure">Infrastructure</a></li>
<li><a href="#why">Why email matters</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="#assessment">Get assessment</a></li>
</ul>
</nav>
@@ -47,7 +46,6 @@ export default function SiteFooter() {
<div className="footer-bottom">
<p>Bay Area Email Services, part of Bay Area IT. All rights reserved.</p>
<div>
<a href="#assessment">Support</a>
<a href="#top" className="back-to-top">
Back to top
<svg viewBox="0 0 24 24" aria-hidden="true">

View File

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

21
package-lock.json generated
View File

@@ -9,11 +9,13 @@
"version": "1.0.0",
"dependencies": {
"next": "^15.3.3",
"nodemailer": "^6.9.16",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.15.24",
"@types/nodemailer": "^6.4.17",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"typescript": "^5.8.3"
@@ -648,6 +650,16 @@
"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": {
"version": "19.2.15",
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",

View File

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