From 575a4a8bd85b5f01abcce3620dd8f3766db6d60f Mon Sep 17 00:00:00 2001 From: Joseph Odunsi Date: Tue, 2 Jan 2024 23:15:09 +0100 Subject: [PATCH] Add createdAt column to products table and implement getAllProductsAdmin endpoint --- client/src/App.jsx | 12 ++ client/src/components/admin/Header.jsx | 77 +++++++++++++ .../admin/sidebar/DesktopSidebar.jsx | 11 ++ .../components/admin/sidebar/MainContent.jsx | 69 ++++++++++++ .../admin/sidebar/MobileSidebar.jsx | 40 +++++++ .../admin/sidebar/SidebarSubMenu.jsx | 61 +++++++++++ client/src/components/admin/sidebar/index.jsx | 13 +++ client/src/context/ProductContext.jsx | 14 ++- client/src/context/SidebarContext.jsx | 36 ++++++ .../hooks/admin/product/useGetProducts.jsx | 0 client/src/hooks/useAxios.jsx | 31 ++++++ client/src/index.jsx | 31 +++--- client/src/layout/AdminLayout.jsx | 40 +++++++ client/src/pages/ProductList.jsx | 5 +- client/src/pages/admin/Dashboard.jsx | 4 + client/src/pages/admin/ProductList.jsx | 103 ++++++++++++++++++ client/src/pages/admin/index.jsx | 2 + client/src/services/product.service.js | 9 +- server/config/index.js | 6 +- server/config/init.sql | 1 + server/controllers/products.controller.js | 12 +- server/db/product.db.js | 49 ++++++++- server/routes/admin/index.js | 6 + server/routes/admin/product.js | 28 +++++ server/routes/index.js | 2 + server/routes/product.js | 4 + server/services/product.service.js | 11 ++ 27 files changed, 645 insertions(+), 32 deletions(-) create mode 100644 client/src/components/admin/Header.jsx create mode 100644 client/src/components/admin/sidebar/DesktopSidebar.jsx create mode 100644 client/src/components/admin/sidebar/MainContent.jsx create mode 100644 client/src/components/admin/sidebar/MobileSidebar.jsx create mode 100644 client/src/components/admin/sidebar/SidebarSubMenu.jsx create mode 100644 client/src/components/admin/sidebar/index.jsx create mode 100644 client/src/context/SidebarContext.jsx create mode 100644 client/src/hooks/admin/product/useGetProducts.jsx create mode 100644 client/src/hooks/useAxios.jsx create mode 100644 client/src/layout/AdminLayout.jsx create mode 100644 client/src/pages/admin/Dashboard.jsx create mode 100644 client/src/pages/admin/ProductList.jsx create mode 100644 client/src/pages/admin/index.jsx create mode 100644 server/routes/admin/index.js create mode 100644 server/routes/admin/product.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 76ded91..6881a07 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,4 +1,5 @@ import Spinner from "components/Spinner"; +import AdminLayout from "layout/AdminLayout"; import Layout from "layout/Layout"; import { Account, @@ -14,6 +15,8 @@ import { Register, ResetPassword, } from "pages"; +import { AdminProductList } from "pages/admin"; +import Dashboard from "pages/admin/Dashboard"; import { Suspense } from "react"; import { Toaster } from "react-hot-toast"; import { Route, Routes } from "react-router-dom"; @@ -36,6 +39,15 @@ function App() { } /> } /> } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/client/src/components/admin/Header.jsx b/client/src/components/admin/Header.jsx new file mode 100644 index 0000000..da8da92 --- /dev/null +++ b/client/src/components/admin/Header.jsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +// import { SidebarContext } from "../context/SidebarContext"; +// import { +// SearchIcon, +// MoonIcon, +// SunIcon, +// BellIcon, +// MenuIcon, +// OutlinePersonIcon, +// OutlineCogIcon, +// OutlineLogoutIcon, +// } from "../icons"; +import { Avatar, Dropdown, DropdownItem } from "@windmill/react-ui"; +import { useSidebar } from "context/SidebarContext"; +import { LogOut, Menu, Settings, User } from "react-feather"; + +function Header() { + const { toggleSidebar } = useSidebar(); + + const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); + + function handleProfileClick() { + setIsProfileMenuOpen(!isProfileMenuOpen); + } + + return ( +
+
+ {/* */} + +
    +
  • + + setIsProfileMenuOpen(false)} + > + + + + + alert("Log out!")}> + + +
  • +
+
+
+ ); +} + +export default Header; diff --git a/client/src/components/admin/sidebar/DesktopSidebar.jsx b/client/src/components/admin/sidebar/DesktopSidebar.jsx new file mode 100644 index 0000000..3da1111 --- /dev/null +++ b/client/src/components/admin/sidebar/DesktopSidebar.jsx @@ -0,0 +1,11 @@ +import SidebarContent from "./MainContent"; + +function DesktopSidebar(props) { + return ( + + ); +} + +export default DesktopSidebar; diff --git a/client/src/components/admin/sidebar/MainContent.jsx b/client/src/components/admin/sidebar/MainContent.jsx new file mode 100644 index 0000000..6512960 --- /dev/null +++ b/client/src/components/admin/sidebar/MainContent.jsx @@ -0,0 +1,69 @@ +import { Button } from "@windmill/react-ui"; +import { Home, Package, ShoppingCart } from "react-feather"; +import { NavLink, useLocation } from "react-router-dom"; +import SidebarSubmenu from "./SidebarSubmenu"; + +const routes = [ + { + path: "/admin", + icon: Home, + name: "Dashboard", + }, + { + path: "/admin/products", + icon: Package, + name: "Products", + }, + { + path: "/admin/orders", + icon: ShoppingCart, + name: "Orders", + }, +]; + +function SidebarContent() { + const location = useLocation(); + + return ( +
+ + PERN Store + +
    + {routes.map((route) => + route.routes ? ( + + ) : ( +
  • + + {location.pathname === route.path && ( + + )} + +
  • + ) + )} +
+
+ +
+
+ ); +} + +export default SidebarContent; diff --git a/client/src/components/admin/sidebar/MobileSidebar.jsx b/client/src/components/admin/sidebar/MobileSidebar.jsx new file mode 100644 index 0000000..53179e1 --- /dev/null +++ b/client/src/components/admin/sidebar/MobileSidebar.jsx @@ -0,0 +1,40 @@ +import { Backdrop, Transition } from "@windmill/react-ui"; +import SidebarContent from "./MainContent"; + +import { useSidebar } from "context/SidebarContext"; + +function MobileSidebar() { + const { isSidebarOpen, closeSidebar } = useSidebar(); + + return ( + + <> + + + + + + + + + + ); +} + +export default MobileSidebar; diff --git a/client/src/components/admin/sidebar/SidebarSubMenu.jsx b/client/src/components/admin/sidebar/SidebarSubMenu.jsx new file mode 100644 index 0000000..f29e3cc --- /dev/null +++ b/client/src/components/admin/sidebar/SidebarSubMenu.jsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +// import { Dropdown } from "../../icons"; +import { Transition } from "@windmill/react-ui"; +import { ChevronDown } from "react-feather"; + +// function Icon({ icon, ...props }) { +// const Icon = Icons[icon]; +// return ; +// } + +function SidebarSubmenu({ route }) { + const [isDropdownMenuOpen, setIsDropdownMenuOpen] = useState(false); + + function handleDropdownMenuClick() { + setIsDropdownMenuOpen(!isDropdownMenuOpen); + } + + return ( +
  • + + +
      + {route.routes.map((r) => ( +
    • + + {r.name} + +
    • + ))} +
    +
    +
  • + ); +} + +export default SidebarSubmenu; diff --git a/client/src/components/admin/sidebar/index.jsx b/client/src/components/admin/sidebar/index.jsx new file mode 100644 index 0000000..bea6cc2 --- /dev/null +++ b/client/src/components/admin/sidebar/index.jsx @@ -0,0 +1,13 @@ +import DesktopSidebar from "./DesktopSidebar"; +import MobileSidebar from "./MobileSidebar"; + +function Sidebar() { + return ( + <> + + + + ); +} + +export default Sidebar; diff --git a/client/src/context/ProductContext.jsx b/client/src/context/ProductContext.jsx index 3aab442..6795b05 100644 --- a/client/src/context/ProductContext.jsx +++ b/client/src/context/ProductContext.jsx @@ -10,10 +10,16 @@ const ProductProvider = ({ children }) => { useEffect(() => { setIsLoading(true); - productService.getProducts(page).then((response) => { - setProducts(response.data); - setIsLoading(false); - }); + productService + .getProducts(page) + .then((response) => { + setProducts(response.data); + setIsLoading(false); + }) + .catch((error) => { + console.log(error); + setIsLoading(false); + }); }, [page]); return ( diff --git a/client/src/context/SidebarContext.jsx b/client/src/context/SidebarContext.jsx new file mode 100644 index 0000000..fccfee2 --- /dev/null +++ b/client/src/context/SidebarContext.jsx @@ -0,0 +1,36 @@ +import React, { useMemo, useState } from "react"; + +export const SidebarContext = React.createContext(); + +export const SidebarProvider = ({ children }) => { + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + function toggleSidebar() { + setIsSidebarOpen(!isSidebarOpen); + } + + function closeSidebar() { + setIsSidebarOpen(false); + } + + const value = useMemo( + () => ({ + isSidebarOpen, + toggleSidebar, + closeSidebar, + }), + [isSidebarOpen] + ); + + return {children}; +}; + +export const useSidebar = () => { + const context = React.useContext(SidebarContext); + + if (context === undefined) { + throw new Error("useSidebar must be used within a SidebarProvider"); + } + + return context; +}; diff --git a/client/src/hooks/admin/product/useGetProducts.jsx b/client/src/hooks/admin/product/useGetProducts.jsx new file mode 100644 index 0000000..e69de29 diff --git a/client/src/hooks/useAxios.jsx b/client/src/hooks/useAxios.jsx new file mode 100644 index 0000000..1c5da83 --- /dev/null +++ b/client/src/hooks/useAxios.jsx @@ -0,0 +1,31 @@ +import API from "api/axios.config"; +import { useEffect, useState } from "react"; + +const useAxios = (axiosParams, deps = []) => { + const [response, setResponse] = useState(undefined); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + const fetchData = async (params) => { + try { + const result = await API.request(params); + setResponse(result.data); + } catch (error) { + setError(error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(axiosParams); + }, deps); + + return { + response, + loading, + error, + }; +}; + +export default useAxios; diff --git a/client/src/index.jsx b/client/src/index.jsx index b93d9f6..a8b0900 100644 --- a/client/src/index.jsx +++ b/client/src/index.jsx @@ -5,6 +5,7 @@ import { CartProvider } from "context/CartContext"; import { OrderProvider } from "context/OrderContext"; import { ProductProvider } from "context/ProductContext"; import { ReviewProvider } from "context/ReviewContext"; +import { SidebarProvider } from "context/SidebarContext"; import { UserProvider } from "context/UserContext"; import { createRoot } from "react-dom/client"; import { HelmetProvider } from "react-helmet-async"; @@ -21,20 +22,22 @@ root.render( - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/client/src/layout/AdminLayout.jsx b/client/src/layout/AdminLayout.jsx new file mode 100644 index 0000000..9fd3518 --- /dev/null +++ b/client/src/layout/AdminLayout.jsx @@ -0,0 +1,40 @@ +import Spinner from "components/Spinner"; +import Header from "components/admin/Header"; +import Sidebar from "components/admin/sidebar"; +import { useSidebar } from "context/SidebarContext"; +import { useUser } from "context/UserContext"; +import { Outlet } from "react-router-dom"; + +export default function AdminLayout() { + const { userData, isLoading } = useUser(); + const { isSidebarOpen } = useSidebar(); + + if (isLoading || !userData) return ; + + if (!userData?.roles.includes("admin")) { + return ( +
    +
    +

    + You are not authorized to access this page. +

    +
    +
    + ); + } + + return ( +
    + + +
    +
    +
    +
    + +
    +
    +
    +
    + ); +} diff --git a/client/src/pages/ProductList.jsx b/client/src/pages/ProductList.jsx index 6cddbcf..551f939 100644 --- a/client/src/pages/ProductList.jsx +++ b/client/src/pages/ProductList.jsx @@ -26,7 +26,8 @@ const ProductList = () => {
    - {products?.map((prod) => ( + {/* TODO: change */} + {products.products?.map((prod) => (
    { ))} Dashboard
    ; +} +export default Dashboard; diff --git a/client/src/pages/admin/ProductList.jsx b/client/src/pages/admin/ProductList.jsx new file mode 100644 index 0000000..cd49788 --- /dev/null +++ b/client/src/pages/admin/ProductList.jsx @@ -0,0 +1,103 @@ +import { + Button, + Pagination, + Table, + TableBody, + TableCell, + TableContainer, + TableFooter, + TableHeader, + TableRow, +} from "@windmill/react-ui"; +import { format, parseISO } from "date-fns"; +import { formatCurrency } from "helpers/formatCurrency"; +import useAxios from "hooks/useAxios"; +import { useState } from "react"; +import { Edit, Trash2 } from "react-feather"; + +function ProductList() { + const [page, setPage] = useState(1); + const { response, loading, error } = useAxios( + { + method: "GET", + url: `/admin/products?page=${page}`, + }, + [page] + ); + + const onPageChange = (page) => { + setPage(page); + window.scrollTo({ behavior: "smooth", top: 0 }); + }; + + if (loading) return
    Loading...
    ; + + return ( +
    +

    Products

    + + + + + Product Name + Price + Orders + Rating + Date Created + + + + {response.products?.map((product, i) => ( + + +
    + Product image +
    +

    {product.name}

    +

    {product.job}

    +
    +
    +
    + + {formatCurrency(product.price)} + + + {product.totalOrders} + + + {product.avgRating} + + + {format(parseISO(product.createdAt), "PPpp")} + + +
    + + +
    +
    +
    + ))} +
    +
    + + + +
    +
    + ); +} +export default ProductList; diff --git a/client/src/pages/admin/index.jsx b/client/src/pages/admin/index.jsx new file mode 100644 index 0000000..b7d7425 --- /dev/null +++ b/client/src/pages/admin/index.jsx @@ -0,0 +1,2 @@ +export { default as Dashboard } from "./Dashboard"; +export { default as AdminProductList } from "./ProductList"; diff --git a/client/src/services/product.service.js b/client/src/services/product.service.js index 37ce2de..ab892ce 100644 --- a/client/src/services/product.service.js +++ b/client/src/services/product.service.js @@ -2,10 +2,13 @@ import API from "api/axios.config"; class ProductService { getProducts(page) { - return API.get(`/products/?page=${page}`); + return API.get(`/products?page=${page}`); } - getProduct(id) { - return API.get(`/products/${id}`); + getProductsAdmin(page) { + return API.get(`/admin/products?page=${page}`); + } + getProduct(slug) { + return API.get(`/products/${slug}`); } getProductByName(name) { return API.get(`/products/${name}`); diff --git a/server/config/index.js b/server/config/index.js index 7526660..ceebaa6 100644 --- a/server/config/index.js +++ b/server/config/index.js @@ -1,5 +1,9 @@ require("dotenv").config(); -const { Pool } = require("pg"); +const { Pool, types } = require("pg"); + +types.setTypeParser(1700, function (val) { + return parseFloat(val); +}); const isProduction = process.env.NODE_ENV === "production"; const database = diff --git a/server/config/init.sql b/server/config/init.sql index 191bc30..7014241 100644 --- a/server/config/init.sql +++ b/server/config/init.sql @@ -50,6 +50,7 @@ CREATE TABLE public.products price real NOT NULL, description text NOT NULL, image_url character varying, + createdAt timestamp without time zone DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (product_id) ); diff --git a/server/controllers/products.controller.js b/server/controllers/products.controller.js index 766a53e..1401aca 100644 --- a/server/controllers/products.controller.js +++ b/server/controllers/products.controller.js @@ -8,9 +8,16 @@ const getAllProducts = async (req, res) => { res.json(products); }; +const getAllProductsAdmin = async (req, res) => { + const { page = 1 } = req.query; + + const products = await productService.getAllProductsAdmin(page); + res.json(products); +}; + const createProduct = async (req, res) => { const newProduct = await productService.addProduct(req.body); - res.status(200).json(newProduct); + res.status(201).json(newProduct); }; const getProduct = async (req, res) => { @@ -44,7 +51,7 @@ const deleteProduct = async (req, res) => { const { id } = req.params; const deletedProduct = await productService.removeProduct(id); - res.status(200).json(deletedProduct); + res.status(200).json({}); }; // TODO create a service for reviews @@ -109,6 +116,7 @@ const updateProductReview = async (req, res) => { module.exports = { getProduct, + getAllProductsAdmin, createProduct, updateProduct, deleteProduct, diff --git a/server/db/product.db.js b/server/db/product.db.js index 1bb903f..6bc5a61 100644 --- a/server/db/product.db.js +++ b/server/db/product.db.js @@ -1,15 +1,51 @@ const pool = require("../config"); const getAllProductsDb = async ({ limit, offset }) => { - const { rows } = await pool.query( - `select products.*, trunc(avg(reviews.rating)) as avg_rating, count(reviews.*) from products + const [productsQueryResult, totalProductsQueryResult] = await Promise.all([ + pool.query( + `select products.*, trunc(avg(reviews.rating)) as avg_rating, count(reviews.*)::int from products LEFT JOIN reviews ON products.product_id = reviews.product_id group by products.product_id limit $1 offset $2 `, - [limit, offset] - ); - const products = [...rows].sort(() => Math.random() - 0.5); - return products; + [limit, offset] + ), + pool.query(`SELECT COUNT(*) FROM products`), + ]); + + const { rows: products } = productsQueryResult; + + const totalProducts = totalProductsQueryResult.rows[0].count; + + return { + products, + totalProducts, + }; +}; + +const getAllProductsAdminDb = async ({ limit, offset }) => { + const [productsQueryResult, totalProductsQueryResult] = await Promise.all([ + pool.query( + `select products.*, coalesce(trunc(avg(reviews.rating), 1), 0) as "avgRating", + count(order_item.*)::int as "totalOrders", + count(reviews.*)::int as "totalReviews" from products + LEFT JOIN reviews + ON products.product_id = reviews.product_id + LEFT JOIN order_item + ON products.product_id = order_item.product_id + group by products.product_id limit $1 offset $2 `, + [limit, offset] + ), + pool.query(`SELECT COUNT(*) FROM products`), + ]); + + const { rows: products } = productsQueryResult; + + const totalProducts = totalProductsQueryResult.rows[0].count; + + return { + products, + totalProducts, + }; }; const createProductDb = async ({ name, price, description, image_url }) => { @@ -74,6 +110,7 @@ const deleteProductDb = async ({ id }) => { module.exports = { getProductDb, + getAllProductsAdminDb, getProductByNameDb, createProductDb, updateProductDb, diff --git a/server/routes/admin/index.js b/server/routes/admin/index.js new file mode 100644 index 0000000..85ff2ba --- /dev/null +++ b/server/routes/admin/index.js @@ -0,0 +1,6 @@ +const product = require("./product"); +const router = require("express").Router(); + +router.use("/products", product); + +module.exports = router; diff --git a/server/routes/admin/product.js b/server/routes/admin/product.js new file mode 100644 index 0000000..6238f72 --- /dev/null +++ b/server/routes/admin/product.js @@ -0,0 +1,28 @@ +const router = require("express").Router(); +const { + createProduct, + updateProduct, + deleteProduct, + getProductBySlug, + getAllProductsAdmin, +} = require("../../controllers/products.controller"); +const verifyAdmin = require("../../middleware/verifyAdmin"); +const verifyToken = require("../../middleware/verifyToken"); + +router.use(verifyToken, verifyAdmin); + +router.route("/").get(getAllProductsAdmin).post(createProduct); + +router + .route("/:slug") + .get(getProductBySlug) + .put(updateProduct) + .delete(deleteProduct); + +// router +// .route("/:id/reviews") +// .get(getProductReviews) +// .post(verifyToken, createProductReview) +// .put(verifyToken, updateProductReview); + +module.exports = router; diff --git a/server/routes/index.js b/server/routes/index.js index d72f3e1..e2c73b1 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -2,6 +2,7 @@ const router = require("express").Router(); const cart = require("./cart"); const order = require("./order"); const product = require("./product"); +const admin = require("./admin"); const users = require("./users"); const auth = require("./auth"); const payment = require("./payment"); @@ -14,6 +15,7 @@ router.use("/products", product); router.use("/orders", order); router.use("/cart", cart); router.use("/payment", payment); +router.use("/admin", admin); router.use("/docs", swaggerUi.serve, swaggerUi.setup(docs)); module.exports = router; diff --git a/server/routes/product.js b/server/routes/product.js index 38e19d3..d2757d6 100644 --- a/server/routes/product.js +++ b/server/routes/product.js @@ -10,6 +10,7 @@ const { createProductReview, updateProductReview, getProductBySlug, + getAllProductsAdmin, } = require("../controllers/products.controller"); const verifyAdmin = require("../middleware/verifyAdmin"); const verifyToken = require("../middleware/verifyToken"); @@ -33,4 +34,7 @@ router .post(verifyToken, createProductReview) .put(verifyToken, updateProductReview); +// admin routes +router.use(verifyToken, verifyAdmin).route("/admin").get(getAllProductsAdmin); + module.exports = router; diff --git a/server/services/product.service.js b/server/services/product.service.js index 565ac64..847170c 100644 --- a/server/services/product.service.js +++ b/server/services/product.service.js @@ -6,6 +6,7 @@ const { deleteProductDb, getProductByNameDb, getProductBySlugDb, + getAllProductsAdminDb, } = require("../db/product.db"); const { ErrorHandler } = require("../helpers/error"); @@ -20,6 +21,16 @@ class ProductService { } }; + getAllProductsAdmin = async (page) => { + const limit = 12; + const offset = (page - 1) * limit; + try { + return await getAllProductsAdminDb({ limit, offset }); + } catch (error) { + throw new ErrorHandler(error.statusCode, error.message); + } + }; + addProduct = async (data) => { try { return await createProductDb(data);