Files
red/.planning/phases/02-marketing-site/02-RESEARCH.md
Chandler Copeland 0cdf3e5f7d docs(02): research phase - marketing site
Research covering Next.js App Router patterns, Nodemailer SMTP contact form,
pure-React testimonials carousel, honeypot spam protection, and React 19
useActionState API for the public marketing homepage phase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 14:50:36 -06:00

26 KiB
Raw Blame History

Phase 2: Marketing Site - Research

Researched: 2026-03-19 Domain: Next.js 16 App Router — public marketing page, Nodemailer SMTP contact form, carousel UI Confidence: HIGH


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

Hero section

  • Layout: Split panel — photo on the left, text content on the right (mirrors the login page feel)
  • Tone: Professional and polished — credentials, experience, trust-building (not casual)
  • CTA button: "Get in Touch" — smooth-scrolls to the contact form section
  • Copy: Claude writes the headline, subheading, and bio (professional Utah real estate agent framing). Wrap text in clearly commented placeholders so Teressa can swap in her own words before launch

Testimonials

  • Display: Auto-scrolling carousel — rotates every 5 seconds, pauses on hover
  • Navigation: Arrow controls and dot indicators so visitors can also advance manually
  • Content: Claude writes 45 sample testimonials with realistic Utah buyer/seller scenarios and placeholder full names (e.g., "Sarah Mitchell"). Clearly commented so real ones can be swapped in
  • Attribution: Quote + full name only (no city, no photo)

Contact form

  • Delivery: Email notification to Teressa via Nodemailer + SMTP (no database storage in this phase)
  • SMTP credentials: CONTACT_EMAIL_USER and CONTACT_EMAIL_PASS environment variables — never hardcoded
  • Success state: Inline — the form is replaced by a thank-you message ("Thanks! Teressa will be in touch soon.") when submission succeeds
  • Spam protection: Honeypot hidden field — bots fill it in, humans never see it; submissions with the honeypot filled are silently discarded
  • Validation: Required fields are name, email, phone, and message — validated server-side in the form action

Page structure

  • Section order (top to bottom): Sticky nav → Hero → Testimonials → Listings placeholder → Contact form → Footer
  • Sticky nav content: Teressa's name as wordmark logo (left), anchor links on the right — Home, About, Listings, Contact — smooth-scroll to each section
  • Listings placeholder: A tasteful "Listings Coming Soon" section with a brief description — no real data, just visual presence
  • Agent portal: No link anywhere on the public site — Teressa navigates to /agent/login directly via URL
  • Footer: Name + Utah real estate license number (14196185-SA00 — NOTE: verify the last two characters, may be letters O or zeros) + copyright year + repeated nav links

Placeholder assets

  • Hero photo: reuse public/red.jpg from Phase 1 (already in the project)
  • Listings placeholder: use a tasteful stock-style image or a solid brand-colored background with an icon — no real property photos needed
  • Background section breaks: subtle texture or light gradient using brand cream/navy — no photography required for non-hero sections
  • Icons: use Lucide React (already likely available) for any UI icons (phone, email, map pin for contact section)

Claude's Discretion

  • Exact hero photo crop/focal point and overlay gradient (if any)
  • Mobile nav pattern (hamburger menu vs collapsed links)
  • Exact card styling, shadow depth, border radius
  • Animation timing beyond the 5-second carousel interval
  • Exact color weights within the brand palette already established (navy #1B2B4B, gold #C9A84C, cream #FAF9F7)

Deferred Ideas (OUT OF SCOPE)

  • None — discussion stayed within phase scope </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
MKTG-01 Visitor sees a hero section with Teressa's professional photo and warm introductory bio Split-panel layout pattern, Next.js Image component, public/red.jpg already in project
MKTG-02 Visitor sees a "listings coming soon" placeholder section (full WFRMLS integration deferred to v2) Static section component, brand color background + Lucide icon
MKTG-03 Visitor can submit a contact form with name, email, phone, and message Next.js Server Action, Nodemailer + SMTP, honeypot pattern, server-side Zod validation
MKTG-04 Visitor sees a testimonials section with client reviews on the homepage Pure React carousel (useState + useEffect interval), no external carousel library needed
</phase_requirements>

Summary

This phase builds a single public-facing homepage for Teressa Copeland Homes on top of the Next.js 16 + Tailwind CSS 4 stack established in Phase 1. The page is a single route (app/page.tsx) composed of full-width section components: sticky nav, hero, testimonials carousel, listings placeholder, contact form, and footer. No new major dependencies are required — Nodemailer is the only addition for contact form email delivery.

The carousel can be built with zero external libraries using React useState / useEffect — a 5-second auto-advance with hover-pause, arrow buttons, and dot indicators is straightforward in ~60 lines of client component code. Reaching for a carousel library like Embla or Swiper would add unnecessary bundle weight for a single simple use case.

The contact form uses a Next.js Server Action (no API route needed) with Zod validation already in the project, Nodemailer for SMTP delivery, and a honeypot hidden field for bot protection. No database writes occur in this phase — the form is fire-and-forget email only.

Primary recommendation: Build everything as Next.js App Router server components with minimal client component islands (carousel, mobile nav toggle, form state). Keep the public page route at app/page.tsx and colocate section components under app/_components/ or components/marketing/.


Standard Stack

Core (already in project — no new installs for UI)

Library Version Purpose Why Standard
Next.js 16.2.0 App Router, Server Actions, Image optimization Already installed; Phase 1 foundation
React 19.2.4 Component model Already installed
Tailwind CSS ^4 Utility-first styling Already installed; brand colors via inline styles or CSS vars
Zod ^4.3.6 Server-side form validation Already installed; used in Phase 1 auth

New Dependency — Contact Form Email

Library Version Purpose Why Standard
nodemailer ^6.9.x SMTP email delivery from Server Action De facto Node.js SMTP library; well-maintained; zero vendor lock-in
@types/nodemailer ^6.4.x TypeScript types Required for TS strict mode

Supporting (already available)

Library Version Purpose When to Use
lucide-react latest Icon set (phone, mail, map-pin, chevron, etc.) Confirmed in CONTEXT.md; check if installed or add

Check if lucide-react is already installed:

grep lucide /Users/ccopeland/temp/red/teressa-copeland-homes/package.json

If absent, add it.

Alternatives Considered

Instead of Could Use Tradeoff
Hand-rolled carousel Embla Carousel or Swiper Overkill for a single 4-5 item testimonials carousel; adds bundle weight
Nodemailer Resend SDK, SendGrid Adds vendor dependency; Nodemailer works with any SMTP provider already owned by Teressa
Server Action form API Route (app/api/contact/route.ts) Server Actions are simpler, no fetch boilerplate; use progressive enhancement

Installation (new packages only):

cd /Users/ccopeland/temp/red/teressa-copeland-homes
npm install nodemailer
npm install --save-dev @types/nodemailer
# If lucide-react not present:
npm install lucide-react

Architecture Patterns

teressa-copeland-homes/
├── app/
│   ├── page.tsx                    # Public homepage — server component, composes sections
│   ├── layout.tsx                  # Root layout (already exists from Phase 1)
│   └── _components/                # Marketing page section components (colocated)
│       ├── SiteNav.tsx             # Sticky nav — client component (mobile toggle state)
│       ├── HeroSection.tsx         # Server component
│       ├── TestimonialsSection.tsx # Client component (carousel state + interval)
│       ├── ListingsPlaceholder.tsx # Server component
│       ├── ContactSection.tsx      # Client component (form state, submission)
│       └── SiteFooter.tsx          # Server component
├── lib/
│   ├── contact-mailer.ts           # Nodemailer transporter + sendContactEmail()
│   └── contact-action.ts           # Server Action — validates, calls mailer, returns state
└── public/
    └── red.jpg                     # Hero photo (already exists)

Why app/_components/: Next.js treats files prefixed with _ as private — they're not treated as routes. Keeps section components colocated with the page without polluting the route tree.

Alternative: components/marketing/ at project root. Either works; pick one and be consistent.

Pattern 1: Server Action for Contact Form (No API Route)

What: Form submits to a Server Action; action validates with Zod, sends email via Nodemailer, returns typed state. When to use: Any form in Next.js App Router that doesn't need a public REST endpoint.

// lib/contact-action.ts
'use server'

import { z } from 'zod'
import { sendContactEmail } from './contact-mailer'

const ContactSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Valid email required'),
  phone: z.string().min(7, 'Phone is required'),
  message: z.string().min(10, 'Message is required'),
  // Honeypot — must be empty
  website: z.string().max(0),
})

export type ContactState = { status: 'idle' | 'success' | 'error'; message?: string }

export async function submitContact(
  _prev: ContactState,
  formData: FormData,
): Promise<ContactState> {
  const raw = {
    name: formData.get('name'),
    email: formData.get('email'),
    phone: formData.get('phone'),
    message: formData.get('message'),
    website: formData.get('website'), // honeypot
  }

  const parsed = ContactSchema.safeParse(raw)
  if (!parsed.success) {
    return { status: 'error', message: 'Please fill in all required fields.' }
  }

  // Honeypot filled — silently succeed (don't tell the bot it failed)
  if (parsed.data.website !== '') {
    return { status: 'success' }
  }

  try {
    await sendContactEmail(parsed.data)
    return { status: 'success' }
  } catch {
    return { status: 'error', message: 'Something went wrong. Please try again.' }
  }
}

Pattern 2: Nodemailer SMTP Transporter

What: Singleton transporter initialized from env vars; called from Server Action. When to use: Any server-side email send in Node.js/Next.js.

// lib/contact-mailer.ts
import nodemailer from 'nodemailer'

const transporter = nodemailer.createTransport({
  host: process.env.CONTACT_SMTP_HOST,      // e.g., smtp.gmail.com
  port: Number(process.env.CONTACT_SMTP_PORT ?? 587),
  secure: false,                             // true for 465, false for 587 (STARTTLS)
  auth: {
    user: process.env.CONTACT_EMAIL_USER,
    pass: process.env.CONTACT_EMAIL_PASS,
  },
})

export async function sendContactEmail(data: {
  name: string
  email: string
  phone: string
  message: string
}) {
  await transporter.sendMail({
    from: `"${data.name}" <${process.env.CONTACT_EMAIL_USER}>`,
    replyTo: data.email,
    to: process.env.CONTACT_EMAIL_USER,     // Teressa's inbox
    subject: `New contact from ${data.name} — Teressa Copeland Homes`,
    text: `Name: ${data.name}\nEmail: ${data.email}\nPhone: ${data.phone}\n\n${data.message}`,
    html: `<p><strong>Name:</strong> ${data.name}</p>
           <p><strong>Email:</strong> ${data.email}</p>
           <p><strong>Phone:</strong> ${data.phone}</p>
           <p><strong>Message:</strong><br/>${data.message.replace(/\n/g, '<br/>')}</p>`,
  })
}

What: Client component with useState (activeIndex), useEffect (5-second interval), pause on hover. When to use: Simple auto-advance carousel with ≤ 10 items — no library needed.

// app/_components/TestimonialsSection.tsx
'use client'
import { useState, useEffect, useRef } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'

const TESTIMONIALS = [
  // PLACEHOLDER — replace with real client testimonials before launch
  { quote: "Working with Teressa made buying our first home in Salt Lake County seamless...", name: "Sarah Mitchell" },
  { quote: "Teressa sold our Provo home in under a week above asking price...", name: "James & Karen Olsen" },
  // add 3-5 total
]

export function TestimonialsSection() {
  const [active, setActive] = useState(0)
  const [paused, setPaused] = useState(false)
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)

  useEffect(() => {
    if (paused) return
    intervalRef.current = setInterval(() => {
      setActive(i => (i + 1) % TESTIMONIALS.length)
    }, 5000)
    return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
  }, [paused])

  const prev = () => setActive(i => (i - 1 + TESTIMONIALS.length) % TESTIMONIALS.length)
  const next = () => setActive(i => (i + 1) % TESTIMONIALS.length)

  return (
    <section
      id="about"
      onMouseEnter={() => setPaused(true)}
      onMouseLeave={() => setPaused(false)}
    >
      {/* carousel content */}
      <blockquote>"{TESTIMONIALS[active].quote}"</blockquote>
      <p> {TESTIMONIALS[active].name}</p>
      <button onClick={prev}><ChevronLeft /></button>
      <button onClick={next}><ChevronRight /></button>
      <div>
        {TESTIMONIALS.map((_, i) => (
          <button key={i} onClick={() => setActive(i)} aria-label={`Testimonial ${i + 1}`}
            style={{ opacity: i === active ? 1 : 0.3 }} />
        ))}
      </div>
    </section>
  )
}

Pattern 4: Smooth-Scroll Anchor Navigation

What: Pure CSS + HTML id anchors; no JavaScript scroll library needed. When to use: Single-page anchor navigation.

// CSS approach — add to globals.css
// html { scroll-behavior: smooth; }

// Nav links
<a href="#hero">Home</a>
<a href="#about">About</a>
<a href="#listings">Listings</a>
<a href="#contact">Contact</a>

// Section targets
<section id="hero"> ... </section>
<section id="about"> ... </section>  // testimonials anchors here per section order
<section id="listings"> ... </section>
<section id="contact"> ... </section>

Pattern 5: Contact Form with useActionState (React 19)

What: React 19's useActionState replaces useFormState from react-dom/server. Works with Server Actions in Next.js App Router.

'use client'
import { useActionState } from 'react'
import { submitContact, type ContactState } from '@/lib/contact-action'

const initialState: ContactState = { status: 'idle' }

export function ContactForm() {
  const [state, action, pending] = useActionState(submitContact, initialState)

  if (state.status === 'success') {
    return <p>Thanks! Teressa will be in touch soon.</p>
  }

  return (
    <form action={action}>
      {/* Honeypot — visually hidden, bots fill it, humans ignore it */}
      <input name="website" type="text" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
      <input name="name" type="text" required placeholder="Your name" />
      <input name="email" type="email" required placeholder="Email address" />
      <input name="phone" type="tel" required placeholder="Phone number" />
      <textarea name="message" required placeholder="How can Teressa help?" />
      {state.status === 'error' && <p role="alert">{state.message}</p>}
      <button type="submit" disabled={pending}>
        {pending ? 'Sending...' : 'Send Message'}
      </button>
    </form>
  )
}

Anti-Patterns to Avoid

  • Using useFormState from react-dom: Removed in React 19. Use useActionState from react instead.
  • Storing contact form submissions in DB in this phase: CONTEXT.md locked to email-only delivery. DB storage is out of scope.
  • Importing carousel library (Swiper, Embla): Adds unnecessary bundle for 4-5 static testimonials. Hand-roll with useState/useEffect.
  • Putting a link to /agent/login on the public site: Explicitly locked out of scope — agent navigates there directly via URL.
  • Hardcoding SMTP credentials: Always read from CONTACT_EMAIL_USER / CONTACT_EMAIL_PASS env vars.
  • Using router.push for smooth scroll: Use CSS scroll-behavior: smooth + <a href="#id"> anchors instead.

Don't Hand-Roll

Problem Don't Build Use Instead Why
SMTP email delivery Custom fetch to mail API nodemailer Handles STARTTLS, auth, connection pooling, error codes
Form validation Manual regex/string checks zod (already installed) Already in project; handles type coercion, error messages
Image optimization Raw <img> tags next/image Automatic WebP conversion, lazy load, responsive srcset
Smooth scroll window.scrollTo() JS CSS scroll-behavior: smooth Native browser feature; no JS required

Key insight: The SMTP connection lifecycle (STARTTLS negotiation, auth failures, timeouts, connection pooling) is non-trivial. Nodemailer handles all of it — never reimplement it.


Common Pitfalls

Pitfall 1: useFormState vs useActionState (React 19 breaking change)

What goes wrong: Developer uses useFormState from react-dom/server — module not found or deprecated warning. Why it happens: Most tutorials pre-date React 19. The hook moved. How to avoid: Import useActionState from react (not react-dom). React 19.2.4 is in the project. Warning signs: TypeScript error "useFormState is not exported from react-dom".

Pitfall 2: Sticky Nav Overlapping Section Anchors

What goes wrong: Clicking "Contact" scrolls so the section heading is hidden behind the sticky nav. Why it happens: Browser positions the anchor at top: 0, but sticky nav covers it. How to avoid: Add scroll-margin-top to each section equal to nav height.

section[id] { scroll-margin-top: 72px; } /* match nav height */

What goes wrong: Multiple intervals accumulate on re-renders; carousel skips or fires multiple times per second. Why it happens: useEffect cleanup not returning clearInterval. How to avoid: Always clear the previous interval in the useEffect cleanup function. Use a ref to track the interval ID (shown in Pattern 3 above).

Pitfall 4: Honeypot Field Autofilled by Browser Password Manager

What goes wrong: Browser autofills the honeypot field, causing legitimate submissions to be silently discarded. Why it happens: The hidden field has a name that triggers autofill (e.g., name="email2"). How to avoid: Name the honeypot something bots seek but browsers won't autofill (e.g., name="website" or name="url"). Add autoComplete="off" and tabIndex={-1}. Use style={{ display: 'none' }} rather than type="hidden" (bots fill visible-in-DOM fields more reliably).

Pitfall 5: Nodemailer from Address Rejected by SMTP Server

What goes wrong: Email silently fails or bounces because from address doesn't match authenticated user. Why it happens: Gmail/SMTP providers require from to match the authenticated account. How to avoid: Set from to process.env.CONTACT_EMAIL_USER (Teressa's SMTP account) and use replyTo for the visitor's email. The pattern in Pattern 2 above handles this correctly.

Pitfall 6: Next.js Image Component with Local File

What goes wrong: <Image src="/red.jpg" /> throws "Image is missing required 'width' or 'height'" or renders distorted. Why it happens: Next.js Image requires explicit dimensions OR fill + positioned parent. How to avoid: For the hero split-panel, use fill with objectFit="cover" on a positioned container.

// Hero photo container
<div style={{ position: 'relative', height: '100%', minHeight: '500px' }}>
  <Image src="/red.jpg" alt="Teressa Copeland" fill style={{ objectFit: 'cover', objectPosition: 'center top' }} priority />
</div>

Code Examples

Environment Variables Required

Add to .env.local (never commit):

# Contact form SMTP
CONTACT_EMAIL_USER=teressa@teressacopelandhomes.com
CONTACT_EMAIL_PASS=your_app_password
CONTACT_SMTP_HOST=smtp.gmail.com
CONTACT_SMTP_PORT=587

Note: Add CONTACT_SMTP_HOST and CONTACT_SMTP_PORT in addition to the CONTEXT.md variables — the host/port pair is required by Nodemailer but not mentioned in the user decisions. Use Gmail app password or equivalent.

Sticky Nav with Mobile Toggle

// app/_components/SiteNav.tsx
'use client'
import { useState } from 'react'
import { Menu, X } from 'lucide-react'

const NAV_LINKS = [
  { label: 'Home', href: '#hero' },
  { label: 'About', href: '#about' },
  { label: 'Listings', href: '#listings' },
  { label: 'Contact', href: '#contact' },
]

export function SiteNav() {
  const [open, setOpen] = useState(false)
  return (
    <nav style={{
      position: 'sticky', top: 0, zIndex: 50,
      backgroundColor: '#1B2B4B', color: '#FAF9F7',
    }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 2rem' }}>
        {/* Wordmark */}
        <span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>Teressa Copeland</span>
        {/* Desktop nav links */}
        <ul className="hidden md:flex gap-8">
          {NAV_LINKS.map(l => <li key={l.href}><a href={l.href}>{l.label}</a></li>)}
        </ul>
        {/* Mobile hamburger */}
        <button className="md:hidden" onClick={() => setOpen(o => !o)}>
          {open ? <X /> : <Menu />}
        </button>
      </div>
      {/* Mobile drawer */}
      {open && (
        <ul style={{ backgroundColor: '#1B2B4B', padding: '1rem 2rem' }}>
          {NAV_LINKS.map(l => (
            <li key={l.href}><a href={l.href} onClick={() => setOpen(false)}>{l.label}</a></li>
          ))}
        </ul>
      )}
    </nav>
  )
}
// app/_components/SiteFooter.tsx
export function SiteFooter() {
  return (
    <footer style={{ backgroundColor: '#1B2B4B', color: '#FAF9F7', padding: '2rem' }}>
      <p>Teressa Copeland</p>
      {/* TODO: Verify license number before launch — last two chars may be "00" (zeros) or "OO" (letters O) */}
      <p>Utah Real Estate License: 14196185-SA00</p>
      <p>&copy; {new Date().getFullYear()} Teressa Copeland Homes. All rights reserved.</p>
    </footer>
  )
}

State of the Art

Old Approach Current Approach When Changed Impact
useFormState from react-dom useActionState from react React 19 (2024) Old import throws; use new hook
API Routes for form submit Server Actions with action={serverAction} Next.js 14+ No fetch, no /api/ endpoint needed
pages/ router App Router (app/) Next.js 13+ Project already uses App Router from Phase 1
<img> tags next/image Next.js 10+ Auto-optimization, lazy loading, responsive

Deprecated/outdated:

  • useFormState from react-dom/server: Removed in React 19. Use useActionState from react.
  • Pages Router API routes for forms: Superseded by Server Actions in App Router.

Open Questions

  1. SMTP provider / Gmail App Password

    • What we know: CONTEXT.md locked CONTACT_EMAIL_USER and CONTACT_EMAIL_PASS as env vars
    • What's unclear: Whether Teressa uses Gmail (app password), Namecheap hosting email, or another provider — affects host/port config
    • Recommendation: Add CONTACT_SMTP_HOST and CONTACT_SMTP_PORT env vars so the planner does not hardcode a provider. Document Gmail setup as the example (host: smtp.gmail.com, port: 587, secure: false, requires App Password with 2FA enabled).
  2. Lucide React installation status

    • What we know: CONTEXT.md says "use Lucide React (already likely available)" but it does not appear in package.json dependencies
    • What's unclear: Whether it was installed but not listed, or needs to be added
    • Recommendation: Plan task should npm install lucide-react as a safe no-op if already present.
  3. License number last two characters

    • What we know: 14196185-SA00 — user flagged uncertainty whether final chars are 00 (zeros) or OO (letters)
    • What's unclear: The correct legal license number
    • Recommendation: Add a // TODO: VERIFY LICENSE NUMBER comment in the footer code. Do not block implementation on this — it's a content swap before launch.

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence — validate before implementation)

  • Gmail SMTP settings (host, port, app password requirement) — widely documented but Teressa's actual provider unknown until confirmed

Metadata

Confidence breakdown:

  • Standard stack: HIGH — package.json confirms exact versions; no guessing
  • Architecture: HIGH — Next.js App Router patterns are stable and well-documented
  • Carousel pattern: HIGH — pure React useState/useEffect; no library dependency
  • Server Action + Nodemailer: HIGH — Next.js official docs confirm pattern; Nodemailer is stable
  • SMTP provider specifics: LOW — depends on Teressa's email provider, unknown at research time

Research date: 2026-03-19 Valid until: 2026-04-19 (30 days — Next.js 16 and Nodemailer are stable; Tailwind 4 and React 19 patterns are settling but unlikely to break)