- 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>
308 lines
12 KiB
Markdown
308 lines
12 KiB
Markdown
---
|
|
phase: 02-marketing-site
|
|
plan: 02
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- 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
|
|
autonomous: true
|
|
requirements:
|
|
- MKTG-03
|
|
|
|
user_setup:
|
|
- service: nodemailer_smtp
|
|
why: "Contact form email delivery to Teressa's inbox"
|
|
env_vars:
|
|
- name: CONTACT_EMAIL_USER
|
|
source: "Teressa's email address (e.g. teressa@teressacopelandhomes.com or Gmail address)"
|
|
- name: CONTACT_EMAIL_PASS
|
|
source: "Gmail: Google Account → Security → App Passwords (requires 2FA enabled). Other providers: SMTP password from hosting control panel."
|
|
- name: CONTACT_SMTP_HOST
|
|
source: "Gmail: smtp.gmail.com | Namecheap: mail.privateemail.com | Other: check hosting provider docs"
|
|
- name: CONTACT_SMTP_PORT
|
|
source: "587 for STARTTLS (recommended) or 465 for SSL"
|
|
|
|
must_haves:
|
|
truths:
|
|
- "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"
|
|
artifacts:
|
|
- path: "teressa-copeland-homes/lib/contact-mailer.ts"
|
|
provides: "Nodemailer transporter + sendContactEmail() function"
|
|
exports: ["sendContactEmail"]
|
|
- path: "teressa-copeland-homes/lib/contact-action.ts"
|
|
provides: "Server Action — validates with Zod, checks honeypot, calls mailer"
|
|
exports: ["submitContact", "ContactState"]
|
|
- path: "teressa-copeland-homes/app/_components/ContactSection.tsx"
|
|
provides: "Client component — useActionState form with honeypot, success swap"
|
|
key_links:
|
|
- from: "teressa-copeland-homes/app/_components/ContactSection.tsx"
|
|
to: "teressa-copeland-homes/lib/contact-action.ts"
|
|
via: "useActionState(submitContact, initialState)"
|
|
pattern: "useActionState"
|
|
- from: "teressa-copeland-homes/lib/contact-action.ts"
|
|
to: "teressa-copeland-homes/lib/contact-mailer.ts"
|
|
via: "await sendContactEmail(parsed.data)"
|
|
pattern: "sendContactEmail"
|
|
- from: "teressa-copeland-homes/lib/contact-mailer.ts"
|
|
to: "process.env.CONTACT_EMAIL_USER / CONTACT_EMAIL_PASS"
|
|
via: "nodemailer.createTransport auth object"
|
|
pattern: "process\\.env\\.CONTACT"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Install nodemailer and create mailer + server action</name>
|
|
<files>
|
|
teressa-copeland-homes/lib/contact-mailer.ts
|
|
teressa-copeland-homes/lib/contact-action.ts
|
|
</files>
|
|
<action>
|
|
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
|
|
```
|
|
|
|
2. Create `lib/contact-mailer.ts`:
|
|
```typescript
|
|
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>`,
|
|
})
|
|
}
|
|
```
|
|
|
|
3. Create `lib/contact-action.ts`:
|
|
```typescript
|
|
'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.' }
|
|
}
|
|
}
|
|
```
|
|
|
|
4. 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
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -20</automated>
|
|
</verify>
|
|
<done>Build succeeds. lib/contact-mailer.ts and lib/contact-action.ts exist with no TypeScript errors. nodemailer appears in package.json dependencies.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Build ContactSection client component and wire into page</name>
|
|
<files>
|
|
teressa-copeland-homes/app/_components/ContactSection.tsx
|
|
teressa-copeland-homes/app/page.tsx
|
|
</files>
|
|
<action>
|
|
Create `app/_components/ContactSection.tsx` — 'use client':
|
|
|
|
```typescript
|
|
'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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -20</automated>
|
|
</verify>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `/Users/ccopeland/temp/red/.planning/phases/02-marketing-site/02-02-SUMMARY.md`
|
|
</output>
|