Compare commits

8 Commits

Author SHA1 Message Date
5ce04ba694 own network 2026-06-16 11:33:19 -05:00
143ca13601 some changes 2026-06-14 18:14:16 -05:00
f36cb9ce51 neue Favicons 2026-06-13 17:03:41 -05:00
fa33ccbe72 favicon 2026-06-13 16:52:25 -05:00
d6f6c6480d layout fix 2026-06-13 16:38:38 -05:00
546d6fbba3 docker + hero change 2026-06-13 15:41:15 -05:00
45422753a3 further fixes 2026-06-13 15:00:40 -05:00
dfd5e744a4 cleanup 2026-06-13 14:36:27 -05:00
27 changed files with 523 additions and 207 deletions

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
node_modules/
.next/
.git/
.gitignore
Dockerfile
.dockerignore
.env
.env.*
npm-debug.log*
.env.example
docker-compose.yml
README.md
PLAN.md
PRODUCT.md
PROJECT_BRIEF.md
SHAPE_BRIEF.md
DESIGN.md
research/
.DS_Store
Thumbs.db
.vscode/
.idea/

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

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# ---- deps ----
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ---- builder ----
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ---- runner ----
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

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

@@ -492,9 +492,17 @@ svg {
align-content: start;
gap: var(--space-3);
padding-block: clamp(0.4rem, 2vw, 1rem);
max-width: 56rem;
margin-inline: auto;
text-align: center;
min-width: 0;
}
.hero-copy h1 {
max-width: none;
margin-inline: auto;
}
.eyebrow {
margin: 0;
color: oklch(80% 0.1 246);
@@ -535,11 +543,23 @@ h3 {
}
.hero-lede {
max-width: 32rem;
max-width: 38rem;
margin-inline: auto;
margin-bottom: 0;
color: var(--text-muted);
font-size: clamp(1rem, 0.45vw + 0.93rem, 1.15rem);
line-height: 1.38;
text-align: center;
}
.hero-local-trust {
max-width: 38rem;
margin-inline: auto;
margin-top: var(--space-2);
color: var(--blue);
font-size: var(--step--1);
font-weight: 700;
text-align: center;
}
.trust-list,
@@ -553,6 +573,7 @@ h3 {
.trust-list {
display: grid;
gap: 0.45rem;
justify-items: center;
color: var(--text-muted);
}
@@ -613,6 +634,7 @@ h3 {
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
justify-content: center;
margin-top: var(--space-1);
}
@@ -1470,6 +1492,7 @@ h3 {
display: grid;
gap: var(--space-3);
max-width: 56rem;
margin-inline: auto;
margin-bottom: var(--space-6);
}
@@ -1489,8 +1512,16 @@ h3 {
.pain-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--space-4);
max-width: 68rem;
margin-inline: auto;
}
@media (min-width: 760px) {
.pain-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.pain-grid article {
@@ -2621,7 +2652,7 @@ h3 {
.pricing-builder {
display: grid;
gap: var(--space-4);
max-width: var(--page-max);
max-width: 68rem;
margin-inline: auto;
padding: 0;
}
@@ -2789,6 +2820,72 @@ h3 {
color: var(--green);
}
.offer-banner {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border: 1px solid oklch(67% 0.16 147 / 0.62);
border-radius: var(--radius-md);
background: oklch(16% 0.06 147 / 0.18);
color: var(--green);
font-size: var(--step--1);
font-weight: 800;
line-height: 1.4;
}
:root[data-theme="light"] .offer-banner {
background: oklch(90% 0.06 147 / 0.32);
}
.offer-banner-icon {
flex: 0 0 auto;
color: var(--green);
font-size: 1.1rem;
}
.price-subtext {
margin: 0;
color: var(--text-muted);
font-size: var(--step--1);
}
.pricing-included-panel {
display: grid;
gap: var(--space-4);
align-content: start;
padding: clamp(1.25rem, 2vw, 2rem);
border: 1px solid var(--line-soft);
border-radius: var(--radius-md);
background: oklch(12% 0.034 245 / 0.62);
}
:root[data-theme="light"] .pricing-included-panel {
background: oklch(99% 0.006 245 / 0.86);
}
.included-feature-list {
display: grid;
gap: var(--space-3);
list-style: none;
padding: 0;
margin: 0;
}
.included-feature-list li {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--text-muted);
}
.included-check {
flex: 0 0 auto;
color: var(--green);
font-weight: 800;
font-size: 0.95rem;
}
.plan-summary,
.pricing-note {
margin: 0;
@@ -2890,6 +2987,36 @@ details p {
padding: 0 var(--space-4) var(--space-4);
}
.faq-answer-body {
padding: 0 var(--space-4) var(--space-4);
}
.faq-answer-body p {
margin: 0;
padding: 0;
}
.faq-diagram-image {
display: block;
width: 100%;
max-width: 42rem;
height: auto;
margin-top: var(--space-4);
border-radius: var(--radius-sm);
}
.faq-diagram-light {
display: none;
}
:root[data-theme="light"] .faq-diagram-dark {
display: none;
}
:root[data-theme="light"] .faq-diagram-light {
display: block;
}
.assessment-section {
position: relative;
display: block;
@@ -3264,6 +3391,13 @@ textarea::placeholder {
font-size: var(--step--1);
}
.form-trust-line {
margin: var(--space-2) 0 0;
color: var(--text-dim);
font-size: var(--step--1);
text-align: center;
}
.form-status {
min-height: 1.5rem;
margin: 0;
@@ -3418,6 +3552,13 @@ textarea::placeholder {
}
@media (min-width: 760px) {
.trust-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-4);
}
.site-header {
grid-template-columns: auto 1fr auto;
}
@@ -3503,8 +3644,12 @@ textarea::placeholder {
}
.hero-grid {
grid-template-columns: minmax(18rem, 0.55fr) minmax(43rem, 1.65fr);
gap: clamp(2.5rem, 4vw, 5rem);
gap: clamp(2.5rem, 4vw, 4rem);
}
.architecture-panel {
max-width: 52rem;
margin-inline: auto;
}
.module-grid {
@@ -3750,10 +3895,6 @@ textarea::placeholder {
max-width: 100%;
}
.hero-lede {
max-width: 100%;
}
.hero-copy {
padding-right: 0;
}

View File

@@ -5,6 +5,17 @@ export const metadata: Metadata = {
title: "Business Email Hosting Corpus Christi | Bay Area Email Services",
description:
"Professional domain email hosting for Corpus Christi businesses. 25 GB mailboxes, Outlook and iPhone setup, SPF/DKIM/DMARC, migration, and local support.",
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/favicon.ico" },
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/favicon-48x48.png", sizes: "48x48", type: "image/png" },
],
shortcut: "/favicon.ico",
apple: { url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
},
};
export default function RootLayout({

16
app/manifest.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Bay Area Email Services",
short_name: "Bay Area Email",
start_url: "/",
display: "standalone",
background_color: "#05070d",
theme_color: "#000080",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" },
],
};
}

View File

@@ -4,72 +4,21 @@ import { FormEvent, MouseEvent, useEffect, useState } from "react";
import SiteHeader from "../components/SiteHeader";
import HeroSection from "../components/HeroSection";
import ProblemSection from "../components/ProblemSection";
import ProcessSection from "../components/ProcessSection";
import DeliverabilitySection from "../components/DeliverabilitySection";
import ContinuitySection, { type ContinuityFeature, type FeatureKey } from "../components/ContinuitySection";
import MigrationProcessSection from "../components/MigrationProcessSection";
import PricingSection, { type Plan } from "../components/PricingSection";
import PricingSection from "../components/PricingSection";
import FaqSection from "../components/FaqSection";
import AssessmentSection from "../components/AssessmentSection";
import SiteFooter from "../components/SiteFooter";
type Theme = "dark" | "light";
const pricingSummaries: Record<Plan, string> = {
hosting:
"Core business email hosting with 25 GB mailboxes, custom domain email, and AWS-backed infrastructure.",
managed:
"Managed setup adds rollout planning, DNS validation, migration coordination, and device handoff checks during the assessment.",
};
const continuityFeatures: Record<FeatureKey, ContinuityFeature> = {
buffering: {
title: "Inbound buffering",
copy: "Incoming mail can be buffered before mailbox delivery during maintenance or provider-side disruption.",
proof: [
"Supports planned maintenance windows",
"Keeps delivery flow observable",
"Feeds mailbox delivery after processing",
],
},
sending: {
title: "Outbound sending",
copy: "Amazon SES gives outbound email an authenticated sending path with reputation tooling and clearer operational visibility.",
proof: [
"Separates outbound sending from old shared hosting mail",
"Uses domain authentication as part of setup",
"Makes sending behavior easier to troubleshoot",
],
},
standby: {
title: "Standby failover",
copy: "A standby environment is part of the continuity plan when primary systems need intervention or provider-side work.",
proof: [
"Keeps the fallback path visible",
"Reduces guesswork during incidents",
"Pairs with status checks and local support",
],
},
local: {
title: "Local management",
copy: "Domain records, migration, mailbox changes, device setup, and troubleshooting stay with a local Corpus Christi team.",
proof: [
"One support path for DNS and devices",
"Migration scope is reviewed before work starts",
"Mailbox changes stay tied to the business context",
],
},
};
export default function Page() {
const [menuOpen, setMenuOpen] = useState(false);
const [theme, setTheme] = useState<Theme>("dark");
const [activeFeature, setActiveFeature] = useState<FeatureKey>("buffering");
const [activePlan, setActivePlan] = useState<Plan>("hosting");
const [mailboxes, setMailboxes] = useState(25);
const [mailboxes, setMailboxes] = useState(10);
const [formErrors, setFormErrors] = useState({ name: "", email: "" });
const [formStatus, setFormStatus] = useState("");
const activeFeatureDetails = continuityFeatures[activeFeature];
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const storedTheme = window.localStorage.getItem("bes-theme");
@@ -164,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);
@@ -191,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 (
@@ -211,25 +192,15 @@ export default function Page() {
<main id="main">
<HeroSection />
<ProblemSection />
<ProcessSection />
<DeliverabilitySection />
<ContinuitySection
activeFeature={activeFeature}
activeFeatureDetails={activeFeatureDetails}
onFeatureChange={setActiveFeature}
/>
<MigrationProcessSection />
<PricingSection
activePlan={activePlan}
mailboxes={mailboxes}
pricingSummaries={pricingSummaries}
onPlanChange={setActivePlan}
onMailboxesChange={setMailboxes}
/>
<FaqSection />
<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,17 +57,23 @@ 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>
</form>
</div>
<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

@@ -18,6 +18,28 @@ export default function FaqSection() {
<summary>Can you migrate our existing email?</summary>
<p>Yes. The migration plan depends on your current provider, mailbox count, domain access, and preferred migration window.</p>
</details>
<details>
<summary>How does mail stay online if a server goes down?</summary>
<div className="faq-answer-body">
<p>Incoming mail is temporarily held in Amazon AWS if the primary server needs maintenance or encounters an issue your messages aren&rsquo;t lost. A standby backup environment is ready to take over, so your team can keep sending and receiving. Once things are back to normal, buffered mail is delivered to your inboxes automatically.</p>
<img
className="faq-diagram-image faq-diagram-dark"
src="/assets/mail-flow-panel.png"
alt="Diagram of Bay Area Email mail flow: inbound buffering, mailbox delivery, outbound via Amazon SES, and standby failover."
loading="lazy"
/>
<img
className="faq-diagram-image faq-diagram-light"
src="/assets/mail-flow-panel-light.png"
alt=""
aria-hidden="true"
loading="lazy"
/>
<p className="sr-only">
Inbound messages are collected from the internet, remote mail servers, and other providers, buffered and processed through Amazon S3, delivered to mailboxes, sent outbound through Amazon SES, and supported by standby infrastructure.
</p>
</div>
</details>
<details>
<summary>Does this work with Outlook, iPhone, and iPad?</summary>
<p>Yes. Outlook, iPhone, iPad, web, and desktop access are part of the setup conversation.</p>
@@ -26,6 +48,10 @@ export default function FaqSection() {
<summary>Is this Microsoft 365 or Google Workspace?</summary>
<p>No. It is a managed business email hosting service for teams that want professional email, DNS correctness, migration help, and local support without buying a full office suite by default.</p>
</details>
<details>
<summary>What does switching cost?</summary>
<p>Setup and migration are included at no extra charge for teams that switch by July 31, 2026. After that, the ongoing cost is $5 per mailbox per month. We confirm the final scope in your assessment before any work begins.</p>
</details>
</div>
</section>
);

View File

@@ -8,6 +8,9 @@ export default function HeroSection() {
<p className="hero-lede">
Infrastructure and support from a local team that understands how business gets done.
</p>
<p className="hero-local-trust">
Local team in downtown Corpus Christi same neighborhood as you.
</p>
<ul className="trust-list" aria-label="Core trust points">
<li>
@@ -32,24 +35,6 @@ export default function HeroSection() {
<a className="call-inline" href="tel:+13617658400">Call (361) 765-8400</a>
</div>
</div>
<section id="infrastructure" className="architecture-panel" aria-labelledby="architecture-title">
<h2 id="architecture-title" className="sr-only">Mail flow designed for continuity</h2>
<img
className="architecture-image architecture-image-dark"
src="/assets/mail-flow-panel.png"
alt="Mail flow diagram showing inbound email, Amazon S3 buffering, mailbox delivery, Amazon SES outbound sending, standby infrastructure, and operational system status."
/>
<img
className="architecture-image architecture-image-light"
src="/assets/mail-flow-panel-light.png"
alt=""
aria-hidden="true"
/>
<p className="sr-only">
Inbound messages are collected from the internet, remote mail servers, and other providers, buffered and processed through Amazon S3, delivered to mailboxes, sent outbound through Amazon SES, and supported by standby infrastructure.
</p>
</section>
</div>
<div className="module-grid" aria-label="Service highlights">

View File

@@ -1,50 +1,18 @@
export type Plan = "hosting" | "managed";
type PricingSectionProps = {
activePlan: Plan;
mailboxes: number;
pricingSummaries: Record<Plan, string>;
onPlanChange: (plan: Plan) => void;
onMailboxesChange: (mailboxes: number) => void;
};
export default function PricingSection({ activePlan, mailboxes, pricingSummaries, onPlanChange, onMailboxesChange }: PricingSectionProps) {
export default function PricingSection({ mailboxes, onMailboxesChange }: PricingSectionProps) {
return (
<section className="content-section pricing-detail" id="pricing-detail" aria-labelledby="pricing-title">
<div className="section-heading">
<p className="eyebrow">Simple mailbox pricing</p>
<h2 id="pricing-title">$5 per inbox per month, scoped before you switch.</h2>
<p>
Pick a mailbox count and plan focus. The base mailbox price is transparent; setup, migration, support scope, minimums, taxes, and add-ons are confirmed during the assessment.
</p>
</div>
<div className="pricing-builder" data-plan={activePlan} data-mailboxes={mailboxes}>
<div className="pricing-builder" data-mailboxes={mailboxes}>
<div className="pricing-control-panel">
<div>
<span className="panel-label">Plan focus</span>
<div className="pricing-segmented" data-active={activePlan} role="group" aria-label="Plan focus">
<button
className={activePlan === "hosting" ? "is-active" : ""}
type="button"
data-plan="hosting"
aria-pressed={activePlan === "hosting"}
onClick={() => onPlanChange("hosting")}
>
Hosting
</button>
<button
className={activePlan === "managed" ? "is-active" : ""}
type="button"
data-plan="managed"
aria-pressed={activePlan === "managed"}
onClick={() => onPlanChange("managed")}
>
Managed setup
</button>
</div>
</div>
<div>
<span className="panel-label">Mailbox count</span>
<div className="mailbox-options" role="group" aria-label="Estimated mailbox count">
@@ -66,66 +34,47 @@ export default function PricingSection({ activePlan, mailboxes, pricingSummaries
<div className="price-estimate">
<span className="panel-label">Estimated base hosting</span>
<p><span className="plan-total">${mailboxes * 5}</span><span>/ month</span></p>
<small>Based on <strong className="selected-mailboxes">{mailboxes}</strong> inboxes at $5 each.</small>
<small>For teams of 10+ mailboxes.</small>
</div>
<p className="plan-summary">
{pricingSummaries[activePlan]}
</p>
<div className="offer-banner">
<span className="offer-banner-icon" aria-hidden="true">&#9733;</span>
Free migration &amp; setup for teams that switch by July&nbsp;31,&nbsp;2026.
</div>
<p className="price-subtext">$5 per mailbox / month. Free migration if you switch by July 31, 2026. Final scope confirmed in your assessment.</p>
<a className="button button-primary" href="#assessment">Get a mailbox count quote</a>
</div>
<div className="pricing-comparison-panel">
<div className="comparison-head">
<div>
<span className="panel-label">What changes by scope</span>
<h3>Hosting first, managed help when the migration needs it.</h3>
</div>
<span className="status-pill">Operational plan</span>
</div>
<div className="plan-feature-grid" role="table" aria-label="Plan feature comparison">
<div className="plan-feature-row table-head" role="row">
<span role="columnheader">Feature</span>
<span role="columnheader">Hosting</span>
<span role="columnheader">Managed setup</span>
</div>
<div className="plan-feature-row" role="row">
<span role="cell">25 GB mailbox</span>
<span role="cell">Included</span>
<span role="cell">Included</span>
</div>
<div className="plan-feature-row" role="row">
<span role="cell">Custom domain email</span>
<span role="cell">Included</span>
<span role="cell">Included</span>
</div>
<div className="plan-feature-row" role="row">
<span role="cell">SPF, DKIM, DMARC check</span>
<span role="cell">Configured</span>
<span role="cell">Configured + validated</span>
</div>
<div className="plan-feature-row" role="row">
<span role="cell">Migration from current provider</span>
<span role="cell">Scoped</span>
<span role="cell">Planned with handoff</span>
</div>
<div className="plan-feature-row" role="row">
<span role="cell">Outlook, iPhone, iPad setup</span>
<span role="cell">Guided</span>
<span role="cell">Checked before handoff</span>
</div>
<div className="plan-feature-row" role="row">
<span role="cell">Local support</span>
<span role="cell">Available</span>
<span role="cell">Priority during rollout</span>
</div>
</div>
<p className="pricing-note">
No inbox placement guarantees, no zero-downtime promise. The assessment confirms DNS access, provider constraints, migration timing, and device needs before work starts.
</p>
<div className="pricing-included-panel">
<span className="panel-label">What&rsquo;s included</span>
<ul className="included-feature-list">
<li>
<span className="included-check" aria-hidden="true">&#10003;</span>
25 GB mailbox
</li>
<li>
<span className="included-check" aria-hidden="true">&#10003;</span>
Custom domain email
</li>
<li>
<span className="included-check" aria-hidden="true">&#10003;</span>
SPF, DKIM, DMARC configured
</li>
<li>
<span className="included-check" aria-hidden="true">&#10003;</span>
Migration from current provider
</li>
<li>
<span className="included-check" aria-hidden="true">&#10003;</span>
Outlook, iPhone, iPad setup
</li>
<li>
<span className="included-check" aria-hidden="true">&#10003;</span>
Local Corpus Christi support
</li>
</ul>
</div>
</div>
</section>

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>
@@ -10,17 +10,13 @@ export default function ProblemSection() {
</div>
<div className="pain-grid">
<article>
<strong>Free addresses cost trust</strong>
<p>Gmail and Yahoo are fine for personal use. A business domain signals that your company is established.</p>
<strong>Unprofessional email hurts trust</strong>
<p>Free Gmail or Yahoo addresses and unreliable shared-hosting mailboxes both signal to customers that your business is not serious about communication.</p>
</article>
<article>
<strong>DNS mistakes send mail to spam</strong>
<p>SPF, DKIM, and DMARC help receiving servers recognize legitimate mail from your domain.</p>
</article>
<article>
<strong>Shared hosting mail breaks quietly</strong>
<p>Old hosting mailboxes often hide weak deliverability, limited storage, and unclear support paths.</p>
</article>
<article>
<strong>Support queues slow the fix</strong>
<p>When email breaks, a local team can assess the domain, provider, device, and DNS together.</p>

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>
@@ -45,9 +44,8 @@ export default function SiteFooter() {
</div>
<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 <a href="https://bayarea-cc.com" target="_blank" rel="noopener noreferrer">Bay Area IT</a>. 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">

28
docker-compose.yml Normal file
View File

@@ -0,0 +1,28 @@
# docker compose up -d --build
#
# The host runs Caddy as a reverse proxy in its own compose project.
# Caddy forwards requests for email-cc.com to this container by name:
# reverse_proxy email-cc-web:3000
# For that to resolve, Caddy and this container must share a Docker network.
# The network "email-cc" is created by the Caddy compose project and joined
# here as external.
#
# Prerequisites: a .env file in the project directory with:
# SES_SMTP_USER=
# SES_SMTP_PASS= (SMTP credentials, not IAM access keys)
# SES_FROM_EMAIL= (must be an SES-verified identity)
services:
web:
build: .
container_name: email-cc-web
env_file:
- .env
restart: unless-stopped
networks:
- email-cc
networks:
email-cc:
external: true
name: email-cc

View File

@@ -1,4 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
output: "standalone",
outputFileTracingRoot: process.cwd(),
};
export default nextConfig;

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"

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 B

BIN
public/favicon-48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

13
public/favicon.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#000080"/>
<stop offset="1" stop-color="#0000FF"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="512" height="512" rx="112" fill="url(#bg)"/>
<rect x="104" y="150" width="304" height="212" rx="28" fill="#FFFFFF"/>
<path d="M132 182 L256 286 L380 182"
fill="none" stroke="#0000FF" stroke-width="30"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 629 B

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB