diff --git a/README.md b/README.md index 944c6f79..0b1874d7 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ on its own, run `yarn frontend-dev` or `yarn backend-dev`. - **Vicky Wang** - Designer - **Kea-Roy Ong** - Developer - **Casper Liao** - Developer +- **Parsa Tehranipoor** - Developer ### 2023-2024 diff --git a/backend/src/app.ts b/backend/src/app.ts index 1e45ca4a..8d365cb5 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -152,28 +152,48 @@ app.get('/api/review/:status', async (req, res) => { }); /** - * Return list of reviews that user marked as helpful (like) + * review/like/:userId – Fetches reviews liked by a user. + * + * @remarks + * This endpoint retrieves reviews that a user has marked as helpful (liked). It can also filter by review status by passing in a query parameter. If no parameter is provided, no additional filter is applied. + * + * @route GET /api/review/like/:userId + * + * @status + * - 200: Successfully retrieved the liked reviews. + * - 401: Error due to unauthorized access or authentication issues. */ -// TODO: uid param is unused here but when remove it encounter 304 status and req.user is null -app.get('/api/review/like/:uid', authenticate, async (req, res) => { - if (!req.user) throw new Error('not authenticated'); - const { uid } = req.user; - const likesDoc = await likesCollection.doc(uid).get(); +app.get('/api/review/like/:userId', authenticate, async (req, res) => { + if (!req.user) { + throw new Error('not authenticated'); + } + const realUserId = req.user.uid; + const { userId } = req.params; + const statusType = req.query.status; + if (userId !== realUserId) { + res.status(401).send("Error: user is not authorized to access another user's likes"); + return; + } + const likesDoc = await likesCollection.doc(realUserId).get(); if (likesDoc.exists) { const data = likesDoc.data(); if (data) { const reviewIds = Object.keys(data); const matchingReviews: ReviewWithId[] = []; - const querySnapshot = await reviewCollection - .where(FieldPath.documentId(), 'in', reviewIds) - .where('status', '==', 'APPROVED') - .get(); - querySnapshot.forEach((doc) => { - const data = doc.data(); - const reviewData = { ...data, date: data.date.toDate() }; - matchingReviews.push({ ...reviewData, id: doc.id } as ReviewWithId); - }); + if (reviewIds.length > 0) { + let query = reviewCollection.where(FieldPath.documentId(), 'in', reviewIds); + if (statusType) { + // filter by status if provided + query = query.where('status', '==', statusType); + } + const querySnapshot = await query.get(); + querySnapshot.forEach((doc) => { + const data = doc.data(); + const reviewData = { ...data, date: data.date.toDate() }; + matchingReviews.push({ ...reviewData, id: doc.id } as ReviewWithId); + }); + } res.status(200).send(JSON.stringify(matchingReviews)); return; } diff --git a/frontend/src/assets/helpful-icon.svg b/frontend/src/assets/helpful-icon.svg new file mode 100644 index 00000000..4755ddf9 --- /dev/null +++ b/frontend/src/assets/helpful-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/components/Admin/AdminReview.tsx b/frontend/src/components/Admin/AdminReview.tsx index 2dfe0c06..56ca6c82 100644 --- a/frontend/src/components/Admin/AdminReview.tsx +++ b/frontend/src/components/Admin/AdminReview.tsx @@ -37,6 +37,8 @@ type Props = { readonly setToggle: React.Dispatch>; /** Indicates if the review is in the declined section. */ readonly declinedSection: boolean; + /** Function to trigger the photo carousel. */ + readonly triggerPhotoCarousel: (photos: readonly string[], startIndex: number) => void; }; /** @@ -70,14 +72,24 @@ const useStyles = makeStyles(() => ({ borderRadius: '4px', height: '15em', width: '15em', + cursor: 'pointer', + transition: '0.3s ease-in-out', + '&:hover': { + filter: 'brightness(0.85)', + boxShadow: '0 4px 4px rgba(0, 0, 0, 0.1)', + transform: 'scale(1.02)', + }, }, photoRowStyle: { overflowX: 'auto', + overflowY: 'hidden', display: 'flex', flexDirection: 'row', gap: '1vw', paddingTop: '2%', paddingLeft: '0.6%', + paddingRight: '0.6%', + paddingBottom: '2%', }, })); @@ -87,7 +99,12 @@ const useStyles = makeStyles(() => ({ * @param review review - The review to approve * @returns The rendered component. */ -const AdminReviewComponent = ({ review, setToggle, declinedSection }: Props): ReactElement => { +const AdminReviewComponent = ({ + review, + setToggle, + declinedSection, + triggerPhotoCarousel, +}: Props): ReactElement => { const { detailedRatings, overallRating, bedrooms, price, date, reviewText, photos } = review; const formattedDate = format(new Date(date), 'MMM dd, yyyy').toUpperCase(); const { root, dateText, bedroomsPriceText, ratingInfo, photoStyle, photoRowStyle } = useStyles(); @@ -202,7 +219,7 @@ const AdminReviewComponent = ({ review, setToggle, declinedSection }: Props): Re {photos.length > 0 && ( - {photos.map((photo) => { + {photos.map((photo, i) => { return ( triggerPhotoCarousel(photos, i)} + loading="lazy" /> ); })} diff --git a/frontend/src/components/PhotoCarousel/PhotoCarousel.tsx b/frontend/src/components/PhotoCarousel/PhotoCarousel.tsx index 10fb4a14..dd0695d0 100644 --- a/frontend/src/components/PhotoCarousel/PhotoCarousel.tsx +++ b/frontend/src/components/PhotoCarousel/PhotoCarousel.tsx @@ -6,6 +6,7 @@ interface Props { photos: readonly string[]; open: boolean; onClose?: () => void; + startIndex: number; } const useStyles = makeStyles((theme) => ({ @@ -18,33 +19,91 @@ const useStyles = makeStyles((theme) => ({ backgroundColor: 'rgba(0, 0, 0, 0.5)', opacity: 1, }, + indicatorContainer: { + position: 'absolute', + bottom: '-10px', + width: '100%', + textAlign: 'center', + }, + carouselContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', + overflow: 'visible', + height: '80vh', + [theme.breakpoints.down('md')]: { + height: '60dvw', + }, + cursor: 'pointer', + }, })); const ImageBox = styled(Box)({ - width: 'fit-content', - margin: 'auto', borderRadius: '10px', - overflow: 'hidden', + maxHeight: '80dvh', + backgroundColor: 'transparent', + '& img': { + borderRadius: '10px', + maxHeight: '80dvh', + objectFit: 'contain', + width: 'calc(69dvw - 96px)', + margin: 'auto', + cursor: 'default', + }, }); -const PhotoCarousel = ({ photos, open, onClose }: Props) => { - const { modalBackground, navButton } = useStyles(); +/** + * PhotoCarousel - this component displays a modal with a carousel of photos. + * + * @remarks + * This component is used to display a modal with a carousel of photos. + * It dynamically adjusts to different screen sizes and can be navigated using the arrow buttons. + * + * @component + * @param {readonly string[]} props.photos - An array of photo URLs to display in the carousel. + * @param {boolean} props.open - A boolean indicating whether the modal is open. + * @param {() => void} [props.onClose] - An optional callback function to handle closing the modal. + * @param {number} props.startIndex - The starting index of the carousel. + * + * @returns {JSX.Element} The rendered PhotoCarousel component. + */ +const PhotoCarousel = ({ photos, open, onClose, startIndex }: Props) => { + const { modalBackground, navButton, indicatorContainer, carouselContainer } = useStyles(); return ( - + { + const target = e.target as HTMLElement; + if ( + target.tagName !== 'IMG' && + target.tagName !== 'BUTTON' && + target.tagName !== 'svg' && + target.tagName !== 'circle' + ) { + console.log(target.tagName); + onClose?.(); + } + }} + > {photos.map((src, index) => { return ( - + ); diff --git a/frontend/src/components/PhotoCarousel/usePhotoCarousel.ts b/frontend/src/components/PhotoCarousel/usePhotoCarousel.ts new file mode 100644 index 00000000..7997344a --- /dev/null +++ b/frontend/src/components/PhotoCarousel/usePhotoCarousel.ts @@ -0,0 +1,39 @@ +// src/hooks/usePhotoCarousel.ts +import { useState } from 'react'; + +const usePhotoCarousel = (defaultPhotos: readonly string[] = []) => { + const [carouselPhotos, setCarouselPhotos] = useState(defaultPhotos); + const [carouselStartIndex, setCarouselStartIndex] = useState(0); + const [carouselOpen, setCarouselOpen] = useState(false); + + /** + * showPhotoCarousel – Opens the photo carousel modal with the provided photos and start index. + * + * @remarks + * This function sets the photos and start index for the photo carousel and then opens the carousel modal. + * If no photos are provided, it defaults to [defaultPhotos]. + * + * @param {readonly string[]} [photos] – The array of photo URLs to display in the carousel. + * @param {number} [startIndex] – The index of the photo to start the carousel from. + * @return {void} – This function does not return anything. + */ + const showPhotoCarousel = (photos: readonly string[] = defaultPhotos, startIndex: number = 0) => { + setCarouselPhotos(photos); + setCarouselStartIndex(startIndex); + setCarouselOpen(true); + }; + + const closePhotoCarousel = () => { + setCarouselOpen(false); + }; + + return { + carouselPhotos, + carouselStartIndex, + carouselOpen, + showPhotoCarousel, + closePhotoCarousel, + }; +}; + +export default usePhotoCarousel; diff --git a/frontend/src/components/Review/Review.tsx b/frontend/src/components/Review/Review.tsx index 050f2a87..3747bc8e 100644 --- a/frontend/src/components/Review/Review.tsx +++ b/frontend/src/components/Review/Review.tsx @@ -17,6 +17,7 @@ import HeartRating from '../utils/HeartRating'; import { format } from 'date-fns'; import { makeStyles } from '@material-ui/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import HelpfulIcon from '../../assets/helpful-icon.svg'; import EditIcon from '@material-ui/icons/Edit'; import DeleteIcon from '@material-ui/icons/Delete'; import { @@ -55,6 +56,7 @@ type Props = { setToggle: React.Dispatch>; readonly triggerEditToast: () => void; readonly triggerDeleteToast: () => void; + readonly triggerPhotoCarousel: (photos: readonly string[], startIndex: number) => void; user: firebase.User | null; setUser: React.Dispatch>; readonly showLabel: boolean; @@ -101,6 +103,13 @@ const useStyles = makeStyles(() => ({ borderRadius: '4px', height: '15em', width: '15em', + cursor: 'pointer', + transition: '0.3s ease-in-out', + '&:hover': { + filter: 'brightness(0.85)', + boxShadow: '0 4px 4px rgba(0, 0, 0, 0.1)', + transform: 'scale(1.02)', + }, }, photoRowStyle: { overflowX: 'auto', @@ -109,6 +118,9 @@ const useStyles = makeStyles(() => ({ gap: '1vw', paddingTop: '2%', paddingLeft: '0.6%', + overflowY: 'hidden', + paddingRight: '0.6%', + paddingBottom: '2%', }, bedroomsPrice: { display: 'flex', @@ -132,6 +144,11 @@ const useStyles = makeStyles(() => ({ width: '21px', height: '21px', }, + helpfulIcon: { + width: '21px', + height: '21px', + marginRight: '5px', + }, submitButton: { borderRadius: '30px', minWidth: '80px', @@ -202,13 +219,13 @@ const ReviewComponent = ({ setToggle, triggerEditToast, triggerDeleteToast, + triggerPhotoCarousel, user, setUser, showLabel, }: Props): ReactElement => { - const [reviewData, setReviewData] = useState(review); - const formattedDate = format(new Date(reviewData.date), 'MMM dd, yyyy').toUpperCase(); - const shortenedDate = format(new Date(reviewData.date), 'MMM yyyy').toUpperCase(); + const formattedDate = format(new Date(review.date), 'MMM dd, yyyy').toUpperCase(); + const shortenedDate = format(new Date(review.date), 'MMM yyyy').toUpperCase(); const { root, expand, @@ -225,6 +242,7 @@ const ReviewComponent = ({ priceWithIcon, bedPriceIcon, bedroomsPriceText, + helpfulIcon, deleteDialogTitle, deleteDialogDesc, deleteDialogActions, @@ -241,14 +259,17 @@ const ReviewComponent = ({ const isSmallScreen = useMediaQuery('(max-width:391px)'); const toastTime = 3500; - const onSuccessfulEdit = () => { - get(`/api/review-by-id/${reviewData.id}`, { + const updateReviewData = () => { + get(`/api/review-by-id/${review.id}`, { callback: (updatedReview: ReviewWithId) => { // Update the review state with the new data - setReviewData(updatedReview); + review = updatedReview; setToggle((cur) => !cur); }, }); + }; + const onSuccessfulEdit = () => { + updateReviewData(); if (triggerEditToast) triggerEditToast(); }; const deleteModal = () => { @@ -299,13 +320,13 @@ const ReviewComponent = ({ open={reviewOpen} onClose={() => setReviewOpen(false)} setOpen={setReviewOpen} - landlordId={reviewData.landlordId} + landlordId={review.landlordId} onSuccess={onSuccessfulEdit} toastTime={toastTime} - aptId={reviewData.aptId ?? ''} + aptId={review.aptId ?? ''} aptName={apt?.[0]?.name ?? ''} user={user} - initialReview={reviewData} + initialReview={review} /> {deleteModal()} @@ -315,12 +336,12 @@ const ReviewComponent = ({ }; //Retrieving apartment data useEffect(() => { - if (reviewData.aptId !== null) { - get(`/api/apts/${reviewData.aptId}`, { + if (review.aptId !== null) { + get(`/api/apts/${review.aptId}`, { callback: setApt, }); } - }, [reviewData]); + }, [review]); const getRatingInfo = (ratings: DetailedRating): RatingInfo[] => { return [ @@ -349,7 +370,7 @@ const ReviewComponent = ({ }; const reportAbuseHandler = async (reviewId: string) => { - const endpoint = `/api/update-review-status/${reviewData.id}/PENDING`; + const endpoint = `/api/update-review-status/${review.id}/PENDING`; if (user) { const token = await user.getIdToken(true); await axios.put(endpoint, {}, createAuthHeaders(token)); @@ -371,7 +392,7 @@ const ReviewComponent = ({ const handleDeleteModalClose = async (deleteit: Boolean) => { if (deleteit) { - const endpoint = `/api/update-review-status/${reviewData.id}/DELETED`; + const endpoint = `/api/update-review-status/${review.id}/DELETED`; if (user) { const token = await user.getIdToken(true); await axios.put(endpoint, {}, createAuthHeaders(token)); @@ -393,16 +414,16 @@ const ReviewComponent = ({ }; const landlordNotFound = useCallback(() => { - console.error('Landlord with id ' + reviewData.landlordId + ' not found.'); - }, [reviewData.landlordId]); + console.error('Landlord with id ' + review.landlordId + ' not found.'); + }, [review.landlordId]); // Fetch landlord data when the component mounts or when landlordId changes useEffect(() => { - get(`/api/landlord/${reviewData.landlordId}`, { + get(`/api/landlord/${review.landlordId}`, { callback: setLandlordData, errorHandler: landlordNotFound, }); - }, [reviewData.landlordId, landlordNotFound]); + }, [review.landlordId, landlordNotFound]); const propertyLandlordLabel = () => { return ( @@ -413,10 +434,7 @@ const ReviewComponent = ({ 0 - ? `/apartment/${reviewData.aptId}` - : `/landlord/${reviewData.landlordId}`, + to: apt.length > 0 ? `/apartment/${review.aptId}` : `/landlord/${review.landlordId}`, style: { color: 'black', textDecoration: 'underline', @@ -454,24 +472,16 @@ const ReviewComponent = ({ }; const bedroomsPriceLabel = (rowNum: number) => { return ( - - {reviewData.bedrooms > 0 && ( + + {review.bedrooms > 0 && (
- {reviewData.bedrooms}{' '} - {reviewData.bedrooms === 1 + {review.bedrooms}{' '} + {review.bedrooms === 1 ? isSmallScreen ? 'Bed' : 'Bedroom' @@ -481,7 +491,7 @@ const ReviewComponent = ({
)} - {reviewData.price > 0 && ( + {review.price > 0 && (
{' '} - {getPriceRange(reviewData.price) || 0} + {getPriceRange(review.price) || 0}
)} @@ -505,7 +515,7 @@ const ReviewComponent = ({ - + {useMediaQuery( - user && reviewData.userId && user.uid === reviewData.userId + user && review.userId && user.uid === review.userId ? '(min-width:1410px)' : '(min-width:1075px)' ) && bedroomsPriceLabel(1)} @@ -530,20 +540,17 @@ const ReviewComponent = ({ {isSmallScreen ? shortenedDate : formattedDate} - {user && - reviewData.userId && - user.uid === reviewData.userId && - editDeleteButtons()} + {user && review.userId && user.uid === review.userId && editDeleteButtons()} {useMediaQuery( - user && reviewData.userId && user.uid === reviewData.userId + user && review.userId && user.uid === review.userId ? '(max-width:1409px)' : '(max-width:1074px)' ) && bedroomsPriceLabel(2)} - + @@ -555,19 +562,19 @@ const ReviewComponent = ({ - {expandedText ? reviewData.reviewText : reviewData.reviewText.substring(0, 500)} - {!expandedText && reviewData.reviewText.length > 500 && '...'} - {reviewData.reviewText.length > 500 ? ( + {expandedText ? review.reviewText : review.reviewText.substring(0, 500)} + {!expandedText && review.reviewText.length > 500 && '...'} + {review.reviewText.length > 500 ? ( ) : null} - {reviewData.photos.length > 0 && ( + {review.photos.length > 0 && ( - {reviewData.photos.map((photo) => { + {review.photos.map((photo, i) => { return ( triggerPhotoCarousel(review.photos, i)} + loading="lazy" /> ); })} @@ -589,21 +598,24 @@ const ReviewComponent = ({ - diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 3116eacd..2fca40b1 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -18,6 +18,8 @@ import AdminReviewComponent from '../components/Admin/AdminReview'; import { useTitle } from '../utils'; import { Chart } from 'react-google-charts'; import { sortReviews } from '../utils/sortReviews'; +import PhotoCarousel from '../components/PhotoCarousel/PhotoCarousel'; +import usePhotoCarousel from '../components/PhotoCarousel/usePhotoCarousel'; const useStyles = makeStyles((theme) => ({ container: { @@ -51,6 +53,14 @@ const AdminPage = (): ReactElement => { const [pendingApartment, setPendingApartmentData] = useState([]); const [pendingContactQuestions, setPendingContactQuestions] = useState([]); + const { + carouselPhotos, + carouselStartIndex, + carouselOpen, + showPhotoCarousel, + closePhotoCarousel, + } = usePhotoCarousel([]); + const { container } = useStyles(); useTitle('Admin'); @@ -115,6 +125,17 @@ const AdminPage = (): ReactElement => { }); }, [toggle]); + const Modals = ( + <> + + + ); + // Reviews tab const reviews = ( @@ -157,6 +178,7 @@ const AdminPage = (): ReactElement => { review={review} setToggle={setToggle} declinedSection={false} + triggerPhotoCarousel={showPhotoCarousel} /> ))} @@ -175,6 +197,7 @@ const AdminPage = (): ReactElement => { review={review} setToggle={setToggle} declinedSection={true} + triggerPhotoCarousel={showPhotoCarousel} /> ))} @@ -246,6 +269,7 @@ const AdminPage = (): ReactElement => { {selectedTab === 'Reviews' && reviews} {selectedTab === 'Contact' && contact} + {Modals} ); }; diff --git a/frontend/src/pages/ApartmentPage.tsx b/frontend/src/pages/ApartmentPage.tsx index 571262b2..179c90de 100644 --- a/frontend/src/pages/ApartmentPage.tsx +++ b/frontend/src/pages/ApartmentPage.tsx @@ -13,6 +13,7 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import ReviewModal from '../components/LeaveReview/ReviewModal'; import PhotoCarousel from '../components/PhotoCarousel/PhotoCarousel'; +import usePhotoCarousel from '../components/PhotoCarousel/usePhotoCarousel'; import ReviewComponent from '../components/Review/Review'; import ReviewHeader from '../components/Review/ReviewHeader'; import { useTitle } from '../utils'; @@ -39,8 +40,8 @@ import { getAverageRating } from '../utils/average'; import { colors } from '../colors'; import clsx from 'clsx'; import { sortReviews } from '../utils/sortReviews'; -import savedIcon from '../assets/filled-large-saved-icon.png'; -import unsavedIcon from '../assets/unfilled-large-saved-icon.png'; +import savedIcon from '../assets/saved-icon-filled.svg'; +import unsavedIcon from '../assets/saved-icon-unfilled.svg'; import MapModal from '../components/Apartment/MapModal'; import DropDownWithLabel from '../components/utils/DropDownWithLabel'; @@ -77,9 +78,6 @@ const useStyles = makeStyles((theme) => ({ container: { marginTop: '20px', }, - root: { - borderRadius: '10px', - }, expand: { transform: 'rotate(0deg)', marginLeft: 'auto', @@ -88,22 +86,22 @@ const useStyles = makeStyles((theme) => ({ expandOpen: { transform: 'rotate(180deg)', }, - dateText: { - color: colors.gray1, - }, - button: { - textTransform: 'none', - '&.Mui-disabled': { - color: 'inherit', + saveButton: { + backgroundColor: 'transparent', + width: '107px', + margin: '10px 16px', + borderRadius: '30px', + border: '2px solid', + fontSize: '15px', + borderColor: colors.red1, + '&:focus': { + borderColor: `${colors.red1} !important`, }, }, - horizontalLine: { - borderTop: '1px solid #C4C4C4', - width: '95%', - marginTop: '20px', - borderLeft: 'none', - borderRight: 'none', - borderBottom: 'none', + bookmarkRibbon: { + width: '19px', + height: '25px', + marginRight: '10px', }, })); @@ -130,13 +128,19 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { const [likeStatuses, setLikeStatuses] = useState({}); const [reviewOpen, setReviewOpen] = useState(false); const [mapOpen, setMapOpen] = useState(false); - const [carouselOpen, setCarouselOpen] = useState(false); const [showConfirmation, setShowConfirmation] = useState(false); const [showEditSuccessConfirmation, setShowEditSuccessConfirmation] = useState(false); const [showDeleteSuccessConfirmation, setShowDeleteSuccessConfirmation] = useState(false); const [buildings, setBuildings] = useState([]); const [aptData, setAptData] = useState([]); const [apt, setApt] = useState(undefined); + const { + carouselPhotos, + carouselStartIndex, + carouselOpen, + showPhotoCarousel, + closePhotoCarousel, + } = usePhotoCarousel(apt ? apt.photos : []); const [loaded, setLoaded] = useState(false); const [showSignInError, setShowSignInError] = useState(false); const [sortBy, setSortBy] = useState('date'); @@ -174,6 +178,8 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { container, expand, expandOpen, + saveButton, + bookmarkRibbon, } = useStyles(); // Set the page title based on whether apartment data is loaded. @@ -249,6 +255,32 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { return subscribeLikes(setLikedReviews); }, []); + // Fetch the reviews that the user has liked and set the liked reviews and like statuses. + useEffect(() => { + getUser(false).then((user) => { + if (user) { + user.getIdToken(true).then((token) => { + get( + `/api/review/like/${user.uid}`, + { + callback: (reviews) => { + const likedReviewsMap: Likes = {}; + const likeStatusesMap: Likes = {}; + reviews.forEach((review) => { + likedReviewsMap[review.id] = true; + likeStatusesMap[review.id] = false; + }); + setLikedReviews(likedReviewsMap); + setLikeStatuses(likeStatusesMap); + }, + }, + createAuthHeaders(token) + ); + }); + } + }); + }, []); + useEffect(() => { get(`/api/buildings/all/${apt?.landlordId}`, { callback: setOtherproperties, @@ -402,9 +434,10 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { user={user} /> setCarouselOpen(false)} + startIndex={carouselStartIndex} + onClose={closePhotoCarousel} /> ); @@ -438,7 +471,7 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { )} - { alt={isSaved ? 'Saved' : 'Unsaved'} style={{ width: '107px', height: '43px' }} /> - + */} + @@ -543,7 +587,7 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { color="secondary" variant="contained" disableElevation - onClick={() => setCarouselOpen(true)} + onClick={() => showPhotoCarousel()} > Show all photos @@ -637,7 +681,7 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { averageRating={getAverageRating(reviewData)} apartment={apt!} numReviews={reviewData.length} - handleClick={() => setCarouselOpen(true)} + handleClick={() => showPhotoCarousel()} /> )} @@ -728,6 +772,7 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { setToggle={setToggle} triggerEditToast={showEditSuccessConfirmationToast} triggerDeleteToast={showDeleteSuccessConfirmationToast} + triggerPhotoCarousel={showPhotoCarousel} user={user} setUser={setUser} /> diff --git a/frontend/src/pages/BookmarksPage.tsx b/frontend/src/pages/BookmarksPage.tsx index 43f37cc6..1da50272 100644 --- a/frontend/src/pages/BookmarksPage.tsx +++ b/frontend/src/pages/BookmarksPage.tsx @@ -16,6 +16,8 @@ import { sortReviews } from '../utils/sortReviews'; import DropDownWithLabel from '../components/utils/DropDownWithLabel'; import { AptSortFields, sortApartments } from '../utils/sortApartments'; import Toast from '../components/utils/Toast'; +import PhotoCarousel from '../components/PhotoCarousel/PhotoCarousel'; +import usePhotoCarousel from '../components/PhotoCarousel/usePhotoCarousel'; type Props = { user: firebase.User | null; @@ -109,6 +111,13 @@ const BookmarksPage = ({ user, setUser }: Props): ReactElement => { const [isMobile, setIsMobile] = useState(false); const [showEditSuccessConfirmation, setShowEditSuccessConfirmation] = useState(false); const [showDeleteSuccessConfirmation, setShowDeleteSuccessConfirmation] = useState(false); + const { + carouselPhotos, + carouselStartIndex, + carouselOpen, + showPhotoCarousel, + closePhotoCarousel, + } = usePhotoCarousel([]); useEffect(() => { const handleResize = () => setIsMobile(window.innerWidth <= 600); @@ -121,17 +130,15 @@ const BookmarksPage = ({ user, setUser }: Props): ReactElement => { // Fetch helpful reviews data when the component mounts or when user changes or when toggle changes useEffect(() => { - const fetchLikedReviews = async () => { - if (user) { - const token = await user.getIdToken(true); + if (user) { + user.getIdToken(true).then((token) => { get( - `/api/review/like/${user.uid}`, + `/api/review/like/${user.uid}?status=APPROVED`, { callback: setHelpfulReviewsData, }, createAuthHeaders(token) ); - // this is here so we can get the token when it's fetched and not cause an unauthorized error get( savedAPI, @@ -142,9 +149,8 @@ const BookmarksPage = ({ user, setUser }: Props): ReactElement => { }, createAuthHeaders(token) ); - } - }; - fetchLikedReviews(); + }); + } }, [user, toggle, savedAPI, sortAptsBy]); // Define the type of the properties used for sorting reviews @@ -188,13 +194,17 @@ const BookmarksPage = ({ user, setUser }: Props): ReactElement => { const token = await user.getIdToken(true); const endpoint = dislike ? '/api/remove-like' : '/api/add-like'; await axios.post(endpoint, { reviewId }, createAuthHeaders(token)); - setHelpfulReviewsData((reviews) => - reviews.map((review) => - review.id === reviewId - ? { ...review, likes: (review.likes || defaultLikes) + offsetLikes } - : review - ) - ); + if (dislike) { + setHelpfulReviewsData((reviews) => reviews.filter((review) => review.id !== reviewId)); + } else { + setHelpfulReviewsData((reviews) => + reviews.map((review) => + review.id === reviewId + ? { ...review, likes: (review.likes || defaultLikes) + offsetLikes } + : review + ) + ); + } } catch (err) { throw new Error('Error with liking review'); } @@ -207,6 +217,17 @@ const BookmarksPage = ({ user, setUser }: Props): ReactElement => { const addLike = likeHelper(false); const removeLike = likeHelper(true); + const Modals = ( + <> + + + ); + return (
{showEditSuccessConfirmation && ( @@ -364,6 +385,7 @@ const BookmarksPage = ({ user, setUser }: Props): ReactElement => { setToggle={setToggle} triggerEditToast={showEditSuccessConfirmationToast} triggerDeleteToast={showDeleteSuccessConfirmationToast} + triggerPhotoCarousel={showPhotoCarousel} user={user} setUser={setUser} showLabel={true} @@ -391,6 +413,7 @@ const BookmarksPage = ({ user, setUser }: Props): ReactElement => { )} + {Modals}
); }; diff --git a/frontend/src/pages/LandlordPage.tsx b/frontend/src/pages/LandlordPage.tsx index 8dff19b0..fc00a1cb 100644 --- a/frontend/src/pages/LandlordPage.tsx +++ b/frontend/src/pages/LandlordPage.tsx @@ -12,6 +12,7 @@ import React, { ReactElement, useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import ReviewModal from '../components/LeaveReview/ReviewModal'; import PhotoCarousel from '../components/PhotoCarousel/PhotoCarousel'; +import usePhotoCarousel from '../components/PhotoCarousel/usePhotoCarousel'; import InfoFeatures from '../components/Review/InfoFeatures'; import ReviewComponent from '../components/Review/Review'; import ReviewHeader from '../components/Review/ReviewHeader'; @@ -92,7 +93,13 @@ const LandlordPage = ({ user, setUser }: Props): ReactElement => { const [likedReviews, setLikedReviews] = useState({}); const [likeStatuses, setLikeStatuses] = useState({}); const [reviewOpen, setReviewOpen] = useState(false); - const [carouselOpen, setCarouselOpen] = useState(false); + const { + carouselPhotos, + carouselStartIndex, + carouselOpen, + showPhotoCarousel, + closePhotoCarousel, + } = usePhotoCarousel(landlordData ? landlordData.photos : []); const [showConfirmation, setShowConfirmation] = useState(false); const [showEditSuccessConfirmation, setShowEditSuccessConfirmation] = useState(false); const [showDeleteSuccessConfirmation, setShowDeleteSuccessConfirmation] = useState(false); @@ -179,6 +186,32 @@ const LandlordPage = ({ user, setUser }: Props): ReactElement => { return subscribeLikes(setLikedReviews); }, []); + // Fetch the reviews that the user has liked and set the liked reviews and like statuses. + useEffect(() => { + getUser(false).then((user) => { + if (user) { + user.getIdToken(true).then((token) => { + get( + `/api/review/like/${user.uid}`, + { + callback: (reviews) => { + const likedReviewsMap: Likes = {}; + const likeStatusesMap: Likes = {}; + reviews.forEach((review) => { + likedReviewsMap[review.id] = true; + likeStatusesMap[review.id] = false; + }); + setLikedReviews(likedReviewsMap); + setLikeStatuses(likeStatusesMap); + }, + }, + createAuthHeaders(token) + ); + }); + } + }); + }, []); + useEffect(() => { const checkIfSaved = async () => { try { @@ -306,9 +339,10 @@ const LandlordPage = ({ user, setUser }: Props): ReactElement => { user={user} /> setCarouselOpen(false)} + startIndex={carouselStartIndex} + onClose={closePhotoCarousel} /> ); @@ -346,7 +380,7 @@ const LandlordPage = ({ user, setUser }: Props): ReactElement => { color="secondary" variant="contained" disableElevation - onClick={() => setCarouselOpen(true)} + onClick={() => showPhotoCarousel()} > Show all photos @@ -444,7 +478,7 @@ const LandlordPage = ({ user, setUser }: Props): ReactElement => { color="secondary" variant="contained" disableElevation - onClick={() => setCarouselOpen(true)} + onClick={() => showPhotoCarousel()} > Show all photos @@ -504,7 +538,7 @@ const LandlordPage = ({ user, setUser }: Props): ReactElement => { <> {landlordData && ( - setCarouselOpen(true)} /> + showPhotoCarousel()} /> )} @@ -562,6 +596,7 @@ const LandlordPage = ({ user, setUser }: Props): ReactElement => { setToggle={setToggle} triggerEditToast={showEditSuccessConfirmationToast} triggerDeleteToast={showDeleteSuccessConfirmationToast} + triggerPhotoCarousel={showPhotoCarousel} user={user} setUser={setUser} showLabel={true} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index b02e7e53..4b91701d 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -21,6 +21,8 @@ import { createAuthHeaders, getUser } from '../utils/firebase'; import defaultProfilePic from '../assets/cuapts-bear.png'; import { useTitle } from '../utils'; import { sortReviews } from '../utils/sortReviews'; +import PhotoCarousel from '../components/PhotoCarousel/PhotoCarousel'; +import usePhotoCarousel from '../components/PhotoCarousel/usePhotoCarousel'; type Props = { user: firebase.User | null; @@ -167,6 +169,13 @@ const ProfilePage = ({ user, setUser }: Props): ReactElement => { const [showEditSuccessConfirmation, setShowEditSuccessConfirmation] = useState(false); const [showDeleteSuccessConfirmation, setShowDeleteSuccessConfirmation] = useState(false); const toastTime = 3500; + const { + carouselPhotos, + carouselStartIndex, + carouselOpen, + showPhotoCarousel, + closePhotoCarousel, + } = usePhotoCarousel([]); useTitle('Profile'); @@ -182,6 +191,32 @@ const ProfilePage = ({ user, setUser }: Props): ReactElement => { }); }, [user?.uid, toggle]); + // Fetch the reviews that the user has liked and set the liked reviews and like statuses. + useEffect(() => { + getUser(false).then((user) => { + if (user) { + user.getIdToken(true).then((token) => { + get( + `/api/review/like/${user.uid}`, + { + callback: (reviews) => { + const likedReviewsMap: Likes = {}; + const likeStatusesMap: Likes = {}; + reviews.forEach((review) => { + likedReviewsMap[review.id] = true; + likeStatusesMap[review.id] = false; + }); + setLikedReviews(likedReviewsMap); + setLikeStatuses(likeStatusesMap); + }, + }, + createAuthHeaders(token) + ); + }); + } + }); + }, []); + const likeHelper = (dislike = false) => { return async (reviewId: string) => { setLikeStatuses((reviews) => ({ ...reviews, [reviewId]: true })); @@ -199,7 +234,16 @@ const ProfilePage = ({ user, setUser }: Props): ReactElement => { const token = await user.getIdToken(true); const endpoint = dislike ? '/api/remove-like' : '/api/add-like'; await axios.post(endpoint, { reviewId }, createAuthHeaders(token)); - setLikedReviews((reviews) => ({ ...reviews, [reviewId]: !dislike })); + setLikedReviews((reviews) => { + return { ...reviews, [reviewId]: !dislike }; + }); + setPendingReviews((reviews) => + reviews.map((review) => + review.id === reviewId + ? { ...review, likes: (review.likes || defaultLikes) + offsetLikes } + : review + ) + ); setApprovedReviews((reviews) => reviews.map((review) => review.id === reviewId @@ -261,6 +305,17 @@ const ProfilePage = ({ user, setUser }: Props): ReactElement => { }; }, [isModalOpen, setIsModalOpen]); + const Modals = ( + <> + + + ); + return (
@@ -327,7 +382,7 @@ const ProfilePage = ({ user, setUser }: Props): ReactElement => { {/* Maps list of pending reviews and calls Review Component with fields for each user*/} {sortReviews(pendingReviews, 'date').map((review, index) => ( - + { setToggle={setToggle} triggerEditToast={showEditSuccessConfirmationToast} triggerDeleteToast={showDeleteSuccessConfirmationToast} + triggerPhotoCarousel={showPhotoCarousel} user={user} setUser={setUser} showLabel={true} @@ -352,6 +408,7 @@ const ProfilePage = ({ user, setUser }: Props): ReactElement => { {sortReviews(approvedReviews, 'date').map((review, index) => ( { setToggle={setToggle} triggerEditToast={showEditSuccessConfirmationToast} triggerDeleteToast={showDeleteSuccessConfirmationToast} + triggerPhotoCarousel={showPhotoCarousel} user={user} setUser={setUser} showLabel={true} @@ -384,6 +442,7 @@ const ProfilePage = ({ user, setUser }: Props): ReactElement => {
)} + {Modals} ); };