diff --git a/TODO b/TODO index 7af26f3..6724f3e 100644 --- a/TODO +++ b/TODO @@ -1,18 +1,42 @@ +TESTS // TODO tests for tournament page: - map and table mounts in tournament page <- get data to send to map/table ----------------------------------------------------------------- -//TODO SRP for web and API data fetching -//TODO about page -//TODO contact page needs working mailer -//TODO font size on mobile screen -//TODO SEO - next headers etc //TODO data fetching tests //TODO redo layer groups tests +//TODO write tests for contact form +//TODO dead link tests take too long, split them into separate tests per page + +----------------------------------------------------------------- +BUGS +//TODO about page is not centred in Safari +//TODO tournament page load is weird on 2nd click. On safari it pauses for a few seconds. Is it trying to load the entire page before displaying? +//TODO Nodemailer 'from' reverts to echecsfrance@gmail.com + ---------------------------------------------------------------- +PAGES +//TODO about page + +---------------------------------------------------------------- +DESIGN CHANGES +//TODO test entire site for english to french translations +//TODO success and error messages into french - mailer +//TODO disable send message button while sending - isSending = true +//TODO font size on mobile screen +//TODO bottom of map is a few pixels short +//TODO mobile navbar is creeping into the page by a few pixels when hidden - move it to the right a bit. It is easier to see in light mode //TODO logo for navbar and favicon //TODO mobile map needs improving -//TODO multi-language i18n support - https://nextjs.org/docs/app/building-your-application/routing/internationalization + +---------------------------------------------------------------- +LOGIC +//TODO SRP for web and API data fetching //TODO error handling //TODO consider offering GraphQL support + +MISC +---------------------------------------------------------------- +//TODO write logger (fullstack open examples) +//TODO SEO - next headers etc +//TODO multi-language i18n support - https://nextjs.org/docs/app/building-your-application/routing/internationalization //TODO move smaller ui components into a new folder, and make them reusable - such as using generic prop names //TODO readme diff --git a/app/api/send-mail/route.ts b/app/api/send-mail/route.ts new file mode 100644 index 0000000..882df1c --- /dev/null +++ b/app/api/send-mail/route.ts @@ -0,0 +1,34 @@ +import NodeMailer from "nodemailer"; +import { NextResponse } from "next/server"; + +export async function POST(req: Request) { + const { email, subject, message } = await req.json(); + + try { + const mailContent = { + from: email, + to: process.env.GMAIL_USER, + subject: `${subject} from ${email}`, + text: message, + html: `

${message}

`, + }; + + const transporter = NodeMailer.createTransport({ + service: "gmail", + auth: { + user: process.env.GMAIL_USER, + pass: process.env.GMAIL_PASS, + }, + }); + + const info = await transporter.sendMail(mailContent); + console.log(info); + + return NextResponse.json( + { success: `Message delivered to ${info.accepted}` }, + { status: 250 } + ); + } catch (error) { + return NextResponse.json({ error: `Connection refused` }, { status: 404 }); + } +} diff --git a/app/api/v1/tournaments/france/route.ts b/app/api/v1/tournaments/france/route.ts index 9b11c04..afcd444 100644 --- a/app/api/v1/tournaments/france/route.ts +++ b/app/api/v1/tournaments/france/route.ts @@ -16,7 +16,7 @@ export async function GET() { const db = client.db("tournamentsFranceDB"); const results = await dateOrderingFrance(db); - const data = results.map(({ _id, __v, ...rest }) => ({ + const data = results.map(({ _id, ...rest }) => ({ id: _id, ...rest, })); diff --git a/app/contactez-nous/page.tsx b/app/contactez-nous/page.tsx index 3006137..b2e8bde 100644 --- a/app/contactez-nous/page.tsx +++ b/app/contactez-nous/page.tsx @@ -1,4 +1,5 @@ import Layout from "@/components/Layout"; +import ContactForm from "@/components/ContactForm"; // TODO fix page sizing export default function Contact() { @@ -17,58 +18,7 @@ export default function Contact() { Vous avez un problème technique? Vous aimeriez participer à ce projet? Contactez-nous.

-
-
- - -
-
- - -
-
- - -
- -
+ diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx new file mode 100644 index 0000000..d80148a --- /dev/null +++ b/components/ContactForm.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { handleEmailSubmit } from "@/handlers/formSubmitHandlers"; +import useContactForm from "@/hooks/useContactForm"; + +const ContactForm = () => { + const { values, handleChange, resetForm } = useContactForm(); + const [responseMessage, setResponseMessage] = useState({ + isSuccessful: false, + message: "", + }); + const [isSending, setIsSending] = useState(false); + + const infoMessage = ( +

+ {responseMessage.message} +

+ ); + + return ( + <> +
+ handleEmailSubmit( + e, + values, + setResponseMessage, + resetForm, + setIsSending + ) + } + className="space-y-8" + > +
+ + +
+
+ + +
+
+ + +
+ + {infoMessage} +
+ + ); +}; + +export default ContactForm; diff --git a/components/ScrollToTopButton.tsx b/components/ScrollToTopButton.tsx index 528492a..ba63e6a 100644 --- a/components/ScrollToTopButton.tsx +++ b/components/ScrollToTopButton.tsx @@ -1,17 +1,32 @@ "use client"; +import { ScrollableElement } from "@/types"; + import { FaArrowUp } from "react-icons/fa"; import { handleScrollToTop } from "@/handlers/scrollHandlers"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; + +const ScrollToTopButton = () => { + const scrollToTopElementRef = useRef(null); + const [isLgScreen, setIsLgScreen] = useState(false); + + // calculate screen size + useEffect(() => { + const handleResize = () => { + setIsLgScreen(window.innerWidth >= 1024); + }; + handleResize(); + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }); -const ScrollToTopButton = ({ isLgScreen }: { isLgScreen: boolean }) => { - const scrollToTopElementRef = useRef(null); // determine scrollable element based on screen size - window or div useEffect(() => { - if (isLgScreen) { - scrollToTopElementRef.current = - document.getElementById("tournament-table"); - } + isLgScreen + ? (scrollToTopElementRef.current = + document.getElementById("tournament-table")) + : (scrollToTopElementRef.current = window); }, [isLgScreen]); const scrollToTopButtonClass = isLgScreen @@ -24,9 +39,7 @@ const ScrollToTopButton = ({ isLgScreen }: { isLgScreen: boolean }) => { data-cy="scroll-to-top-button" > - handleScrollToTop(scrollToTopElementRef.current || window) - } + onClick={() => handleScrollToTop(scrollToTopElementRef.current)} /> ); diff --git a/components/TournamentMap.tsx b/components/TournamentMap.tsx index e8a420a..0323030 100644 --- a/components/TournamentMap.tsx +++ b/components/TournamentMap.tsx @@ -6,7 +6,7 @@ import { LatLngLiteral } from "leaflet"; import "leaflet/dist/leaflet.css"; import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css"; import "leaflet-defaulticon-compatibility"; -import { MapContainer, TileLayer, LayersControl, useMap } from "react-leaflet"; +import { MapContainer, TileLayer, LayersControl } from "react-leaflet"; import { createLayerGroups } from "@/utils/layerGroups"; import Legend from "@/components/Legend"; diff --git a/components/TournamentTable.tsx b/components/TournamentTable.tsx index 4740b75..ace2453 100644 --- a/components/TournamentTable.tsx +++ b/components/TournamentTable.tsx @@ -12,7 +12,6 @@ export default function TournamentTable({ const [searchQuery, setSearchQuery] = useState(""); // text from search bar const [filteredTournamentData, setFilteredTournamentData] = useState(tournamentData); - const [isLgScreen, setIsLgScreen] = useState(false); useEffect(() => { setFilteredTournamentData( @@ -20,18 +19,6 @@ export default function TournamentTable({ ); }, [searchQuery]); - useEffect(() => { - const handleResize = () => { - setIsLgScreen(window.innerWidth >= 1024); - }; - handleResize(); - - window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); - // TODO move this section into its own function if (filteredTournamentData.length === 0) { tableData = ( @@ -83,7 +70,7 @@ export default function TournamentTable({ setTournamentFilter={setSearchQuery} />
- +
{ it("desktop scroll button clickable", () => { cy.viewport("macbook-15"); - cy.mount(); + cy.mount(); cy.get("[data-cy='scroll-to-top-button']").should("exist").click(); }); it("mobile scroll button clickable", () => { cy.viewport(600, 600); - cy.mount(); + cy.mount(); cy.get("[data-cy='scroll-to-top-button']").should("exist").click(); }); }); diff --git a/cypress/screenshots/links.cy.ts/Test all links -- Check navbar links point to correct pathname as a slug (failed).png b/cypress/screenshots/links.cy.ts/Test all links -- Check navbar links point to correct pathname as a slug (failed).png new file mode 100644 index 0000000..b7a9591 Binary files /dev/null and b/cypress/screenshots/links.cy.ts/Test all links -- Check navbar links point to correct pathname as a slug (failed).png differ diff --git a/cypress/videos/data.cy.tsx.mp4 b/cypress/videos/data.cy.tsx.mp4 index da8a0bd..7d5e483 100644 Binary files a/cypress/videos/data.cy.tsx.mp4 and b/cypress/videos/data.cy.tsx.mp4 differ diff --git a/cypress/videos/links.cy.ts.mp4 b/cypress/videos/links.cy.ts.mp4 index 6702f32..85a1e19 100644 Binary files a/cypress/videos/links.cy.ts.mp4 and b/cypress/videos/links.cy.ts.mp4 differ diff --git a/cypress/videos/navbar.cy.tsx.mp4 b/cypress/videos/navbar.cy.tsx.mp4 index 9c8c0e7..63f71ca 100644 Binary files a/cypress/videos/navbar.cy.tsx.mp4 and b/cypress/videos/navbar.cy.tsx.mp4 differ diff --git a/cypress/videos/scroll.cy.tsx.mp4 b/cypress/videos/scroll.cy.tsx.mp4 index 84b62b8..e82e785 100644 Binary files a/cypress/videos/scroll.cy.tsx.mp4 and b/cypress/videos/scroll.cy.tsx.mp4 differ diff --git a/handlers/formSubmitHandlers.ts b/handlers/formSubmitHandlers.ts new file mode 100644 index 0000000..8a4692e --- /dev/null +++ b/handlers/formSubmitHandlers.ts @@ -0,0 +1,45 @@ +import { Dispatch, FormEvent, SetStateAction } from "react"; +import sendMail from "@/lib/sendMail"; + +export const handleEmailSubmit = async ( + e: FormEvent, + values: Record, + setResponseMessage: Dispatch< + SetStateAction<{ isSuccessful: boolean; message: string }> + >, + resetForm: () => void, + setIsSending: Dispatch> +) => { + e.preventDefault(); + setIsSending(true); + + const clearMessage = () => { + setTimeout(() => { + setResponseMessage({ + isSuccessful: false, + message: "", + }); + }, 10000); + }; + + try { + const response = await sendMail(values); + if (response.status === 250) { + setResponseMessage({ + isSuccessful: true, + message: "Thank you for your message.", + }); + resetForm(); + clearMessage(); + setIsSending(false); + } + } catch (error) { + console.log(error); //TODO add to logger + setResponseMessage({ + isSuccessful: false, + message: "Oops something went wrong. Please try again.", + }); + clearMessage(); + setIsSending(false); + } +}; diff --git a/hooks/useContactForm.ts b/hooks/useContactForm.ts new file mode 100644 index 0000000..f917a5c --- /dev/null +++ b/hooks/useContactForm.ts @@ -0,0 +1,28 @@ +import { ChangeEvent, useState } from "react"; + +const useContactForm = () => { + const [values, setValues] = useState({ + email: "", + subject: "", + message: "", + }); + + const handleChange = ( + e: ChangeEvent + ) => { + setValues((prevState) => { + return { + ...prevState, + [e.target.id]: e.target.value, + }; + }); + }; + + const resetForm = () => { + setValues({ email: "", subject: "", message: "" }); + }; + + return { values, handleChange, resetForm }; +}; + +export default useContactForm; diff --git a/hooks/useHamburgerClose.ts b/hooks/useHamburgerClose.ts index caed63f..a26e6ae 100644 --- a/hooks/useHamburgerClose.ts +++ b/hooks/useHamburgerClose.ts @@ -1,3 +1,5 @@ +// TODO is this really a hook? I think it is more of a util function + interface HamburgerClose { menuVisible: boolean; setMenuVisible: Dispatch>; diff --git a/lib/sendMail.ts b/lib/sendMail.ts new file mode 100644 index 0000000..5735fe8 --- /dev/null +++ b/lib/sendMail.ts @@ -0,0 +1,22 @@ +const sendMail = async ({ + email, + subject, + message, +}: Record) => { + const data = { + email: email, + subject: subject, + message: message, + }; + + const response = await fetch("/api/send-mail", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + return response; +}; + +export default sendMail; diff --git a/package-lock.json b/package-lock.json index 73b87a4..daf5dfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "leaflet-defaulticon-compatibility": "^0.1.1", "mongodb": "^5.5.0", "next": "^13.4.5", + "nodemailer": "^6.9.3", "postcss": "8.4.23", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -32,10 +33,15 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@types/leaflet": "^1.9.3", + "@types/nodemailer": "^6.4.8", "cypress": "^12.13.0", "eslint-plugin-cypress": "^2.13.3", "jest": "^29.5.0", "jest-environment-jsdom": "^29.5.0" + }, + "engines": { + "node": ">=18.16.0", + "npm": "please-use-npm" } }, "node_modules/@adobe/css-tools": { @@ -1810,6 +1816,15 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.2.3.tgz", "integrity": "sha512-pg9d0yC4rVNWQzX8U7xb4olIOFuuVL9za3bzMT2pu2SU0SNEi66i2qrvhE2qt0HvkhuCaWJu7pLNOt/Pj8BIrw==" }, + "node_modules/@types/nodemailer": { + "version": "6.4.8", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.8.tgz", + "integrity": "sha512-oVsJSCkqViCn8/pEu2hfjwVO+Gb3e+eTWjg3PcjeFKRItfKpKwHphQqbYmPQrlMk+op7pNNWPbsJIEthpFN/OQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prettier": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.2.tgz", @@ -7413,6 +7428,14 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.11.tgz", "integrity": "sha512-+M0PwXeU80kRohZ3aT4J/OnR+l9/KD2nVLNNoRgFtnf+umQVFdGBAO2N8+nCnEi0xlh/Wk3zOGC+vNNx+uM79Q==" }, + "node_modules/nodemailer": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.3.tgz", + "integrity": "sha512-fy9v3NgTzBngrMFkDsKEj0r02U7jm6XfC3b52eoNV+GCrGj+s8pt5OqhiJdWKuw51zCTdiNR/IUD1z33LIIGpg==", + "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", diff --git a/package.json b/package.json index 3e29ac2..96dbc13 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "leaflet-defaulticon-compatibility": "^0.1.1", "mongodb": "^5.5.0", "next": "^13.4.5", + "nodemailer": "^6.9.3", "postcss": "8.4.23", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -41,6 +42,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@types/leaflet": "^1.9.3", + "@types/nodemailer": "^6.4.8", "cypress": "^12.13.0", "eslint-plugin-cypress": "^2.13.3", "jest": "^29.5.0",