From 0ec72be36a96dd7f2ff0e1e7f55e642c024f0346 Mon Sep 17 00:00:00 2001 From: minsoeaung Date: Sun, 24 Dec 2023 11:40:42 +0630 Subject: [PATCH] Improve performance and ui --- API/API.csproj | 2 + API/Entities/Brand.cs | 1 + API/Program.cs | 11 +- Client/src/components/AddToCartButton.tsx | 2 +- Client/src/components/Header.tsx | 17 ++- Client/src/components/ProductFilters.tsx | 135 +++++++++++------- Client/src/components/ProductSortBy.tsx | 5 +- .../components/Products/FavouriteButton.tsx | 3 +- .../src/components/Products/ProductCard.tsx | 108 +++++++------- Client/src/components/Products/index.tsx | 77 ++++++---- Client/src/components/ReviewItem.tsx | 10 +- Client/src/components/Reviews.tsx | 6 +- .../components/WriteAReviewModalButton.tsx | 2 +- .../mutations/useUpdateProfilePicture.ts | 11 +- Client/src/pages/Catalog/ProductDetail.tsx | 15 +- Client/src/pages/Catalog/ProductReviews.tsx | 3 +- Client/src/pages/User/MyAccount.tsx | 34 ++++- 17 files changed, 280 insertions(+), 162 deletions(-) diff --git a/API/API.csproj b/API/API.csproj index a7faa50..8376df5 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -9,6 +9,7 @@ + @@ -32,6 +33,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/API/Entities/Brand.cs b/API/Entities/Brand.cs index 9a48339..d344332 100644 --- a/API/Entities/Brand.cs +++ b/API/Entities/Brand.cs @@ -5,5 +5,6 @@ namespace API.Entities; public class Brand { public int Id { get; set; } + // TODO: no comma allowed [Required] public string Name { get; set; } } \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 54fce48..d0300ee 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -6,7 +6,9 @@ using API.Entities; using API.Extensions; using API.Services; +using HealthChecks.UI.Client; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; @@ -15,7 +17,6 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. - builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); @@ -109,6 +110,9 @@ builder.Services.AddMappings(); +builder.Services.AddHealthChecks() + .AddDbContextCheck(); + var app = builder.Build(); app.UseExceptionHandler("/error"); @@ -137,4 +141,9 @@ app.MapFallbackToFile("index.html"); +app.MapHealthChecks("/_health", new HealthCheckOptions +{ + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse +}); + app.Run(); \ No newline at end of file diff --git a/Client/src/components/AddToCartButton.tsx b/Client/src/components/AddToCartButton.tsx index f3cf61d..f8660b0 100644 --- a/Client/src/components/AddToCartButton.tsx +++ b/Client/src/components/AddToCartButton.tsx @@ -37,7 +37,7 @@ export const AddToCartButton = ({ isInCart, productId, buttonProps }: Props) => variant="outline" {...buttonProps} onClick={async (e) => { - e.preventDefault(); + e.stopPropagation(); if (user) { await mutation.mutateAsync({ diff --git a/Client/src/components/Header.tsx b/Client/src/components/Header.tsx index 3d3d033..3ad44c1 100644 --- a/Client/src/components/Header.tsx +++ b/Client/src/components/Header.tsx @@ -67,6 +67,11 @@ const Header = () => { } }, [myAccount]); + useEffect(() => { + const searchTerm = searchParams.get('searchTerm'); + if (!searchTerm) setSearchInputValue(''); + }, [searchParams]); + const search = () => { searchParams.set('searchTerm', searchInputValue); setSearchParams(searchParams); @@ -109,7 +114,13 @@ const Header = () => { - } onClick={onOpen} /> + } + onClick={onOpen} + /> { to="/user/wishlist" aria-label="Wish list" variant="ghost" + colorScheme={window.location.pathname === '/user/wishlist' ? 'blue' : 'gray'} icon={} /> {Array.isArray(wishList) && wishList.length > 0 && ( @@ -201,6 +213,7 @@ const Header = () => { to="/user/cart" aria-label="Cart" variant="ghost" + colorScheme={window.location.pathname === '/user/cart' ? 'blue' : 'gray'} icon={} /> {Array.isArray(cart?.cartItems) && cart!.cartItems.length > 0 && ( @@ -255,7 +268,7 @@ const Header = () => { minW={0} pl={3} > - +
diff --git a/Client/src/components/ProductFilters.tsx b/Client/src/components/ProductFilters.tsx index 974e1ca..20e4c8f 100644 --- a/Client/src/components/ProductFilters.tsx +++ b/Client/src/components/ProductFilters.tsx @@ -1,8 +1,9 @@ import { Box, Button, - Checkbox, Heading, + HStack, + Icon, IconButton, Modal, ModalBody, @@ -11,16 +12,21 @@ import { ModalFooter, ModalHeader, ModalOverlay, + Tag, + TagLabel, + TagLeftIcon, useColorModeValue, useDisclosure, Wrap, } from '@chakra-ui/react'; import { AiOutlineFilter } from 'react-icons/ai'; -import { useCallback, useEffect, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import useProductFilters from '../hooks/queries/useProductFilters.ts'; +import { IoMdRadioButtonOff } from 'react-icons/io'; +import { CheckCircleIcon } from '@chakra-ui/icons'; -export const ProductFilters = () => { +export const ProductFilters = memo(() => { const [searchParams, setSearchParams] = useSearchParams(); const { isOpen, onOpen, onClose } = useDisclosure(); @@ -42,31 +48,25 @@ export const ProductFilters = () => { const catsObj: Record = {}; catsArray.forEach((b) => (catsObj[b] = true)); setSelectedCategories(catsObj); - }, []); + }, [isOpen]); - const handleBrandsChange = useCallback((event: React.ChangeEvent) => { + const handleBrandsChange = (name: string) => { setSelectedBrands((prevState) => { const d = { ...prevState }; - if (event.target.checked) { - d[event.target.name] = true; - } else { - delete d[event.target.name]; - } + if (d[name]) delete d[name]; + else d[name] = true; return d; }); - }, []); + }; - const handleCategoriesChange = useCallback((event: React.ChangeEvent) => { + const handleCategoriesChange = (name: string) => { setSelectedCategories((prevState) => { const d = { ...prevState }; - if (event.target.checked) { - d[event.target.name] = true; - } else { - delete d[event.target.name]; - } + if (d[name]) delete d[name]; + else d[name] = true; return d; }); - }, []); + }; const handleSave = () => { searchParams.set('brands', Object.keys(selectedBrands).join(',')); @@ -75,16 +75,20 @@ export const ProductFilters = () => { onClose(); }; - // const filterApplied = !!Object.keys(selectedCategories).length || !!Object.keys(selectedCategories).length + const handleReset = () => { + setSelectedCategories({}); + setSelectedBrands({}); + }; + const filterApplied = !!Object.keys(selectedCategories).length || !!Object.keys(selectedBrands).length; + return ( <> } onClick={onOpen} isLoading={isLoading} @@ -96,50 +100,79 @@ export const ProductFilters = () => { - Brands + Filter by brand - + {data && - data.brands.map((brand) => ( - - {brand.name} - - ))} + data.brands.map((brand) => { + const isSelected = selectedBrands[brand.name]; + + return ( + handleBrandsChange(brand.name)} + cursor="pointer" + userSelect="none" + > + + {isSelected ? ( + + ) : ( + + )} + + {brand.name} + + ); + })} - - Categories + + Filter by category - + {data && - data.categories.map((category) => ( - - {category.name} - - ))} + data.categories.map((category) => { + const isSelected = selectedCategories[category.name]; + + return ( + handleCategoriesChange(category.name)} + cursor="pointer" + userSelect="none" + > + + {isSelected ? ( + + ) : ( + + )} + + {category.name} + + ); + })} - - + + + + + ); -}; +}); diff --git a/Client/src/components/ProductSortBy.tsx b/Client/src/components/ProductSortBy.tsx index 19c6c37..30ff144 100644 --- a/Client/src/components/ProductSortBy.tsx +++ b/Client/src/components/ProductSortBy.tsx @@ -1,6 +1,7 @@ import { Button, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react'; import { ChevronDownIcon } from '@chakra-ui/icons'; import { useSearchParams } from 'react-router-dom'; +import { memo } from 'react'; const sortMenus = { name: 'Name', @@ -10,7 +11,7 @@ const sortMenus = { _: '', }; -export const ProductSortBy = () => { +export const ProductSortBy = memo(() => { const [params, setParams] = useSearchParams(); const handleSortMenuClick = (value: string) => () => { @@ -40,4 +41,4 @@ export const ProductSortBy = () => {
); -}; +}); diff --git a/Client/src/components/Products/FavouriteButton.tsx b/Client/src/components/Products/FavouriteButton.tsx index c7f4927..6f77666 100644 --- a/Client/src/components/Products/FavouriteButton.tsx +++ b/Client/src/components/Products/FavouriteButton.tsx @@ -31,7 +31,8 @@ export const FavouriteButton = ({ isChecked, productId, iconButtonProps }: Props boxShadow="base" {...iconButtonProps} onClick={async (e) => { - e.preventDefault(); + e.stopPropagation(); + await mutation.mutateAsync({ type: isChecked ? 'REMOVE' : 'ADD', productId, diff --git a/Client/src/components/Products/ProductCard.tsx b/Client/src/components/Products/ProductCard.tsx index cd90952..5ae14d7 100644 --- a/Client/src/components/Products/ProductCard.tsx +++ b/Client/src/components/Products/ProductCard.tsx @@ -13,7 +13,7 @@ import { import { FavouriteButton } from './FavouriteButton'; import { PriceTag } from '../PriceTag'; import { Product } from '../../types/product'; -import { Link } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { Rating } from '../Rating.tsx'; import { AddToCartButton } from '../AddToCartButton.tsx'; import { PRODUCT_IMAGES } from '../../constants/fileUrls.ts'; @@ -35,61 +35,63 @@ export const ProductCard = memo((props: Props) => { const { user } = useAuth(); + const navigate = useNavigate(); + + const goToDetailPage = () => navigate(`/catalog/${product.id}`); + return ( - - - - - {name}} - onError={() => { - setImageSrc(placeholderImage); - }} - borderRadius={{ base: 'md', md: 'xl' }} - /> - - {user && ( - - )} - - - - - - {name} - - - - - - - - ({numOfRatings}) + + + + {name}} + onError={() => { + setImageSrc(placeholderImage); + }} + borderRadius={{ base: 'md', md: 'xl' }} + /> + + {user && ( + + )} + + + + + + {name} - - - - {product.quantityInStock === 0 ? ( - - Out of stock - - ) : ( - - )} + + + + + + ({numOfRatings}) + + + + + {product.quantityInStock === 0 ? ( + + Out of stock + + ) : ( + + )} - +
); }); diff --git a/Client/src/components/Products/index.tsx b/Client/src/components/Products/index.tsx index 303074e..472722b 100644 --- a/Client/src/components/Products/index.tsx +++ b/Client/src/components/Products/index.tsx @@ -1,10 +1,9 @@ -import { Box, Center, Flex, IconButton } from '@chakra-ui/react'; +import { Box, CloseButton, Flex, HStack, Icon, Text, VStack } from '@chakra-ui/react'; import { ProductGrid } from './ProductGrid.tsx'; import { ProductCard } from './ProductCard.tsx'; import { usePaginatedProducts } from '../../hooks/queries/usePaginatedProducts.ts'; import ResponsivePagination from 'react-responsive-pagination'; import 'react-responsive-pagination/themes/classic.css'; -import { ArrowUpIcon } from '@chakra-ui/icons'; import { useSearchParams } from 'react-router-dom'; import AntdSpin from '../AntdSpin'; import { useWishList } from '../../hooks/queries/useWishList.ts'; @@ -14,6 +13,7 @@ import { ProductSortBy } from '../ProductSortBy.tsx'; import { ProductFilters } from '../ProductFilters.tsx'; import { ErrorDisplay } from '../ErrorDisplay.tsx'; import { ApiError } from '../../types/apiError.ts'; +import { FcSearch } from 'react-icons/fc'; export const Products = () => { const [params, setParams] = useSearchParams(); @@ -29,6 +29,13 @@ export const Products = () => { const { data: wishList } = useWishList(); const { data: cart } = useCart(); + const searchTerm = params.get('searchTerm') || ''; + + const clearSearchTerm = () => { + params.delete('searchTerm'); + setParams(params); + }; + return ( {isFetching && !isLoading && } @@ -41,6 +48,15 @@ export const Products = () => { ) : ( data && ( <> + {!!searchTerm && ( + + + Search Results for '{searchTerm}' ({data.pagination.totalCount}{' '} + {data.pagination.totalCount > 0 ? 'items' : 'item'}) + + + + )} @@ -63,35 +79,40 @@ export const Products = () => { /> ))} {data.results.length === 0 && ( -
-

No product found!

-
+ + + + No product found! + + )}
- -
- {data.results.length > 10 && ( -
- } - onClick={() => { - window.scrollTo({ - top: 0, - behavior: 'smooth', - }); - }} - /> -
- )} + + + + {/*
*/} + {/*{data.results.length > 10 && (*/} + {/*
*/} + {/* }*/} + {/* onClick={() => {*/} + {/* window.scrollTo({*/} + {/* top: 0,*/} + {/* behavior: 'smooth',*/} + {/* });*/} + {/* }}*/} + {/* />*/} + {/*
*/} + {/*)}*/} ) )} diff --git a/Client/src/components/ReviewItem.tsx b/Client/src/components/ReviewItem.tsx index 28942cf..84be3b2 100644 --- a/Client/src/components/ReviewItem.tsx +++ b/Client/src/components/ReviewItem.tsx @@ -32,7 +32,7 @@ import { } from '@chakra-ui/react'; import { Rating } from './Rating.tsx'; import { Review } from '../types/review.ts'; -import { useRef, useState } from 'react'; +import { memo, useRef, useState } from 'react'; import { useReviewCUD } from '../hooks/mutations/useReviewCUD.ts'; import { DeleteIcon, EditIcon, HamburgerIcon } from '@chakra-ui/icons'; @@ -41,7 +41,7 @@ type Props = { ownByUser?: boolean; }; -export const ReviewItem = ({ data, ownByUser }: Props) => { +export const ReviewItem = memo(({ data, ownByUser }: Props) => { const { review, rating, userName, userProfilePicture, updatedAt, productId } = data; const [ratingInput, setRatingInput] = useState(String(rating)); const [reviewInput, setReviewInput] = useState(review); @@ -51,7 +51,7 @@ export const ReviewItem = ({ data, ownByUser }: Props) => { const cancelRef = useRef(null); const mutation = useReviewCUD(); - + const handleDelete = async () => { await mutation .mutateAsync({ @@ -110,7 +110,7 @@ export const ReviewItem = ({ data, ownByUser }: Props) => { - + Edit review @@ -207,4 +207,4 @@ export const ReviewItem = ({ data, ownByUser }: Props) => {
); -}; +}); diff --git a/Client/src/components/Reviews.tsx b/Client/src/components/Reviews.tsx index f845791..fbd1976 100644 --- a/Client/src/components/Reviews.tsx +++ b/Client/src/components/Reviews.tsx @@ -5,14 +5,14 @@ import { useReviews } from '../hooks/queries/useReviews.ts'; import AntdSpin from './AntdSpin'; import { useAuth } from '../context/AuthContext.tsx'; import { useMyReview } from '../hooks/queries/useMyReview.ts'; -import { useEffect, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import { useIsVisible } from '../hooks/useIsVisible.ts'; type Props = { firstPageOnly?: boolean; }; -export const Reviews = ({ firstPageOnly }: Props) => { +export const Reviews = memo(({ firstPageOnly }: Props) => { const { id } = useParams(); const { user } = useAuth(); @@ -71,4 +71,4 @@ export const Reviews = ({ firstPageOnly }: Props) => {
); -}; +}); diff --git a/Client/src/components/WriteAReviewModalButton.tsx b/Client/src/components/WriteAReviewModalButton.tsx index 707f32c..876091e 100644 --- a/Client/src/components/WriteAReviewModalButton.tsx +++ b/Client/src/components/WriteAReviewModalButton.tsx @@ -57,7 +57,7 @@ export const WriteAReviewModalButton = ({ productId }: Props) => { - + Write a review diff --git a/Client/src/hooks/mutations/useUpdateProfilePicture.ts b/Client/src/hooks/mutations/useUpdateProfilePicture.ts index 022fdc4..04847c5 100644 --- a/Client/src/hooks/mutations/useUpdateProfilePicture.ts +++ b/Client/src/hooks/mutations/useUpdateProfilePicture.ts @@ -9,16 +9,18 @@ export const useUpdateProfilePicture = () => { const toast = useToast(); return useMutation( - async (picture: FileList) => { + async (picture: File) => { const formData = new FormData(); - formData.set('picture', picture[0]); + formData.set('picture', picture); return await ApiClient.post(`api/Accounts/profile-picture`, formData); }, { onSuccess: async () => { toast({ - title: 'Success', + title: 'Profile picture updated.', + description: 'Picture may take up to 15 minutes to reflect in the UI due to caching.', status: 'success', + duration: 9000, isClosable: true, }); @@ -28,8 +30,7 @@ export const useUpdateProfilePicture = () => { if (!data.profilePicture) { await queryClient.invalidateQueries(ACCOUNT); } else { - // TODO: image is cached for 300s, user might think the image is not updated - data.profilePicture = `${data.profilePicture}?time=${Date.now()}`; + data.profilePicture = `${data.profilePicture}?abc=${Date.now()}`; queryClient.setQueryData(ACCOUNT, data); } } diff --git a/Client/src/pages/Catalog/ProductDetail.tsx b/Client/src/pages/Catalog/ProductDetail.tsx index 5a26095..9869d52 100644 --- a/Client/src/pages/Catalog/ProductDetail.tsx +++ b/Client/src/pages/Catalog/ProductDetail.tsx @@ -34,7 +34,7 @@ import AntdSpin from '../../components/AntdSpin'; import { AiOutlineHeart } from 'react-icons/ai'; import { useToggleWishList } from '../../hooks/mutations/useToggleWishList.ts'; import { PRODUCT_IMAGES } from '../../constants/fileUrls.ts'; -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { useAuth } from '../../context/AuthContext.tsx'; import { AddToCartButton } from '../../components/AddToCartButton.tsx'; import { Reviews } from '../../components/Reviews.tsx'; @@ -65,6 +65,13 @@ const ProductDetailPage = () => { const goToLoginPage = () => navigate('/login'); + useEffect(() => { + window.scrollTo({ + top: 0, + behavior: 'instant', + }); + }, []); + if (isLoading) { return ( @@ -96,7 +103,7 @@ const ProductDetailPage = () => { return ( - + @@ -130,9 +137,7 @@ const ProductDetailPage = () => { colorScheme={isFavorite ? 'red' : 'blue'} width="50%" variant={'outline'} - onClick={async (e) => { - e.preventDefault(); - + onClick={async () => { if (user) { await favoriteMutation.mutateAsync({ type: isFavorite ? 'REMOVE' : 'ADD', diff --git a/Client/src/pages/Catalog/ProductReviews.tsx b/Client/src/pages/Catalog/ProductReviews.tsx index 2580104..2ff473b 100644 --- a/Client/src/pages/Catalog/ProductReviews.tsx +++ b/Client/src/pages/Catalog/ProductReviews.tsx @@ -47,12 +47,13 @@ const ProductReviewsPage = () => { return ( - + {data && ( { const { data, isLoading, isError } = useMyAccount(); const inputRef = useRef(null); const updateProfileMutation = useUpdateProfilePicture(); + const toast = useToast(); - const updateProfilePicture = async (pic: FileList | null) => { - !!pic && (await updateProfileMutation.mutateAsync(pic)); + const updateProfilePicture = async (files: FileList | null) => { + const maxSize = 1024 * 1024 * 5; // 5MB + + if (files?.length) { + const file = files[0]; + if (file.size > maxSize) { + toast({ + title: 'Picture Size Cannot Be More Than 5MB', + status: 'warning', + isClosable: true, + duration: 5000, + position: 'top', + }); + } else { + await updateProfileMutation.mutateAsync(file); + } + } }; if (isLoading) {