diff --git a/client/.eslintignore b/client/.eslintignore
index bb0c74738..f8ddbf77f 100644
--- a/client/.eslintignore
+++ b/client/.eslintignore
@@ -2,4 +2,4 @@
*.test.js*
v1/*
dist/*
-
+webpack.*.js
\ No newline at end of file
diff --git a/client/Routes.jsx b/client/Routes.jsx
index 5b3f8f375..53745589c 100644
--- a/client/Routes.jsx
+++ b/client/Routes.jsx
@@ -12,7 +12,7 @@ import Privacy from '@components/main/Privacy';
import Faqs from '@components/main/Faqs';
import About from '@components/main/About';
import Blog from '@components/main/Blog';
-import ContactForm from '@components/main/ContactForm';
+import Contact from '@components/contact/Contact';
export default function Routes() {
const { pathname } = useLocation();
@@ -28,7 +28,7 @@ export default function Routes() {
-
+
diff --git a/client/__tests__/components/contact/__snapshots__/contactForm.test.js.snap b/client/__tests__/components/contact/__snapshots__/contactForm.test.js.snap
new file mode 100644
index 000000000..278215d6e
--- /dev/null
+++ b/client/__tests__/components/contact/__snapshots__/contactForm.test.js.snap
@@ -0,0 +1,299 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ContactForm General should match snapshot 1`] = `
+
+`;
diff --git a/client/components/contact/Contact.jsx b/client/components/contact/Contact.jsx
new file mode 100644
index 000000000..7625b9729
--- /dev/null
+++ b/client/components/contact/Contact.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { ToastContainer } from 'react-toastify';
+import ContactImage from './ContactImage';
+import ContactIntro from './ContactIntro';
+import ContactForm from './ContactForm';
+
+import 'react-toastify/dist/ReactToastify.css';
+
+const Contact = () => (
+ <>
+
+
+
+
+ Contact Us
+
+
+
+ >
+);
+
+export default Contact;
diff --git a/client/components/contact/ContactForm.jsx b/client/components/contact/ContactForm.jsx
new file mode 100644
index 000000000..158e69d32
--- /dev/null
+++ b/client/components/contact/ContactForm.jsx
@@ -0,0 +1,261 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { sendGitRequest } from '@reducers/data';
+import { showFeedbackSuccess, setErrorModal } from '@reducers/ui';
+import { toast } from 'react-toastify';
+import {
+ Container,
+ Grid,
+ Button,
+ TextField,
+ CircularProgress,
+} from '@material-ui/core';
+import 'react-toastify/dist/ReactToastify.css';
+
+const initialFormValues = {
+ firstName: '',
+ lastName: '',
+ email: '',
+ association: '',
+ message: '',
+ errors: {
+ missingFirstName: false,
+ missingLastName: false,
+ missingEmail: false,
+ invalidEmail: false,
+ missingMessage: false,
+ },
+ loading: false,
+};
+
+const toastEmitterSettings = {
+ position: 'bottom-right',
+ autoClose: 5000,
+ hideProgressBar: false,
+ closeOnClick: true,
+ pauseOnHover: true,
+ draggable: true,
+ progress: undefined,
+};
+
+const ContactForm = () => {
+ const dispatch = useDispatch();
+
+ // mapStateToProps equivalent.
+ const displayFeedbackSuccess = useSelector(state => state.ui.displayFeedbackSuccess);
+ const openErrorModal = useSelector(state => state.ui.error.isOpen);
+
+ const [formValues, setFormValues] = useState(initialFormValues);
+
+ function clearFields() {
+ setFormValues({
+ ...initialFormValues,
+ });
+ }
+
+ function setLoading(isLoading) {
+ setFormValues(prevState => ({
+ ...prevState,
+ ...{
+ loading: isLoading,
+ },
+ }));
+ }
+
+ // Initialize component.
+ useEffect(() => {
+ // componentDidMount code goes here...
+ if (displayFeedbackSuccess) {
+ toast.success('We received your message. Our team will contact you at the email address provided.', toastEmitterSettings);
+ clearFields();
+ }
+
+ if (openErrorModal) {
+ toast.error('We failed to process your message. Please try again later.', toastEmitterSettings);
+ setLoading(false);
+ }
+
+ return () => {
+ // componentWillUnmount code goes here...
+ dispatch(showFeedbackSuccess(false));
+ dispatch(setErrorModal(false));
+ };
+ }, [dispatch, displayFeedbackSuccess, openErrorModal]);
+
+ // Helper methods.
+ function validateEmail(emailAddress) {
+ // A regular expression checking for a valid email format.
+ const VALID_EMAIL_FORMAT_REGEX = /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/;
+ return VALID_EMAIL_FORMAT_REGEX.test(emailAddress);
+ }
+
+ const clearErrors = useCallback(() => {
+ setFormValues(prevState => ({
+ ...prevState,
+ ...{
+ errors: {
+ missingFirstName: false,
+ missingLastName: false,
+ missingEmail: false,
+ invalidEmail: false,
+ missingMessage: false,
+ },
+ },
+ }));
+ }, []);
+
+ const validateForm = useCallback(() => {
+ const noFirstName = formValues.firstName.trim().length === 0;
+ const noLastName = formValues.lastName.trim().length === 0;
+ const noEmail = formValues.email.trim().length === 0;
+ const noMessage = formValues.message.trim().length === 0;
+ const incompleteEmail = (!noEmail && !validateEmail(formValues.email));
+ if (!noFirstName && !noLastName && !noEmail && !noMessage && !incompleteEmail) {
+ return true;
+ }
+
+ setFormValues(prevState => ({
+ ...prevState,
+ ...{
+ errors: {
+ missingFirstName: noFirstName,
+ missingLastName: noLastName,
+ missingEmail: noEmail,
+ invalidEmail: incompleteEmail,
+ missingMessage: noMessage,
+ },
+ },
+ }));
+ return false;
+ }, [formValues]);
+
+ // Event handlers.
+ const onInputChange = useCallback(event => {
+ const { name, value } = event.target;
+ setFormValues(prevState => ({ ...prevState, [name]: value }));
+ }, []);
+
+ const handleSubmit = useCallback(event => {
+ event.preventDefault();
+
+ if (!validateForm()) {
+ return;
+ }
+
+ const body = [
+ `First name: ${formValues.firstName.trim()}`,
+ `Last name: ${formValues.lastName.trim()}`,
+ `Email: ${formValues.email.trim()}`,
+ `Association: ${formValues.association.trim() || 'Not provided'}`,
+ `Message: ${formValues.message.trim()}`,
+ ].join('\n');
+
+ setLoading(true);
+
+ // Dispatch action to redux with payload.
+ dispatch(sendGitRequest({ title: formValues.email, body }));
+ }, [dispatch,
+ formValues.association,
+ formValues.email,
+ formValues.firstName,
+ formValues.lastName,
+ formValues.message,
+ validateForm]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+
+
+
+
+ );
+};
+
+export default ContactForm;
diff --git a/client/components/contact/ContactImage.jsx b/client/components/contact/ContactImage.jsx
new file mode 100644
index 000000000..a6d2738e8
--- /dev/null
+++ b/client/components/contact/ContactImage.jsx
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { makeStyles } from '@material-ui/core/styles';
+import coverImage from '@assets/contact_bg.png';
+
+const useStyles = makeStyles(() => ({
+ contactImageCover: {
+ height: '25vh',
+ backgroundImage: `url(${coverImage})`,
+ backgroundPosition: 'top',
+ backgroundRepeat: 'no-repeat',
+ backgroundSize: 'cover',
+ position: 'relative',
+
+ },
+ contactImageOverlayText: {
+ left: '50%',
+ fontSize: '40px',
+ fontWeight: 'bold',
+ position: 'absolute',
+ textAlign: 'center',
+ top: '50%',
+ transform: 'translate(-50%, -50%)',
+ },
+}));
+
+const ContactImage = ({ children }) => {
+ const classes = useStyles();
+
+ return (
+
+ );
+};
+
+ContactImage.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+export default ContactImage;
diff --git a/client/components/contact/ContactIntro.jsx b/client/components/contact/ContactIntro.jsx
new file mode 100644
index 000000000..c5f4270d1
--- /dev/null
+++ b/client/components/contact/ContactIntro.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import {
+ Grid,
+} from '@material-ui/core';
+
+const ContactIntro = () => (
+
+
+
+ {'Don\'t See What You Need?'}
+
+
+ We want to build a tool that works for you. We are open to suggestions and
+ feedback and would love the opportunity to get connected. Feel free to input
+ your information in the contact form below and we will be sure to get back to
+ you within 2-3 business days. Thank you!
+
+
+
+);
+
+export default ContactIntro;
diff --git a/client/components/contact/__tests__/__snapshots__/contactForm.test.js.snap b/client/components/contact/__tests__/__snapshots__/contactForm.test.js.snap
new file mode 100644
index 000000000..278215d6e
--- /dev/null
+++ b/client/components/contact/__tests__/__snapshots__/contactForm.test.js.snap
@@ -0,0 +1,299 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ContactForm General should match snapshot 1`] = `
+
+
+
+
+
+
+
+ First Name *
+
+
+
+
+
+
+
+
+
+
+ Last Name *
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Association
+
+
+
+
+
+
+
+
+
+ Message *
+
+
+
+
+
+
+ Message *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+
+
+
+
+`;
diff --git a/client/components/main/ContactForm.jsx b/client/components/main/ContactForm.jsx
deleted file mode 100644
index d0dad2dae..000000000
--- a/client/components/main/ContactForm.jsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import React from 'react';
-import { useForm } from '@formspree/react';
-import {
- makeStyles,
- Container,
- Grid,
- Button,
-} from '@material-ui/core';
-
-const useStyles = makeStyles({
- root: {
- color: 'black',
- backgroundColor: 'white',
- minHeight: '40em',
- padding: '2em',
- '& h1': {
- fontSize: '2.5em',
- },
- '& img': {
- maxWidth: '100%',
- height: 'auto',
- display: 'block',
- marginLeft: 'auto',
- marginRight: 'auto',
- },
- '& label': {
- marginTop: '1em',
- fontWeight: 500,
- display: 'block',
- width: '20em',
- },
- '& input': {
- width: '40em',
- padding: '0.5em',
- },
- '& textarea': {
- width: '40em',
- padding: '0.5em',
- },
- },
-});
-
-const FORMSPREE_FORM_ID = 'xknkwwez';
-
-const ContactForm = () => {
- const [state, handleSubmit] = useForm(FORMSPREE_FORM_ID);
- const classes = useStyles();
-
- React.useEffect(() => {
- console.log(state);
- });
-
- return (
-
-
-
- Contact Us
- { state.succeeded
- && (
-
- Thanks for contacting us! We will get back to you in 2-3 business days.
-
- )}
- { !state.succeeded
- && (
- <>
-
- Don't See What You Need?
- We want to build a tool that works for you.
- We are open to suggestions and feedback and would love the opportunity
- to get connected.
- Feel free to input your information in the contact form below
- and we will be sure to get back to you within 2-3 business days.
-
-
-
- Full Name
-
-
-
- Email Address
-
-
-
- Message
-
-
-
- Send Message
-
- >
- )}
-
-
-
- );
-};
-
-export default ContactForm;
diff --git a/client/package-lock.json b/client/package-lock.json
index 35de9a3cf..81c1de013 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1182,24 +1182,6 @@
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
"integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="
},
- "@formspree/core": {
- "version": "2.6.2",
- "resolved": "https://registry.npmjs.org/@formspree/core/-/core-2.6.2.tgz",
- "integrity": "sha512-cbaNhWQ4BFZWosh1Oa4pKD3CWAK3sn+nK3D1UQj6hO1O+AUH502OabKo5+ugC/P2+d4YZLadIY3mC11Ig4kEzA==",
- "requires": {
- "@types/promise-polyfill": "^6.0.3",
- "fetch-ponyfill": "^6.1.0",
- "promise-polyfill": "^8.1.3"
- }
- },
- "@formspree/react": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/@formspree/react/-/react-2.2.3.tgz",
- "integrity": "sha512-paiDNr0lsf3XwNiV8SrYQGXdSN471c9boFLesqxM01VimCgA0MwZbyo46cDxWO7ZML6FsuKUOvdjnRguSHFkow==",
- "requires": {
- "@formspree/core": "^2.6.1"
- }
- },
"@jest/console": {
"version": "24.9.0",
"resolved": "https://registry.npmjs.org/@jest/console/-/console-24.9.0.tgz",
@@ -3470,11 +3452,6 @@
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==",
"dev": true
},
- "@types/promise-polyfill": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/@types/promise-polyfill/-/promise-polyfill-6.0.3.tgz",
- "integrity": "sha512-f/BFgF9a+cgsMseC7rpv9+9TAE3YNjhfYrtwCo/pIeCDDfQtE6PY0b5bao2eIIEpZCBUy8Y5ToXd4ObjPSJuFw=="
- },
"@types/prop-types": {
"version": "15.7.3",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
@@ -7975,14 +7952,6 @@
"bser": "2.1.1"
}
},
- "fetch-ponyfill": {
- "version": "6.1.1",
- "resolved": "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-6.1.1.tgz",
- "integrity": "sha512-rWLgTr5A44/XhvCQPYj0X9Tc+cjUaHofSM4lcwjc9MavD5lkjIhJ+h8JQlavPlTIgDpwhuRozaIykBvX9ItaSA==",
- "requires": {
- "node-fetch": "~2.6.0"
- }
- },
"figgy-pudding": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
@@ -10974,14 +10943,6 @@
"vt-pbf": "^3.1.1"
}
},
- "material-ui-image": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/material-ui-image/-/material-ui-image-3.3.2.tgz",
- "integrity": "sha512-WE5QE0Rjdx9jPKuI0LWI7s8VQ9cifPIXObu8AUCRcidXGV3NqPI9C8c9A/C0MofKpkZ3buG8+IT9N7GgSmxXeg==",
- "requires": {
- "prop-types": "^15.5.8"
- }
- },
"md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@@ -12610,11 +12571,6 @@
"integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==",
"dev": true
},
- "promise-polyfill": {
- "version": "8.2.0",
- "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.2.0.tgz",
- "integrity": "sha512-k/TC0mIcPVF6yHhUvwAp7cvL6I2fFV7TzF1DuGPI8mBh4QQazf36xCKEHKTZKRysEoTQoQdKyP25J8MPJp7j5g=="
- },
"prompts": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.3.2.tgz",
@@ -13091,6 +13047,14 @@
}
}
},
+ "react-toastify": {
+ "version": "9.0.8",
+ "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.0.8.tgz",
+ "integrity": "sha512-EwM+teWt49HSHx+67qI08yLAW1zAsBxCXLCsUfxHYv1W7/R3ZLhrqKalh7j+kjgPna1h5LQMSMwns4tB4ww2yQ==",
+ "requires": {
+ "clsx": "^1.1.1"
+ }
+ },
"react-transition-group": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
diff --git a/client/package.json b/client/package.json
index 55ae290aa..1206b6cd5 100644
--- a/client/package.json
+++ b/client/package.json
@@ -6,7 +6,6 @@
},
"homepage": "https://www.311-data.org/",
"dependencies": {
- "@formspree/react": "^2.2.3",
"@mapbox/mapbox-gl-geocoder": "^4.7.0",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -46,6 +45,7 @@
"react-router-dom": "^5.1.2",
"react-swipeable": "^6.0.1",
"react-test-renderer": "^16.12.0",
+ "react-toastify": "^9.0.8",
"react-vis": "^1.11.7",
"redux": "^4.0.4",
"redux-devtools-extension": "^2.13.8",
@@ -102,4 +102,4 @@
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.9.0"
}
-}
\ No newline at end of file
+}
diff --git a/client/redux/sagas/data.js b/client/redux/sagas/data.js
index ee173f26c..a78b7b403 100644
--- a/client/redux/sagas/data.js
+++ b/client/redux/sagas/data.js
@@ -16,10 +16,13 @@ import {
getPinInfoFailure,
getNcByLngLatSuccess,
getNcByLngLatFailure,
+ gitResponseSuccess,
+ gitResponseFailure,
} from '../reducers/data';
import {
setErrorModal,
+ showFeedbackSuccess,
} from '../reducers/ui';
import {
@@ -56,6 +59,15 @@ function* fetchNcByLngLat({ longitude, latitude }) {
return data;
}
+/* //// OTHER //// */
+
+function* postFeedback(message) {
+ const contactURL = `${BASE_URL}/feedback`;
+
+ const response = yield call(axios.post, contactURL, message);
+ return response;
+}
+
/* ////////////////// FILTERS //////////////// */
const getState = (state, slice) => state[slice];
@@ -120,8 +132,21 @@ function* getNcByLngLat(action) {
}
}
+function* sendContactData(action) {
+ try {
+ const message = action.payload;
+ const data = yield call(postFeedback, message);
+ yield put(gitResponseSuccess(data));
+ yield put(showFeedbackSuccess(true));
+ } catch (e) {
+ yield put(gitResponseFailure(e));
+ yield put(setErrorModal(true));
+ }
+}
+
export default function* rootSaga() {
yield takeLatest(mapFiltersTypes.UPDATE_MAP_DATE_RANGE, getMapData);
yield takeLatest(types.GET_NC_BY_LNG_LAT, getNcByLngLat);
yield takeEvery(types.GET_PIN_INFO_REQUEST, getPinData);
+ yield takeLatest(types.SEND_GIT_REQUEST, sendContactData);
}
diff --git a/client/webpack.config.js b/client/webpack.config.js
index fe244bb28..649925695 100644
--- a/client/webpack.config.js
+++ b/client/webpack.config.js
@@ -27,6 +27,11 @@ module.exports = {
},
module: {
rules: [
+ {
+ test: /\.mjs$/,
+ include: /node_modules/,
+ type: 'javascript/auto',
+ },
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,