Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ce04ba694 | |||
| 143ca13601 | |||
| f36cb9ce51 | |||
| fa33ccbe72 | |||
| d6f6c6480d | |||
| 546d6fbba3 | |||
| 45422753a3 | |||
| dfd5e744a4 |
22
.dockerignore
Normal 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
@@ -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
@@ -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"]
|
||||
92
app/api/assessment/route.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
159
app/globals.css
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
],
|
||||
};
|
||||
}
|
||||
107
app/page.tsx
@@ -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>
|
||||
|
||||
@@ -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’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">Let’s review your email setup.</h2>
|
||||
<h2 id="assessment-title">Let’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>
|
||||
|
||||
@@ -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’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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">★</span>
|
||||
Free migration & setup for teams that switch by July 31, 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’s included</span>
|
||||
<ul className="included-feature-list">
|
||||
<li>
|
||||
<span className="included-check" aria-hidden="true">✓</span>
|
||||
25 GB mailbox
|
||||
</li>
|
||||
<li>
|
||||
<span className="included-check" aria-hidden="true">✓</span>
|
||||
Custom domain email
|
||||
</li>
|
||||
<li>
|
||||
<span className="included-check" aria-hidden="true">✓</span>
|
||||
SPF, DKIM, DMARC configured
|
||||
</li>
|
||||
<li>
|
||||
<span className="included-check" aria-hidden="true">✓</span>
|
||||
Migration from current provider
|
||||
</li>
|
||||
<li>
|
||||
<span className="included-check" aria-hidden="true">✓</span>
|
||||
Outlook, iPhone, iPad setup
|
||||
</li>
|
||||
<li>
|
||||
<span className="included-check" aria-hidden="true">✓</span>
|
||||
Local Corpus Christi support
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 425 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 757 B |
BIN
public/favicon-48x48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 426 B |
13
public/favicon.svg
Normal 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
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 19 KiB |