From 844e3cb8756495bd6987994cf18a63eeefc005c7 Mon Sep 17 00:00:00 2001
From: DrMint <thomas@barillot.net>
Date: Wed, 23 Mar 2022 16:19:23 +0100
Subject: [PATCH 1/2] Added contact system

---
 README.md                             |   3 +
 package-lock.json                     |  45 +++++-
 package.json                          |   2 +
 src/graphql/operation.graphql         |   7 +
 src/graphql/operations-types.ts       |   7 +
 src/pages/about-us/contact.tsx        | 225 ++++++++++++++++++++++++++
 src/pages/about-us/sharing-policy.tsx |   2 +-
 src/pages/api/mail.ts                 |  51 ++++++
 src/pages/editor.tsx                  |   2 +-
 src/queries/helpers.ts                |   4 +
 src/tailwind.css                      |  18 ++-
 11 files changed, 354 insertions(+), 12 deletions(-)
 create mode 100644 src/pages/about-us/contact.tsx
 create mode 100644 src/pages/api/mail.ts

diff --git a/README.md b/README.md
index 7912e64..a37b4b6 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,9 @@ Enter the followind information:
 ```txt
 URL_GRAPHQL=https://url-to.strapi-accords-library.com/graphql
 ACCESS_TOKEN=genatedcode-by-strapi-api
+SMTP_HOST=email.provider.com
+SMTP_USER=email@example.com
+SMTP_PASSWORD=mypassword123
 NEXT_PUBLIC_URL_CMS=https://url-to.strapi-accords-library.com/
 NEXT_PUBLIC_URL_IMG=https://url-to.img-accords-library.com/
 NEXT_PUBLIC_URL_SELF=https://url-to-front-accords-library.com
diff --git a/package-lock.json b/package-lock.json
index a6f4ebf..d38fcc9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
         "@tippyjs/react": "^4.2.6",
         "markdown-to-jsx": "^7.1.7",
         "next": "^12.1.0",
+        "nodemailer": "^6.7.3",
         "react": "17.0.2",
         "react-dom": "17.0.2",
         "react-image-lightbox": "^5.1.4",
@@ -22,6 +23,7 @@
       },
       "devDependencies": {
         "@types/node": "17.0.21",
+        "@types/nodemailer": "^6.4.4",
         "@types/react": "17.0.40",
         "@types/react-dom": "^17.0.13",
         "eslint": "8.10.0",
@@ -489,6 +491,15 @@
       "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==",
       "dev": true
     },
+    "node_modules/@types/nodemailer": {
+      "version": "6.4.4",
+      "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.4.tgz",
+      "integrity": "sha512-Ksw4t7iliXeYGvIQcSIgWQ5BLuC/mljIEbjf615svhZL10PE9t+ei8O9gDaD3FPCasUJn9KTLwz2JFJyiiyuqw==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/parse-json": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -2493,9 +2504,9 @@
       }
     },
     "node_modules/minimist": {
-      "version": "1.2.5",
-      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
-      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
+      "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
       "dev": true
     },
     "node_modules/ms": {
@@ -2594,6 +2605,14 @@
       "dev": true,
       "peer": true
     },
+    "node_modules/nodemailer": {
+      "version": "6.7.3",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.3.tgz",
+      "integrity": "sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g==",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/normalize-path": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -4004,6 +4023,15 @@
       "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==",
       "dev": true
     },
+    "@types/nodemailer": {
+      "version": "6.4.4",
+      "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.4.tgz",
+      "integrity": "sha512-Ksw4t7iliXeYGvIQcSIgWQ5BLuC/mljIEbjf615svhZL10PE9t+ei8O9gDaD3FPCasUJn9KTLwz2JFJyiiyuqw==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/parse-json": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -5485,9 +5513,9 @@
       }
     },
     "minimist": {
-      "version": "1.2.5",
-      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
-      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
+      "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
       "dev": true
     },
     "ms": {
@@ -5549,6 +5577,11 @@
       "dev": true,
       "peer": true
     },
+    "nodemailer": {
+      "version": "6.7.3",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.3.tgz",
+      "integrity": "sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g=="
+    },
     "normalize-path": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
diff --git a/package.json b/package.json
index 69a7da8..9b9738f 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
     "@tippyjs/react": "^4.2.6",
     "markdown-to-jsx": "^7.1.7",
     "next": "^12.1.0",
+    "nodemailer": "^6.7.3",
     "react": "17.0.2",
     "react-dom": "17.0.2",
     "react-image-lightbox": "^5.1.4",
@@ -24,6 +25,7 @@
   },
   "devDependencies": {
     "@types/node": "17.0.21",
+    "@types/nodemailer": "^6.4.4",
     "@types/react": "17.0.40",
     "@types/react-dom": "^17.0.13",
     "eslint": "8.10.0",
diff --git a/src/graphql/operation.graphql b/src/graphql/operation.graphql
index e4fa3f8..f4353e7 100644
--- a/src/graphql/operation.graphql
+++ b/src/graphql/operation.graphql
@@ -125,6 +125,13 @@ query getWebsiteInterface($language_code: String) {
         members
         sharing_policy
         contact_us
+        email
+        email_gdpr_notice
+        message
+        send
+        response_invalid_code
+        response_invalid_email
+        response_email_success
       }
     }
   }
diff --git a/src/graphql/operations-types.ts b/src/graphql/operations-types.ts
index 13ff3ef..9a5259a 100644
--- a/src/graphql/operations-types.ts
+++ b/src/graphql/operations-types.ts
@@ -214,6 +214,13 @@ export type GetWebsiteInterfaceQuery = {
         members: string;
         sharing_policy: string;
         contact_us: string;
+        email: string;
+        email_gdpr_notice: string;
+        message: string;
+        send: string;
+        response_invalid_code: string;
+        response_invalid_email: string;
+        response_email_success: string;
       };
     }>;
   };
diff --git a/src/pages/about-us/contact.tsx b/src/pages/about-us/contact.tsx
new file mode 100644
index 0000000..1121dba
--- /dev/null
+++ b/src/pages/about-us/contact.tsx
@@ -0,0 +1,225 @@
+import SubPanel from "components/Panels/SubPanel";
+import { AppStaticProps, getAppStaticProps } from "queries/getAppStaticProps";
+import ReturnButton, {
+  ReturnButtonType,
+} from "components/PanelComponents/ReturnButton";
+import AppLayout from "components/AppLayout";
+import ContentPanel from "components/Panels/ContentPanel";
+import { GetStaticProps } from "next";
+import { getPost, getPostLanguages } from "graphql/operations";
+import { GetPostQuery } from "graphql/operations-types";
+import { useRouter } from "next/router";
+import LanguageSwitcher from "components/LanguageSwitcher";
+import Markdawn from "components/Markdown/Markdawn";
+import { RequestMailProps, ResponseMailProps } from "pages/api/mail";
+import { useState } from "react";
+import InsetBox from "components/InsetBox";
+import { randomInt } from "queries/helpers";
+import TOC from "components/Markdown/TOC";
+
+interface ContactProps extends AppStaticProps {
+  post: GetPostQuery["posts"]["data"][number]["attributes"];
+  locales: string[];
+}
+
+export default function AboutUs(props: ContactProps): JSX.Element {
+  const { langui, post, locales } = props;
+  const router = useRouter();
+  const [formResponse, setFormResponse] = useState("");
+  const [formCompleted, setFormCompleted] = useState(false);
+
+  const random1 = randomInt(0, 10);
+  const random2 = randomInt(0, 10);
+
+  const subPanel = (
+    <SubPanel>
+      <ReturnButton
+        href="/about-us"
+        displayOn={ReturnButtonType.Desktop}
+        langui={langui}
+        title={langui.about_us}
+        horizontalLine
+      />
+      {post.translations.length > 0 && post.translations[0].body && (
+        <TOC
+          text={post.translations[0].body}
+          router={router}
+          title={post.translations[0].title}
+        />
+      )}
+    </SubPanel>
+  );
+
+  const contentPanel = (
+    <ContentPanel>
+      <ReturnButton
+        href="/about-us"
+        displayOn={ReturnButtonType.Mobile}
+        langui={langui}
+        title={langui.about_us}
+        className="mb-10"
+      />
+      {locales.includes(router.locale || "en") ? (
+        <Markdawn router={router} text={post.translations[0].body} />
+      ) : (
+        <LanguageSwitcher
+          locales={locales}
+          router={router}
+          languages={props.languages}
+          langui={props.langui}
+        />
+      )}
+
+      <div className="flex flex-col gap-8 text-center">
+        <form
+          className={`gap-8 grid ${
+            formCompleted &&
+            "opacity-60 cursor-not-allowed touch-none pointer-events-none"
+          }`}
+          onSubmit={(e) => {
+            e.preventDefault();
+
+            if (e.target.verif.value == random1 + random2 && !formCompleted) {
+              const content: RequestMailProps = {
+                name: e.target.name.value,
+                email: e.target.email.value,
+                message: e.target.message.value,
+                formName: "Contact Form",
+              };
+              fetch("/api/mail", {
+                method: "POST",
+                body: JSON.stringify(content),
+                headers: {
+                  "Content-type": "application/json; charset=UTF-8",
+                },
+              })
+                .then((response) => response.json())
+                .then((data: ResponseMailProps) => {
+                  switch (data.code) {
+                    case "OKAY":
+                      setFormResponse(langui.response_email_success);
+                      setFormCompleted(true);
+                      break;
+
+                    case "EENVELOPE":
+                      langui.response_invalid_email;
+                      break;
+
+                    default:
+                      setFormResponse(data.message || "");
+                      break;
+                  }
+                });
+            } else {
+              setFormResponse(langui.response_invalid_code);
+            }
+
+            router.replace("#send-response");
+            e.target.verif.value = "";
+          }}
+        >
+          <div className="flex flex-col place-items-center gap-1">
+            <label htmlFor="name">{langui.name}:</label>
+            <input
+              type="text"
+              className="mobile:w-full"
+              name="name"
+              id="name"
+              required
+              disabled={formCompleted}
+            />
+          </div>
+
+          <div className="flex flex-col place-items-center gap-1">
+            <label htmlFor="email">{langui.email}:</label>
+            <input
+              type="email"
+              className="mobile:w-full"
+              name="email"
+              id="email"
+              required
+              disabled={formCompleted}
+            />
+            <p className="text-sm text-dark italic opacity-70">
+              {langui.email_gdpr_notice}
+            </p>
+          </div>
+
+          <div className="flex flex-col place-items-center gap-1 w-full">
+            <label htmlFor="message">{langui.message}:</label>
+            <textarea
+              name="message"
+              id="message"
+              className="w-full"
+              rows={8}
+              required
+              disabled={formCompleted}
+            />
+          </div>
+
+          <div className="grid grid-cols-2 place-items-center">
+            <div className="flex flex-row place-items-center gap-2">
+              <label
+                className="flex-shrink-0"
+                htmlFor="verif"
+              >{`${random1} + ${random2} =`}</label>
+              <input
+                className="w-24"
+                type="number"
+                name="verif"
+                id="verif"
+                required
+                disabled={formCompleted}
+              />
+            </div>
+
+            <input
+              type="submit"
+              value={langui.send}
+              className="w-min !px-6"
+              disabled={formCompleted}
+            />
+          </div>
+        </form>
+
+        <div id="send-response">
+          {formResponse && (
+            <InsetBox>
+              <p>{formResponse}</p>
+            </InsetBox>
+          )}
+        </div>
+      </div>
+    </ContentPanel>
+  );
+
+  return (
+    <AppLayout
+      navTitle={"Contact"}
+      subPanel={subPanel}
+      contentPanel={contentPanel}
+      {...props}
+    />
+  );
+}
+
+export const getStaticProps: GetStaticProps = async (context) => {
+  const slug = "contact";
+  const props: ContactProps = {
+    ...(await getAppStaticProps(context)),
+    post: (
+      await getPost({
+        slug: slug,
+        language_code: context.locale || "en",
+      })
+    ).posts.data[0].attributes,
+    locales: (
+      await getPostLanguages({ slug: slug })
+    ).posts.data[0].attributes.translations.map((translation) => {
+      return translation.language.data.attributes.code;
+    }),
+  };
+  return {
+    props: props,
+  };
+};
diff --git a/src/pages/about-us/sharing-policy.tsx b/src/pages/about-us/sharing-policy.tsx
index f0a9a25..90e045d 100644
--- a/src/pages/about-us/sharing-policy.tsx
+++ b/src/pages/about-us/sharing-policy.tsx
@@ -84,7 +84,7 @@ export const getStaticProps: GetStaticProps = async (context) => {
     ...(await getAppStaticProps(context)),
     post: (
       await getPost({
-        slug: "sharing-policy",
+        slug: slug,
         language_code: context.locale || "en",
       })
     ).posts.data[0].attributes,
diff --git a/src/pages/api/mail.ts b/src/pages/api/mail.ts
new file mode 100644
index 0000000..c7e38c1
--- /dev/null
+++ b/src/pages/api/mail.ts
@@ -0,0 +1,51 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import nodemailer from "nodemailer";
+import { SMTPError } from "nodemailer/lib/smtp-connection";
+
+export type ResponseMailProps = {
+  code?: string;
+  message?: string;
+};
+
+export type RequestMailProps = {
+  name: string;
+  email: string;
+  message: string;
+  formName: string;
+};
+
+export default async function Mail(
+  req: NextApiRequest,
+  res: NextApiResponse<ResponseMailProps>
+) {
+  if (req.method === "POST") {
+    const body = req.body as RequestMailProps;
+
+    let transporter = nodemailer.createTransport({
+      host: process.env.SMTP_HOST,
+      port: 587,
+      secure: false, // true for 465, false for other ports
+      auth: {
+        user: process.env.SMTP_USER,
+        pass: process.env.SMTP_PASSWORD,
+      },
+    });
+
+    // send mail with defined transport object
+    let info = await transporter
+      .sendMail({
+        from: `"${body.name}" <${body.email}>`,
+        to: "contact@accords-library.com",
+        subject: `New ${body.formName} from ${body.name}`,
+        text: body.message,
+      })
+      .catch((reason: SMTPError) => {
+        res.status(reason.responseCode || 500).json({
+          code: reason.code,
+          message: reason.response,
+        });
+      });
+  }
+
+  res.status(200).json({ code: "OKAY" });
+}
diff --git a/src/pages/editor.tsx b/src/pages/editor.tsx
index b770c0e..9859fe5 100644
--- a/src/pages/editor.tsx
+++ b/src/pages/editor.tsx
@@ -72,7 +72,7 @@ export default function Editor(props: EditorProps): JSX.Element {
               target.select();
               event.preventDefault();
             }}
-            className="bg-mid rounded-xl p-8 w-full font-monospace"
+            className="font-monospace"
           />
         </div>
         <div>
diff --git a/src/queries/helpers.ts b/src/queries/helpers.ts
index 7393aa1..9b938c5 100644
--- a/src/queries/helpers.ts
+++ b/src/queries/helpers.ts
@@ -291,3 +291,7 @@ export function slugify(string: string | undefined): string {
     .replace(/ /gi, "-")
     .toLowerCase();
 }
+
+export function randomInt(min: number, max: number) {
+  return Math.floor(Math.random() * (max - min)) + min;
+}
diff --git a/src/tailwind.css b/src/tailwind.css
index 80f03cd..e13be31 100644
--- a/src/tailwind.css
+++ b/src/tailwind.css
@@ -136,16 +136,26 @@
 
   /* INPUT */
 
-  input {
-    @apply rounded-full p-2 text-center bg-light outline outline-mid outline-2 outline-offset-[-2px] hover:outline-[transparent] text-dark hover:bg-mid transition-all;
+  input,
+  textarea {
+    @apply rounded-full p-2 text-center bg-light outline outline-mid outline-2 outline-offset-[-2px] hover:outline-[transparent] text-dark hover:bg-mid transition-all placeholder:text-dark placeholder:opacity-60;
   }
 
   input::placeholder {
     @apply text-dark opacity-60;
   }
 
-  input:focus-visible {
-    @apply outline-none bg-mid shadow-inner-sm;
+  input:focus-visible,
+  textarea:focus-within {
+    @apply outline-none bg-mid shadow-inner-sm shadow-shade;
+  }
+
+  textarea {
+    @apply rounded-2xl text-left p-6;
+  }
+
+  input[type="submit"] {
+    @apply grid place-content-center place-items-center border-[1px] border-dark text-dark rounded-full px-4 pt-[0.4rem] pb-[0.5rem] transition-all cursor-pointer hover:text-light hover:bg-dark hover:drop-shadow-shade-lg active:bg-black active:text-light active:drop-shadow-black-lg active:border-black;
   }
 }
 

From b946974267cb5f7ee652577956c671ce35ecea35 Mon Sep 17 00:00:00 2001
From: DrMint <thomas@barillot.net>
Date: Wed, 23 Mar 2022 16:45:41 +0100
Subject: [PATCH 2/2] Added more states to the form

---
 src/pages/about-us/contact.tsx | 46 +++++++++++++++++++++++-----------
 1 file changed, 32 insertions(+), 14 deletions(-)

diff --git a/src/pages/about-us/contact.tsx b/src/pages/about-us/contact.tsx
index 1121dba..071f133 100644
--- a/src/pages/about-us/contact.tsx
+++ b/src/pages/about-us/contact.tsx
@@ -26,7 +26,9 @@ export default function AboutUs(props: ContactProps): JSX.Element {
   const { langui, post, locales } = props;
   const router = useRouter();
   const [formResponse, setFormResponse] = useState("");
-  const [formCompleted, setFormCompleted] = useState(false);
+  const [formState, setFormState] = useState<"stale" | "ongoing" | "completed">(
+    "stale"
+  );
 
   const random1 = randomInt(0, 10);
   const random2 = randomInt(0, 10);
@@ -73,17 +75,29 @@ export default function AboutUs(props: ContactProps): JSX.Element {
       <div className="flex flex-col gap-8 text-center">
         <form
           className={`gap-8 grid ${
-            formCompleted &&
+            formState !== "stale" &&
             "opacity-60 cursor-not-allowed touch-none pointer-events-none"
           }`}
           onSubmit={(e) => {
             e.preventDefault();
 
-            if (e.target.verif.value == random1 + random2 && !formCompleted) {
+            const fields = e.target as unknown as {
+              verif: HTMLInputElement;
+              name: HTMLInputElement;
+              email: HTMLInputElement;
+              message: HTMLInputElement;
+            };
+
+            setFormState("ongoing");
+
+            if (
+              parseInt(fields.verif.value) == random1 + random2 &&
+              formState !== "completed"
+            ) {
               const content: RequestMailProps = {
-                name: e.target.name.value,
-                email: e.target.email.value,
-                message: e.target.message.value,
+                name: fields.name.value,
+                email: fields.email.value,
+                message: fields.message.value,
                 formName: "Contact Form",
               };
               fetch("/api/mail", {
@@ -98,24 +112,28 @@ export default function AboutUs(props: ContactProps): JSX.Element {
                   switch (data.code) {
                     case "OKAY":
                       setFormResponse(langui.response_email_success);
-                      setFormCompleted(true);
+                      setFormState("completed");
+
                       break;
 
                     case "EENVELOPE":
-                      langui.response_invalid_email;
+                      setFormResponse(langui.response_invalid_email);
+                      setFormState("stale");
                       break;
 
                     default:
                       setFormResponse(data.message || "");
+                      setFormState("stale");
                       break;
                   }
                 });
             } else {
               setFormResponse(langui.response_invalid_code);
+              setFormState("stale");
             }
 
             router.replace("#send-response");
-            e.target.verif.value = "";
+            fields.verif.value = "";
           }}
         >
           <div className="flex flex-col place-items-center gap-1">
@@ -126,7 +144,7 @@ export default function AboutUs(props: ContactProps): JSX.Element {
               name="name"
               id="name"
               required
-              disabled={formCompleted}
+              disabled={formState !== "stale"}
             />
           </div>
 
@@ -138,7 +156,7 @@ export default function AboutUs(props: ContactProps): JSX.Element {
               name="email"
               id="email"
               required
-              disabled={formCompleted}
+              disabled={formState !== "stale"}
             />
             <p className="text-sm text-dark italic opacity-70">
               {langui.email_gdpr_notice}
@@ -153,7 +171,7 @@ export default function AboutUs(props: ContactProps): JSX.Element {
               className="w-full"
               rows={8}
               required
-              disabled={formCompleted}
+              disabled={formState !== "stale"}
             />
           </div>
 
@@ -169,7 +187,7 @@ export default function AboutUs(props: ContactProps): JSX.Element {
                 name="verif"
                 id="verif"
                 required
-                disabled={formCompleted}
+                disabled={formState !== "stale"}
               />
             </div>
 
@@ -177,7 +195,7 @@ export default function AboutUs(props: ContactProps): JSX.Element {
               type="submit"
               value={langui.send}
               className="w-min !px-6"
-              disabled={formCompleted}
+              disabled={formState !== "stale"}
             />
           </div>
         </form>