From 60215556388c565aad9b5a6369e8ee0ace150320 Mon Sep 17 00:00:00 2001 From: TalkativeUser Date: Sun, 8 Mar 2026 23:12:34 +0200 Subject: [PATCH 01/11] add filters feature , create zustand store and create drawer filters and make it responsive , apply debounce topic , create price rang slider , create products layout for reusability , change filters position in UI --- package-lock.json | 12 - src/App.jsx | 31 +- src/components/Atoms/PriceRangeSlider.jsx | 99 +++++ src/components/FiltersDrawer.jsx | 97 +++++ src/components/ProductsLayout.jsx | 76 ++++ src/context.jsx | 41 ++ .../products/services/productService.js | 6 +- src/index.css | 22 ++ src/pages/ProductsPage.jsx | 353 +++++++++--------- src/store/useProductsStore.js | 27 ++ 10 files changed, 562 insertions(+), 202 deletions(-) create mode 100644 src/components/Atoms/PriceRangeSlider.jsx create mode 100644 src/components/FiltersDrawer.jsx create mode 100644 src/components/ProductsLayout.jsx create mode 100644 src/context.jsx create mode 100644 src/store/useProductsStore.js diff --git a/package-lock.json b/package-lock.json index 0593704..740b71a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1660,7 +1659,6 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1671,7 +1669,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1841,7 +1838,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1960,7 +1956,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2246,7 +2241,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2596,7 +2590,6 @@ "integrity": "sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -3258,7 +3251,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3319,7 +3311,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3329,7 +3320,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3668,7 +3658,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3917,7 +3906,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/App.jsx b/src/App.jsx index 2bd0ff4..b4427da 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,21 +7,26 @@ import CartPage from "./pages/CartPage"; import WishlistPage from "./pages/WishlistPage"; import ComparePage from "./pages/ComparePage"; import CheckoutPage from "./pages/CheckoutPage"; +import { ProductsProvider } from "./context"; + +// I forgot and use Context insted of zustand 😂 but edit it again. export default function App() { return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + // + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + // ); } diff --git a/src/components/Atoms/PriceRangeSlider.jsx b/src/components/Atoms/PriceRangeSlider.jsx new file mode 100644 index 0000000..f89c8ee --- /dev/null +++ b/src/components/Atoms/PriceRangeSlider.jsx @@ -0,0 +1,99 @@ +import { useRef, useEffect, memo } from "react"; +import useProductsStore from "../../store/useProductsStore"; + +function PriceRangeSlider() { + const minRangeRef = useRef(null); + const maxRangeRef = useRef(null); + const rangeTrackRef = useRef(null); + const minValueRef = useRef(null); + const maxValueRef = useRef(null); + const {setFilters }=useProductsStore((state)=>state) + + const minGap = 10; + const maxValue = 400; + + const updateRange = (e) => { + let min = parseInt(minRangeRef.current.value); + let max = parseInt(maxRangeRef.current.value); + + if (max - min < minGap) { + if (e.target === minRangeRef.current) { + minRangeRef.current.value = max - minGap; + } else { + maxRangeRef.current.value = min + minGap; + } + } + + minValueRef.current.textContent = minRangeRef.current.value; + maxValueRef.current.textContent = maxRangeRef.current.value; + + let minPercent = (minRangeRef.current.value / maxValue) * 100; + let maxPercent = (maxRangeRef.current.value / maxValue) * 100; + + rangeTrackRef.current.style.left = `${minPercent}%`; + rangeTrackRef.current.style.right = `${100 - maxPercent}%`; + }; + + useEffect(() => { + updateRange({ target: minRangeRef.current }); + }, []); + + return ( <> + +
+
+ {/* Min Range Input */} + setFilters({ minPrice : Number(minRangeRef.current.value) })} + /> + + {/* Max Range Input */} + setFilters({ maxPrice : Number(maxRangeRef.current.value) })} + /> + + {/* Custom Track */} +
+
+
+
+
+ +
+
+ Maximum Price : + + 400 + + $ +
+
+ Minimum Price : + + 0 + + $ +
+
+ + ); +} + +export default memo(PriceRangeSlider); diff --git a/src/components/FiltersDrawer.jsx b/src/components/FiltersDrawer.jsx new file mode 100644 index 0000000..a0414cb --- /dev/null +++ b/src/components/FiltersDrawer.jsx @@ -0,0 +1,97 @@ +// import { useContext } from "react" +// import { ProductsContext } from "../context" +import PriceRangeSlider from './Atoms/PriceRangeSlider' +import useProductsStore from "../store/useProductsStore" +export default function FiltersDrawer(){ + + const { filters,setFilters}=useProductsStore((state)=>state) + const {search,selectedCategory + ,sortBy ,sortOrder , productsPerPage ,categories }=filters; + + // const { search, setSearch, selectedCategory, setSelectedCategory + // ,sortBy, setSortBy ,sortOrder, setSortOrder , setMaxPrice, + // setMinPrice , productsPerPage, setProductsPerPage ,categories }=useContext(ProductsContext) + + + + + return
+
+ {/* Search */} +
+ + + + setFilters({search : e.target.value})} + className="w-full h-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+ + {/* Category Filter */} + + + {/* Sort */} + + + + + {/* products per page */} + + +
+ {/* memoized component */} + +
+
+
+} \ No newline at end of file diff --git a/src/components/ProductsLayout.jsx b/src/components/ProductsLayout.jsx new file mode 100644 index 0000000..faf5ae0 --- /dev/null +++ b/src/components/ProductsLayout.jsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import FiltersDrawer from "./FiltersDrawer"; + +export default function ProductsLayout({ children }) { + const [drawerMenu, setDrawerMenu] = useState(false); + + + return ( +
+ + {/* sidebar static show in md screen more than */} +
+ +
+ +
+ {/* drawer menu button */} + + + + {/* mybe children are ProductsPage.jsx */} +
{children}
+
+ + {/* sidebar daynamic or drawer show in sm screen more less */} + { drawerMenu ? +
{ setDrawerMenu( prev=>!prev ) }} > +
e.stopPropagation()} > + + +
+
:'' + + + } + + + +
+ ); +} diff --git a/src/context.jsx b/src/context.jsx new file mode 100644 index 0000000..7798c28 --- /dev/null +++ b/src/context.jsx @@ -0,0 +1,41 @@ +// src/context/ProductsContext.jsx +import { createContext, useState } from "react"; + +export const ProductsContext = createContext(); + +export const ProductsProvider = ({ children }) => { + const [categories, setCategories] = useState([]); + + const [search, setSearch] = useState(""); + const [selectedCategory, setSelectedCategory] = useState(""); + const [sortBy, setSortBy] = useState("title"); + const [sortOrder, setSortOrder] = useState("asc"); + const [maxPrice, setMaxPrice] = useState(400); + const [minPrice, setMinPrice] = useState(0); + const [productsPerPage, setProductsPerPage] = useState(8); + + return ( + + {children} + + ); +}; diff --git a/src/features/products/services/productService.js b/src/features/products/services/productService.js index 68e1ec4..2a6dfcd 100644 --- a/src/features/products/services/productService.js +++ b/src/features/products/services/productService.js @@ -38,11 +38,12 @@ export async function getReviewsByProductId(productId) { export async function filterProducts({ search = "", category = "", - minPrice = 0, - maxPrice = Infinity, sortBy = "title", sortOrder = "asc", page = 1, + // Three properties need ui to completed his features + maxPrice = Infinity, + minPrice = 0, limit = 8, } = {}) { await wait(500); @@ -83,6 +84,7 @@ export async function filterProducts({ comparison = a.title.localeCompare(b.title); break; } + return sortOrder === "desc" ? -comparison : comparison; }); diff --git a/src/index.css b/src/index.css index 9bff3f1..fa9386e 100644 --- a/src/index.css +++ b/src/index.css @@ -30,3 +30,25 @@ -moz-osx-font-smoothing: grayscale; } } + + +/* index.css */ +@layer components { + .price-slider-input { + /* Base input styling */ + @apply absolute w-full appearance-none bg-transparent pointer-events-none z-20 h-2 outline-none; + -webkit-appearance: none; + } + + /* Styling the thumb for Chrome, Safari, Edge, and Opera */ + .price-slider-input::-webkit-slider-thumb { + @apply w-[18px] h-[18px] bg-white border-[3px] border-[#23a9f7] rounded-full cursor-pointer pointer-events-auto relative z-30; + -webkit-appearance: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + /* Styling the thumb for Firefox */ + .price-slider-input::-moz-range-thumb { + @apply w-[18px] h-[18px] bg-white border-[3px] border-[#23a9f7] rounded-full cursor-pointer pointer-events-auto relative z-30 border-none; + } +} diff --git a/src/pages/ProductsPage.jsx b/src/pages/ProductsPage.jsx index d7cfe04..75dddca 100644 --- a/src/pages/ProductsPage.jsx +++ b/src/pages/ProductsPage.jsx @@ -1,184 +1,187 @@ import { useState, useEffect } from "react"; -import { getProducts, getCategories } from "../features/products/services/productService"; +// import { useContext } from "react"; +import { + getProducts, + getCategories, + filterProducts, +} from "../features/products/services/productService"; import ProductCard from "../features/products/components/ProductCard"; +import ProductsLayout from "../components/ProductsLayout"; +// import { ProductsContext } from "../context"; +import useProductsStore from "../store/useProductsStore"; + +// very important +// 1- react don't create new useEffect before remove cleanUp functon in old useEffect +// 2- when we have any state contains any value and update this state with +// tha same value 😂 in this case react don't reRender because there is not any differents export default function ProductsPage() { - const [products, setProducts] = useState([]); - const [categories, setCategories] = useState([]); - const [loading, setLoading] = useState(true); - - // Basic state — not wired to filterProducts yet (student task) - const [search, setSearch] = useState(""); - const [selectedCategory, setSelectedCategory] = useState(""); - const [sortBy, setSortBy] = useState("title"); - const [sortOrder, setSortOrder] = useState("asc"); - const [currentPage, setCurrentPage] = useState(1); - const productsPerPage = 8; - - useEffect(() => { - async function load() { - setLoading(true); - // Currently just loads all products — students should use filterProducts() - const allProducts = await getProducts(); - setProducts(allProducts); - const cats = await getCategories(); - setCategories(cats); - // Simulate a short loading time so the spinner is visible - setTimeout(() => setLoading(false), 600); - } - load(); - }, []); - - // Basic client-side pagination (not using filterProducts — student task) - const startIndex = (currentPage - 1) * productsPerPage; - const paginatedProducts = products.slice( - startIndex, - startIndex + productsPerPage - ); - const totalPages = Math.ceil(products.length / productsPerPage); - - return ( -
- {/* Header */} -
-

All Products

-

- Browse our collection of {products.length} premium products -

-
+ const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const {filters,setFilters }=useProductsStore((state)=>state) + const {search, selectedCategory ,sortBy ,sortOrder , maxPrice,minPrice, productsPerPage}=filters; + // const { search, selectedCategory ,sortBy ,sortOrder , maxPrice,minPrice, productsPerPage, setCategories }=useContext(ProductsContext) - {/* Filters Bar */} -
-
- {/* Search */} -
- - - - setSearch(e.target.value)} - className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" - /> -
- - {/* Category Filter */} - - - {/* Sort */} - -
+ const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + + + useEffect(() => { + async function load() { + setLoading(true); + // Currently just loads all products — students should use filterProducts() + const allProducts = await getProducts(); + console.log("all products => ", allProducts); + + setProducts(allProducts); + setTotalPages(Math.ceil(allProducts.length / productsPerPage)); + const cats = await getCategories(); + setFilters({ categories: cats }) + // Simulate a short loading time so the spinner is visible + setTimeout(() => setLoading(false), 600); + } + load(); + }, []); + + + // apply debouncce topic ✅ + useEffect(() => { + const timer = setTimeout(() => { + console.log('setTimeOut start ✅'); + + setDebouncedSearch(search); + }, 300); + + return () => clearTimeout(timer); +}, [search]); + + + useEffect(() => { + async function getFilteredProducts() { + const { data, totalPages } = await filterProducts({ + search:debouncedSearch, + category: selectedCategory, + sortBy, + sortOrder, + page: currentPage, + maxPrice, + minPrice, + limit: productsPerPage, + }); + setProducts(data); + setTotalPages(totalPages); + } + + + getFilteredProducts() + + + }, [ + debouncedSearch, + selectedCategory, + sortBy, + sortOrder, + currentPage, + minPrice, + maxPrice, + productsPerPage, + ]); + + + + + + return ( + +
+ {/* Header */} +
+

+ All Products +

+

+ Browse our collection of {products.length} premium products +

+
+ + + + {/* Loading Spinner */} + {loading ? ( +
+
+

Loading products...

+
+ ) : ( + <> + {/* Product Grid */} +
+ {products.map((product) => ( + + ))}
- {/* Loading Spinner */} - {loading ? ( -
-
-

Loading products...

-
- ) : ( - <> - {/* Product Grid */} -
- {paginatedProducts.map((product) => ( - - ))} -
- - {/* Empty State */} - {paginatedProducts.length === 0 && ( -
- - - -

No products found

-
- )} - - {/* Pagination */} - {totalPages > 1 && ( -
- - {Array.from({ length: totalPages }, (_, i) => i + 1).map( - (pageNum) => ( - - ) - )} - -
- )} - + {/* Empty State , not found products */} + {products.length === 0 && ( +
+ + + +

No products found

+
)} -
- ); + + {/* Pagination */} + {totalPages > 1 && ( +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map( + (pageNum) => ( + + ), + )} + +
+ )} + + )} +
+ + ); } diff --git a/src/store/useProductsStore.js b/src/store/useProductsStore.js new file mode 100644 index 0000000..9e46d42 --- /dev/null +++ b/src/store/useProductsStore.js @@ -0,0 +1,27 @@ +import { create } from 'zustand' + +const useProductsStore = create((set) => ({ + + filters: { + + categories: [], + search: "", + selectedCategory: '', + sortBy: "title", + sortOrder: "asc", + maxPrice: 400, + minPrice: 0, + productsPerPage: 8, + }, + + setFilters: (newFilter) => + set((state) => ({ + filters: { + ...state.filters, + ...newFilter + } + })) + +})) + +export default useProductsStore From c91d218d8eea82ce08208a4e26d69f070696c427 Mon Sep 17 00:00:00 2001 From: TalkativeUser Date: Thu, 12 Mar 2026 15:15:38 +0200 Subject: [PATCH 02/11] swith filter states with query params state , add persist data feature , apply depounce patterns for search input , occur lag when I write in search input and set search input value in search query param , resolved by depounce pattern --- src/components/Atoms/PriceRangeSlider.jsx | 107 +++++----- src/components/FiltersDrawer.jsx | 189 ++++++++++-------- src/components/ProductsLayout.jsx | 6 +- .../products/services/productService.js | 23 ++- src/hooks/useFilterActions.js | 30 +++ src/pages/ProductsPage.jsx | 181 +++++++---------- src/store/useProductsStore.js | 44 ++-- 7 files changed, 289 insertions(+), 291 deletions(-) create mode 100644 src/hooks/useFilterActions.js diff --git a/src/components/Atoms/PriceRangeSlider.jsx b/src/components/Atoms/PriceRangeSlider.jsx index f89c8ee..d7ab8f4 100644 --- a/src/components/Atoms/PriceRangeSlider.jsx +++ b/src/components/Atoms/PriceRangeSlider.jsx @@ -1,13 +1,16 @@ import { useRef, useEffect, memo } from "react"; -import useProductsStore from "../../store/useProductsStore"; -function PriceRangeSlider() { +function PriceRangeSlider({ filterParams, updateURL }) { + + // reade max and min prices from url to keep ui updated when happen any refresh + const urlMin = filterParams.get("minPrice") || "0"; + const urlMax = filterParams.get("maxPrice") || "400"; + const minRangeRef = useRef(null); const maxRangeRef = useRef(null); const rangeTrackRef = useRef(null); const minValueRef = useRef(null); const maxValueRef = useRef(null); - const {setFilters }=useProductsStore((state)=>state) const minGap = 10; const maxValue = 400; @@ -17,83 +20,79 @@ function PriceRangeSlider() { let max = parseInt(maxRangeRef.current.value); if (max - min < minGap) { - if (e.target === minRangeRef.current) { + if (e?.target === minRangeRef.current) { minRangeRef.current.value = max - minGap; + min = max - minGap; } else { maxRangeRef.current.value = min + minGap; + max = min + minGap; } } - minValueRef.current.textContent = minRangeRef.current.value; - maxValueRef.current.textContent = maxRangeRef.current.value; + // تحديث الأرقام والخط الملون + minValueRef.current.textContent = min; + maxValueRef.current.textContent = max; - let minPercent = (minRangeRef.current.value / maxValue) * 100; - let maxPercent = (maxRangeRef.current.value / maxValue) * 100; + let minPercent = (min / maxValue) * 100; + let maxPercent = (max / maxValue) * 100; rangeTrackRef.current.style.left = `${minPercent}%`; rangeTrackRef.current.style.right = `${100 - maxPercent}%`; }; + // 2. تأكد إن الـ UI يتحدث أول ما الصفحة تفتح بناءً على قيم الـ URL useEffect(() => { - updateRange({ target: minRangeRef.current }); - }, []); - - return ( <> - -
-
- {/* Min Range Input */} - setFilters({ minPrice : Number(minRangeRef.current.value) })} - /> + updateRange(); + }, [urlMin, urlMax]); // لو اللينك اتغير من بره، الـ Slider يتحرك - {/* Max Range Input */} - setFilters({ maxPrice : Number(maxRangeRef.current.value) })} - /> + return ( + <> +
+
+ updateURL({ minPrice: minRangeRef.current.value })} + className="price-slider-input" + /> - {/* Custom Track */} -
-
updateURL({ maxPrice: maxRangeRef.current.value })} + className="price-slider-input" /> + +
+
+
-
-
+
- Maximum Price : - - 400 - + Maximum Price: {urlMax} $
- Minimum Price : - - 0 - + Minimum Price: {urlMin} $
- + ); } -export default memo(PriceRangeSlider); +export default memo(PriceRangeSlider); \ No newline at end of file diff --git a/src/components/FiltersDrawer.jsx b/src/components/FiltersDrawer.jsx index a0414cb..ed745c6 100644 --- a/src/components/FiltersDrawer.jsx +++ b/src/components/FiltersDrawer.jsx @@ -1,97 +1,110 @@ -// import { useContext } from "react" -// import { ProductsContext } from "../context" -import PriceRangeSlider from './Atoms/PriceRangeSlider' -import useProductsStore from "../store/useProductsStore" -export default function FiltersDrawer(){ - - const { filters,setFilters}=useProductsStore((state)=>state) - const {search,selectedCategory - ,sortBy ,sortOrder , productsPerPage ,categories }=filters; - - // const { search, setSearch, selectedCategory, setSelectedCategory - // ,sortBy, setSortBy ,sortOrder, setSortOrder , setMaxPrice, - // setMinPrice , productsPerPage, setProductsPerPage ,categories }=useContext(ProductsContext) - +import PriceRangeSlider from "./Atoms/PriceRangeSlider"; +import useProductsStore from "../store/useProductsStore"; +import { useFilterActions } from "../hooks/useFilterActions"; +import { useEffect, useState } from "react"; +export default function FiltersDrawer() { + const { categories } = useProductsStore((state) => state); + const { filterParams, updateURL } = useFilterActions(); + const [searchTerm, setSearchTerm] = useState( + filterParams.get("search") || "", + ); + {/* test persisting zustand store */} + // const {count , incCount}=useProductsStore() - - return
-
- {/* Search */} -
- - - - setFilters({search : e.target.value})} - className="w-full h-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" - /> -
+ // debounce pattern + useEffect(() => { + if (searchTerm !== (filterParams.get("search") || "")) { + const timer = setTimeout(() => { + updateURL({ search: searchTerm }); + }, 400); - {/* Category Filter */} - + return () => { + clearTimeout(timer); + }; + } + }, [searchTerm]); - {/* Sort */} - + return ( +
+
+ {/* Search */} +
+ + + + setSearchTerm(e.target.value)} + className="w-full h-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+ + {/* Category Filter */} + - + {/* Sort */} + - {/* products per page */} - updateURL({ pageLimit: e.target.value })} + className="px-8 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white" + > + - - - - - + + + + + -
- {/* memoized component */} - -
-
+
+ {/* memoized component */} +
-} \ No newline at end of file +
+ + {/* test persisting zustand store */} + {/* +

count : {count}

*/} +
+ ); +} diff --git a/src/components/ProductsLayout.jsx b/src/components/ProductsLayout.jsx index faf5ae0..66dc82a 100644 --- a/src/components/ProductsLayout.jsx +++ b/src/components/ProductsLayout.jsx @@ -6,10 +6,10 @@ export default function ProductsLayout({ children }) { return ( -
+
{/* sidebar static show in md screen more than */} -
+
@@ -60,7 +60,7 @@ export default function ProductsLayout({ children }) { {/* sidebar daynamic or drawer show in sm screen more less */} { drawerMenu ?
{ setDrawerMenu( prev=>!prev ) }} > -
e.stopPropagation()} > +
e.stopPropagation()} >
diff --git a/src/features/products/services/productService.js b/src/features/products/services/productService.js index 2a6dfcd..ab7ff8c 100644 --- a/src/features/products/services/productService.js +++ b/src/features/products/services/productService.js @@ -5,7 +5,7 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export async function getProducts() { await wait(500); - return Promise.resolve(products); + return Promise.resolve( products ); } export async function getProductById(id) { @@ -40,12 +40,14 @@ export async function filterProducts({ category = "", sortBy = "title", sortOrder = "asc", - page = 1, + pageNum = 1, // Three properties need ui to completed his features maxPrice = Infinity, minPrice = 0, - limit = 8, + pageLimit=8, } = {}) { + + await wait(500); let filtered = [...products]; @@ -54,8 +56,7 @@ export async function filterProducts({ const searchLower = search.toLowerCase(); filtered = filtered.filter( (p) => - p.title.toLowerCase().includes(searchLower) || - p.description.toLowerCase().includes(searchLower) + p.title.toLowerCase().includes(searchLower) ); } @@ -90,15 +91,15 @@ export async function filterProducts({ // Calculate pagination AFTER filtering const total = filtered.length; - const totalPages = Math.ceil(total / limit); - const startIndex = (page - 1) * limit; - const data = filtered.slice(startIndex, startIndex + limit); - + const totalPages = Math.ceil(total / pageLimit); + const startIndex = (pageNum - 1) * pageLimit; + const data = filtered.slice(startIndex, startIndex + pageLimit); + return Promise.resolve({ data, total, - page, + pageNum, totalPages, - limit, + pageLimit, }); } diff --git a/src/hooks/useFilterActions.js b/src/hooks/useFilterActions.js new file mode 100644 index 0000000..39733e5 --- /dev/null +++ b/src/hooks/useFilterActions.js @@ -0,0 +1,30 @@ +import { useSearchParams } from 'react-router-dom'; + +export const useFilterActions = () => { + const [filterParams, setFilterParams] = useSearchParams(); + + const updateURL = (newValues) => { + const params = new URLSearchParams(filterParams); + + Object.entries(newValues).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + +// this condition for user if change in pagination dont reset pageNum + if (!newValues.pageNum) { + params.set("pageNum", 1); + } + + setFilterParams(params); + }; + + return { + updateURL, + filterParams, + currentPage: Number(filterParams.get("pageNum")) || 1 + }; +}; \ No newline at end of file diff --git a/src/pages/ProductsPage.jsx b/src/pages/ProductsPage.jsx index 75dddca..fd124e4 100644 --- a/src/pages/ProductsPage.jsx +++ b/src/pages/ProductsPage.jsx @@ -1,14 +1,13 @@ import { useState, useEffect } from "react"; -// import { useContext } from "react"; import { - getProducts, getCategories, filterProducts, + getProducts, } from "../features/products/services/productService"; import ProductCard from "../features/products/components/ProductCard"; import ProductsLayout from "../components/ProductsLayout"; -// import { ProductsContext } from "../context"; import useProductsStore from "../store/useProductsStore"; +import { useFilterActions } from "../hooks/useFilterActions"; // very important // 1- react don't create new useEffect before remove cleanUp functon in old useEffect @@ -18,80 +17,47 @@ import useProductsStore from "../store/useProductsStore"; export default function ProductsPage() { const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); - const {filters,setFilters }=useProductsStore((state)=>state) - const {search, selectedCategory ,sortBy ,sortOrder , maxPrice,minPrice, productsPerPage}=filters; - // const { search, selectedCategory ,sortBy ,sortOrder , maxPrice,minPrice, productsPerPage, setCategories }=useContext(ProductsContext) + const { setCategories }=useProductsStore((state)=>state) - const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); - const [debouncedSearch, setDebouncedSearch] = useState(""); - - - - useEffect(() => { - async function load() { - setLoading(true); - // Currently just loads all products — students should use filterProducts() - const allProducts = await getProducts(); - console.log("all products => ", allProducts); - - setProducts(allProducts); - setTotalPages(Math.ceil(allProducts.length / productsPerPage)); - const cats = await getCategories(); - setFilters({ categories: cats }) - // Simulate a short loading time so the spinner is visible - setTimeout(() => setLoading(false), 600); - } - load(); - }, []); - - - // apply debouncce topic ✅ - useEffect(() => { - const timer = setTimeout(() => { - console.log('setTimeOut start ✅'); +const [allRawProducts, setAllRawProducts] = useState([]); +const { updateURL, currentPage , filterParams } = useFilterActions(); +// to filters only , based second useEffect +useEffect(() => { + + if (allRawProducts.length === 0) return; + + const currentFilters = Object.fromEntries([...filterParams]); + + async function applyCurrentFilters() { + const { data, totalPages } = await filterProducts(currentFilters); + console.log('Filtration Applied ✅'); + setProducts(data); + setTotalPages(totalPages); + } + + applyCurrentFilters(); +}, [filterParams, allRawProducts]); + + +// initial Load only +useEffect(() => { + async function loadInitialData() { + setLoading(true); - setDebouncedSearch(search); - }, 300); - - return () => clearTimeout(timer); -}, [search]); - - - useEffect(() => { - async function getFilteredProducts() { - const { data, totalPages } = await filterProducts({ - search:debouncedSearch, - category: selectedCategory, - sortBy, - sortOrder, - page: currentPage, - maxPrice, - minPrice, - limit: productsPerPage, - }); - setProducts(data); - setTotalPages(totalPages); - } - - - getFilteredProducts() - + const [rawProducts, cats] = await Promise.all([ + getProducts(), + getCategories() + ]); - }, [ - debouncedSearch, - selectedCategory, - sortBy, - sortOrder, - currentPage, - minPrice, - maxPrice, - productsPerPage, - ]); - - - - + setAllRawProducts(rawProducts); + setCategories(cats); + + setTimeout(() => setLoading(false), 600); + } + + loadInitialData(); +}, []); return ( @@ -143,42 +109,39 @@ export default function ProductsPage() {
)} - {/* Pagination */} - {totalPages > 1 && ( -
- - {Array.from({ length: totalPages }, (_, i) => i + 1).map( - (pageNum) => ( - - ), - )} - -
- )} + {totalPages > 1 && ( +
+ + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => ( + + ))} + + +
+)} )}
diff --git a/src/store/useProductsStore.js b/src/store/useProductsStore.js index 9e46d42..f345eba 100644 --- a/src/store/useProductsStore.js +++ b/src/store/useProductsStore.js @@ -1,27 +1,19 @@ -import { create } from 'zustand' +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +const useProductsStore = create()( + persist( + (set, get) => ({ + categories: [], + setCategories: (newCategories) => set({ categories: newCategories }), + // for test persisting zustand store + // count: 0, + // incCount: () => set({ count: get().count + 1 }), + }), + { + name: "shopHup-store", + storage: createJSONStorage(() => sessionStorage), // (optional) by default, 'localStorage' is used + }, + ), +); -const useProductsStore = create((set) => ({ - - filters: { - - categories: [], - search: "", - selectedCategory: '', - sortBy: "title", - sortOrder: "asc", - maxPrice: 400, - minPrice: 0, - productsPerPage: 8, - }, - - setFilters: (newFilter) => - set((state) => ({ - filters: { - ...state.filters, - ...newFilter - } - })) - -})) - -export default useProductsStore +export default useProductsStore; From 54bb95dc9627e3cbeea8c3c7ff3012f27b74bc87 Mon Sep 17 00:00:00 2001 From: TalkativeUser Date: Wed, 25 Mar 2026 15:14:38 +0200 Subject: [PATCH 03/11] impelement removeFromWishList functionality and replace addToWishList by toggleWishList , persist wishList in session storage --- .../products/components/ProductCard.jsx | 4 +- .../wishlist/hooks/useWishlistStore.js | 43 +++++++++++++------ src/pages/WishlistPage.jsx | 2 +- src/store/useProductsStore.js | 16 ++----- 4 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/features/products/components/ProductCard.jsx b/src/features/products/components/ProductCard.jsx index 3a7c138..ed0d79c 100644 --- a/src/features/products/components/ProductCard.jsx +++ b/src/features/products/components/ProductCard.jsx @@ -4,7 +4,7 @@ import useWishlistStore from "../../wishlist/hooks/useWishlistStore"; export default function ProductCard({ product }) { const addToCart = useCartStore((s) => s.addToCart); - const addToWishlist = useWishlistStore((s) => s.addToWishlist); + const toggleWishlist = useWishlistStore((s) => s.toggleWishlist); const isInWishlist = useWishlistStore((s) => s.isInWishlist(product.id)); const renderStars = (rating) => { @@ -65,7 +65,7 @@ export default function ProductCard({ product }) {
@@ -50,9 +67,9 @@ export default function Navbar() { - {totalItems > 0 && ( + {totalCartItems > 0 && ( - {totalItems} + {totalCartItems} )} @@ -60,8 +77,7 @@ export default function Navbar() { {/* Mobile menu button */} - + return ( +
+ {/* Image */} + + {product.title} + {product.stock <= 10 && product.stock > 0 && ( + + Only {product.stock} left + + )} + {product.stock === 0 && ( + + Out of Stock + + )} + {/* Wishlist button */} + + {/* compare button */} + + - {/* Content */} -
- - {product.category} - - - {product.title} - + {/* Content */} +
+ + {product.category} + + + {product.title} + -
- {renderStars(product.rating)} - ({product.rating}) -
+
+ {renderStars(product.rating)} + ({product.rating}) +
-
- - ${product.price.toFixed(2)} - - -
-
+
+ + ${product.price.toFixed(2)} + +
- ); +
+
+ ); } + diff --git a/src/pages/ComparePage.jsx b/src/pages/ComparePage.jsx index d013238..639f7fb 100644 --- a/src/pages/ComparePage.jsx +++ b/src/pages/ComparePage.jsx @@ -1,178 +1,196 @@ -import { useState, useEffect } from "react"; -import { getProducts } from "../features/products/services/productService"; +import useCompareStore from "../features/compare/hooks/useCompareStore"; +import { Link } from "react-router-dom"; export default function ComparePage() { - const [products, setProducts] = useState([]); - const [selectedProductA, setSelectedProductA] = useState(""); - const [selectedProductB, setSelectedProductB] = useState(""); + const compareItems = useCompareStore((s) => s.items); + const removeFromCompare = useCompareStore((s) => s.removeFromCompare); - useEffect(() => { - async function load() { - const allProducts = await getProducts(); - setProducts(allProducts); - } - load(); - }, []); + function whichIsBetter() { + if (compareItems.length < 2) return; - // Comparison logic not implemented — student task - const productA = products.find((p) => p.id === Number(selectedProductA)); - const productB = products.find((p) => p.id === Number(selectedProductB)); + const productA = compareItems[0]; + const productB = compareItems[1]; + return [ + { + price: productA.price <= productB.price, + rating: productA.rating >= productB.rating, + stock: productA.stock >= productB.stock, + }, - const comparisonFields = [ - { label: "Price", key: "price", format: (v) => `$${v?.toFixed(2) || "—"}` }, - { label: "Category", key: "category", format: (v) => v || "—" }, - { label: "Rating", key: "rating", format: (v) => (v ? `${v} / 5` : "—") }, - { label: "Stock", key: "stock", format: (v) => (v != null ? `${v} units` : "—") }, + { + price: productB.price <= productA.price, + rating: productB.rating >= productA.rating, + stock: productB.stock >= productA.stock, + }, ]; + } + const coparisonFlags = whichIsBetter(); + + + const comparisonFields = [ + { + label: "Price", + key: "price", + format: (v) => ( + ${v?.toFixed(2)} + ), + }, + { label: "Category", key: "category" }, + { label: "Rating", key: "rating", format: (v) => `⭐ ${v}` }, + { + label: "Stock", + key: "stock", + format: (v) => (v > 0 ? `In Stock : ${v}` : `Out`), + }, + { label: "Brand", key: "brand" }, + ]; + + if (compareItems.length === 0) { return ( -
-
-

Compare Products

-

- Select two products to compare them side by side -

-
+
+
+

+ No products to compare +

+ + Return to Shop + +
+
+ ); + } - {/* Product Selectors */} -
-
- - -
-
- - -
+ return ( +
+

+ Product Comparison +

+ +
+
+ {/* --- Sidebar Labels --- */} +
+
+ + +
+ {comparisonFields.map((field) => ( +
+ {field.label} +
+ ))} +
+ Description +
+
- {/* Comparison Table */} - {productA || productB ? ( -
- {/* Product Headers */} -
-
- Feature -
-
- {productA ? ( -
- {productA.title} -

- {productA.title} -

-
- ) : ( -

- Select Product A -

- )} -
-
- {productB ? ( -
- {productB.title} -

- {productB.title} -

-
- ) : ( -

- Select Product B -

- )} -
-
+ {/* --- Products Grid --- */} +
+ {[0, 1].map((index) => { + const item = compareItems[index]; + return ( +
+ {item ? ( + <> + {/* Product Header */} +
+ + {item.title} +

+ {item.title} +

+ + Details + +
- {/* Comparison Rows */} - {comparisonFields.map((field) => ( + {/* Data Rows */} + {comparisonFields.map((field) => (
-
- {field.label} -
-
- {productA ? field.format(productA[field.key]) : "—"} -
-
- {productB ? field.format(productB[field.key]) : "—"} -
+ {field.format + ? field.format(item[field.key]) + : item[field.key] || "—"}
- ))} + ))} - {/* Description */} -
-
- Description -
-
- {productA?.description || "—"} -
-
- {productB?.description || "—"} -
+ {/* Description Row */} +
+ {item.description} +
+ + ) : ( +
+ Slot Empty
+ )}
- ) : ( -
- - - -

- Select Products to Compare -

-

- Choose two products from the dropdowns above to see a detailed - side-by-side comparison. -

-
- )} + ); + })} +
- ); +
+ +

+ Tip: Adding a 3rd product will replace the oldest one. +

+
+ ); } diff --git a/src/pages/ProductDetailsPage.jsx b/src/pages/ProductDetailsPage.jsx index 13e0735..37e7a3d 100644 --- a/src/pages/ProductDetailsPage.jsx +++ b/src/pages/ProductDetailsPage.jsx @@ -3,249 +3,290 @@ import { useParams, Link } from "react-router-dom"; import { getProductById } from "../features/products/services/productService"; import useCartStore from "../features/cart/hooks/useCartStore"; import useWishlistStore from "../features/wishlist/hooks/useWishlistStore"; +import useCompareStore from "../features/compare/hooks/useCompareStore"; export default function ProductDetailsPage() { - const { id } = useParams(); - const [product, setProduct] = useState(null); - const [loading, setLoading] = useState(true); - const [countdown, setCountdown] = useState(null); - const addToCart = useCartStore((s) => s.addToCart); - const addToWishlist = useWishlistStore((s) => s.addToWishlist); - const isInWishlist = useWishlistStore((s) => s.isInWishlist(Number(id))); - - useEffect(() => { - async function load() { - setLoading(true); - const p = await getProductById(id); - setProduct(p); - setLoading(false); - } - load(); - }, [id]); - - useEffect(() => { - // Initialize countdown in seconds - const now = Date.now(); - const offerEndsAt = now + 2 * 24 * 60 * 60 * 1000; // 2 days from now - let remainingSeconds = Math.floor( - (offerEndsAt - now) / 1000 - ); - - // Set initial value via setTimeout to avoid synchronous setState in effect - const initTimeout = setTimeout(() => setCountdown(remainingSeconds), 0); - - const interval = setInterval(() => { - remainingSeconds -= 1; - if (remainingSeconds <= 0) { - setCountdown(0); - clearInterval(interval); - } else { - setCountdown(remainingSeconds); - } - }, 1000); - - return () => { - clearTimeout(initTimeout); - clearInterval(interval); - }; - }, []); - - const formatCountdown = (totalSeconds) => { - if (totalSeconds === null || totalSeconds <= 0) return null; - const days = Math.floor(totalSeconds / 86400); - const hours = Math.floor((totalSeconds % 86400) / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - return { days, hours, minutes, seconds }; - }; + const { id } = useParams(); + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [countdown, setCountdown] = useState(null); + const addToCart = useCartStore((s) => s.addToCart); + const toggleWishlist = useWishlistStore((s) => s.toggleWishlist); + const isInWishlist = useWishlistStore((s) => s.isInWishlist(Number(id))); + const { toggleCompareItem } = useCompareStore((s) => s); + const isInCompare = useCompareStore((s) => s.isInCompare(Number(id))); - const renderStars = (rating) => { - const stars = []; - for (let i = 1; i <= 5; i++) { - stars.push( - - - - ); - } - return stars; - }; - if (loading) { - return ( -
-
-
- ); + useEffect(() => { + async function load() { + setLoading(true); + const p = await getProductById(id); + setProduct(p); + setLoading(false); } + load(); + }, [id]); - if (!product) { - return ( -
-

- Product Not Found -

- - ← Back to Products - -
- ); + useEffect(() => { + // Initialize countdown in seconds + const now = Date.now(); + const offerEndsAt = now + 2 * 24 * 60 * 60 * 1000; // 2 days from now + let remainingSeconds = Math.floor((offerEndsAt - now) / 1000); + + // Set initial value via setTimeout to avoid synchronous setState in effect + const initTimeout = setTimeout(() => setCountdown(remainingSeconds), 0); + + const interval = setInterval(() => { + remainingSeconds -= 1; + if (remainingSeconds <= 0) { + setCountdown(0); + clearInterval(interval); + } else { + setCountdown(remainingSeconds); + } + }, 1000); + + return () => { + clearTimeout(initTimeout); + clearInterval(interval); + }; + }, []); + + const formatCountdown = (totalSeconds) => { + if (totalSeconds === null || totalSeconds <= 0) return null; + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return { days, hours, minutes, seconds }; + }; + + const renderStars = (rating) => { + const stars = []; + for (let i = 1; i <= 5; i++) { + stars.push( + + + , + ); } + return stars; + }; - const time = formatCountdown(countdown); + if (loading) { + return ( +
+
+
+ ); + } + if (!product) { return ( -
- {/* Breadcrumb */} - - -
- {/* Image */} -
- {product.title} -
- - {/* Info */} -
- - {product.category} - -

- {product.title} -

- - {/* Rating */} -
-
{renderStars(product.rating)}
- - ({product.rating} rating) - -
+
+

+ Product Not Found +

+ + ← Back to Products + +
+ ); + } - {/* Price */} -
- ${product.price.toFixed(2)} -
+ const time = formatCountdown(countdown); - {/* Offer Countdown */} - {time && ( -
-

- 🔥 Limited Time Offer — Ends In: -

-
- {[ - { value: time.days, label: "Days" }, - { value: time.hours, label: "Hours" }, - { value: time.minutes, label: "Min" }, - { value: time.seconds, label: "Sec" }, - ].map((unit) => ( -
-
- {String(unit.value).padStart(2, "0")} -
-
{unit.label}
-
- ))} -
-
- )} - - {/* Description */} -

- {product.description} -

- - {/* Stock */} -
-
10 - ? "bg-emerald-500" - : product.stock > 0 - ? "bg-amber-500" - : "bg-red-500" - }`} - /> - - {product.stock > 10 - ? "In Stock" - : product.stock > 0 - ? `Only ${product.stock} left` - : "Out of Stock"} - -
+ return ( +
+ {/* Breadcrumb */} + - {/* Actions */} -
- - -
+
+ {/* Image */} +
+ {product.title} +
+ + {/* Info */} +
+ + {product.category} + +

+ {product.title} +

- {/* Reviews Placeholder — Student task to implement */} -
-

- Customer Reviews -

-
-

- Reviews will be displayed here. -

-
+ {/* Rating */} +
+
{renderStars(product.rating)}
+ + ({product.rating} rating) + +
+ + {/* Price */} +
+ ${product.price.toFixed(2)} +
+ + {/* Offer Countdown */} + {time && ( +
+

+ 🔥 Limited Time Offer — Ends In: +

+
+ {[ + { value: time.days, label: "Days" }, + { value: time.hours, label: "Hours" }, + { value: time.minutes, label: "Min" }, + { value: time.seconds, label: "Sec" }, + ].map((unit) => ( +
+
+ {String(unit.value).padStart(2, "0")}
-
+
{unit.label}
+
+ ))} +
+ )} + + {/* Description */} +

+ {product.description} +

+ + {/* Stock */} +
+
10 + ? "bg-emerald-500" + : product.stock > 0 + ? "bg-amber-500" + : "bg-red-500" + }`} + /> + + {product.stock > 10 + ? "In Stock" + : product.stock > 0 + ? `Only ${product.stock} left` + : "Out of Stock"} + +
+ + {/* Actions */} +
+ + + +
+ + {/* Reviews Placeholder — Student task to implement */} +
+

+ Customer Reviews +

+
+

+ Reviews will be displayed here. +

+
+
- ); +
+
+ ); } diff --git a/src/pages/ProductsPage.jsx b/src/pages/ProductsPage.jsx index fd124e4..ee6290e 100644 --- a/src/pages/ProductsPage.jsx +++ b/src/pages/ProductsPage.jsx @@ -1,12 +1,10 @@ import { useState, useEffect } from "react"; import { - getCategories, filterProducts, getProducts, } from "../features/products/services/productService"; import ProductCard from "../features/products/components/ProductCard"; import ProductsLayout from "../components/ProductsLayout"; -import useProductsStore from "../store/useProductsStore"; import { useFilterActions } from "../hooks/useFilterActions"; // very important @@ -17,12 +15,25 @@ import { useFilterActions } from "../hooks/useFilterActions"; export default function ProductsPage() { const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); - const { setCategories }=useProductsStore((state)=>state) - const [totalPages, setTotalPages] = useState(0); const [allRawProducts, setAllRawProducts] = useState([]); const { updateURL, currentPage , filterParams } = useFilterActions(); -// to filters only , based second useEffect + + +// initial Load only +useEffect(() => { + async function loadInitialData() { + setLoading(true); + + const rawProducts = await getProducts(); + setAllRawProducts(rawProducts); + + } + + loadInitialData(); +}, []); + +// to filters only , based first useEffect useEffect(() => { if (allRawProducts.length === 0) return; @@ -33,6 +44,8 @@ useEffect(() => { const { data, totalPages } = await filterProducts(currentFilters); console.log('Filtration Applied ✅'); setProducts(data); + setLoading(false) + setTotalPages(totalPages); } @@ -40,25 +53,6 @@ useEffect(() => { }, [filterParams, allRawProducts]); -// initial Load only -useEffect(() => { - async function loadInitialData() { - setLoading(true); - - const [rawProducts, cats] = await Promise.all([ - getProducts(), - getCategories() - ]); - - setAllRawProducts(rawProducts); - setCategories(cats); - - setTimeout(() => setLoading(false), 600); - } - - loadInitialData(); -}, []); - return (
@@ -90,7 +84,7 @@ useEffect(() => {
{/* Empty State , not found products */} - {products.length === 0 && ( + { products.length === 0 && (
s.items); - const removeFromWishlist = useWishlistStore((s) => s.removeFromWishlist); - const addToCart = useCartStore((s) => s.addToCart); + + if (items.length === 0) { return ( @@ -49,10 +50,29 @@ export default function WishlistPage() {
- {items.map((item) => ( -
+ )} +
+
+ ); +} + + + + +const WishProductCard=({item})=>{ + + + + const removeFromWishlist = useWishlistStore((s) => s.removeFromWishlist); + const addToCart = useCartStore((s) => s.addToCart); + const { toggleCompareItem } = useCompareStore((s) => s); + + const isInCompare = useCompareStore((s) => s.isInCompare(item.id)); + + return (
+ + {/* compare button */} + +
- ))} -
-
- ); -} + ) +} \ No newline at end of file diff --git a/src/store/useProductsStore.js b/src/store/useProductsStore.js deleted file mode 100644 index 9fe8642..0000000 --- a/src/store/useProductsStore.js +++ /dev/null @@ -1,11 +0,0 @@ -import { create } from "zustand"; -const useProductsStore = create()( (set) => ({ - categories: [], - setCategories: (newCategories) => set({ categories: newCategories }), - - }), - - -); - -export default useProductsStore; From ec89d7708023518eca262351be6a7394b2032bce Mon Sep 17 00:00:00 2001 From: TalkativeUser Date: Fri, 27 Mar 2026 15:42:05 +0200 Subject: [PATCH 06/11] inhance prev commit with ai , not prefix just inhancement --- src/pages/ComparePage.jsx | 89 +++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 46 deletions(-) diff --git a/src/pages/ComparePage.jsx b/src/pages/ComparePage.jsx index 639f7fb..856ad76 100644 --- a/src/pages/ComparePage.jsx +++ b/src/pages/ComparePage.jsx @@ -5,43 +5,43 @@ export default function ComparePage() { const compareItems = useCompareStore((s) => s.items); const removeFromCompare = useCompareStore((s) => s.removeFromCompare); - function whichIsBetter() { - if (compareItems.length < 2) return; - - const productA = compareItems[0]; - const productB = compareItems[1]; - return [ - { - price: productA.price <= productB.price, - rating: productA.rating >= productB.rating, - stock: productA.stock >= productB.stock, - }, - - { - price: productB.price <= productA.price, - rating: productB.rating >= productA.rating, - stock: productB.stock >= productA.stock, - }, - ]; - } - const coparisonFlags = whichIsBetter(); + function getComparisonResult(item1, item2, field) { + if (!item1 || !item2 || !field.compareType) return null; + + const val1 = item1[field.key]; // سعر المنتج الاول + const val2 = item2[field.key]; // سعر المنتج الثانى + + if (val1 === val2) return "equal"; // لو قد بعض يعنى +// هل المقارنه على الكمبه او الريت +// انما دى للسعر فقط المقارنه دى نتيجتها للكميه والمعدل + const isFirstBetter = field.compareType === "higherIsBetter" ? val1 > val2 : val1 < val2; + + return isFirstBetter; + } const comparisonFields = [ { label: "Price", key: "price", + compareType: "lowerIsBetter", format: (v) => ( ${v?.toFixed(2)} ), }, { label: "Category", key: "category" }, - { label: "Rating", key: "rating", format: (v) => `⭐ ${v}` }, + { + label: "Rating", + key: "rating", + format: (v) => `⭐ ${v}`, + compareType: "higherIsBetter", + }, { label: "Stock", key: "stock", format: (v) => (v > 0 ? `In Stock : ${v}` : `Out`), + compareType: "higherIsBetter", }, { label: "Brand", key: "brand" }, ]; @@ -148,33 +148,30 @@ export default function ComparePage() {
{/* Data Rows */} - {comparisonFields.map((field) => ( -
- {field.format - ? field.format(item[field.key]) - : item[field.key] || "—"} -
- ))} + {comparisonFields.map((field) => { // biger == smal + // pro A pro B [ {pric...} , {rat...} , {stok..} ] + const isBetter = getComparisonResult(compareItems[0],compareItems[1], field ); + + // تحديد اللون بناءً على النتيجة (المنتج الحالي هو index) + // لو النتيجة true والمنتج هو الأول، يبقى أخضر. لو النتيجة false والمنتج هو الأول، يبقى أحمر. + const bgClass = isBetter === null || isBetter === "equal" ? "" : (index === 0 ? isBetter : !isBetter) ? "bg-green-100": "bg-red-100"; + + return ( +
+ {field.format + ? field.format(item[field.key]) + : item[field.key]} +
+ ); + })} {/* Description Row */} -
- {item.description} -
+
+ {item.description} +
) : (
From dfcee99d66763b9a445b291fe774174aa02ccb13 Mon Sep 17 00:00:00 2001 From: TalkativeUser Date: Sat, 28 Mar 2026 14:53:35 +0200 Subject: [PATCH 07/11] add react-hook-form and for control inputs and yup for validation and show errors , spreat checkout page into useCheckout hook and sperated ui --- package-lock.json | 79 ++++ package.json | 3 + .../checkout/components/FieldForm.jsx | 26 ++ .../checkout/components/OrderSummery.jsx | 61 +++ src/features/checkout/data/shippingFields.js | 56 +++ .../checkout/hooks/useCheckoutForm.js | 39 ++ .../checkout/schemas/checkoutSchema.js | 46 +++ src/pages/CheckoutPage.jsx | 386 ++++++------------ src/pages/ComparePage.jsx | 2 +- src/pages/ProductDetailsPage.jsx | 94 ++++- 10 files changed, 507 insertions(+), 285 deletions(-) create mode 100644 src/features/checkout/components/FieldForm.jsx create mode 100644 src/features/checkout/components/OrderSummery.jsx create mode 100644 src/features/checkout/data/shippingFields.js create mode 100644 src/features/checkout/hooks/useCheckoutForm.js create mode 100644 src/features/checkout/schemas/checkoutSchema.js diff --git a/package-lock.json b/package-lock.json index 740b71a..861954f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,14 @@ "name": "ecommerce-training-app", "version": "0.0.0", "dependencies": { + "@hookform/resolvers": "^5.2.2", "@tailwindcss/vite": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-hook-form": "^7.72.0", "react-router-dom": "^7.13.0", "tailwindcss": "^4.2.0", + "yup": "^1.7.1", "zustand": "^5.0.11" }, "devDependencies": { @@ -884,6 +887,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1320,6 +1335,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", @@ -3296,6 +3317,12 @@ "node": ">= 0.8.0" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3327,6 +3354,22 @@ "react": "^19.2.4" } }, + "node_modules/react-hook-form": { + "version": "7.72.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.0.tgz", + "integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3549,6 +3592,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3592,6 +3641,12 @@ "node": ">=14.0.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3605,6 +3660,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -3900,6 +3967,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 2f25bba..6f664c0 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,14 @@ "test:watch": "vitest" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", "@tailwindcss/vite": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-hook-form": "^7.72.0", "react-router-dom": "^7.13.0", "tailwindcss": "^4.2.0", + "yup": "^1.7.1", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/src/features/checkout/components/FieldForm.jsx b/src/features/checkout/components/FieldForm.jsx new file mode 100644 index 0000000..68a7fc2 --- /dev/null +++ b/src/features/checkout/components/FieldForm.jsx @@ -0,0 +1,26 @@ +import { useFormContext } from "react-hook-form"; + +export default function FieldForm({ label, name, placeholder, gridSpan }) { + + const { register, formState: { errors } } = useFormContext(); + + const error = errors[name]?.message; + return ( +
+ + + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/src/features/checkout/components/OrderSummery.jsx b/src/features/checkout/components/OrderSummery.jsx new file mode 100644 index 0000000..678945d --- /dev/null +++ b/src/features/checkout/components/OrderSummery.jsx @@ -0,0 +1,61 @@ +export default function OrderSummery ({items , totalPrice}) { + + + return <> + {/* Order Summary */} +
+
+

+ Your Order +

+
+ {items.map((item) => ( +
+ {item.title} +
+

+ {item.title} +

+

+ Qty: {item.quantity} +

+
+ + ${(item.price * item.quantity).toFixed(2)} + +
+ ))} +
+ +
+
+ Subtotal + ${totalPrice.toFixed(2)} +
+
+ Shipping + Free +
+
+ Tax + ${(totalPrice * 0.08).toFixed(2)} +
+
+ Total + ${(totalPrice * 1.08).toFixed(2)} +
+
+ + +
+
+} \ No newline at end of file diff --git a/src/features/checkout/data/shippingFields.js b/src/features/checkout/data/shippingFields.js new file mode 100644 index 0000000..2f605e7 --- /dev/null +++ b/src/features/checkout/data/shippingFields.js @@ -0,0 +1,56 @@ + export const defValues_shippingFields = { + firstName: "", + lastName: "", + email: "", + phone: "", + address: "", + city: "", + zipCode: "", + country: "", + }; + + export const shippingFields = [ + { + name: "firstName", + label: "First Name", + placeholder: "John", + gridSpan: "sm:col-span-1", + }, + { + name: "lastName", + label: "Last Name", + placeholder: "Doe", + gridSpan: "sm:col-span-1", + }, + { + name: "email", + label: "Email", + placeholder: "john@example.com", + gridSpan: "sm:col-span-1", + }, + { + name: "phone", + label: "Phone", + placeholder: "+1 (555) 000-0000", + gridSpan: "sm:col-span-1", + }, + { + name: "address", + label: "Address", + placeholder: "123 Main Street", + gridSpan: "sm:col-span-2", + }, + { + name: "city", + label: "City", + placeholder: "New York", + gridSpan: "sm:col-span-1", + }, + { + name: "zipCode", + label: "ZIP Code", + placeholder: "10001", + gridSpan: "sm:col-span-1", + }, + ]; + diff --git a/src/features/checkout/hooks/useCheckoutForm.js b/src/features/checkout/hooks/useCheckoutForm.js new file mode 100644 index 0000000..87d4d1f --- /dev/null +++ b/src/features/checkout/hooks/useCheckoutForm.js @@ -0,0 +1,39 @@ +import { yupResolver } from "@hookform/resolvers/yup"; +import { checkoutSchema } from "../schemas/checkoutSchema"; +import { useForm } from "react-hook-form"; +import { useState } from "react"; +import { defValues_shippingFields } from "../data/shippingFields"; +import useCartStore from '../../cart/hooks/useCartStore' + +export default function useCheckoutForm() { + const items = useCartStore((s) => s.items); + const clearCart = useCartStore((s) => s.clearCart); + const [orderPlaced, setOrderPlaced] = useState(false); + + + const methods = useForm({ + mode: "onChange", + resolver: yupResolver(checkoutSchema), + defaultValues: defValues_shippingFields, + }); + + const totalPrice = items.reduce( + (sum, item) => sum + item.price * item.quantity, + 0 + ); + + const onSubmit = (data) => { + clearCart(); + setOrderPlaced(true); + console.log(data); + methods.reset(); // نستخدم methods هنا + }; + + return { + ...methods, // 2. passing all react-hook-form methods (register, control, formState, etc.) + onSubmit, + orderPlaced, + totalPrice, + items, + }; +} \ No newline at end of file diff --git a/src/features/checkout/schemas/checkoutSchema.js b/src/features/checkout/schemas/checkoutSchema.js new file mode 100644 index 0000000..8d62e35 --- /dev/null +++ b/src/features/checkout/schemas/checkoutSchema.js @@ -0,0 +1,46 @@ +import * as yup from "yup"; + +export const checkoutSchema = yup + .object({ + firstName: yup + .string() + .trim() + .min(2, "First name must be at least 2 characters") + .max(20, "First name is too long") + .required("First name is required"), + + lastName: yup + .string() + .trim() + .min(2, "Last name must be at least 2 characters") + .required("Last name is required"), + + email: yup + .string() + .trim() + .email("Invalid email format") + .required("Email address is required"), + + phone: yup + .string() + .required("Phone number is required") + .matches(/^[0-9]+$/, "Phone number must contain only digits") + .min(10, "Phone number must be at least 10 digits") + .max(15, "Phone number cannot exceed 15 digits"), + + address: yup + .string() + .trim() + .min(10, "Address must be at least 10 characters long") + .required("Shipping address is required"), + + city: yup.string().required("Please select or enter your city"), + + zipCode: yup + .string() + .required("Zip code is required") + .matches(/^[0-9]{5}$/, "Zip code must be exactly 5 digits"), + + country: yup.string().required("Please select your country"), + }) + .required("All form fields are required"); \ No newline at end of file diff --git a/src/pages/CheckoutPage.jsx b/src/pages/CheckoutPage.jsx index 1c04448..9ee80a7 100644 --- a/src/pages/CheckoutPage.jsx +++ b/src/pages/CheckoutPage.jsx @@ -1,283 +1,135 @@ -import { useState } from "react"; import { Link } from "react-router-dom"; -import useCartStore from "../features/cart/hooks/useCartStore"; -export default function CheckoutPage() { - const items = useCartStore((s) => s.items); - const clearCart = useCartStore((s) => s.clearCart); - const [orderPlaced, setOrderPlaced] = useState(false); - const [form, setForm] = useState({ - firstName: "", - lastName: "", - email: "", - phone: "", - address: "", - city: "", - zipCode: "", - country: "", - }); +import { shippingFields } from "../features/checkout/data/shippingFields"; +import OrderSummery from "../features/checkout/components/OrderSummery"; +import FieldForm from "../features/checkout/components/FieldForm"; +import useCheckoutForm from "../features/checkout/hooks/useCheckoutForm"; +import { FormProvider } from "react-hook-form"; - const totalPrice = items.reduce( - (sum, item) => sum + item.price * item.quantity, - 0 - ); +export default function CheckoutPage() { - const handleChange = (e) => { - setForm({ ...form, [e.target.name]: e.target.value }); - }; - // No validation implemented — student task - const handleSubmit = (e) => { - e.preventDefault(); - clearCart(); - setOrderPlaced(true); - }; + const methods=useCheckoutForm() - if (orderPlaced) { - return ( -
-
- - - -
-

- Order Placed Successfully! -

-

- Thank you for your purchase. Your order has been confirmed and will be - shipped shortly. -

- - Continue Shopping - -
- ); - } - - if (items.length === 0) { - return ( -
-

- Your cart is empty -

-

- Add some items to your cart before checking out. -

- - Browse Products - -
- ); - } + + if (methods.orderPlaced) { return ( -
-
-

Checkout

-

- Complete your order by filling in the details below -

-
+
+
+ + + +
+

+ Order Placed Successfully! +

+

+ Thank you for your purchase. Your order has been confirmed and will be + shipped shortly. +

+ + Continue Shopping + +
+ ); + } -
-
- {/* Shipping Form */} -
-
-

- Shipping Information -

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
+ if (methods.items.length === 0) { + return ( +
+

+ Your cart is empty +

+

+ Add some items to your cart before checking out. +

+ + Browse Products + +
+ ); + } + return ( +
+
+

Checkout

+

+ Complete your order by filling in the details below +

+
- {/* Order Summary */} -
-
-

- Your Order -

-
- {items.map((item) => ( -
- {item.title} -
-

- {item.title} -

-

- Qty: {item.quantity} -

-
- - ${(item.price * item.quantity).toFixed(2)} - -
- ))} -
+ + +
+ {/* Shipping Form */} +
+
+

+ Shipping Information +

+
+ {shippingFields.map((field) => ( + -
- Subtotal - ${totalPrice.toFixed(2)} -
-
- Shipping - Free -
-
- Tax - ${(totalPrice * 0.08).toFixed(2)} -
-
- Total - ${(totalPrice * 1.08).toFixed(2)} -
-
+ /> + ))} - -
-
+ {/* Country , don't put it in map because does not care */} +
+ + + {methods.formState.errors.country && ( +

+ {methods.formState.errors.country.message} +

+ )}
- +
+
+
+ +
- ); + + + +
+ ); } + + + diff --git a/src/pages/ComparePage.jsx b/src/pages/ComparePage.jsx index 856ad76..cf452fe 100644 --- a/src/pages/ComparePage.jsx +++ b/src/pages/ComparePage.jsx @@ -148,7 +148,7 @@ export default function ComparePage() {
{/* Data Rows */} - {comparisonFields.map((field) => { // biger == smal + {comparisonFields.map((field) => { // pro A pro B [ {pric...} , {rat...} , {stok..} ] const isBetter = getComparisonResult(compareItems[0],compareItems[1], field ); diff --git a/src/pages/ProductDetailsPage.jsx b/src/pages/ProductDetailsPage.jsx index 37e7a3d..8eec4de 100644 --- a/src/pages/ProductDetailsPage.jsx +++ b/src/pages/ProductDetailsPage.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { useParams, Link } from "react-router-dom"; -import { getProductById } from "../features/products/services/productService"; +import { getProductById , getReviewsByProductId } from "../features/products/services/productService"; import useCartStore from "../features/cart/hooks/useCartStore"; import useWishlistStore from "../features/wishlist/hooks/useWishlistStore"; import useCompareStore from "../features/compare/hooks/useCompareStore"; @@ -15,17 +15,39 @@ export default function ProductDetailsPage() { const isInWishlist = useWishlistStore((s) => s.isInWishlist(Number(id))); const { toggleCompareItem } = useCompareStore((s) => s); const isInCompare = useCompareStore((s) => s.isInCompare(Number(id))); + const [reviews , setReviews]=useState([]); - useEffect(() => { - async function load() { - setLoading(true); - const p = await getProductById(id); - setProduct(p); - setLoading(false); +useEffect(() => { + async function load() { + setLoading(true); + + // I prefer use Promise.allsettled insted of Promise.All because promis.all it's reject all requests when fail any request but allSettled it's not + const results = await Promise.allSettled([ + getProductById(id), + getReviewsByProductId(id) + ]); + + // (Product) + if (results[0].status === "fulfilled") { + setProduct(results[0].value); + } else { + console.error("Product Load Failed:", results[0].reason); + } + + // (Reviews) + if (results[1].status === "fulfilled") { + setReviews(results[1].value); + } else { + setReviews([]); + console.warn("Reviews Load Failed, but product is shown."); } - load(); - }, [id]); + + setLoading(false); + } + load(); +}, [id]); + useEffect(() => { // Initialize countdown in seconds @@ -275,16 +297,54 @@ export default function ProductDetailsPage() {
{/* Reviews Placeholder — Student task to implement */} -
-

- Customer Reviews -

-
-

- Reviews will be displayed here. -

+
+

+ Customer Reviews ({reviews.length}) +

+ + {reviews.length > 0 ? ( +
+ {reviews.map((review) => ( +
+
+
+ +
+ {review.user.charAt(0)} +
+
+

{review.user}

+

{review.date}

+
+
+ + {/* عرض النجوم بناءً على التقييم */} +
+ {[...Array(5)].map((_, i) => ( + + ⭐ + + ))}
+ +

+ "{review.comment}" +

+
+ ))} +
+ ) : ( +
+

+ No reviews yet. Be the first to share your thoughts! +

+
+ )} +
From ce7c68a002fdee08205d7c36b2ef8eb73cbcdf36 Mon Sep 17 00:00:00 2001 From: TalkativeUser Date: Sat, 28 Mar 2026 15:31:55 +0200 Subject: [PATCH 08/11] fix quantity product issue by only line , and enhancement inc and dec btns , inhance inc btn then make it disable when item.quantity equal item.stock --- src/features/cart/hooks/useCartStore.js | 4 ++++ src/pages/CartPage.jsx | 11 ++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/features/cart/hooks/useCartStore.js b/src/features/cart/hooks/useCartStore.js index 0dd0ae1..01b59e7 100644 --- a/src/features/cart/hooks/useCartStore.js +++ b/src/features/cart/hooks/useCartStore.js @@ -41,7 +41,11 @@ const useCartStore = create((set, get) => ({ // Prevent exceeding stock if (newQuantity > item.stock) return; + // Prevent set new quantity if equal 0 or more less , just add this line + if (newQuantity < 1) return; + // BUG: No check for newQuantity <= 0 + // fix this bug set({ items: items.map((i) => i.id === productId ? { ...i, quantity: newQuantity } : i diff --git a/src/pages/CartPage.jsx b/src/pages/CartPage.jsx index 857046a..8165026 100644 --- a/src/pages/CartPage.jsx +++ b/src/pages/CartPage.jsx @@ -121,7 +121,9 @@ export default function CartPage() { onClick={() => updateQuantity(item.id, item.quantity - 1) } - className="w-8 h-8 flex items-center justify-center text-gray-500 hover:text-primary-600 transition-colors" + className={`w-8 h-8 flex items-center justify-center text-gray-500 hover:text-primary-600 transition-colors + ${item.quantity == 1 ?"cursor-not-allowed opacity-40 font-normal ":"cursor-pointer opacity-100 font-extrabold"} + `} > − @@ -130,9 +132,12 @@ export default function CartPage() { From 7ff4e7e9d2efda1755534a45ca9a9034c07b49cb Mon Sep 17 00:00:00 2001 From: TalkativeUser Date: Sun, 29 Mar 2026 06:23:30 +0200 Subject: [PATCH 09/11] add products skeleton --- src/pages/ProductsPage.jsx | 47 ++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/pages/ProductsPage.jsx b/src/pages/ProductsPage.jsx index ee6290e..51bf7be 100644 --- a/src/pages/ProductsPage.jsx +++ b/src/pages/ProductsPage.jsx @@ -69,11 +69,11 @@ useEffect(() => { {/* Loading Spinner */} - {loading ? ( -
-
-

Loading products...

-
+ {loading ? ( + //
+ //
+ //

Loading products...

+ //
) : ( <> {/* Product Grid */} @@ -142,3 +142,40 @@ useEffect(() => { ); } + + +const ProductsSkeleton = () => { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+ +
+ + + +
+ + +
+
 
+
 
+
 
+
 
+
 
+
+ + {/* زر التفاعل */} +
+
 
+
 
+
+
+ ))} +
+ ); +}; + From 839ed1177935fd05e8dfdd31550f7835f535e278 Mon Sep 17 00:00:00 2001 From: TalkativeUser Date: Sun, 29 Mar 2026 07:52:41 +0200 Subject: [PATCH 10/11] complete intern2Grow training , persist cart in session storage and toggle toast notification and fix bug in useCartStore --- package-lock.json | 28 ++++- package.json | 1 + src/App.jsx | 16 ++- src/features/cart/hooks/useCartStore.js | 115 ++++++++++-------- .../checkout/hooks/useCheckoutForm.js | 3 +- src/features/compare/hooks/useCompareStore.js | 6 +- .../wishlist/hooks/useWishlistStore.js | 4 + src/pages/CartPage.jsx | 3 + src/pages/ProductsPage.jsx | 1 - src/pages/WishlistPage.jsx | 1 - 10 files changed, 120 insertions(+), 58 deletions(-) diff --git a/package-lock.json b/package-lock.json index 861954f..bcb23de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.72.0", + "react-hot-toast": "^2.6.0", "react-router-dom": "^7.13.0", "tailwindcss": "^4.2.0", "yup": "^1.7.1", @@ -2115,7 +2116,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -2599,6 +2599,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3370,6 +3379,23 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", diff --git a/package.json b/package.json index 6f664c0..f1bb0aa 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.72.0", + "react-hot-toast": "^2.6.0", "react-router-dom": "^7.13.0", "tailwindcss": "^4.2.0", "yup": "^1.7.1", diff --git a/src/App.jsx b/src/App.jsx index b4427da..85efb3b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,13 +7,13 @@ import CartPage from "./pages/CartPage"; import WishlistPage from "./pages/WishlistPage"; import ComparePage from "./pages/ComparePage"; import CheckoutPage from "./pages/CheckoutPage"; -import { ProductsProvider } from "./context"; +import { Toaster } from "react-hot-toast"; // I forgot and use Context insted of zustand 😂 but edit it again. export default function App() { return ( - // + <> }> @@ -27,6 +27,16 @@ export default function App() { - // + {/* used Toaster inside these files + 1-useCartStore.js 📁 Add to & Remove from wishList , toast.success('Added to Wishlist!') , toast.success('Removed from Wishlist!') + 2-useWishlistStore.js 📁 Add to & Remove from Cart , toast.success('Added to Cart!') , toast.success('Removed from Cart!') + 3-useCompareStore.js 📁 Add to & Remove from Compare , toast.success('Added to Comare!') , toast.success('Removed from Comare!') + 4- 📁 Place order , toast.success('Successfull place order !') + + + + */} + + ); } diff --git a/src/features/cart/hooks/useCartStore.js b/src/features/cart/hooks/useCartStore.js index 01b59e7..73ecaa0 100644 --- a/src/features/cart/hooks/useCartStore.js +++ b/src/features/cart/hooks/useCartStore.js @@ -1,81 +1,96 @@ +import toast from "react-hot-toast"; import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; -const useCartStore = create((set, get) => ({ +const useCartStore = create( + persist((set, get) => ({ items: [], addToCart: (product) => { - const items = get().items; - const existingItem = items.find((item) => item.id === product.id); - - if (existingItem) { - // Prevent exceeding stock - if (existingItem.quantity >= product.stock) return; - set({ - items: items.map((item) => - item.id === product.id - ? { ...item, quantity: item.quantity + 1 } - : item - ), - }); - } else { - set({ - items: [...items, { ...product, quantity: 1 }], - }); + const items = get().items; + const existingItem = items.find((item) => item.id === product.id); + + if (existingItem) { + // Prevent exceeding stock + if (existingItem.quantity >= product.stock) { + toast.error( + "The item quantity more than item stock , can not increase ", + ); + return; } + set({ + items: items.map((item) => + item.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item, + ), + }); + toast.success("item exist , just only increase quantity"); + } else { + set({ + items: [...items, { ...product, quantity: 1 }], + }); + + toast.success("Added to cart"); + } }, removeFromCart: (productId) => { - set({ - items: get().items.filter((item) => item.id !== productId), - }); + set({ + items: get().items.filter((item) => item.id !== productId), + }); + toast.success("Removed item"); }, // BUG: This function does NOT prevent quantity from going to 0 or negative. // Students must fix this by adding a minimum quantity check (quantity >= 1). updateQuantity: (productId, newQuantity) => { - const items = get().items; - const item = items.find((i) => i.id === productId); + const items = get().items; + const item = items.find((i) => i.id === productId); - if (!item) return; + if (!item) return; - // Prevent exceeding stock - if (newQuantity > item.stock) return; + // Prevent exceeding stock + if (newQuantity > item.stock) { + toast.error("The item quantity more than item stock , can not increase "); + return; + } - // Prevent set new quantity if equal 0 or more less , just add this line - if (newQuantity < 1) return; + // Prevent set new quantity if equal 0 or more less , just add this line + if (newQuantity < 1) return; - // BUG: No check for newQuantity <= 0 - // fix this bug - set({ - items: items.map((i) => - i.id === productId ? { ...i, quantity: newQuantity } : i - ), - }); + // BUG: No check for newQuantity <= 0 + // fix this bug + // fixed ✅ + set({ + items: items.map((i) => + i.id === productId ? { ...i, quantity: newQuantity } : i, + ), + }); }, clearCart: () => set({ items: [] }), - get totalItems() { - return get().items.reduce((sum, item) => sum + item.quantity, 0); - }, - get totalPrice() { - return get().items.reduce( - (sum, item) => sum + item.price * item.quantity, - 0 - ); - }, getTotalItems: () => { - return get().items.reduce((sum, item) => sum + item.quantity, 0); + return get().items.reduce((sum, item) => sum + item.quantity, 0); }, getTotalPrice: () => { - return get().items.reduce( - (sum, item) => sum + item.price * item.quantity, - 0 - ); + return get().items.reduce( + (sum, item) => sum + item.price * item.quantity, + 0, + ); }, -})); + }) , + { + name: "shopHup-cartItems", + storage: createJSONStorage(() => sessionStorage), // (optional) by default, 'localStorage' is used + } + +) + +); export default useCartStore; diff --git a/src/features/checkout/hooks/useCheckoutForm.js b/src/features/checkout/hooks/useCheckoutForm.js index 87d4d1f..4903bb4 100644 --- a/src/features/checkout/hooks/useCheckoutForm.js +++ b/src/features/checkout/hooks/useCheckoutForm.js @@ -4,6 +4,7 @@ import { useForm } from "react-hook-form"; import { useState } from "react"; import { defValues_shippingFields } from "../data/shippingFields"; import useCartStore from '../../cart/hooks/useCartStore' +import toast from "react-hot-toast"; export default function useCheckoutForm() { const items = useCartStore((s) => s.items); @@ -25,8 +26,8 @@ export default function useCheckoutForm() { const onSubmit = (data) => { clearCart(); setOrderPlaced(true); - console.log(data); methods.reset(); // نستخدم methods هنا + toast.success('successfull placed order') }; return { diff --git a/src/features/compare/hooks/useCompareStore.js b/src/features/compare/hooks/useCompareStore.js index f756314..d0c2644 100644 --- a/src/features/compare/hooks/useCompareStore.js +++ b/src/features/compare/hooks/useCompareStore.js @@ -1,3 +1,4 @@ +import toast from "react-hot-toast"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -17,18 +18,20 @@ if(exists) { get().removeFromCompare(newItem.id) + } else { if(items.length==2 ) { set({ items: [...items.slice(1), newItem] }); + } else { set({items:[...items,newItem]}) } - +toast.success('Added item to compare page') } @@ -41,6 +44,7 @@ if(exists) { const newItems = get().items.filter((item) => item.id !== productId); set({ items: newItems }); + toast.success('Removed item from compare page') }, isInCompare: (productId) => { diff --git a/src/features/wishlist/hooks/useWishlistStore.js b/src/features/wishlist/hooks/useWishlistStore.js index bd71d2b..cac657a 100644 --- a/src/features/wishlist/hooks/useWishlistStore.js +++ b/src/features/wishlist/hooks/useWishlistStore.js @@ -1,3 +1,4 @@ +import toast from "react-hot-toast"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -10,9 +11,11 @@ const useWishlistStore = create( persist( (set, get) => ({ if (exists) { get().removeFromWishlist(product.id ) + } else { set({ items: [...items, product] }); + toast.success('Added item to wishlist page') } }, @@ -21,6 +24,7 @@ const useWishlistStore = create( persist( (set, get) => ({ // TODO: Implement removal logic const newItems = get().items.filter((item) => item.id !== productId); set({ items: newItems }); + toast.success('Removed item from wishlist page') }, isInWishlist: (productId) => { diff --git a/src/pages/CartPage.jsx b/src/pages/CartPage.jsx index 8165026..c6dc106 100644 --- a/src/pages/CartPage.jsx +++ b/src/pages/CartPage.jsx @@ -1,12 +1,15 @@ import { Link } from "react-router-dom"; import useCartStore from "../features/cart/hooks/useCartStore"; + export default function CartPage() { const items = useCartStore((s) => s.items); const updateQuantity = useCartStore((s) => s.updateQuantity); const removeFromCart = useCartStore((s) => s.removeFromCart); const clearCart = useCartStore((s) => s.clearCart); + console.log('cart items => ' , items); + const totalPrice = items.reduce( (sum, item) => sum + item.price * item.quantity, 0 diff --git a/src/pages/ProductsPage.jsx b/src/pages/ProductsPage.jsx index 51bf7be..9436a69 100644 --- a/src/pages/ProductsPage.jsx +++ b/src/pages/ProductsPage.jsx @@ -42,7 +42,6 @@ useEffect(() => { async function applyCurrentFilters() { const { data, totalPages } = await filterProducts(currentFilters); - console.log('Filtration Applied ✅'); setProducts(data); setLoading(false) diff --git a/src/pages/WishlistPage.jsx b/src/pages/WishlistPage.jsx index a05f4ee..879d5db 100644 --- a/src/pages/WishlistPage.jsx +++ b/src/pages/WishlistPage.jsx @@ -7,7 +7,6 @@ export default function WishlistPage() { const items = useWishlistStore((s) => s.items); - if (items.length === 0) { return (
From 051e2bea0e1704e98d53b04c8031c1591401ad3c Mon Sep 17 00:00:00 2001 From: TalkativeUser Date: Sun, 29 Mar 2026 08:21:02 +0200 Subject: [PATCH 11/11] fix vercel 404 not found when i navigate to /products or /any routing then make refresh , create vercel.json file and configured --- vercel.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 vercel.json diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..1db5d80 --- /dev/null +++ b/vercel.json @@ -0,0 +1,5 @@ +{ + "rewrites": [ + { "source": "/(.*)", "destination": "/" } + ] +} \ No newline at end of file