Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions src/blocks/ResumeContactBlock/Renderer/ContactForm.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
'use client'

import { useActionState, useEffect, useId, useRef } from 'react'

import { Button } from '@/components/Button'
import { type ContactFormState, submitContactForm } from '@/lib/actions/submitContactForm'
import { cn } from '@/lib/cn'

const initialState: ContactFormState = {
status: 'idle',
}

const inputStyles = cn(
'w-full rounded-none border border-border bg-transparent px-4 py-3',
'text-base placeholder:text-muted-foreground',
'outline-none transition-colors',
'focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30',
'aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20',
)

export const ContactForm = () => {
const [state, formAction, isPending] = useActionState(submitContactForm, initialState)
const formRef = useRef<HTMLFormElement>(null)
const nameId = useId()
const emailId = useId()
const messageId = useId()
const companyId = useId()

useEffect(() => {
if (state.status === 'success') {
formRef.current?.reset()
}
}, [
state.status,
])

return (
<form
ref={formRef}
action={formAction}
noValidate
className="col-span-12 md:col-start-3 md:col-span-8 my-8 flex flex-col gap-6 text-left"
>
<div className="flex flex-col gap-2">
<label htmlFor={nameId} className="text-sm font-mono uppercase tracking-wide">
Name
</label>
<input
id={nameId}
name="name"
type="text"
autoComplete="name"
required
maxLength={200}
aria-invalid={Boolean(state.fieldErrors?.name)}
aria-describedby={state.fieldErrors?.name ? `${nameId}-error` : undefined}
className={inputStyles}
/>
{state.fieldErrors?.name && (
<p id={`${nameId}-error`} className="text-sm text-destructive">
{state.fieldErrors.name}
</p>
)}
</div>

<div className="flex flex-col gap-2">
<label htmlFor={emailId} className="text-sm font-mono uppercase tracking-wide">
Email
</label>
<input
id={emailId}
name="email"
type="email"
autoComplete="email"
required
maxLength={320}
aria-invalid={Boolean(state.fieldErrors?.email)}
aria-describedby={state.fieldErrors?.email ? `${emailId}-error` : undefined}
className={inputStyles}
/>
{state.fieldErrors?.email && (
<p id={`${emailId}-error`} className="text-sm text-destructive">
{state.fieldErrors.email}
</p>
)}
</div>

<div className="flex flex-col gap-2">
<label htmlFor={messageId} className="text-sm font-mono uppercase tracking-wide">
Message
</label>
<textarea
id={messageId}
name="message"
rows={6}
required
maxLength={5000}
aria-invalid={Boolean(state.fieldErrors?.message)}
aria-describedby={state.fieldErrors?.message ? `${messageId}-error` : undefined}
className={cn(inputStyles, 'resize-y')}
/>
{state.fieldErrors?.message && (
<p id={`${messageId}-error`} className="text-sm text-destructive">
{state.fieldErrors.message}
</p>
)}
</div>

{/*
Honeypot: hidden from sighted and keyboard users via CSS + tabIndex,
and out of the accessibility tree via aria-hidden, but still present
for naive bots that fill in every input they find. Any value here
makes submitContactForm silently no-op.
*/}
<div aria-hidden="true" className="absolute left-[-9999px] top-auto h-0 w-0 overflow-hidden">
<label htmlFor={companyId}>Company</label>
<input id={companyId} name="company" type="text" tabIndex={-1} autoComplete="off" />
</div>

<div className="flex flex-col items-start gap-3">
<Button type="submit" variant="default" disabled={isPending}>
{isPending ? 'Sending…' : 'Send message'}
</Button>

{state.status === 'success' && (
<p role="status" className="text-sm text-muted-foreground">
Thanks for reaching out — I'll get back to you soon.
</p>
)}
{state.status === 'error' && state.message && !state.fieldErrors && (
<p role="alert" className="text-sm text-destructive">
{state.message}
</p>
)}
</div>
</form>
)
}
10 changes: 3 additions & 7 deletions src/blocks/ResumeContactBlock/Renderer/Renderer.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { SectionContainer } from '@/components/SectionContainer'
import { cn } from '@/lib/cn'
import { ResumeContactBlock } from '@/types/payload'

import { ContactForm } from './ContactForm.client'

interface ResumeContactBlockClientRendererProps extends ResumeContactBlock {
title: string
}
Expand All @@ -27,13 +29,7 @@ export const ResumeContactBlockClientRenderer = ({
<RichText data={caption} enableGutter={false} className="text-lg" />
</div>
)}
{/*{mailButton?.label && mailButton?.href && (*/}
{/* <Button asChild variant="default" type="submit">*/}
{/* <Link className="font-bold text-xl col-span-12 md:col-start-4 md:col-span-6 my-2 cursor-pointer" href={mailButton.href}>*/}
{/* {mailButton.label}*/}
{/* </Link>*/}
{/* </Button>*/}
{/*)}*/}
<ContactForm />
</div>
</SectionContainer>
)
152 changes: 152 additions & 0 deletions src/lib/actions/submitContactForm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { getPayload } from 'payload'

import { beforeEach, describe, expect, it, vi } from 'vitest'

import { type ContactFormState, submitContactForm } from './submitContactForm'

vi.mock('@payload-config', () => ({
default: {},
}))

const RECIPIENT_EMAIL = 'owner@example.com'

const findGlobal = vi.fn(async () => ({
email: RECIPIENT_EMAIL,
}))
const sendEmail = vi.fn(async () => ({}))
const loggerError = vi.fn()

beforeEach(() => {
findGlobal.mockClear()
findGlobal.mockResolvedValue({
email: RECIPIENT_EMAIL,
})
sendEmail.mockClear()
sendEmail.mockResolvedValue({})
loggerError.mockClear()

vi.mocked(getPayload).mockResolvedValue({
findGlobal,
sendEmail,
logger: {
info: vi.fn(),
error: loggerError,
},
} as never)
})

const initialState: ContactFormState = {
status: 'idle',
}

const formData = (fields: Partial<Record<'name' | 'email' | 'message' | 'company', string>>) => {
const data = new FormData()
for (const [key, value] of Object.entries(fields)) {
if (value !== undefined) {
data.set(key, value)
}
}
return data
}

describe('submitContactForm', () => {
it('sends an email to the configured recipient with the submitter as reply-to', async () => {
const result = await submitContactForm(
initialState,
formData({
name: 'Ada Lovelace',
email: 'ada@example.com',
message: 'Hello there',
}),
)

expect(result.status).toBe('success')
expect(sendEmail).toHaveBeenCalledTimes(1)
expect(sendEmail.mock.calls[0][0]).toMatchObject({
to: RECIPIENT_EMAIL,
replyTo: 'Ada Lovelace <ada@example.com>',
subject: 'New contact form message from Ada Lovelace',
})
expect(sendEmail.mock.calls[0][0].text).toContain('Hello there')
})

it('rejects an invalid email without sending', async () => {
const result = await submitContactForm(
initialState,
formData({
name: 'Ada',
email: 'not-an-email',
message: 'Hi',
}),
)

expect(result.status).toBe('error')
expect(result.fieldErrors?.email).toBeTruthy()
expect(sendEmail).not.toHaveBeenCalled()
})

it('rejects an empty message without sending', async () => {
const result = await submitContactForm(
initialState,
formData({
name: 'Ada',
email: 'ada@example.com',
message: ' ',
}),
)

expect(result.status).toBe('error')
expect(result.fieldErrors?.message).toBeTruthy()
expect(sendEmail).not.toHaveBeenCalled()
})

it('silently succeeds without sending when the honeypot field is filled', async () => {
const result = await submitContactForm(
initialState,
formData({
name: 'Bot',
email: 'bot@example.com',
message: 'buy now',
company: 'Acme Spam Co',
}),
)

expect(result.status).toBe('success')
expect(sendEmail).not.toHaveBeenCalled()
})

it('reports an error without leaking details when no recipient email is configured', async () => {
findGlobal.mockResolvedValue({
email: undefined,
})

const result = await submitContactForm(
initialState,
formData({
name: 'Ada',
email: 'ada@example.com',
message: 'Hi',
}),
)

expect(result.status).toBe('error')
expect(sendEmail).not.toHaveBeenCalled()
})

it('reports a generic error and logs details when sending fails', async () => {
sendEmail.mockRejectedValue(new Error('usesend: 500 upstream error'))

const result = await submitContactForm(
initialState,
formData({
name: 'Ada',
email: 'ada@example.com',
message: 'Hi',
}),
)

expect(result.status).toBe('error')
expect(result.message).not.toContain('usesend')
expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('usesend: 500 upstream error'))
})
})
Loading