mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 04:26:57 +00:00
Merge pull request #9 from TheRealOwenRees/scroll-fix
Mailer + scroll fix
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: `<p>${message}</p>`,
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
<form action="#" className="space-y-8">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300"
|
||||
>
|
||||
Adresse e-mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
className="shadow-sm bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500 dark:shadow-sm-light"
|
||||
placeholder="nom@exemple.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="subject"
|
||||
className="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300"
|
||||
>
|
||||
Sujet
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
className="block p-3 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 shadow-sm focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500 dark:shadow-sm-light"
|
||||
placeholder="Le motif de ma demande"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label
|
||||
htmlFor="message"
|
||||
className="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300"
|
||||
>
|
||||
Votre message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
rows={6}
|
||||
className="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg shadow-sm border border-gray-300 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="Détaillez ici votre demande..."
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="py-3 px-5 text-sm font-medium text-center text-white rounded-lg bg-teal-600 sm:w-fit hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 dark:hover:bg-primary-700 dark:focus:ring-primary-800 dark:text-white"
|
||||
>
|
||||
Send message
|
||||
</button>
|
||||
</form>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
@@ -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 = (
|
||||
<p
|
||||
className={`${
|
||||
responseMessage.isSuccessful ? "text-green-600" : "text-red-600"
|
||||
} italic`}
|
||||
>
|
||||
{responseMessage.message}
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
onSubmit={(e) =>
|
||||
handleEmailSubmit(
|
||||
e,
|
||||
values,
|
||||
setResponseMessage,
|
||||
resetForm,
|
||||
setIsSending
|
||||
)
|
||||
}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300"
|
||||
>
|
||||
Adresse e-mail
|
||||
</label>
|
||||
<input
|
||||
value={values.email}
|
||||
onChange={handleChange}
|
||||
type="email"
|
||||
id="email"
|
||||
className="shadow-sm bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500 dark:shadow-sm-light"
|
||||
placeholder="nom@exemple.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="subject"
|
||||
className="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300"
|
||||
>
|
||||
Sujet
|
||||
</label>
|
||||
<input
|
||||
value={values.subject}
|
||||
onChange={handleChange}
|
||||
type="text"
|
||||
id="subject"
|
||||
className="block p-3 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 shadow-sm focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500 dark:shadow-sm-light"
|
||||
placeholder="Le motif de ma demande"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label
|
||||
htmlFor="message"
|
||||
className="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300"
|
||||
>
|
||||
Votre message
|
||||
</label>
|
||||
<textarea
|
||||
value={values.message}
|
||||
onChange={handleChange}
|
||||
id="message"
|
||||
rows={6}
|
||||
className="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg shadow-sm border border-gray-300 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="Détaillez ici votre demande..."
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
disabled={isSending}
|
||||
type="submit"
|
||||
className="py-3 px-5 text-sm font-medium text-center text-white rounded-lg bg-teal-600 sm:w-fit hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 dark:hover:bg-primary-700 dark:focus:ring-primary-800 dark:text-white disabled:opacity-25"
|
||||
>
|
||||
{isSending ? "Sending..." : "Send Message"}
|
||||
</button>
|
||||
{infoMessage}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactForm;
|
||||
@@ -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<ScrollableElement | null>(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<HTMLElement | null>(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"
|
||||
>
|
||||
<FaArrowUp
|
||||
onClick={() =>
|
||||
handleScrollToTop(scrollToTopElementRef.current || window)
|
||||
}
|
||||
onClick={() => handleScrollToTop(scrollToTopElementRef.current)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<div>
|
||||
<ScrollToTopButton isLgScreen={isLgScreen} />
|
||||
<ScrollToTopButton />
|
||||
</div>
|
||||
</div>
|
||||
<table
|
||||
|
||||
@@ -3,13 +3,13 @@ import ScrollToTopButton from "@/components/ScrollToTopButton";
|
||||
describe("Scroll to top button", () => {
|
||||
it("desktop scroll button clickable", () => {
|
||||
cy.viewport("macbook-15");
|
||||
cy.mount(<ScrollToTopButton isLgScreen={true} />);
|
||||
cy.mount(<ScrollToTopButton />);
|
||||
cy.get("[data-cy='scroll-to-top-button']").should("exist").click();
|
||||
});
|
||||
|
||||
it("mobile scroll button clickable", () => {
|
||||
cy.viewport(600, 600);
|
||||
cy.mount(<ScrollToTopButton isLgScreen={false} />);
|
||||
cy.mount(<ScrollToTopButton />);
|
||||
cy.get("[data-cy='scroll-to-top-button']").should("exist").click();
|
||||
});
|
||||
});
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 276 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
import { Dispatch, FormEvent, SetStateAction } from "react";
|
||||
import sendMail from "@/lib/sendMail";
|
||||
|
||||
export const handleEmailSubmit = async (
|
||||
e: FormEvent<HTMLFormElement>,
|
||||
values: Record<string, string>,
|
||||
setResponseMessage: Dispatch<
|
||||
SetStateAction<{ isSuccessful: boolean; message: string }>
|
||||
>,
|
||||
resetForm: () => void,
|
||||
setIsSending: Dispatch<SetStateAction<boolean>>
|
||||
) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ChangeEvent, useState } from "react";
|
||||
|
||||
const useContactForm = () => {
|
||||
const [values, setValues] = useState({
|
||||
email: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const handleChange = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
setValues((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
[e.target.id]: e.target.value,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setValues({ email: "", subject: "", message: "" });
|
||||
};
|
||||
|
||||
return { values, handleChange, resetForm };
|
||||
};
|
||||
|
||||
export default useContactForm;
|
||||
@@ -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<SetStateAction<boolean>>;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const sendMail = async ({
|
||||
email,
|
||||
subject,
|
||||
message,
|
||||
}: Record<string, string>) => {
|
||||
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;
|
||||
Generated
+23
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user