Skip to content

Commit

Permalink
feat(contact): implemented contact page and form with server actions
Browse files Browse the repository at this point in the history
  • Loading branch information
charles4221 committed Jan 25, 2024
1 parent 1307000 commit b0bc91c
Show file tree
Hide file tree
Showing 20 changed files with 439 additions and 101 deletions.
11 changes: 9 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@
],
"unicorn/no-array-callback-reference": "off",
"unicorn/no-useless-undefined": "off", // causes eslint to remove usages of undefined e.g. `someFunction(undefined)` which is dangerous and not advisable
"unicorn/prevent-abbreviations": "off" // this one is annoying due to prevalence of the term Props within React code
"unicorn/prevent-abbreviations": "off", // this one is annoying due to prevalence of the term Props within React code
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"ignoreRestSiblings": true
}
]
},
"overrides": [
{
Expand Down Expand Up @@ -94,4 +101,4 @@
}
}
]
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@
"slice-machine-ui": "1.23.0",
"tailwindcss": "3.4.1"
}
}
}
143 changes: 143 additions & 0 deletions src/app/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
'use server';

import {
ContactFormFields,
ContactFormState,
} from '@/components/forms/ContactForm';

const apiUrl = process.env.EMAIL_API_URL;
const apiKey = process.env.EMAIL_API_KEY;

type SenderOrRecipient = {
name: string;
email: string;
};

type Attachment = ({ url: string } | { content: string }) & {
name: string;
};

type MessageBody = {
sender: SenderOrRecipient;
to: SenderOrRecipient[];
bcc?: SenderOrRecipient[];
cc?: SenderOrRecipient[];
replyTo?: SenderOrRecipient;
subject: string;
htmlContent: string;
textContent: string;
attachment?: Attachment[];
templateId?: number;
params?: { [key: string]: string };
tags?: string[];
scheduledAt?: string;
batchId?: string;
};

type MessageResponseSuccess = {
messageId: string;
};
type MessageResponseScheduled = {
messageId: string;
batchId: string;
};
type MessageResponseErrorCode =
| 'invalid_parameter'
| 'missing_parameter'
| 'out_of_range'
| 'campaign_processing'
| 'campaign_sent'
| 'document_not_found'
| 'reseller_permission_denied'
| 'not_enough_credits'
| 'permission_denied'
| 'duplicate_parameter'
| 'duplicate_request'
| 'method_not_allowed'
| 'unauthorized'
| 'account_under_validation'
| 'not_acceptable'
| 'bad_request';
type MessageResponseError = {
message: string;
code: MessageResponseErrorCode;
};
type MessageResponse =
| MessageResponseSuccess
| MessageResponseScheduled
| MessageResponseError;

export async function sendMessage(
_prevState: ContactFormState,
formData: FormData,
): Promise<ContactFormState> {
const {
firstName,
lastName,
email,
company,
website,
description,
}: ContactFormFields = {
firstName: formData.get('firstName') as string,
lastName: formData.get('lastName') as string,
email: formData.get('email') as string,
company: formData.get('company') as string,
website: formData.get('website') as string,
description: formData.get('description') as string,
};

const messageBody: MessageBody = {
sender: {
name: 'charlesharwood.dev',
email: 'no-reply@charlesharwood.dev',
},
to: [
{
email: 'info@charlesharwood.dev',
name: 'Charles Harwood',
},
],
replyTo: {
email,
name: `${firstName} ${lastName}`,
},
subject: 'New enquiry from charlesharwood.dev',
htmlContent: `<!DOCTYPE html><html><head></head><body><h1>You've received a new enquiry from https://charlesharwood.dev:</h1><p>First Name: ${firstName}</p><p>Last Name: ${lastName}</p><p>Email: ${email}</p><p>Company: ${company}</p><p>Website: ${website}</p><p>Message: ${description}</p></body></html>`,
textContent: `First Name: ${firstName}\nLast Name: ${lastName}\nEmail: ${email}\nCompany: ${company}\nWebsite: ${website}\nMessage: ${description}`,
};

const fetchOptions: RequestInit = {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'api-key': apiKey,
},
body: JSON.stringify(messageBody),
};

try {
const response = await fetch(apiUrl, fetchOptions);
const data: MessageResponse = await response.json();

if ('code' in data) {
throw new Error(data.message, {
cause: data.code,
});
}

return {
message:
'Message sent successfully! I will be in touch as soon as I can.',
success: true,
};
} catch (error) {
console.error(error);

return {
message: error instanceof Error ? error.message : 'Failed to send email',
success: false,
};
}
}
51 changes: 0 additions & 51 deletions src/app/api/form/route.ts

This file was deleted.

51 changes: 51 additions & 0 deletions src/app/contact/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { asText } from '@prismicio/client';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';

import { ContactForm } from '@/components/forms/ContactForm';
import { Container } from '@/components/layout/Container';
import { Heading } from '@/components/typography/Heading';
import { createClient } from '@/prismic-config';
import { METADATA_BASE } from '@/utils/constants';

export default async function ContactPage() {
const client = createClient();
const page = await client.getByUID('page', 'contact');

return (
<main>
<Container as="section" yPadding="sm">
<Heading as="h1" className="mb-10">
{asText(page.data.title)}
</Heading>
<p>
Please fill out the form below to send me a message about your
project!
</p>
</Container>
<Container as="section" yPadding="sm">
<ContactForm />
</Container>
</main>
);
}

export async function generateMetadata(): Promise<Metadata> {
const client = createClient();
const page = await client.getByUID('page', 'contact').catch(notFound);
const settings = await client.getSingle('settings');

return {
metadataBase: METADATA_BASE,
title: `${asText(page.data.title)} | ${asText(settings.data.siteTitle)}`,
description: page.data.meta_description,
openGraph: {
title: page.data.meta_title,
images: [
{
url: page.data.meta_image.url,
},
],
},
};
}
43 changes: 24 additions & 19 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,38 @@

@layer components {
.heading {
@apply leading-tight tracking-tight text-slate-950 uppercase font-headings;
text-shadow: -0.042em 0.042em theme('colors.red.500');
-webkit-text-stroke: 0.027em theme('colors.sky.300');
@apply leading-tight tracking-tight uppercase font-headings;

/* Replace text-stroke with base colour for IE10/11 */
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
-webkit-text-stroke: 0;
&--display {
@apply text-slate-950;
text-shadow: -0.042em 0.042em theme('colors.red.500');
-webkit-text-stroke: 0.027em theme('colors.sky.300');

.dark & {
color: theme('colors.sky.300');
/* Replace text-stroke with base colour for IE10/11 */
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
-webkit-text-stroke: 0;

.dark & {
color: theme('colors.sky.300');
}
}
}

/* Replace text-stroke with base colour for pre-Chromium Edge */
@supports (-ms-accelerator: true) {
-webkit-text-stroke: 0;
/* Replace text-stroke with base colour for pre-Chromium Edge */
@supports (-ms-accelerator: true) {
-webkit-text-stroke: 0;

.dark & {
color: theme('colors.sky.300');
.dark & {
color: theme('colors.sky.300');
}
}
}

&.heading--shadow-transition {
transition: all 0.15s ease-in-out;
&.heading--shadow-transition {
transition: all 0.15s ease-in-out;

&:hover {
text-shadow: -0.07em 0.06em theme('colors.red.500');
&:hover,
.group:hover & {
text-shadow: -0.07em 0.06em theme('colors.red.500');
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default function NotFound() {
Error 404
</Heading>
<Heading as="h2">Not Found</Heading>
<p className="text-lg">
<p className="text-lg my-10">
Sorry, this is not the page you&apos;re searching for.
</p>
<PlainLink href="/">Move Along (return home)</PlainLink>
Expand Down
4 changes: 4 additions & 0 deletions src/components/PrismicRichText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { JSXMapSerializer, PrismicRichTextProps } from '@prismicio/react';
import { Heading1 } from './rich-text/Heading1';
import { Heading2 } from './rich-text/Heading2';
import { Heading3 } from './rich-text/Heading3';
import { Heading4 } from './rich-text/Heading4';
import { Heading5 } from './rich-text/Heading5';
import { Hyperlink } from './rich-text/Hyperlink';
import { OrderedList, OrderedListItem } from './rich-text/OrderedList';
import { Paragraph } from './rich-text/Paragraph';
Expand All @@ -15,6 +17,8 @@ const defaultComponents: JSXMapSerializer = {
heading1: Heading1,
heading2: Heading2,
heading3: Heading3,
heading4: Heading4,
heading5: Heading5,
paragraph: Paragraph,
oList: OrderedList,
oListItem: OrderedListItem,
Expand Down
Loading

0 comments on commit b0bc91c

Please sign in to comment.