diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-05-06 18:21:16 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2022-05-07 15:54:35 +0200 |
| commit | 339c6957fe92c4ec1809159f09c55201d3794c18 (patch) | |
| tree | 74f1dd407c7871c46db6583f2b44cebbe20f7fdd | |
| parent | a13022cd4c0a7cf0f00a6db49fad13db22d63dd6 (diff) | |
chore: add a Contact page
| -rw-r--r-- | src/components/atoms/layout/notice.stories.tsx | 13 | ||||
| -rw-r--r-- | src/components/atoms/layout/notice.tsx | 14 | ||||
| -rw-r--r-- | src/components/organisms/forms/contact-form.stories.tsx | 2 | ||||
| -rw-r--r-- | src/components/organisms/forms/contact-form.test.tsx | 4 | ||||
| -rw-r--r-- | src/components/organisms/forms/contact-form.tsx | 18 | ||||
| -rw-r--r-- | src/pages/contact.tsx | 181 | ||||
| -rw-r--r-- | src/services/graphql/api.ts | 26 | ||||
| -rw-r--r-- | src/services/graphql/contact.mutation.ts | 25 | ||||
| -rw-r--r-- | src/services/graphql/contact.ts | 26 | ||||
| -rw-r--r-- | src/styles/pages/contact.module.scss | 3 | ||||
| -rw-r--r-- | src/utils/config.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-settings.tsx | 8 |
12 files changed, 305 insertions, 16 deletions
diff --git a/src/components/atoms/layout/notice.stories.tsx b/src/components/atoms/layout/notice.stories.tsx index 62f4cba..dedf834 100644 --- a/src/components/atoms/layout/notice.stories.tsx +++ b/src/components/atoms/layout/notice.stories.tsx @@ -8,6 +8,19 @@ export default { title: 'Atoms/Layout/Notice', component: NoticeComponent, argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the notice wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, kind: { control: { type: 'select', diff --git a/src/components/atoms/layout/notice.tsx b/src/components/atoms/layout/notice.tsx index 115bd9c..a0d1d3e 100644 --- a/src/components/atoms/layout/notice.tsx +++ b/src/components/atoms/layout/notice.tsx @@ -5,6 +5,10 @@ export type NoticeKind = 'error' | 'info' | 'success' | 'warning'; export type NoticeProps = { /** + * Set additional classnames to the notice wrapper. + */ + className?: string; + /** * The notice kind. */ kind: NoticeKind; @@ -19,11 +23,15 @@ export type NoticeProps = { * * Render a colored message depending on notice kind. */ -const Notice: FC<NoticeProps> = ({ kind, message }) => { +const Notice: FC<NoticeProps> = ({ className = '', kind, message }) => { const kindClass = `wrapper--${kind}`; - return ( - <div className={`${styles.wrapper} ${styles[kindClass]}`}>{message}</div> + return message ? ( + <div className={`${styles.wrapper} ${styles[kindClass]} ${className}`}> + {message} + </div> + ) : ( + <></> ); }; diff --git a/src/components/organisms/forms/contact-form.stories.tsx b/src/components/organisms/forms/contact-form.stories.tsx index 9b936f9..39d0b71 100644 --- a/src/components/organisms/forms/contact-form.stories.tsx +++ b/src/components/organisms/forms/contact-form.stories.tsx @@ -64,7 +64,7 @@ const Template: ComponentStory<typeof ContactForm> = (args) => ( */ export const Contact = Template.bind({}); Contact.args = { - sendMail: (reset: () => void) => { + sendMail: async (_data, reset: () => void) => { reset(); }, }; diff --git a/src/components/organisms/forms/contact-form.test.tsx b/src/components/organisms/forms/contact-form.test.tsx index 744f147..6225fa9 100644 --- a/src/components/organisms/forms/contact-form.test.tsx +++ b/src/components/organisms/forms/contact-form.test.tsx @@ -2,7 +2,9 @@ import { render, screen } from '@test-utils'; import ContactForm from './contact-form'; const props = { - sendMail: () => null, + sendMail: async () => { + /** Do nothing. */ + }, }; describe('ContactForm', () => { diff --git a/src/components/organisms/forms/contact-form.tsx b/src/components/organisms/forms/contact-form.tsx index 4a6902b..912402c 100644 --- a/src/components/organisms/forms/contact-form.tsx +++ b/src/components/organisms/forms/contact-form.tsx @@ -6,6 +6,13 @@ import { FC, ReactNode, useState } from 'react'; import { useIntl } from 'react-intl'; import styles from './contact-form.module.scss'; +export type ContactFormData = { + email: string; + message: string; + name: string; + subject: string; +}; + export type ContactFormProps = { /** * Set additional classnames to the form wrapper. @@ -16,10 +23,9 @@ export type ContactFormProps = { */ Notice?: ReactNode; /** - * A callback function to send mail. It takes a function as parameter to - * reset the form. + * A callback function to send mail. */ - sendMail: (reset: () => void) => void; + sendMail: (data: ContactFormData, reset: () => void) => Promise<void>; }; /** @@ -80,9 +86,11 @@ const ContactForm: FC<ContactFormProps> = ({ id: 'yN5P+m', }); - const submitHandler = () => { + const submitHandler = async () => { setIsSubmitting(true); - sendMail(resetForm); + sendMail({ email, message, name, subject: object }, resetForm).then(() => + setIsSubmitting(false) + ); }; return ( diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx new file mode 100644 index 0000000..d88a8a1 --- /dev/null +++ b/src/pages/contact.tsx @@ -0,0 +1,181 @@ +import Notice, { NoticeKind } from '@components/atoms/layout/notice'; +import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb'; +import ContactForm, { + ContactFormProps, +} from '@components/organisms/forms/contact-form'; +import SocialMedia from '@components/organisms/widgets/social-media'; +import PageLayout from '@components/templates/page/page-layout'; +import { meta } from '@content/pages/contact.mdx'; +import styles from '@styles/pages/contact.module.scss'; +import { sendMail } from '@services/graphql/contact'; +import { loadTranslation } from '@utils/helpers/i18n'; +import useSettings from '@utils/hooks/use-settings'; +import { GetStaticProps, NextPage } from 'next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import Script from 'next/script'; +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { ContactPage as ContactPageSchema, Graph, WebPage } from 'schema-dts'; + +const ContactPage: NextPage = () => { + const { dates, intro, seo, title } = meta; + const intl = useIntl(); + const homeLabel = intl.formatMessage({ + defaultMessage: 'Home', + description: 'Breadcrumb: home label', + id: 'j5k9Fe', + }); + const breadcrumb: BreadcrumbItem[] = [ + { id: 'home', name: homeLabel, url: '/' }, + { id: 'contact', name: title, url: '/contact' }, + ]; + + const socialMediaTitle = intl.formatMessage({ + defaultMessage: 'Find me elsewhere', + description: 'ContactPage: social media widget title', + id: 'Qh2CwH', + }); + + const { website } = useSettings(); + const { asPath } = useRouter(); + const pageUrl = `${website.url}${asPath}`; + const pagePublicationDate = new Date(dates.publication); + const pageUpdateDate = new Date(dates.update); + + const webpageSchema: WebPage = { + '@id': `${pageUrl}`, + '@type': 'WebPage', + breadcrumb: { '@id': `${website.url}/#breadcrumb` }, + name: seo.title, + description: seo.description, + reviewedBy: { '@id': `${website.url}/#branding` }, + url: `${pageUrl}`, + isPartOf: { + '@id': `${website.url}`, + }, + }; + + const contactSchema: ContactPageSchema = { + '@id': `${website.url}/#contact`, + '@type': 'ContactPage', + name: title, + description: intro, + author: { '@id': `${website.url}/#branding` }, + creator: { '@id': `${website.url}/#branding` }, + dateCreated: pagePublicationDate.toISOString(), + dateModified: pageUpdateDate.toISOString(), + datePublished: pagePublicationDate.toISOString(), + editor: { '@id': `${website.url}/#branding` }, + inLanguage: website.locales.default, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + mainEntityOfPage: { '@id': `${pageUrl}` }, + }; + + const schemaJsonLd: Graph = { + '@context': 'https://schema.org', + '@graph': [webpageSchema, contactSchema], + }; + + const widgets = [ + <SocialMedia + key="social-media" + title={socialMediaTitle} + level={2} + media={[ + { name: 'Github', url: 'https://github.com/ArmandPhilippot' }, + { name: 'Gitlab', url: 'https://gitlab.com/ArmandPhilippot' }, + { + name: 'LinkedIn', + url: 'https://www.linkedin.com/in/armandphilippot', + }, + ]} + />, + ]; + + const [status, setStatus] = useState<NoticeKind>('info'); + const [statusMessage, setStatusMessage] = useState<string>(''); + + const submitMail: ContactFormProps['sendMail'] = async (data, reset) => { + const { email, message, name, subject } = data; + const messageHTML = message.replace(/\r?\n/g, '<br />'); + const body = `Message received from ${name} <${email}> on ${website.url}.<br /><br />${messageHTML}`; + const replyTo = `${name} <${email}>`; + const mailData = { + body, + clientMutationId: 'contact', + replyTo, + subject, + }; + const { message: mutationMessage, sent } = await sendMail(mailData); + + if (sent) { + setStatus('success'); + setStatusMessage( + intl.formatMessage({ + defaultMessage: + 'Thanks. Your message was successfully sent. I will answer it as soon as possible.', + description: 'Contact: success message', + id: '3Pipok', + }) + ); + reset(); + } else { + const errorPrefix = intl.formatMessage({ + defaultMessage: 'An error occurred:', + description: 'Contact: error message', + id: 'TpyFZ6', + }); + const error = `${errorPrefix} ${mutationMessage}`; + setStatus('error'); + setStatusMessage(error); + } + }; + + return ( + <> + <Head> + <title>{`${seo.title} - ${website.name}`}</title> + <meta name="description" content={seo.description} /> + <meta property="og:url" content={`${pageUrl}`} /> + <meta property="og:type" content="article" /> + <meta property="og:title" content={title} /> + <meta property="og:description" content={intro} /> + </Head> + <Script + id="schema-contact" + type="application/ld+json" + dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} + /> + <PageLayout + title="Contact" + intro={intro} + breadcrumb={breadcrumb} + widgets={widgets} + > + <ContactForm + sendMail={submitMail} + Notice={ + <Notice + kind={status} + message={statusMessage} + className={styles.notice} + /> + } + /> + </PageLayout> + </> + ); +}; + +export const getStaticProps: GetStaticProps = async ({ locale }) => { + const translation = await loadTranslation(locale); + + return { + props: { + translation, + }, + }; +}; + +export default ContactPage; diff --git a/src/services/graphql/api.ts b/src/services/graphql/api.ts index b0e8d3a..9811d86 100644 --- a/src/services/graphql/api.ts +++ b/src/services/graphql/api.ts @@ -7,6 +7,7 @@ import { totalArticlesQuery, } from './articles.query'; import { commentsQuery } from './comments.query'; +import { sendMailMutation } from './contact.mutation'; import { thematicBySlugQuery, thematicsListQuery, @@ -18,6 +19,8 @@ import { topicsSlugQuery, } from './topics.query'; +export type Mutations = typeof sendMailMutation; + export type Queries = | typeof articlesQuery | typeof articleBySlugQuery @@ -44,6 +47,10 @@ export type CommentsResponse<T> = { comments: T[]; }; +export type SendMailResponse<T> = { + sendEmail: T; +}; + export type ThematicResponse<T> = { thematic: T; }; @@ -85,15 +92,16 @@ export type NodesResponse<T> = { }; export type ResponseMap<T> = { - [articleBySlugQuery]: ArticleResponse<NodesResponse<T>>; + [articleBySlugQuery]: ArticleResponse<T>; [articlesCardQuery]: ArticlesResponse<NodesResponse<T>>; [articlesQuery]: ArticlesResponse<EdgesResponse<T>>; [articlesSlugQuery]: ArticlesResponse<EdgesResponse<T>>; [commentsQuery]: CommentsResponse<NodesResponse<T>>; - [thematicBySlugQuery]: ThematicResponse<NodesResponse<T>>; + [sendMailMutation]: SendMailResponse<T>; + [thematicBySlugQuery]: ThematicResponse<T>; [thematicsListQuery]: ThematicsResponse<EdgesResponse<T>>; [thematicsSlugQuery]: ThematicsResponse<EdgesResponse<T>>; - [topicBySlugQuery]: TopicResponse<NodesResponse<T>>; + [topicBySlugQuery]: TopicResponse<T>; [topicsListQuery]: TopicsResponse<EdgesResponse<T>>; [topicsSlugQuery]: TopicsResponse<EdgesResponse<T>>; [totalArticlesQuery]: ArticlesResponse<T>; @@ -133,12 +141,20 @@ export type ByContentIdVar = { contentId: number; }; +export type sendMailVars = { + body: string; + clientMutationId: string; + replyTo: string; + subject: string; +}; + export type VariablesMap = { [articleBySlugQuery]: BySlugVar; [articlesCardQuery]: EdgesVars; [articlesQuery]: EdgesVars; [articlesSlugQuery]: EdgesVars; [commentsQuery]: ByContentIdVar; + [sendMailMutation]: sendMailVars; [thematicBySlugQuery]: BySlugVar; [thematicsListQuery]: EdgesVars; [thematicsSlugQuery]: EdgesVars; @@ -148,7 +164,7 @@ export type VariablesMap = { [totalArticlesQuery]: null; }; -export type FetchAPIProps<T extends Queries> = { +export type FetchAPIProps<T extends Queries | Mutations> = { /** * A GraphQL API URL. */ @@ -170,7 +186,7 @@ export type FetchAPIProps<T extends Queries> = { * @param {Queries} obj.query - A GraphQL query. * @param {object} [obj.variables] - The query variables. */ -export async function fetchAPI<T, U extends Queries>({ +export async function fetchAPI<T, U extends Queries | Mutations>({ api, query, variables, diff --git a/src/services/graphql/contact.mutation.ts b/src/services/graphql/contact.mutation.ts new file mode 100644 index 0000000..b82fc07 --- /dev/null +++ b/src/services/graphql/contact.mutation.ts @@ -0,0 +1,25 @@ +/** + * Send mail mutation. + */ +export const sendMailMutation = `mutation SendEmail( + $subject: String! + $body: String! + $replyTo: String! + $clientMutationId: String! +) { + sendEmail( + input: { + clientMutationId: $clientMutationId + body: $body + replyTo: $replyTo + subject: $subject + } + ) { + clientMutationId + message + sent + origin + replyTo + to + } +}`; diff --git a/src/services/graphql/contact.ts b/src/services/graphql/contact.ts new file mode 100644 index 0000000..fca718f --- /dev/null +++ b/src/services/graphql/contact.ts @@ -0,0 +1,26 @@ +import { fetchAPI, getAPIUrl, sendMailVars } from './api'; +import { sendMailMutation } from './contact.mutation'; + +export type SentEmail = { + clientMutationId: string; + message: string; + origin: string; + replyTo: string; + sent: boolean; +}; + +/** + * Send an email using GraphQL API. + * + * @param {sendMailVars} data - The mail data. + * @returns {Promise<SentEmail>} The mutation response. + */ +export const sendMail = async (data: sendMailVars): Promise<SentEmail> => { + const response = await fetchAPI<SentEmail, typeof sendMailMutation>({ + api: getAPIUrl(), + query: sendMailMutation, + variables: { ...data }, + }); + + return response.sendEmail; +}; diff --git a/src/styles/pages/contact.module.scss b/src/styles/pages/contact.module.scss new file mode 100644 index 0000000..65fb0d6 --- /dev/null +++ b/src/styles/pages/contact.module.scss @@ -0,0 +1,3 @@ +.notice { + margin-top: var(--spacing-md); +} diff --git a/src/utils/config.ts b/src/utils/config.ts index 6ec8c82..61a46b4 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -20,6 +20,7 @@ export const settings = { startYear: '2012', endYear: new Date().getFullYear().toString(), }, + email: process.env.APP_AUTHOR_EMAIL || '', locales: { defaultLocale: 'fr', defaultCountry: 'FR', diff --git a/src/utils/hooks/use-settings.tsx b/src/utils/hooks/use-settings.tsx index a45e934..cc5261b 100644 --- a/src/utils/hooks/use-settings.tsx +++ b/src/utils/hooks/use-settings.tsx @@ -60,6 +60,10 @@ export type WebsiteSettings = { */ copyright: CopyrightSettings; /** + * The website admin email. + */ + email: string; + /** * The website locales. */ locales: LocaleSettings; @@ -84,7 +88,8 @@ export type UseSettingsReturn = { * @returns {UseSettingsReturn} - An object describing settings. */ const useSettings = (): UseSettingsReturn => { - const { baseline, copyright, locales, name, postsPerPage, url } = settings; + const { baseline, copyright, email, locales, name, postsPerPage, url } = + settings; const router = useRouter(); const locale = router.locale || locales.defaultLocale; @@ -98,6 +103,7 @@ const useSettings = (): UseSettingsReturn => { end: copyright.endYear, start: copyright.startYear, }, + email, locales: { default: locales.defaultLocale, supported: locales.supported, |
