From 85a82b1eaffeefc1dd6c751b6f114dd37447069d Mon Sep 17 00:00:00 2001 From: Saddock Kabandana Date: Mon, 26 Aug 2024 11:38:27 +0200 Subject: [PATCH] ft-shop-page (#84) --- src/App.scss | 4 +- src/assets/styles/ShopCard.scss | 57 +++++++ src/assets/styles/ShopPage.scss | 13 ++ src/components/layout/Footer.tsx | 4 +- src/components/layout/Header.tsx | 2 +- src/components/layout/Sample.tsx | 14 +- src/components/product/ShopCard.tsx | 64 ++++++++ src/pages/AboutUs.tsx | 2 +- src/pages/ProductsByShopPage.tsx | 142 ++++++++++++++++++ src/pages/ShopPage.tsx | 39 +++++ src/router.tsx | 8 +- src/store/features/product/productService.tsx | 12 ++ src/store/features/product/shopSlice.tsx | 99 ++++++++++++ src/store/reducers/index.ts | 4 +- 14 files changed, 447 insertions(+), 17 deletions(-) create mode 100644 src/assets/styles/ShopCard.scss create mode 100644 src/assets/styles/ShopPage.scss create mode 100644 src/components/product/ShopCard.tsx create mode 100644 src/pages/ProductsByShopPage.tsx create mode 100644 src/pages/ShopPage.tsx create mode 100644 src/store/features/product/shopSlice.tsx diff --git a/src/App.scss b/src/App.scss index c415562e..15dd7ec1 100644 --- a/src/App.scss +++ b/src/App.scss @@ -51,4 +51,6 @@ @import "./assets/styles/SellerRegistration.scss"; @import "./assets/styles/ServicesPage.scss"; @import "./assets/styles/Settings.scss"; -@import "./assets/styles/HomePage.scss"; \ No newline at end of file +@import "./assets/styles/HomePage.scss"; +@import "./assets/styles/ShopCard.scss"; +@import "./assets/styles/ShopPage.scss"; \ No newline at end of file diff --git a/src/assets/styles/ShopCard.scss b/src/assets/styles/ShopCard.scss new file mode 100644 index 00000000..99db7ba7 --- /dev/null +++ b/src/assets/styles/ShopCard.scss @@ -0,0 +1,57 @@ +.shop-card { + background-color: white; + padding: 20px; + border-radius: 8px; + text-align: left; + box-shadow: 0.1rem 0.1rem 0.5rem rgba(0, 0, 0, 0.2); + background-color: $secondary-color-light; + transform: none; + &:hover { + transform: scale(1.02) !important; + box-shadow: 1rem 1rem 1rem rgba(0, 0, 0, 0.3); + transition: transform 0.1s ease !important; + } + + h2 { + font-size: 1.5em; + margin-bottom: 1em; + } + + .items { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + margin-bottom: 20px; + + .item { + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + + img { + width: 100%; + height: 10rem; + object-fit: cover; + } + + p { + margin-top: 10px; + font-size: 1em; + color: #333; + } + } + } + + .see-more { + color: $primary-color; + text-decoration: none; + font-weight: bold; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + } + \ No newline at end of file diff --git a/src/assets/styles/ShopPage.scss b/src/assets/styles/ShopPage.scss new file mode 100644 index 00000000..4bf30df2 --- /dev/null +++ b/src/assets/styles/ShopPage.scss @@ -0,0 +1,13 @@ +.shop-container{ + display: grid; + grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr)); + gap: 3rem; + padding: 4rem; + .loader { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; + } +} \ No newline at end of file diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index da8c6912..1e806a77 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -107,10 +107,10 @@ function Footer() { Email: - ecommerceninjas@gmail.com + ecommerceninjas45@gmail.com diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 9b660745..a447c71b 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -145,7 +145,7 @@ const Header: React.FC = () => {

Email us

-

support@ecommerce-ninjas.com

+

ecommerceninjas45@gmail.com

diff --git a/src/components/layout/Sample.tsx b/src/components/layout/Sample.tsx index 2975a3e6..86b09c1a 100644 --- a/src/components/layout/Sample.tsx +++ b/src/components/layout/Sample.tsx @@ -11,13 +11,13 @@ import rightTop from "../../../public/assets/images/right-top.png"; import leftBottom from "../../../public/assets/images/left-bottom.png"; import rightBottom from "../../../public/assets/images/right-bottom.png"; const images = [ - '/assets/middle.png', - '/assets/images/1293.jpg', - '/assets/images/add-cart-buy-now-online-commerce-graphic-concept.jpg', - '/assets/images/cropped-image-woman-inputting-card-information-key-phone-laptop-while-shopping-online.jpg', - '/assets/images/cyber-monday-shopping-sales.jpg', - '/assets/images/happy-man-with-handbags-dancing-after-shopping-spree.jpg', - '/assets/images/laptop-shopping-bags-online-shopping-concept.jpg', + 'https://res.cloudinary.com/djrmfg6k9/image/upload/v1724663918/middle_pmpcqw.png', + 'https://res.cloudinary.com/djrmfg6k9/image/upload/v1724663915/add-cart-buy-now-online-commerce-graphic-concept_mvuvex.jpg', + 'https://res.cloudinary.com/djrmfg6k9/image/upload/v1724663912/happy-man-with-handbags-dancing-after-shopping-spree_kieiwn.jpg', + 'https://res.cloudinary.com/djrmfg6k9/image/upload/v1724663909/laptop-shopping-bags-online-shopping-concept_pytoky.jpg', + 'https://res.cloudinary.com/djrmfg6k9/image/upload/v1724663908/cropped-image-woman-inputting-card-information-key-phone-laptop-while-shopping-online_l7ioph.jpg', + 'https://res.cloudinary.com/djrmfg6k9/image/upload/v1724663906/1293_b6kg3u.jpg', + 'https://res.cloudinary.com/djrmfg6k9/image/upload/v1724663890/cyber-monday-shopping-sales_d1gjm6.jpg', ]; const Sample: React.FC = () => { diff --git a/src/components/product/ShopCard.tsx b/src/components/product/ShopCard.tsx new file mode 100644 index 00000000..a3a53152 --- /dev/null +++ b/src/components/product/ShopCard.tsx @@ -0,0 +1,64 @@ +/* eslint-disable */ +import React, { useEffect } from 'react'; +import { useAppDispatch, useAppSelector } from '../../store/store'; +import { fetchProductsByShopId } from '../../store/features/product/shopSlice'; +import { useNavigate } from 'react-router-dom'; + +interface ShopCardProps { + shopId: string; +} + +const ShopCard: React.FC = ({ shopId }) => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + // const shop = useAppSelector((state) => state.shop.shops.find((shop) => shop.id === shopId)); + // const products = useAppSelector((state) => state.shop.shopProductsByShop?.[shopId] || []); + const { + shops, + shopProductsByShop + } = useAppSelector((state: any) => state.shop); + + const shop = shops.find((shop: any) => shop.id === shopId); + const products = shopProductsByShop?.[shopId] || []; + + useEffect(() => { + if (shopId) { + dispatch(fetchProductsByShopId(shopId)); + } + }, [dispatch, shopId]); + + const truncateText = (text: string, length: number) => { + return text.length > length ? `${text.substring(0, length)}...` : text; + }; + + if (products.length < 4) { + return null; + } + + return ( +
+

{shop?.name || 'Shop Name'}

+
+ {products.slice(0, 4).map((product) => ( +
navigate(`/product/${product.id}`)} + > + {product.name} +

{truncateText(product.name, 20)}

+
+ ))} +
+ +
+ ); +}; + +export default ShopCard; diff --git a/src/pages/AboutUs.tsx b/src/pages/AboutUs.tsx index 2f66d596..d552a161 100644 --- a/src/pages/AboutUs.tsx +++ b/src/pages/AboutUs.tsx @@ -25,7 +25,7 @@ export const AboutUs = () => { image: "https://res.cloudinary.com/djrmfg6k9/image/upload/v1723551875/SaddockAime1_bqtq7b.jpg", position: "Full Stack Developer", - linkedIn: "https://github.com/SaddockAime", + linkedIn: "https://www.linkedin.com/in/saddock-kabandana-89b914237/", github: "https://github.com/SaddockAime", }, { diff --git a/src/pages/ProductsByShopPage.tsx b/src/pages/ProductsByShopPage.tsx new file mode 100644 index 00000000..b66d99d7 --- /dev/null +++ b/src/pages/ProductsByShopPage.tsx @@ -0,0 +1,142 @@ +/* eslint-disable */ +import React, { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useAppDispatch, useAppSelector } from "../store/store"; +import { fetchProductsByShopId } from "../store/features/product/shopSlice"; +import Product from "../components/product/Product"; +import { PuffLoader } from "react-spinners"; +import { Meta } from "../components/Meta"; +import { toast } from "react-toastify"; +import { createCart, getUserCarts } from "../store/features/carts/cartSlice"; + +const ProductsByShopPage: React.FC = () => { + const { shopId } = useParams<{ shopId: string }>(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const [cartResponseData, setCartResponseData] = useState(null); + + const { shops, shopProductsByShop, isLoadingProducts, isErrorProducts, isSuccessProducts, message } = useAppSelector( + (state: any) => state.shop + ); + + const [priceRange, setPriceRange] = useState([0, 0]); + const [maxPrice, setMaxPrice] = useState(0); + const [minPrice, setMinPrice] = useState(0); + const [visibleProducts, setVisibleProducts] = useState(20); + + useEffect(() => { + if (shopId) { + dispatch(fetchProductsByShopId(shopId)); + } + }, [dispatch, shopId]); + + const shop = shops.find((shop: any) => shop.id === shopId); + const products = shopProductsByShop ? shopProductsByShop[shopId] : []; + + useEffect(() => { + if (products && products.length > 0) { + const calculatedMaxPrice = products.reduce((max, product) => Math.max(max, product.price), 0); + const calculatedMinPrice = products.reduce((min, product) => Math.min(min, product.price), calculatedMaxPrice); + + setMaxPrice(calculatedMaxPrice); + setMinPrice(calculatedMinPrice); + setPriceRange([calculatedMinPrice, calculatedMaxPrice]); + } + }, [products]); + + const handleAddProductToCart = async (productId: string, quantity = 1) => { + try { + const response = await dispatch( + createCart({ productId, quantity }) + ).unwrap(); + + if (response.data) { + toast.success(response.message); + const updatedResponse = await dispatch(getUserCarts()).unwrap(); + setCartResponseData(updatedResponse.data); + } else { + toast.error(response.message); + } + } catch (error: any) { + if (error === "Not authorized") { + localStorage.setItem("pendingCartProduct", productId); + toast.error("Please login first"); + navigate("/login"); + } else { + toast.error("Something went wrong. Please try again later."); + } + } + }; + + const filteredProducts = products.filter((product: any) => { + const price = parseFloat(product.price); + return price >= priceRange[0] && price <= priceRange[1]; + }); + + const handleLoadMore = () => { + setVisibleProducts((prevVisibleProducts) => prevVisibleProducts + 20); + }; + + return ( + <> + +
+ {isLoadingProducts ? ( +
+ +
+ ) : isErrorProducts ? ( +
+

{message || "Something went wrong. Please try again later."}

+
+ ) : ( +
+
+

{shop?.name || 'Shop'}

+
+
+
+ + + setPriceRange([priceRange[0], Number(e.target.value)]) + } + /> + {priceRange[0]}RWF - {priceRange[1]}RWF +
+
+
+ {isSuccessProducts && + Array.isArray(filteredProducts) && + filteredProducts + .slice(0,visibleProducts) + .map((product: any) => ( + + ))} +
+ {visibleProducts < products.length && ( +
+ +
+ )} +
+ )} +
+ + ); +}; + +export default ProductsByShopPage; diff --git a/src/pages/ShopPage.tsx b/src/pages/ShopPage.tsx new file mode 100644 index 00000000..06d28f35 --- /dev/null +++ b/src/pages/ShopPage.tsx @@ -0,0 +1,39 @@ +/* eslint-disable */ +import React, { useEffect } from 'react'; +import { useAppDispatch, useAppSelector } from '../store/store'; +import { fetchAllShops } from '../store/features/product/shopSlice'; +import { Meta } from "../components/Meta"; +import ShopCard from '../components/product/ShopCard'; +import { PuffLoader } from 'react-spinners'; + +const ShopPage: React.FC = () => { + const dispatch = useAppDispatch(); + const { shops, isLoadingShops, isErrorShops, isSuccessShops } = useAppSelector((state) => state.shop); + + useEffect(() => { + dispatch(fetchAllShops()); + }, [dispatch]); + + return ( + <> + +
+ {isLoadingShops ? ( +
+ +
+ ) : isErrorShops ? ( +

Something went wrong. Please try again later.

+ ) : isSuccessShops && shops && shops.length > 0 ? ( + shops.map((shop) => ( + + )) + ) : ( +

No shops available

+ )} +
+ + ); +}; + +export default ShopPage; diff --git a/src/router.tsx b/src/router.tsx index 4f4146eb..e9540b07 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -34,6 +34,8 @@ import { Requests } from "./pages/admin/Requests"; import { ViewRequest } from "./pages/admin/VewRequest"; import { AboutUs } from "./pages/AboutUs"; import { SellerRegistrationPage } from "./pages/seller/SellerRegistrationPage"; +import ShopPage from './pages/ShopPage'; +import ProductsByShopPage from './pages/ProductsByShopPage'; import { Settings } from "./pages/admin/Settings"; const AppRouter: React.FC = () => { @@ -60,12 +62,12 @@ const AppRouter: React.FC = () => { - } /> } /> + } /> + } /> { - } /> } /> diff --git a/src/store/features/product/productService.tsx b/src/store/features/product/productService.tsx index b0a7d7f1..a7f05bc5 100644 --- a/src/store/features/product/productService.tsx +++ b/src/store/features/product/productService.tsx @@ -102,6 +102,16 @@ const deleteProduct = async (productId) => { return response.data; } +const fetchAllShops = async () => { + const response = await axiosInstance.get(`/api/shop/get-all-shops`); + return response.data; +}; + +const fetchProductsByShopId = async (id: string) => { + const response = await axiosInstance.get(`/api/shop/get-products-by-shop/${id}`); + return response.data; +}; + const productService = { fetchProducts, fetchSingleProduct, @@ -116,5 +126,7 @@ const productService = { sellerGetAllProducts, sellerGetOrderHistory, deleteProduct, + fetchAllShops, + fetchProductsByShopId, } export default productService; \ No newline at end of file diff --git a/src/store/features/product/shopSlice.tsx b/src/store/features/product/shopSlice.tsx new file mode 100644 index 00000000..fbf82cc6 --- /dev/null +++ b/src/store/features/product/shopSlice.tsx @@ -0,0 +1,99 @@ +/* eslint-disable */ +import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; +import productService from "./productService"; +import { IShop, IProduct } from "../../../utils/types/product"; +import { getErrorMessage } from "../../../utils/axios/axiosInstance"; + +const initialState: { + shops: IShop[] | null; + shopProductsByShop: { [key: string]: IProduct[] } | null; + isLoadingShops: boolean; + isErrorShops: boolean; + isSuccessShops: boolean; + isLoadingProducts: boolean; + isErrorProducts: boolean; + isSuccessProducts: boolean; + message: string; +} = { + shops: [], + shopProductsByShop: null, + isLoadingShops: false, + isErrorShops: false, + isSuccessShops: false, + isLoadingProducts: false, + isErrorProducts: false, + isSuccessProducts: false, + message: '', +}; + +export const fetchAllShops = createAsyncThunk("shop/fetchAllShops", async (_, thunkApi) => { + try { + const response = await productService.fetchAllShops(); + return response.data.shops; + } catch (error) { + return thunkApi.rejectWithValue(getErrorMessage(error)); + } +}); + +export const fetchProductsByShopId = createAsyncThunk("shop/fetchProductsByShopId", async (id, thunkApi) => { + try { + const response = await productService.fetchProductsByShopId(id); + return response.data.products; + } catch (error) { + return thunkApi.rejectWithValue(getErrorMessage(error)); + } +}); + +const shopSlice = createSlice({ + name: "shop", + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + // Fetch All Shops + .addCase(fetchAllShops.pending, (state) => { + state.isLoadingShops = true; + state.isErrorShops = false; + state.isSuccessShops = false; + state.message = ''; // Clear previous messages + }) + .addCase(fetchAllShops.fulfilled, (state, action: PayloadAction) => { + state.isLoadingShops = false; + state.isSuccessShops = true; + state.isErrorShops = false; + state.shops = action.payload; + }) + .addCase(fetchAllShops.rejected, (state, action: PayloadAction) => { + state.isLoadingShops = false; + state.isErrorShops = true; + state.isSuccessShops = false; + state.message = action.payload; + }) + + // Fetch Products by Shop ID + .addCase(fetchProductsByShopId.pending, (state) => { + state.isLoadingProducts = true; + state.isErrorProducts = false; + state.isSuccessProducts = false; + state.message = ''; // Clear previous messages + }) + .addCase(fetchProductsByShopId.fulfilled, (state, action) => { + state.isLoadingProducts = false; + state.isSuccessProducts = true; + state.isErrorProducts = false; + const shopId = action.meta.arg; + if (!state.shopProductsByShop) { + state.shopProductsByShop = {}; + } + state.shopProductsByShop[shopId] = action.payload; + }) + .addCase(fetchProductsByShopId.rejected, (state, action: PayloadAction) => { + state.isLoadingProducts = false; + state.isErrorProducts = true; + state.isSuccessProducts = false; + state.message = action.payload; + }); + }, +}); + +export default shopSlice.reducer; diff --git a/src/store/reducers/index.ts b/src/store/reducers/index.ts index f53fa139..9af15a23 100644 --- a/src/store/reducers/index.ts +++ b/src/store/reducers/index.ts @@ -13,6 +13,7 @@ import cartReducer from "../features/carts/cartSlice"; import chatReducer from '../features/chat/chatSlice'; import userReducer from "../features/user/userSlice" import { CLEAR_IMAGES, RESET_STATE } from '../actions/resetAction'; +import shopReducer from "../features/product/shopSlice"; const appReducer = combineReducers({ initialMessage: welcomeReducer, @@ -26,7 +27,8 @@ const appReducer = combineReducers({ admin: adminReducer, cart: cartReducer, chat: chatReducer, - user: userReducer + user: userReducer, + shop: shopReducer }); const rootReducer = (state, action) => {