Files
red/.planning/phases/02-marketing-site/02-02-PLAN.md
Chandler Copeland 5842ffc9f3 docs(02-02): complete contact form plan — SUMMARY, STATE, plans tracked
- Create 02-02-SUMMARY.md: contact form outcomes, decisions, deviations
- Update STATE.md: position, decisions (nodemailer v7 pin, useActionState React 19, honeypot pattern)
- Add 02-01-PLAN.md, 02-02-PLAN.md, 02-03-PLAN.md to version control
- Mark MKTG-03 requirement complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 15:04:45 -06:00

12 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
02-marketing-site 02 execute 1
teressa-copeland-homes/lib/contact-mailer.ts
teressa-copeland-homes/lib/contact-action.ts
teressa-copeland-homes/app/_components/ContactSection.tsx
teressa-copeland-homes/app/page.tsx
teressa-copeland-homes/.env.local
true
MKTG-03
service why env_vars
nodemailer_smtp Contact form email delivery to Teressa's inbox
name source
CONTACT_EMAIL_USER Teressa's email address (e.g. teressa@teressacopelandhomes.com or Gmail address)
name source
CONTACT_EMAIL_PASS Gmail: Google Account → Security → App Passwords (requires 2FA enabled). Other providers: SMTP password from hosting control panel.
name source
CONTACT_SMTP_HOST Gmail: smtp.gmail.com | Namecheap: mail.privateemail.com | Other: check hosting provider docs
name source
CONTACT_SMTP_PORT 587 for STARTTLS (recommended) or 465 for SSL
truths artifacts key_links
Visitor can fill in name, email, phone, and message and submit the contact form
On success, the form is replaced with 'Thanks! Teressa will be in touch soon.'
Submissions with the honeypot field filled are silently discarded (no email sent)
Server-side Zod validation rejects missing required fields
SMTP credentials are read from env vars — never hardcoded
path provides exports
teressa-copeland-homes/lib/contact-mailer.ts Nodemailer transporter + sendContactEmail() function
sendContactEmail
path provides exports
teressa-copeland-homes/lib/contact-action.ts Server Action — validates with Zod, checks honeypot, calls mailer
submitContact
ContactState
path provides
teressa-copeland-homes/app/_components/ContactSection.tsx Client component — useActionState form with honeypot, success swap
from to via pattern
teressa-copeland-homes/app/_components/ContactSection.tsx teressa-copeland-homes/lib/contact-action.ts useActionState(submitContact, initialState) useActionState
from to via pattern
teressa-copeland-homes/lib/contact-action.ts teressa-copeland-homes/lib/contact-mailer.ts await sendContactEmail(parsed.data) sendContactEmail
from to via pattern
teressa-copeland-homes/lib/contact-mailer.ts process.env.CONTACT_EMAIL_USER / CONTACT_EMAIL_PASS nodemailer.createTransport auth object process.env.CONTACT
Build the contact form: Nodemailer SMTP mailer utility, Server Action with Zod validation and honeypot check, and the client-side form component with success-state swap.

Purpose: Visitors can reach Teressa directly from the homepage without any third-party form service. Output: lib/contact-mailer.ts, lib/contact-action.ts, app/_components/ContactSection.tsx wired into page.tsx.

<execution_context> @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md </execution_context>

@/Users/ccopeland/temp/red/.planning/phases/02-marketing-site/02-CONTEXT.md @/Users/ccopeland/temp/red/.planning/phases/02-marketing-site/02-RESEARCH.md

Key patterns from research:

  • Use useActionState from 'react' (NOT useFormState from react-dom — removed in React 19)
  • Nodemailer from must match CONTACT_EMAIL_USER; use replyTo for visitor's email
  • Honeypot field name="website" with display:none, tabIndex={-1}, autoComplete="off"
  • Server Action file must have 'use server' directive at top
Task 1: Install nodemailer and create mailer + server action teressa-copeland-homes/lib/contact-mailer.ts teressa-copeland-homes/lib/contact-action.ts 1. Install dependencies from the teressa-copeland-homes directory: ```bash cd /Users/ccopeland/temp/red/teressa-copeland-homes npm install nodemailer npm install --save-dev @types/nodemailer ```
  1. Create lib/contact-mailer.ts:

    import nodemailer from 'nodemailer'
    
    const transporter = nodemailer.createTransport({
      host: process.env.CONTACT_SMTP_HOST,
      port: Number(process.env.CONTACT_SMTP_PORT ?? 587),
      secure: false, // STARTTLS on port 587
      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: `"Teressa Copeland Homes Website" <${process.env.CONTACT_EMAIL_USER}>`,
        replyTo: data.email,
        to: process.env.CONTACT_EMAIL_USER,
        subject: `New contact from ${data.name} — Teressa Copeland Homes`,
        text: `Name: ${data.name}\nEmail: ${data.email}\nPhone: ${data.phone}\n\nMessage:\n${data.message}`,
        html: `<p><strong>Name:</strong> ${data.name}</p>
               <p><strong>Email:</strong> <a href="mailto:${data.email}">${data.email}</a></p>
               <p><strong>Phone:</strong> ${data.phone}</p>
               <p><strong>Message:</strong></p>
               <p>${data.message.replace(/\n/g, '<br/>')}</p>`,
      })
    }
    
  2. Create 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 address required'),
      phone: z.string().min(7, 'Phone number is required'),
      message: z.string().min(10, 'Message must be at least 10 characters'),
      website: z.string(), // honeypot — must remain empty
    })
    
    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') ?? '',
      }
    
      const parsed = ContactSchema.safeParse(raw)
      if (!parsed.success) {
        return { status: 'error', message: 'Please fill in all required fields correctly.' }
      }
    
      // Honeypot check — if filled, silently succeed (don't reveal rejection to bots)
      if (parsed.data.website !== '') {
        return { status: 'success' }
      }
    
      try {
        await sendContactEmail(parsed.data)
        return { status: 'success' }
      } catch (err) {
        console.error('Contact form email error:', err)
        return { status: 'error', message: 'Something went wrong. Please try again or call directly.' }
      }
    }
    
  3. Add SMTP placeholder vars to .env.local (append — do not overwrite existing vars):

    # Contact form SMTP — fill in before testing email delivery
    CONTACT_EMAIL_USER=your_email@example.com
    CONTACT_EMAIL_PASS=your_app_password
    CONTACT_SMTP_HOST=smtp.gmail.com
    CONTACT_SMTP_PORT=587
    
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -20 Build succeeds. lib/contact-mailer.ts and lib/contact-action.ts exist with no TypeScript errors. nodemailer appears in package.json dependencies. Task 2: Build ContactSection client component and wire into page teressa-copeland-homes/app/_components/ContactSection.tsx teressa-copeland-homes/app/page.tsx Create `app/_components/ContactSection.tsx` — 'use client':
'use client'
import { useActionState } from 'react'
import { submitContact, type ContactState } from '@/lib/contact-action'

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

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

  return (
    <section id="contact" style={{ backgroundColor: '#FAF9F7', padding: '4rem 2rem' }}>
      <div style={{ maxWidth: '600px', margin: '0 auto' }}>
        <h2 style={{ color: '#1B2B4B', marginBottom: '0.5rem' }}>Get in Touch</h2>
        <p style={{ color: '#555', marginBottom: '2rem' }}>
          Ready to find your Utah home? Teressa is here to help.
        </p>

        {state.status === 'success' ? (
          <div role="status" style={{ padding: '2rem', backgroundColor: '#e8f5e9', borderRadius: '8px', color: '#2e7d32', textAlign: 'center' }}>
            <p style={{ fontSize: '1.1rem', fontWeight: 600 }}>Thanks! Teressa will be in touch soon.</p>
          </div>
        ) : (
          <form action={action} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
            {/* Honeypot — hidden from humans, bots fill it in */}
            <input
              name="website"
              type="text"
              style={{ display: 'none' }}
              tabIndex={-1}
              autoComplete="off"
              aria-hidden="true"
            />

            <input name="name" type="text" required placeholder="Your full 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 you?" rows={5} />

            {state.status === 'error' && (
              <p role="alert" style={{ color: '#c62828' }}>{state.message}</p>
            )}

            <button
              type="submit"
              disabled={pending}
              style={{
                backgroundColor: '#C9A84C',
                color: '#1B2B4B',
                padding: '0.75rem 2rem',
                border: 'none',
                borderRadius: '4px',
                fontWeight: 700,
                cursor: pending ? 'not-allowed' : 'pointer',
                opacity: pending ? 0.7 : 1,
              }}
            >
              {pending ? 'Sending...' : 'Send Message'}
            </button>
          </form>
        )}
      </div>
    </section>
  )
}

Update app/page.tsx: replace the <section id="contact" /> stub with <ContactSection />. Import ContactSection at the top. Ensure all section imports are present and in correct order: SiteNav, HeroSection, TestimonialsSection (from plan 01), ListingsPlaceholder, ContactSection, SiteFooter. cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -20 Build succeeds. ContactSection renders a form with name, email, phone, message fields and a gold "Send Message" button. Form wires to submitContact server action via useActionState.

- `npm run build` exits 0 - `lib/contact-action.ts` has 'use server' at top - `useActionState` imported from 'react' (not react-dom) - Honeypot field has display:none, tabIndex={-1}, autoComplete="off" - No SMTP credentials hardcoded — all from process.env.* - `.env.local` has CONTACT_* placeholder vars with instructions

<success_criteria>

  • Contact form renders with all four required fields
  • Submitting with the honeypot filled returns success without sending email
  • Missing required fields return an error message
  • On real success, form is replaced by thank-you message
  • Build passes with zero TypeScript errors </success_criteria>
After completion, create `/Users/ccopeland/temp/red/.planning/phases/02-marketing-site/02-02-SUMMARY.md`