diff --git a/package-lock.json b/package-lock.json index 0593704..bcb23de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,15 @@ "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-hot-toast": "^2.6.0", "react-router-dom": "^7.13.0", "tailwindcss": "^4.2.0", + "yup": "^1.7.1", "zustand": "^5.0.11" }, "devDependencies": { @@ -60,7 +64,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", @@ -885,6 +888,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", @@ -1321,6 +1336,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", @@ -1660,7 +1681,6 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1671,7 +1691,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1841,7 +1860,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1960,7 +1978,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2099,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": { @@ -2246,7 +2262,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", @@ -2584,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", @@ -2596,7 +2620,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 +3281,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3304,6 +3326,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", @@ -3319,7 +3347,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 +3356,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" }, @@ -3337,6 +3363,39 @@ "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-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", @@ -3559,6 +3618,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", @@ -3602,6 +3667,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", @@ -3615,6 +3686,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", @@ -3668,7 +3751,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", @@ -3911,13 +3993,24 @@ "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", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 2f25bba..f1bb0aa 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,15 @@ "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-hot-toast": "^2.6.0", "react-router-dom": "^7.13.0", "tailwindcss": "^4.2.0", + "yup": "^1.7.1", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/src/App.jsx b/src/App.jsx index 2bd0ff4..85efb3b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,21 +7,36 @@ import CartPage from "./pages/CartPage"; import WishlistPage from "./pages/WishlistPage"; import ComparePage from "./pages/ComparePage"; import CheckoutPage from "./pages/CheckoutPage"; +import { Toaster } from "react-hot-toast"; + +// I forgot and use Context insted of zustand 😂 but edit it again. export default function App() { return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + <> + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + {/* 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/components/Atoms/PriceRangeSlider.jsx b/src/components/Atoms/PriceRangeSlider.jsx new file mode 100644 index 0000000..d7ab8f4 --- /dev/null +++ b/src/components/Atoms/PriceRangeSlider.jsx @@ -0,0 +1,98 @@ +import { useRef, useEffect, memo } from "react"; + +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 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; + min = max - minGap; + } else { + maxRangeRef.current.value = min + minGap; + max = min + minGap; + } + } + + // تحديث الأرقام والخط الملون + minValueRef.current.textContent = min; + maxValueRef.current.textContent = max; + + 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(); + }, [urlMin, urlMax]); // لو اللينك اتغير من بره، الـ Slider يتحرك + + return ( + <> +
+
+ updateURL({ minPrice: minRangeRef.current.value })} + className="price-slider-input" + /> + + updateURL({ maxPrice: maxRangeRef.current.value })} + className="price-slider-input" + /> + +
+
+
+
+
+ +
+
+ Maximum Price: {urlMax} + $ +
+
+ Minimum Price: {urlMin} + $ +
+
+ + ); +} + +export default memo(PriceRangeSlider); \ No newline at end of file diff --git a/src/components/FiltersDrawer.jsx b/src/components/FiltersDrawer.jsx new file mode 100644 index 0000000..400b965 --- /dev/null +++ b/src/components/FiltersDrawer.jsx @@ -0,0 +1,121 @@ +import PriceRangeSlider from "./Atoms/PriceRangeSlider"; +import { useFilterActions } from "../hooks/useFilterActions"; +import { getCategories } from '../features/products/services/productService' +import { useEffect, useState } from "react"; +export default function FiltersDrawer() { + const [categories , setCategories] = useState([]); + const { filterParams, updateURL } = useFilterActions(); + const [searchTerm, setSearchTerm] = useState( + filterParams.get("search") || "", + ); + + {/* test persisting zustand store */} + // const {count , incCount}=useProductsStore() + + // debounce pattern + useEffect(() => { + if (searchTerm !== (filterParams.get("search") || "")) { + const timer = setTimeout(() => { + updateURL({ search: searchTerm }); + }, 400); + + return () => { + clearTimeout(timer); + }; + } + }, [searchTerm]); + + + useEffect(()=>{ + + const fetchCats= async ()=>{ + + const cats=await getCategories(); + setCategories(cats) + } + fetchCats() + },[]) + + 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 */} + + +
+ {/* memoized component */} + +
+
+ + {/* test persisting zustand store */} + {/* +

count : {count}

*/} +
+ ); +} diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index c411958..30d7ed5 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -1,11 +1,19 @@ import { Link } from "react-router-dom"; import { useState } from "react"; import useCartStore from "../features/cart/hooks/useCartStore"; +import useWishlistStore from "../features/wishlist/hooks/useWishlistStore"; +import useCompareStore from "../features/compare/hooks/useCompareStore"; export default function Navbar() { const [mobileOpen, setMobileOpen] = useState(false); - const items = useCartStore((s) => s.items); - const totalItems = items.reduce((sum, item) => sum + item.quantity, 0); + const cartItems = useCartStore((s) => s.items); + const wishItems = useWishlistStore((s)=>s.items) + const compareItems = useCompareStore((s)=>s.items) + + const totalCartItems = cartItems.reduce((sum, item) => sum + item.quantity, 0); + const totalWishItems = wishItems.length; + const totalCompareItems = compareItems.length; + const links = [ { to: "/", label: "Home" }, @@ -34,13 +42,41 @@ export default function Navbar() { {link.label} + { link.label=="Wishlist" ? + + <> + + {totalWishItems > 0 && ( + + {totalWishItems} + + )} + + + :link.label=="Compare"? + + <> + + {totalCompareItems > 0 && ( + + {totalCompareItems} + + )} + + + + + :"" } ))}
+ + + {/* Cart + Mobile Toggle */}
- {totalItems > 0 && ( + {totalCartItems > 0 && ( - {totalItems} + {totalCartItems} )} @@ -60,8 +96,7 @@ export default function Navbar() { {/* Mobile 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/cart/hooks/useCartStore.js b/src/features/cart/hooks/useCartStore.js index 0dd0ae1..73ecaa0 100644 --- a/src/features/cart/hooks/useCartStore.js +++ b/src/features/cart/hooks/useCartStore.js @@ -1,77 +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; + } - // BUG: No check for newQuantity <= 0 - set({ - items: items.map((i) => - i.id === productId ? { ...i, quantity: newQuantity } : i - ), - }); + // 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 + // 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/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..4903bb4 --- /dev/null +++ b/src/features/checkout/hooks/useCheckoutForm.js @@ -0,0 +1,40 @@ +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' +import toast from "react-hot-toast"; + +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); + methods.reset(); // نستخدم methods هنا + toast.success('successfull placed order') + }; + + 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/features/compare/hooks/useCompareStore.js b/src/features/compare/hooks/useCompareStore.js new file mode 100644 index 0000000..d0c2644 --- /dev/null +++ b/src/features/compare/hooks/useCompareStore.js @@ -0,0 +1,67 @@ +import toast from "react-hot-toast"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +// used this store in : +// 1 productCard page +// 2 productDetalis page +// 3 wishlist page +// 4 cart page + +const useCompareStore = create( persist( (set, get) => ({ + items: [] , + toggleCompareItem:(newItem)=>{ + + const items = get().items; + const exists = items.find((item) => item.id === newItem.id); +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') + +} + + + + + }, + + removeFromCompare: (productId) => { + + const newItems = get().items.filter((item) => item.id !== productId); + set({ items: newItems }); + toast.success('Removed item from compare page') + }, + + isInCompare: (productId) => { + return get().items.some((item) => item.id === productId); + }, + + + +}) , +{ + + name:"shopHup-compareItems", + storage: createJSONStorage(() => sessionStorage), // (optional) by default, 'localStorage' is used +} + +) ); + +export default useCompareStore; + + diff --git a/src/features/products/components/ProductCard.jsx b/src/features/products/components/ProductCard.jsx index 3a7c138..b691b42 100644 --- a/src/features/products/components/ProductCard.jsx +++ b/src/features/products/components/ProductCard.jsx @@ -1,117 +1,171 @@ import { Link } from "react-router-dom"; import useCartStore from "../../cart/hooks/useCartStore"; import useWishlistStore from "../../wishlist/hooks/useWishlistStore"; +import useCompareStore from "../../compare/hooks/useCompareStore"; export default function ProductCard({ product }) { - const addToCart = useCartStore((s) => s.addToCart); - const addToWishlist = useWishlistStore((s) => s.addToWishlist); - const isInWishlist = useWishlistStore((s) => s.isInWishlist(product.id)); + const addToCart = useCartStore((s) => s.addToCart); + const toggleWishlist = useWishlistStore((s) => s.toggleWishlist); + const isInWishlist = useWishlistStore((s) => s.isInWishlist(product.id)); + const { toggleCompareItem } = useCompareStore((s) => s); + const isInCompare = useCompareStore((s) => s.isInCompare(product.id)); + - const renderStars = (rating) => { - const stars = []; - const full = Math.floor(rating); - const hasHalf = rating - full >= 0.5; - for (let i = 0; i < 5; i++) { - if (i < full) { - stars.push( - - - - ); - } else if (i === full && hasHalf) { - stars.push( - - - - - - - - - - ); - } else { - stars.push( - - - - ); - } - } - return stars; - }; + const renderStars = (rating) => { + const stars = []; + const full = Math.floor(rating); + const hasHalf = rating - full >= 0.5; + for (let i = 0; i < 5; i++) { + if (i < full) { + stars.push( + + + , + ); + } else if (i === full && hasHalf) { + stars.push( + + + + + + + + + , + ); + } else { + stars.push( + + + , + ); + } + } + return stars; + }; - return ( -
- {/* Image */} - - {product.title} - {product.stock <= 10 && product.stock > 0 && ( - - Only {product.stock} left - - )} - {product.stock === 0 && ( - - Out of Stock - - )} - {/* Wishlist 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/features/products/services/productService.js b/src/features/products/services/productService.js index 68e1ec4..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) { @@ -38,13 +38,16 @@ export async function getReviewsByProductId(productId) { export async function filterProducts({ search = "", category = "", - minPrice = 0, - maxPrice = Infinity, sortBy = "title", sortOrder = "asc", - page = 1, - limit = 8, + pageNum = 1, + // Three properties need ui to completed his features + maxPrice = Infinity, + minPrice = 0, + pageLimit=8, } = {}) { + + await wait(500); let filtered = [...products]; @@ -53,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) ); } @@ -83,20 +85,21 @@ export async function filterProducts({ comparison = a.title.localeCompare(b.title); break; } + return sortOrder === "desc" ? -comparison : comparison; }); // 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/features/wishlist/hooks/useWishlistStore.js b/src/features/wishlist/hooks/useWishlistStore.js index a7522d2..cac657a 100644 --- a/src/features/wishlist/hooks/useWishlistStore.js +++ b/src/features/wishlist/hooks/useWishlistStore.js @@ -1,23 +1,44 @@ +import toast from "react-hot-toast"; import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; -const useWishlistStore = create((set, get) => ({ - items: [], +const useWishlistStore = create( persist( (set, get) => ({ + items: [], - addToWishlist: (product) => { - const items = get().items; - const exists = items.find((item) => item.id === product.id); - if (exists) return; - set({ items: [...items, product] }); - }, + toggleWishlist: (product) => { + const items = get().items; + const exists = items.find((item) => item.id === product.id); + if (exists) { + + get().removeFromWishlist(product.id ) + + + } else { + set({ items: [...items, product] }); + toast.success('Added item to wishlist page') + } + }, - // Partial implementation — students should complete this + // Partial implementation — students should complete this removeFromWishlist: (productId) => { // eslint-disable-line no-unused-vars // 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) => { - return get().items.some((item) => item.id === productId); - }, -})); + isInWishlist: (productId) => { + return get().items.some((item) => item.id === productId); + }, +}) , +{ + + name:"shopHup-wishList", + storage: createJSONStorage(() => sessionStorage), // (optional) by default, 'localStorage' is used +} + +) ); export default useWishlistStore; + + 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/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/CartPage.jsx b/src/pages/CartPage.jsx index 857046a..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 @@ -121,7 +124,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 +135,12 @@ export default function CartPage() { 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 d013238..cf452fe 100644 --- a/src/pages/ComparePage.jsx +++ b/src/pages/ComparePage.jsx @@ -1,178 +1,193 @@ -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(""); - - useEffect(() => { - async function load() { - const allProducts = await getProducts(); - setProducts(allProducts); - } - load(); - }, []); - - // 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 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` : "—") }, - ]; + const compareItems = useCompareStore((s) => s.items); + const removeFromCompare = useCompareStore((s) => s.removeFromCompare); + 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}`, + compareType: "higherIsBetter", + }, + { + label: "Stock", + key: "stock", + format: (v) => (v > 0 ? `In Stock : ${v}` : `Out`), + compareType: "higherIsBetter", + }, + { 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 + +
+ + {/* Data Rows */} + {comparisonFields.map((field) => { + // 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"; - {/* Comparison Rows */} - {comparisonFields.map((field) => ( -
-
- {field.label} -
-
- {productA ? field.format(productA[field.key]) : "—"} -
-
- {productB ? field.format(productB[field.key]) : "—"} -
-
- ))} - - {/* Description */} -
-
- Description -
-
- {productA?.description || "—"} -
-
- {productB?.description || "—"} -
+ className={`h-14 ${bgClass} flex items-center justify-center px-4 border-b border-gray-200 text-sm text-gray-700 `} + > + {field.format + ? field.format(item[field.key]) + : item[field.key]} +
+ ); + })} + + {/* 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..8eec4de 100644 --- a/src/pages/ProductDetailsPage.jsx +++ b/src/pages/ProductDetailsPage.jsx @@ -1,251 +1,352 @@ 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"; 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 [reviews , setReviews]=useState([]); - 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); + + // 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); } - if (!product) { - return ( -
-

- Product Not Found -

- - ← Back to Products - -
- ); + // (Reviews) + if (results[1].status === "fulfilled") { + setReviews(results[1].value); + } else { + setReviews([]); + console.warn("Reviews Load Failed, but product is shown."); } - const time = formatCountdown(countdown); + 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 renderStars = (rating) => { + const stars = []; + for (let i = 1; i <= 5; i++) { + stars.push( + + + , + ); + } + return stars; + }; + + if (loading) { return ( -
- {/* Breadcrumb */} - - -
- {/* Image */} -
- {product.title} -
- - {/* Info */} -
- - {product.category} - -

- {product.title} -

- - {/* Rating */} -
-
{renderStars(product.rating)}
- - ({product.rating} rating) - -
+
+
+
+ ); + } - {/* Price */} -
- ${product.price.toFixed(2)} -
+ if (!product) { + return ( +
+

+ Product Not Found +

+ + ← Back to Products + +
+ ); + } - {/* 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"} - -
+ const time = formatCountdown(countdown); - {/* Actions */} -
- - -
+ return ( +
+ {/* Breadcrumb */} + + +
+ {/* Image */} +
+ {product.title} +
+ + {/* Info */} +
+ + {product.category} + +

+ {product.title} +

+ + {/* Rating */} +
+
{renderStars(product.rating)}
+ + ({product.rating} rating) + +
- {/* Reviews Placeholder — Student task to implement */} -
-

- Customer Reviews -

-
-

- Reviews will be displayed here. -

-
+ {/* 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.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! +

+
+ )} +
+
+
+
+ ); } diff --git a/src/pages/ProductsPage.jsx b/src/pages/ProductsPage.jsx index d7cfe04..9436a69 100644 --- a/src/pages/ProductsPage.jsx +++ b/src/pages/ProductsPage.jsx @@ -1,184 +1,180 @@ import { useState, useEffect } from "react"; -import { getProducts, getCategories } from "../features/products/services/productService"; +import { + filterProducts, + getProducts, +} from "../features/products/services/productService"; import ProductCard from "../features/products/components/ProductCard"; +import ProductsLayout from "../components/ProductsLayout"; +import { useFilterActions } from "../hooks/useFilterActions"; + +// 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 [totalPages, setTotalPages] = useState(0); +const [allRawProducts, setAllRawProducts] = useState([]); +const { updateURL, currentPage , filterParams } = useFilterActions(); + + +// 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; + + const currentFilters = Object.fromEntries([...filterParams]); + + async function applyCurrentFilters() { + const { data, totalPages } = await filterProducts(currentFilters); + setProducts(data); + setLoading(false) + + setTotalPages(totalPages); + } + + applyCurrentFilters(); +}, [filterParams, allRawProducts]); + + + return ( + +
+ {/* Header */} +
+

+ All Products +

+

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

+
- {/* 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 */} - -
+ + + {/* 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

+
)} -
- ); + + {totalPages > 1 && ( +
+ + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => ( + + ))} + + +
+)} + + )} +
+ + ); } + + +const ProductsSkeleton = () => { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+ +
+ + + +
+ + +
+
 
+
 
+
 
+
 
+
 
+
+ + {/* زر التفاعل */} +
+
 
+
 
+
+
+ ))} +
+ ); +}; + diff --git a/src/pages/WishlistPage.jsx b/src/pages/WishlistPage.jsx index 9f5097b..879d5db 100644 --- a/src/pages/WishlistPage.jsx +++ b/src/pages/WishlistPage.jsx @@ -1,11 +1,11 @@ import { Link } from "react-router-dom"; import useWishlistStore from "../features/wishlist/hooks/useWishlistStore"; import useCartStore from "../features/cart/hooks/useCartStore"; +import useCompareStore from "../features/compare/hooks/useCompareStore"; export default function WishlistPage() { const items = useWishlistStore((s) => s.items); - const removeFromWishlist = useWishlistStore((s) => s.removeFromWishlist); - const addToCart = useCartStore((s) => s.addToCart); + if (items.length === 0) { return ( @@ -49,10 +49,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 (
{ addToCart(item); - // Note: removeFromWishlist is not fully implemented + // Note: Now removeFromWishlist is fully implemented removeFromWishlist(item.id); }} className="flex-1 py-2 bg-primary-600 text-white text-xs font-medium rounded-lg hover:bg-primary-700 transition-colors" @@ -105,9 +124,35 @@ export default function WishlistPage() {
+ + {/* compare button */} + +
- ))} -
-
- ); -} + ) +} \ No newline at end of file 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