Skip to content

Commit

Permalink
feat: add contact form in contact section
Browse files Browse the repository at this point in the history
  • Loading branch information
gonzalojparra committed May 30, 2024
1 parent 729f791 commit f636e6e
Show file tree
Hide file tree
Showing 18 changed files with 1,225 additions and 14 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
RESEND_API_KEY=YOUR_API_KEY
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.4.2",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@vercel/analytics": "^1.2.2",
"@vercel/speed-insights": "^1.0.10",
Expand All @@ -26,9 +28,12 @@
"next-view-transitions": "^0.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.5",
"resend": "^3.2.0",
"sonner": "^1.4.41",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.11.19",
Expand Down
273 changes: 273 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions src/app/api/send/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Resend } from 'resend';
import { NextResponse } from 'next/server';

import { ContactEmailTemplate } from '@/components/contact-email-template';

export const runtime = 'edge';
export const dynamic = 'force-dynamic';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(request: Request) {
const { firstName, lastName, email, message } = await request.json();

try {
const { data, error } = await resend.emails.send({
from: '[email protected]',
to: '[email protected]',
subject: 'Message from contact form',
react: ContactEmailTemplate({
firstName,
lastName,
email,
message
})
});

if (error) {
return NextResponse.json({
status: 500,
body: { message: 'Error sending email' }
});
}

return NextResponse.json({
status: 200,
body: { message: data }
});
} catch (error) {
return NextResponse.json({
status: 500,
body: { message: error }
});
}
}
12 changes: 12 additions & 0 deletions src/app/contact/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Section } from '@/components/ui/section'
import { Contact } from '@/components/contact'

export default function CareerPage() {
return (
<div className='flex flex-col flex-1'>
<Section id='contact' className='pb-24'>
<Contact />
</Section>
</div>
)
}
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Footer } from '@/components/footer'
import { ThemeProvider } from '@/components/theme-provider'
import { TooltipProvider } from '@/components/ui/tooltip'
import { Toaster } from '@/components/ui/sonner'
import { Toaster as ToasterProvider } from '@/components/ui/toaster';
import { Analytics } from '@vercel/analytics/react'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { cn } from '@/lib/utils'
Expand Down Expand Up @@ -51,6 +52,7 @@ export default function RootLayout({
</main>
</TooltipProvider>
<Toaster />
<ToasterProvider />
</ThemeProvider>
<Analytics />
<SpeedInsights />
Expand Down
32 changes: 32 additions & 0 deletions src/components/contact-email-template.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
interface ContactEmailTemplateProps {
firstName: string;
lastName: string;
email: string;
message: string;
};

export function ContactEmailTemplate({
firstName,
lastName,
email,
message
}: ContactEmailTemplateProps) {
return (
<div className='bg-white dark:bg-gray-950 rounded-lg shadow-lg overflow-hidden'>
<header className='bg-gray-100 dark:bg-gray-800 px-6 py-4 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-lg font-semibold'>Portfolio | Emanuel Peire</span>
</div>
</header>
<div className='p-6 space-y-4'>
<div className='flex items-center gap-4'>
<p className='text-lg font-medium'>{firstName} {lastName}</p>
<p className='text-gray-500 dark:text-gray-400'>{email}</p>
</div>
<div className='text-gray-700 dark:text-gray-300 whitespace-pre-wrap'>
{message}
</div>
</div>
</div>
)
}
247 changes: 247 additions & 0 deletions src/components/contact-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
'use client'

import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader
} from '@/components/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { Loader2 } from 'lucide-react'

import { getFormSchema, FormValues } from '@/lib/validation';

export function ContactForm() {
const { toast } = useToast();
const formSchema = getFormSchema();

const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
mode: 'onChange',
defaultValues: {
firstName: '',
lastName: '',
email: '',
message: '',
honeypot: '',
}
});

const {
handleSubmit,
formState,
control,
watch,
setError,
clearErrors
} = form;
const { isSubmitting } = formState;

/* Watches for changes in the form fields and checks if the message field contains any URLs.
If a URL is found, it sets an error message for the message field. */
useEffect(() => {
const subscription = watch((value, { name }) => {
if (name === 'message' && /http|www|href/.test(value.message ?? '')) {
setError('message', {
type: 'manual',
message: 'Message must not contain URLs',
});
} else {
clearErrors('message');
}
});
return () => subscription.unsubscribe();
}, [watch, setError, clearErrors]);

async function onSubmit(data: FormValues) {
if (data.honeypot) {
toast({
title: 'Spam detected',
description: 'Please, fill out the form correctly and without spam. Thanks!',
variant: 'destructive',
});
return;
}

const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
if (value) {
formData.append(key, value);
}
});

try {
const response = await fetch('/api/send', {
method: 'POST',
body: JSON.stringify({
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
message: data.message,
}),
});

const result = await response.json();
toast({
title: 'Email sent',
description: 'I will get back to you as soon as possible!',
variant: 'default',
});
form.reset();
} catch (error) {
toast({
title: 'Error',
description: 'There was an error while submitting the email. Please try again later.',
variant: 'destructive',
});
}
}

return (
<div className='flex justify-center items-center'>
<Card className='w-full max-w-md'>
<CardHeader>
<CardDescription className='font-mono text-center'>
Please, fill out the form below and I'll get back to you as soon as possible.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form noValidate onSubmit={handleSubmit(onSubmit)}>
<div className='space-y-4'>
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-2'>
<FormField
control={control}
name='firstName'
render={({ field }) => (
<FormItem>
<FormLabel>
First Name
</FormLabel>
<FormControl>
<Input
{...field}
id='first-name'
placeholder='Your first name'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='space-y-2'>
<FormField
control={control}
name='lastName'
render={({ field }) => (
<FormItem>
<FormLabel>
Last Name
</FormLabel>
<FormControl>
<Input
{...field}
id='last-name'
placeholder='Your last name'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className='space-y-2'>
<FormField
control={control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>
Email
</FormLabel>
<FormControl>
<Input
{...field}
id='email'
placeholder='Your email address'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='space-y-2'>
<FormField
control={control}
name='message'
render={({ field }) => (
<FormItem>
<FormLabel>
Message
</FormLabel>
<FormControl>
<Textarea
{...field}
id='message'
placeholder='Enter your message here...'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Honeypot Field */}
<div style={{ display: 'none' }}>
<FormField
control={control}
name='honeypot'
render={({ field }) => (
<FormItem>
<Input
{...field}
id='honeypot'
tabIndex={-1}
autoComplete='off'
/>
</FormItem>
)}
/>
</div>
</div>
<Button
type='submit'
disabled={isSubmitting}
className='w-full mt-6'
>
{isSubmitting && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
Submit
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
)
}
Loading

0 comments on commit f636e6e

Please sign in to comment.