diff --git a/backend/src/app.ts b/backend/src/app.ts index c67f18b2..7e735f39 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -12,6 +12,8 @@ import { LandlordWithLabel, ApartmentWithLabel, ApartmentWithId, + CantFindApartmentForm, + QuestionForm, } from '@common/types/db-types'; // Import Firebase configuration and types import { auth } from 'firebase-admin'; @@ -34,6 +36,8 @@ const landlordCollection = db.collection('landlords'); const buildingsCollection = db.collection('buildings'); const likesCollection = db.collection('likes'); const usersCollection = db.collection('users'); +const pendingBuildingsCollection = db.collection('pendingBuildings'); +const contactQuestionsCollection = db.collection('contactQuestions'); // Middleware setup const app: Express = express(); @@ -861,4 +865,36 @@ app.put('/api/update-review-status/:reviewDocId/:newStatus', authenticate, async } }); +// API endpoint to submit a "Can't Find Your Apartment?" form. +app.post('/api/add-pending-building', authenticate, async (req, res) => { + try { + const doc = pendingBuildingsCollection.doc(); + const apartment = req.body as CantFindApartmentForm; + if (apartment.name === '') { + res.status(401).send('Error: missing fields'); + } + doc.set({ ...apartment, date: new Date(apartment.date), status: 'PENDING' }); + res.status(201).send(doc.id); + } catch (err) { + console.error(err); + res.status(401).send('Error'); + } +}); + +// API endpoint to submit a "Ask Us A Question" form. +app.post('/api/add-contact-question', authenticate, async (req, res) => { + try { + const doc = contactQuestionsCollection.doc(); + const question = req.body as QuestionForm; + if (question.name === '') { + res.status(401).send('Error: missing fields'); + } + doc.set({ ...question, date: new Date(question.date), status: 'PENDING' }); + res.status(201).send(doc.id); + } catch (err) { + console.error(err); + res.status(401).send('Error'); + } +}); + export default app; diff --git a/common/types/db-types.ts b/common/types/db-types.ts index 80e26535..108e6806 100644 --- a/common/types/db-types.ts +++ b/common/types/db-types.ts @@ -63,10 +63,18 @@ export type LandlordOrApartmentWithLabel = LandlordWithLabel | ApartmentWithLabe export type Likes = StringSet; -export type CantFindApartment = { +export type CantFindApartmentForm = { readonly date: Date; readonly name: string; readonly address: string; readonly photos: readonly string[]; readonly userId?: string | null; }; + +export type QuestionForm = { + readonly date: Date; + readonly name: string; + readonly email: string; + readonly msg: string; + readonly userId?: string | null; +}; diff --git a/frontend/src/assets/xIcon.svg b/frontend/src/assets/xIcon.svg index 8b388fc7..ee4862fb 100644 --- a/frontend/src/assets/xIcon.svg +++ b/frontend/src/assets/xIcon.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/src/components/LeaveReview/ReviewModal.tsx b/frontend/src/components/LeaveReview/ReviewModal.tsx index b9ad28ad..20485352 100644 --- a/frontend/src/components/LeaveReview/ReviewModal.tsx +++ b/frontend/src/components/LeaveReview/ReviewModal.tsx @@ -585,16 +585,16 @@ const ReviewModal = ({ )} - + ({ }, bodyText: { fontSize: '18px', - // padding: '16px 0 16px 0', margin: '22px 0 22px 0', }, - // Contact modal + xButton: { + fill: colors.black, + cursor: 'pointer', + position: 'absolute', + right: '32px', + top: '33px', + }, + optionGrid: { display: 'flex', flexDirection: 'column', @@ -78,7 +88,6 @@ const useStyles = makeStyles((theme) => ({ paddingBottom: '16px', }, - // Can't find your apartment modal hollowRedButton: { minWidth: '80px', height: '35px', @@ -94,12 +103,11 @@ const useStyles = makeStyles((theme) => ({ }, submitButton: { borderRadius: '30px', - marginTop: '10px', - marginBottom: '10px', width: '80px', }, })); +// Can't Find Your Apartment Modal Data interface CantFindApartmentFormData { name: string; address: string; @@ -118,7 +126,7 @@ type apartmentFormAction = | { type: 'updatePhotos'; photos: File[] } | { type: 'reset' }; -const reducer = ( +const apartmentReducer = ( state: CantFindApartmentFormData, action: apartmentFormAction ): CantFindApartmentFormData => { @@ -136,6 +144,40 @@ const reducer = ( } }; +// Question Modal Data +interface QuestionFormData { + name: string; + email: string; + msg: string; +} + +const defaultQuestionForm: QuestionFormData = { + name: '', + email: '', + msg: '', +}; + +type questionFormAction = + | { type: 'updateQuestionName'; name: string } + | { type: 'updateQuestionEmail'; email: string } + | { type: 'updateQuestionMsg'; msg: string } + | { type: 'reset' }; + +const questionReducer = (state: QuestionFormData, action: questionFormAction): QuestionFormData => { + switch (action.type) { + case 'updateQuestionName': + return { ...state, name: action.name }; + case 'updateQuestionEmail': + return { ...state, email: action.email }; + case 'updateQuestionMsg': + return { ...state, msg: action.msg }; + case 'reset': + return defaultQuestionForm; + default: + throw new Error('invalid action type'); + } +}; + /** * ContactModal Component * @@ -149,18 +191,32 @@ const reducer = ( const ContactModal = ({ user }: Props) => { const { modalOpen, closeModal } = useModal(); + const modalRef = useRef(null); const [currModal, setCurrModal] = useState('contact'); - const [apartmentForm, dispatch] = useReducer(reducer, defaultApartmentForm); - const [emptyTextError, setEmptyTextError] = useState(false); - const [includesProfanityError, setIncludesProfanityError] = useState(false); - const [addedPhoto, setAddedPhoto] = useState(false); + const [isMobile, setIsMobile] = useState(false); const [sending, setSending] = useState(false); - const modalRef = useRef(null); + const [showConfirmation, setShowConfirmation] = useState(false); + const [confirmationType, setConfirmationType] = useState(''); + const [showError, setShowError] = useState(false); + + // Can't Find Your Apartment Modal + const [apartmentForm, apartmentDispatch] = useReducer(apartmentReducer, defaultApartmentForm); + const [addedPhoto, setAddedPhoto] = useState(false); + const [emptyNameError, setEmptyNameError] = useState(false); + const [nameProfanityError, setNameProfanityError] = useState(false); + const [addressProfanityError, setAddressProfanityError] = useState(false); + + // Question Modal + const [questionForm, questionDispatch] = useReducer(questionReducer, defaultQuestionForm); + const [emptyEmailError, setEmptyEmailError] = useState(false); + const [emptyMsgError, setEmptyMsgError] = useState(false); + const [msgProfanityError, setMsgProfanityError] = useState(false); const { divider, modalStyle, bodyText, + xButton, optionGrid, optionButton, optionText, @@ -169,12 +225,60 @@ const ContactModal = ({ user }: Props) => { submitButton, } = useStyles(); + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth <= 600); + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => { + const updateScrollPosition = () => { + if (modalRef.current) { + const { scrollHeight, clientHeight } = modalRef.current; + const maxScrollTop = scrollHeight - clientHeight; + modalRef.current.scrollTop = maxScrollTop > 0 ? maxScrollTop : 0; + } + }; + const timer = setTimeout(updateScrollPosition, 100); + return () => clearTimeout(timer); + }, [addedPhoto]); + + const clearPhotosAndErrors = () => { + apartmentDispatch({ type: 'updatePhotos', photos: [] }); + // Clear apartment errors + setNameProfanityError(false); + setAddressProfanityError(false); + setEmptyNameError(false); + + // Clear question errors + setEmptyEmailError(false); + setEmptyMsgError(false); + setMsgProfanityError(false); + }; + + // Can't Find Your Apartment Modal Constants/Functions + const apartmentFormDataToReview = async ({ + name, + address, + localPhotos, + }: CantFindApartmentFormData): Promise => { + const photos = await Promise.all(localPhotos.map(uploadFile)); + return { + date: new Date(), + name: name, + address: address, + photos, + userId: user?.uid, + }; + }; + const updateApartmentName = (event: React.ChangeEvent) => { - dispatch({ type: 'updateApartmentName', name: event.target.value }); + apartmentDispatch({ type: 'updateApartmentName', name: event.target.value }); }; const updateApartmentAddress = (event: React.ChangeEvent) => { - dispatch({ type: 'updateApartmentAddress', address: event.target.value }); + apartmentDispatch({ type: 'updateApartmentAddress', address: event.target.value }); }; const updateApartmentPhotos = (event: React.ChangeEvent) => { @@ -192,93 +296,146 @@ const ContactModal = ({ user }: Props) => { console.log(`File ${bigPhoto.name} exceeds max size of ${PHOTO_MAX_MB}`); return; } - dispatch({ type: 'updatePhotos', photos: [...apartmentForm.localPhotos, ...newFiles] }); + apartmentDispatch({ + type: 'updatePhotos', + photos: [...apartmentForm.localPhotos, ...newFiles], + }); }; const removePhoto = (index: number) => { const newPhotos = apartmentForm.localPhotos.filter((_, photoIndex) => index !== photoIndex); - dispatch({ type: 'updatePhotos', photos: newPhotos }); - }; - - useEffect(() => { - const updateScrollPosition = () => { - if (modalRef.current) { - const { scrollHeight, clientHeight } = modalRef.current; - const maxScrollTop = scrollHeight - clientHeight; - modalRef.current.scrollTop = maxScrollTop > 0 ? maxScrollTop : 0; - } - }; - const timer = setTimeout(updateScrollPosition, 100); - return () => clearTimeout(timer); - }, [addedPhoto]); - - const onBackClearPhotos = () => { - dispatch({ type: 'updatePhotos', photos: [] }); - setCurrModal('contact'); + apartmentDispatch({ type: 'updatePhotos', photos: newPhotos }); }; - const formDataToReview = async ({ + // Question Modal Constants/Functions + const questionFormDataToReview = async ({ name, - address, - localPhotos, - }: CantFindApartmentFormData): Promise => { - const photos = await Promise.all(localPhotos.map(uploadFile)); + email, + msg, + }: QuestionFormData): Promise => { return { date: new Date(), name: name, - address: address, - photos, + email: email, + msg: msg, userId: user?.uid, }; }; - const onSubmit = async () => { - // try { - // setSending(true); - // const token = await user!.getIdToken(true); - // const data = await formDataToReview(apartmentForm); - // if ( - // data.name === '' || - // includesProfanity(data.name) || - // includesProfanity(data.address) - // ) { - // data.name === '' ? setEmptyTextError(true) : setEmptyTextError(false); - // includesProfanity(data.name) - // ? setIncludesProfanityError(true) - // : setIncludesProfanityError(false); - // includesProfanity(data.address) - // ? setIncludesProfanityError(true) - // : setIncludesProfanityError(false); - // if (modalRef.current) { - // modalRef.current.scrollTop = 0; - // } - // return; - // } - // const res = await axios.post('/api/new-review', data, createAuthHeaders(token)); - // if (res.status !== 201) { - // throw new Error('Failed to submit review'); - // } - // closeModal(); - // dispatch({ type: 'reset' }); - // onSuccess(); - // } catch (err) { - // console.log(err); - // console.log('Failed to submit form'); - // setShowError(true); - // setTimeout(() => { - // setShowError(false); - // }, toastTime); - // } finally { - // setSending(false); - // } + const updateQuestionName = (event: React.ChangeEvent) => { + questionDispatch({ type: 'updateQuestionName', name: event.target.value }); }; - // Contact Us Modal + const updateQuestionEmail = (event: React.ChangeEvent) => { + questionDispatch({ type: 'updateQuestionEmail', email: event.target.value }); + }; + + const updateQuestionMsg = (event: React.ChangeEvent) => { + questionDispatch({ type: 'updateQuestionMsg', msg: event.target.value }); + }; + + // Toast + const showToast = (setState: (value: React.SetStateAction) => void) => { + setState(true); + setTimeout(() => { + setState(false); + }, TOAST_TIME); + }; + const showConfirmationToast = (type: string) => { + setConfirmationType(type); + showToast(setShowConfirmation); + }; + + // onSubmit functions for each modal + const onApartmentSubmit = async () => { + try { + setSending(true); + const token = await user!.getIdToken(true); + const data = await apartmentFormDataToReview(apartmentForm); + if (data.name === '' || includesProfanity(data.name) || includesProfanity(data.address)) { + data.name === '' ? setEmptyNameError(true) : setEmptyNameError(false); + includesProfanity(data.name) ? setNameProfanityError(true) : setNameProfanityError(false); + includesProfanity(data.address) + ? setAddressProfanityError(true) + : setAddressProfanityError(false); + if (modalRef.current) { + modalRef.current.scrollTop = 0; + } + return; + } + const res = await axios.post('/api/add-pending-building', data, createAuthHeaders(token)); + if (res.status !== 201) { + throw new Error('Failed to submit form'); + } + closeModal(); + clearPhotosAndErrors(); + apartmentDispatch({ type: 'reset' }); + showConfirmationToast('apartment'); + } catch (err) { + console.log(err); + console.log('Failed to submit form'); + setShowError(true); + setTimeout(() => { + setShowError(false); + }, TOAST_TIME); + } finally { + setSending(false); + } + }; + + const onQuestionSubmit = async () => { + console.log('on question submit'); + try { + setSending(true); + const token = await user!.getIdToken(true); + const data = await questionFormDataToReview(questionForm); + if (data.name === '' || data.email === '' || data.msg === '' || includesProfanity(data.msg)) { + data.name === '' ? setEmptyNameError(true) : setEmptyNameError(false); + data.email === '' ? setEmptyEmailError(true) : setEmptyEmailError(false); + data.msg === '' ? setEmptyMsgError(true) : setEmptyMsgError(false); + includesProfanity(data.msg) ? setMsgProfanityError(true) : setMsgProfanityError(false); + if (modalRef.current) { + modalRef.current.scrollTop = 0; + } + return; + } + const res = await axios.post('/api/add-contact-question', data, createAuthHeaders(token)); + if (res.status !== 201) { + throw new Error('Failed to submit form'); + } + closeModal(); + clearPhotosAndErrors(); + questionDispatch({ type: 'reset' }); + showConfirmationToast('question'); + } catch (err) { + console.log(err); + console.log('Failed to submit form'); + setShowError(true); + setTimeout(() => { + setShowError(false); + }, TOAST_TIME); + } finally { + setSending(false); + } + }; + // Contact Us Modal const contactModal = ( <>

Contact Us

+ { + clearPhotosAndErrors(); + closeModal(); + }} + />
Choose from the following: @@ -320,11 +477,22 @@ const ContactModal = ({ user }: Props) => { ); // Can't Find Your Apartment? Modal - const cantFindApartmentModal = ( <>

Can't Find Your Apartment?

+ { + clearPhotosAndErrors(); + closeModal(); + }} + />
@@ -337,16 +505,14 @@ const ContactModal = ({ user }: Props) => { { Address { setAddedPhoto={setAddedPhoto} /> - - - - - ); - // Ask Us a Question Modal + // Question Modal const questionModal = ( <>

Ask Us a Question

+ { + clearPhotosAndErrors(); + closeModal(); + }} + />
Want to get in touch with our team? Write your message below and we’ll get back to you as soon as we can. +
+ + + Name + + + + + Cornell Email + + + + + Leave a Note + +
); return ( - setCurrModal('contact')} - fullWidth - maxWidth="md" - classes={{ paper: modalStyle }} - > - {currModal == 'contact' && contactModal} - {currModal == 'apartment' && cantFindApartmentModal} - {currModal == 'question' && questionModal} - + <> + {showConfirmation && ( + + )} + { + clearPhotosAndErrors(); + closeModal(); + }} + onExited={() => setCurrModal('contact')} + fullWidth + maxWidth="md" + classes={{ paper: modalStyle }} + > + {showError && ( + + )} + {currModal === 'contact' && contactModal} + {currModal === 'apartment' && cantFindApartmentModal} + {currModal === 'question' && questionModal} + + {currModal != 'contact' && ( + + + + + + )} + + ); }; diff --git a/frontend/src/components/utils/UploadPhotos.tsx b/frontend/src/components/utils/UploadPhotos.tsx index e9881fdb..ae5c4c31 100644 --- a/frontend/src/components/utils/UploadPhotos.tsx +++ b/frontend/src/components/utils/UploadPhotos.tsx @@ -57,6 +57,7 @@ const useStyle = makeStyles({ display: 'flex', gap: '12px', alignItems: 'center', + marginTop: '10px', }, photoRemoveButton: { fill: 'white', @@ -142,7 +143,14 @@ export default function UploadPhotos({
Upload Pictures: - + {`Reviewers may upload up to ${photosLimit} photos. Max photo size of ${photoMaxMB}MB`}