feat: wire contact form to Resend email notifications

- Add Server Action (actions.ts) sending to info@ and anastasia@axilaccountants.co.uk
- Add ContactForm client component with useActionState, loading and success states
- Replace static HTML form (action="#") with live ContactForm component
- Add @eslint/eslintrc as direct devDependency (fix ESLint resolution on Node.js 25)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-02-23 15:45:11 +00:00
parent 932b7143fe
commit 338b47d4c1
5 changed files with 302 additions and 117 deletions

View file

@ -24,9 +24,11 @@
"payload": "^3.77.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"resend": "^6.9.2",
"sharp": "^0.34.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
"@swc-node/register": "^1.11.1",
"@swc/core": "^1.15.11",
"@tailwindcss/postcss": "^4",

51
pnpm-lock.yaml generated
View file

@ -47,10 +47,16 @@ importers:
react-dom:
specifier: 19.2.3
version: 19.2.3(react@19.2.3)
resend:
specifier: ^6.9.2
version: 6.9.2
sharp:
specifier: ^0.34.5
version: 0.34.5
devDependencies:
'@eslint/eslintrc':
specifier: ^3.3.3
version: 3.3.3
'@swc-node/register':
specifier: ^1.11.1
version: 1.11.1(@swc/core@1.15.11)(@swc/types@0.1.25)(typescript@5.9.3)
@ -1298,6 +1304,9 @@ packages:
'@rushstack/eslint-patch@1.16.1':
resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@swc-node/core@1.14.1':
resolution: {integrity: sha512-jrt5GUaZUU6cmMS+WTJEvGvaB6j1YNKPHPzC2PUi2BjaFbtxURHj6641Az6xN7b665hNniAIdvjxWcRml5yCnw==}
engines: {node: '>= 10'}
@ -2384,6 +2393,9 @@ packages:
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
@ -3335,6 +3347,9 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
postal-mime@2.7.3:
resolution: {integrity: sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==}
postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
@ -3543,6 +3558,15 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resend@6.9.2:
resolution: {integrity: sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==}
engines: {node: '>=20'}
peerDependencies:
'@react-email/render': '*'
peerDependenciesMeta:
'@react-email/render':
optional: true
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -3701,6 +3725,9 @@ packages:
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
@ -3794,6 +3821,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
svix@1.84.1:
resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==}
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
@ -5166,6 +5196,8 @@ snapshots:
'@rushstack/eslint-patch@1.16.1': {}
'@stablelib/base64@1.0.1': {}
'@swc-node/core@1.14.1(@swc/core@1.15.11)(@swc/types@0.1.25)':
dependencies:
'@swc/core': 1.15.11
@ -6342,6 +6374,8 @@ snapshots:
fast-safe-stringify@2.1.1: {}
fast-sha256@1.3.0: {}
fast-uri@3.1.0: {}
fastq@1.20.1:
@ -7448,6 +7482,8 @@ snapshots:
possible-typed-array-names@1.1.0: {}
postal-mime@2.7.3: {}
postcss@8.4.31:
dependencies:
nanoid: 3.3.11
@ -7605,6 +7641,11 @@ snapshots:
require-from-string@2.0.2: {}
resend@6.9.2:
dependencies:
postal-mime: 2.7.3
svix: 1.84.1
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@ -7800,6 +7841,11 @@ snapshots:
stable-hash@0.0.5: {}
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
state-local@1.0.7: {}
stop-iteration-iterator@1.1.0:
@ -7905,6 +7951,11 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svix@1.84.1:
dependencies:
standardwebhooks: 1.0.0
uuid: 10.0.0
tabbable@6.4.0: {}
tailwindcss@4.2.0: {}

View file

@ -0,0 +1,151 @@
'use client';
import { useActionState } from 'react';
import { Button } from '@/components/ui/Button';
import { submitContactForm } from './actions';
export function ContactForm() {
const [state, action, isPending] = useActionState(submitContactForm, null);
if (state?.success) {
return (
<div className="rounded-card flex flex-col items-center justify-center gap-4 border border-black/8 bg-white p-10 text-center">
<div className="bg-emerald-mist flex size-14 items-center justify-center rounded-full">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-emerald"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</div>
<div>
<h3 className="font-display text-charcoal mb-1 text-xl font-bold">Message sent!</h3>
<p className="text-muted text-sm">
We&apos;ll get back to you within 2 hours on business days.
</p>
</div>
</div>
);
}
return (
<form id="contact-form" className="space-y-5" action={action}>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label htmlFor="first-name" className="text-charcoal mb-1.5 block text-sm font-medium">
First name
</label>
<input
id="first-name"
name="firstName"
type="text"
required
placeholder="James"
className="rounded-card bg-bg text-charcoal placeholder:text-muted focus:border-emerald focus:ring-emerald/20 w-full border border-black/12 px-4 py-3 text-sm transition-colors focus:ring-2 focus:outline-none"
/>
</div>
<div>
<label htmlFor="last-name" className="text-charcoal mb-1.5 block text-sm font-medium">
Last name
</label>
<input
id="last-name"
name="lastName"
type="text"
required
placeholder="Wilson"
className="rounded-card bg-bg text-charcoal placeholder:text-muted focus:border-emerald focus:ring-emerald/20 w-full border border-black/12 px-4 py-3 text-sm transition-colors focus:ring-2 focus:outline-none"
/>
</div>
</div>
<div>
<label htmlFor="email" className="text-charcoal mb-1.5 block text-sm font-medium">
Email address
</label>
<input
id="email"
name="email"
type="email"
required
placeholder="james@yourbusiness.co.uk"
className="rounded-card bg-bg text-charcoal placeholder:text-muted focus:border-emerald focus:ring-emerald/20 w-full border border-black/12 px-4 py-3 text-sm transition-colors focus:ring-2 focus:outline-none"
/>
</div>
<div>
<label htmlFor="phone" className="text-charcoal mb-1.5 block text-sm font-medium">
Phone number <span className="text-muted">(optional)</span>
</label>
<input
id="phone"
name="phone"
type="tel"
placeholder="07700 900000"
className="rounded-card bg-bg text-charcoal placeholder:text-muted focus:border-emerald focus:ring-emerald/20 w-full border border-black/12 px-4 py-3 text-sm transition-colors focus:ring-2 focus:outline-none"
/>
</div>
<div>
<label htmlFor="service" className="text-charcoal mb-1.5 block text-sm font-medium">
I&apos;m interested in
</label>
<select
id="service"
name="service"
className="rounded-card bg-bg text-charcoal focus:border-emerald focus:ring-emerald/20 w-full border border-black/12 px-4 py-3 text-sm transition-colors focus:ring-2 focus:outline-none"
>
<option value="">Select a service...</option>
<option value="bookkeeping">Bookkeeping</option>
<option value="tax-returns">Tax Returns</option>
<option value="payroll">Payroll</option>
<option value="vat-returns">VAT Returns</option>
<option value="all">Full accounting package</option>
<option value="courses">Courses</option>
<option value="other">Other / Not sure</option>
</select>
</div>
<div>
<label htmlFor="message" className="text-charcoal mb-1.5 block text-sm font-medium">
Message
</label>
<textarea
id="message"
name="message"
rows={4}
required
placeholder="Tell us a bit about your business and what you're looking for..."
className="rounded-card bg-bg text-charcoal placeholder:text-muted focus:border-emerald focus:ring-emerald/20 w-full resize-none border border-black/12 px-4 py-3 text-sm transition-colors focus:ring-2 focus:outline-none"
/>
</div>
{state?.error && (
<p className="rounded-card border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{state.error}
</p>
)}
<Button
type="submit"
size="lg"
className="w-full justify-center"
trailingArrow
disabled={isPending}
>
{isPending ? 'Sending...' : 'Send message'}
</Button>
<p className="text-muted text-center text-xs">
We typically respond within 2 hours on business days. No spam, ever.
</p>
</form>
);
}

View file

@ -0,0 +1,96 @@
'use server';
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const RECIPIENTS = ['info@axilaccountants.co.uk', 'anastasia@axilaccountants.co.uk'];
const SERVICE_LABELS: Record<string, string> = {
bookkeeping: 'Bookkeeping',
'tax-returns': 'Tax Returns',
payroll: 'Payroll',
'vat-returns': 'VAT Returns',
all: 'Full accounting package',
courses: 'Courses',
other: 'Other / Not sure',
};
export async function submitContactForm(
_prev: { success: boolean; error?: string } | null,
formData: FormData,
): Promise<{ success: boolean; error?: string }> {
const firstName = (formData.get('firstName') as string)?.trim();
const lastName = (formData.get('lastName') as string)?.trim();
const email = (formData.get('email') as string)?.trim();
const phone = (formData.get('phone') as string)?.trim();
const service = (formData.get('service') as string)?.trim();
const message = (formData.get('message') as string)?.trim();
if (!firstName || !lastName || !email || !message) {
return { success: false, error: 'Please fill in all required fields.' };
}
const serviceLabel = service ? (SERVICE_LABELS[service] ?? service) : 'Not specified';
const html = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; color: #162520;">
<div style="background: #3CC68A; padding: 24px 32px; border-radius: 8px 8px 0 0;">
<h1 style="color: white; margin: 0; font-size: 20px;">New enquiry Axil Accountants</h1>
</div>
<div style="background: #F5FEFA; padding: 32px; border: 1px solid #E8F8F1; border-top: none; border-radius: 0 0 8px 8px;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 10px 0; border-bottom: 1px solid #E8F8F1; width: 140px; color: #6B7280; font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;">Name</td>
<td style="padding: 10px 0; border-bottom: 1px solid #E8F8F1; font-size: 15px;">${firstName} ${lastName}</td>
</tr>
<tr>
<td style="padding: 10px 0; border-bottom: 1px solid #E8F8F1; color: #6B7280; font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;">Email</td>
<td style="padding: 10px 0; border-bottom: 1px solid #E8F8F1; font-size: 15px;"><a href="mailto:${email}" style="color: #3CC68A;">${email}</a></td>
</tr>
<tr>
<td style="padding: 10px 0; border-bottom: 1px solid #E8F8F1; color: #6B7280; font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;">Phone</td>
<td style="padding: 10px 0; border-bottom: 1px solid #E8F8F1; font-size: 15px;">${phone || '—'}</td>
</tr>
<tr>
<td style="padding: 10px 0; border-bottom: 1px solid #E8F8F1; color: #6B7280; font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;">Service</td>
<td style="padding: 10px 0; border-bottom: 1px solid #E8F8F1; font-size: 15px;">${serviceLabel}</td>
</tr>
<tr>
<td style="padding: 10px 0; vertical-align: top; color: #6B7280; font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;">Message</td>
<td style="padding: 10px 0; font-size: 15px; line-height: 1.6; white-space: pre-wrap;">${message}</td>
</tr>
</table>
<div style="margin-top: 24px; padding-top: 16px; border-top: 1px solid #E8F8F1;">
<a href="mailto:${email}?subject=Re: Your enquiry to Axil Accountants" style="display: inline-block; background: #3CC68A; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-size: 14px; font-weight: 600;">Reply to ${firstName}</a>
</div>
</div>
</div>
`;
try {
const { error } = await resend.emails.send({
from: 'Axil Website <noreply@axilaccountants.co.uk>',
to: RECIPIENTS,
replyTo: email,
subject: `New enquiry from ${firstName} ${lastName}${serviceLabel}`,
html,
});
if (error) {
console.error('[contact form] Resend error:', error);
return {
success: false,
error: 'Failed to send message. Please try again or email us directly.',
};
}
return { success: true };
} catch (err) {
console.error('[contact form] Unexpected error:', err);
return {
success: false,
error: 'Failed to send message. Please try again or email us directly.',
};
}
}

View file

@ -4,6 +4,7 @@ import { Footer } from '@/components/layout/Footer';
import { Button } from '@/components/ui/Button';
import { FadeIn } from '@/components/ui/FadeIn';
import { SpotlightCard } from '@/components/ui/SpotlightCard';
import { ContactForm } from './ContactForm';
export const metadata: Metadata = {
title: 'Contact — Axil Accountants',
@ -128,123 +129,7 @@ export default function ContactPage() {
<h2 className="font-display text-charcoal mb-6 text-2xl font-bold">
Send us a message
</h2>
<form id="contact-form" className="space-y-5" action="#" method="POST">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label
htmlFor="first-name"
className="text-charcoal mb-1.5 block text-sm font-medium"
>
First name
</label>
<input
id="first-name"
name="firstName"
type="text"
required
placeholder="James"
className="rounded-card bg-bg text-charcoal placeholder:text-muted focus:border-emerald focus:ring-emerald/20 w-full border border-black/12 px-4 py-3 text-sm transition-colors focus:ring-2 focus:outline-none"
/>
</div>
<div>
<label
htmlFor="last-name"
className="text-charcoal mb-1.5 block text-sm font-medium"
>
Last name
</label>
<input
id="last-name"
name="lastName"
type="text"
required
placeholder="Wilson"
className="rounded-card bg-bg text-charcoal placeholder:text-muted focus:border-emerald focus:ring-emerald/20 w-full border border-black/12 px-4 py-3 text-sm transition-colors focus:ring-2 focus:outline-none"
/>
</div>
</div>
<div>
<label
htmlFor="email"
className="text-charcoal mb-1.5 block text-sm font-medium"
>
Email address
</label>
<input
id="email"
name="email"
type="email"
required
placeholder="james@yourbusiness.co.uk"
className="rounded-card bg-bg text-charcoal placeholder:text-muted focus:border-emerald focus:ring-emerald/20 w-full border border-black/12 px-4 py-3 text-sm transition-colors focus:ring-2 focus:outline-none"
/>
</div>
<div>
<label
htmlFor="phone"
className="text-charcoal mb-1.5 block text-sm font-medium"
>
Phone number <span className="text-muted">(optional)</span>
</label>
<input
id="phone"
name="phone"
type="tel"
placeholder="07700 900000"
className="rounded-card bg-bg text-charcoal placeholder:text-muted focus:border-emerald focus:ring-emerald/20 w-full border border-black/12 px-4 py-3 text-sm transition-colors focus:ring-2 focus:outline-none"
/>
</div>
<div>
<label
htmlFor="service"
className="text-charcoal mb-1.5 block text-sm font-medium"
>
I&apos;m interested in
</label>
<select
id="service"
name="service"
className="rounded-card bg-bg text-charcoal focus:border-emerald focus:ring-emerald/20 w-full border border-black/12 px-4 py-3 text-sm transition-colors focus:ring-2 focus:outline-none"
>
<option value="">Select a service...</option>
<option value="bookkeeping">Bookkeeping</option>
<option value="tax-returns">Tax Returns</option>
<option value="payroll">Payroll</option>
<option value="vat-returns">VAT Returns</option>
<option value="all">Full accounting package</option>
<option value="courses">Courses</option>
<option value="other">Other / Not sure</option>
</select>
</div>
<div>
<label
htmlFor="message"
className="text-charcoal mb-1.5 block text-sm font-medium"
>
Message
</label>
<textarea
id="message"
name="message"
rows={4}
required
placeholder="Tell us a bit about your business and what you're looking for..."
className="rounded-card bg-bg text-charcoal placeholder:text-muted focus:border-emerald focus:ring-emerald/20 w-full resize-none border border-black/12 px-4 py-3 text-sm transition-colors focus:ring-2 focus:outline-none"
/>
</div>
<Button type="submit" size="lg" className="w-full justify-center" trailingArrow>
Send message
</Button>
<p className="text-muted text-center text-xs">
We typically respond within 2 hours on business days. No spam, ever.
</p>
</form>
<ContactForm />
</SpotlightCard>
</FadeIn>