diff --git a/src/api/collectionService.js b/src/api/collectionService.js new file mode 100644 index 00000000..d59292dc --- /dev/null +++ b/src/api/collectionService.js @@ -0,0 +1,11 @@ +import axiosInstance from './axiosInstance'; + +/** + * Fetch collection by ID with its products + * @param {string} collectionId - The collection identifier (e.g., 'weight-management') + * @returns {Promise} Collection data with products array + */ +export const getCollectionById = async (collectionId) => { + const response = await axiosInstance.get(`/collections/${collectionId}`); + return response.data; +}; diff --git a/src/api/index.js b/src/api/index.js index cc7481ad..52e1e71e 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -18,5 +18,8 @@ export { clearCart, } from './cartService.js'; +export { getCollectionById } from './collectionService.js'; + export * from './productService.js'; export * from './cartService.js'; +export * from './collectionService.js'; diff --git a/src/components/CollectionSection.jsx b/src/components/CollectionSection.jsx index 0ea7aeea..20fc9858 100644 --- a/src/components/CollectionSection.jsx +++ b/src/components/CollectionSection.jsx @@ -6,7 +6,7 @@ import weightManagementImg from '/images/weight-management.png'; import healthWellnessImg from '/images/health-wellness.png'; import './collection.css'; -const collections = [ +export const collections = [ { id: 'pre-workout', title: 'PRE-WORKOUT', image: preWorkoutImg }, { id: 'build-muscle', title: 'BUILD MUSCLE', image: buildMuscleImg }, { @@ -55,8 +55,8 @@ const CollectionSection = () => { }; }, []); - const handleCardClick = () => { - navigate('/collections'); + const handleCardClick = (collection) => { + navigate(`/collections/${collection.id}`); }; return ( @@ -73,7 +73,7 @@ const CollectionSection = () => { key={collection.id} ref={(el) => (cardsRef.current[index] = el)} className="collection-card" - onClick={handleCardClick} + onClick={() => handleCardClick(collection)} >
{ diff --git a/src/components/SupplementForGoalsSection.jsx b/src/components/SupplementForGoalsSection.jsx index 316249be..ef3fadd8 100644 --- a/src/components/SupplementForGoalsSection.jsx +++ b/src/components/SupplementForGoalsSection.jsx @@ -9,21 +9,34 @@ import { import { useIntersectionObserver } from '../hooks/useIntersectionObserver'; import './SupplementForGoalsSection.css'; -const goalCards = [ +export const goalCards = [ { label: 'protein powder', + id: 'protein-powder', image: proteinPowder, href: '/collections/protein-powder', }, - { label: 'pre-workout', image: preWorkout, href: '/collections/pre-workout' }, + { + label: 'pre-workout', + id: 'pre-workout', + image: preWorkout, + href: '/collections/pre-workout', + }, { label: 'intra-workout', + id: 'intra-workout', image: intraWorkout, href: '/collections/intra-workout', }, - { label: 'amino acids', image: aminoAcids, href: '/collections/amino-acids' }, + { + label: 'amino acids', + id: 'amino-acids', + image: aminoAcids, + href: '/collections/amino-acids', + }, { label: 'weight management', + id: 'weight-management', image: weightManagement, href: '/collections/weight-management', }, diff --git a/src/pages/Collections/CollectionsPage.jsx b/src/pages/Collections/CollectionsPage.jsx index 5a94a808..73bd7324 100644 --- a/src/pages/Collections/CollectionsPage.jsx +++ b/src/pages/Collections/CollectionsPage.jsx @@ -1,3 +1,388 @@ -export default function CollectionsPage() { - return <>; +import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import { useParams, Navigate } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { motion } from 'framer-motion'; + +import { + fetchCollectionById, + clearCurrentCollection, +} from '../../store/CollectionSlice'; +import ProductGrid from '../../components/Products/ProductGrid'; +import SortDropdown from '../../components/Products/SortDropdown'; +import ProductSkeleton from '../../components/Products/ProductSkeleton'; +import FilterPanel from '../../components/Products/FilterPanel/FilterPanel'; +import RecentlyViewed from '../../components/RecentlyViewed'; +import SEO from '../../components/SEO'; +import { collections } from '../../components/CollectionSection'; + +export default function CollectionPage() { + const { name } = useParams(); + + const dispatch = useDispatch(); + const { + currentCollection, + products: collectionProducts, + loading, + error, + pagination, + } = useSelector((state) => state.collections || {}); + + const observer = useRef(); + const [displayedCount, setDisplayedCount] = useState(12); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [filters, setFilters] = useState({ + priceRange: [0, 100], + categories: [], + goals: [], + garageSaleOnly: false, + }); + const [sortBy, setSortBy] = useState('featured'); + const [sortOrder, setSortOrder] = useState('desc'); + + // scroll to top on load + useEffect(() => { + const current = document.getElementById('collection-page'); + if (current) { + const elementTop = current.getBoundingClientRect().top + window.scrollY; + window.scrollTo({ + top: elementTop - 150, + behavior: 'auto', + }); + } + }, []); + + // Fetch collection data + useEffect(() => { + if (!name) return; + dispatch(fetchCollectionById(name)); + + return () => { + dispatch(clearCurrentCollection()); + }; + }, [dispatch, name]); + + // Calculate max price from collection products + const maxProductPrice = useMemo(() => { + if (!collectionProducts || collectionProducts.length === 0) return 100; + const prices = collectionProducts + .map((p) => Number(p.price || 0)) + .filter((p) => !isNaN(p) && p > 0); + return Math.ceil(Math.max(...prices)) || 100; + }, [collectionProducts]); + + // Apply filters to collection products + const filteredProducts = useMemo(() => { + if (!collectionProducts || collectionProducts.length === 0) return []; + const [minPrice, maxPrice] = filters.priceRange || [0, maxProductPrice]; + + return collectionProducts.filter((p) => { + const price = Number(p.price || 0); + if (Number.isNaN(price)) return false; + if (price < minPrice || price > maxPrice) return false; + + if (filters.garageSaleOnly) { + const saleVal = Number(p.sale || 0) || (p.onSale ? 1 : 0); + if (!(saleVal > 0)) return false; + } + + if (filters.categories && filters.categories.length > 0) { + if (!filters.categories.includes(p.category)) return false; + } + + if (filters.goals && filters.goals.length > 0) { + const productGoals = Array.isArray(p.goals) ? p.goals : []; + const hasGoal = filters.goals.some((g) => productGoals.includes(g)); + if (!hasGoal) return false; + } + + return true; + }); + }, [collectionProducts, filters, maxProductPrice]); + + // Apply sorting + const sortedProducts = useMemo(() => { + if (!filteredProducts || filteredProducts.length === 0) return []; + const sorted = [...filteredProducts]; + + if (sortBy === 'title') { + sorted.sort((a, b) => { + const titleA = (a.name || a.title || '').toLowerCase(); + const titleB = (b.name || b.title || '').toLowerCase(); + return sortOrder === 'asc' + ? titleA.localeCompare(titleB) + : titleB.localeCompare(titleA); + }); + } else if (sortBy === 'price') { + sorted.sort((a, b) => { + const priceA = Number(a.price || 0); + const priceB = Number(b.price || 0); + return sortOrder === 'asc' ? priceA - priceB : priceB - priceA; + }); + } else if (sortBy === 'rating') { + sorted.sort((a, b) => { + const ratingA = Number(a.rating || 0); + const ratingB = Number(b.rating || 0); + return sortOrder === 'asc' ? ratingA - ratingB : ratingB - ratingA; + }); + } + + return sorted; + }, [filteredProducts, sortBy, sortOrder]); + + const displayedProducts = sortedProducts.slice(0, displayedCount); + const hasMoreProducts = displayedCount < sortedProducts.length; + + // Infinite scroll observer + const lastProductElementRef = useCallback( + (node) => { + if (loading) return; + if (observer.current) observer.current.disconnect(); + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMoreProducts) { + setDisplayedCount((prevCount) => + Math.min(prevCount + 8, sortedProducts.length) + ); + } + }); + if (node) observer.current.observe(node); + }, + [loading, hasMoreProducts, sortedProducts.length] + ); + + // Format collection name for display + const collectionTitle = + currentCollection?.replace(/-/g, ' ') || name.replace(/-/g, ' '); + const collectionTitleCapitalized = + collectionTitle.charAt(0).toUpperCase() + collectionTitle.slice(1); + + if (error) { + return ( + <> + +
+
+
+ + + +
+

+ Unable to Load Collection +

+

{error}

+ +
+
+ + ); + } + + const imageUrl = collections.find((col) => col.id === name)?.image || null; + + console.log(imageUrl); + return ( + <> + + +
+ {isFilterOpen && ( +
setIsFilterOpen(false)} + /> + )} + + {/* Collection Banner */} +
+ +
+

+ {collectionTitleCapitalized} +

+

+ Browse our {collectionTitle} collection and find premium + supplements that support your fitness goals. +

+ + + {pagination?.total || sortedProducts.length} products available + +
+
+
+ + {/* Toolbar */} +
+
+ + +
+ { + setSortBy(field || 'featured'); + setSortOrder(order || 'desc'); + setDisplayedCount(12); + }} + /> +
+
+
+ + {/* Product Grid Section */} +
+
+
+ {loading && } + + {!loading && !error && displayedProducts.length > 0 && ( + <> + + {hasMoreProducts && ( +
+
Loading more...
+
+ )} + + )} + + {!loading && !error && displayedProducts.length === 0 && ( +
+
+ + + +
+

+ No Products Found +

+

+ We couldn't find any products matching your filters in this + collection. +

+ +
+ )} +
+
+
+ + setIsFilterOpen(false)} + products={collectionProducts || []} + filters={filters} + onChangeFilters={(next) => { + const pr = next.priceRange || [0, maxProductPrice]; + setFilters({ + priceRange: [pr[0] ?? 0, pr[1] ?? maxProductPrice], + categories: next.categories || [], + goals: next.goals || [], + garageSaleOnly: !!next.garageSaleOnly, + }); + setDisplayedCount(12); + }} + /> + + {!loading && !error && } +
+ + ); } diff --git a/src/routes/RouterConfig.jsx b/src/routes/RouterConfig.jsx index f8e403a4..ec0e6ae1 100755 --- a/src/routes/RouterConfig.jsx +++ b/src/routes/RouterConfig.jsx @@ -17,6 +17,7 @@ const ShippingPolicy = lazy( ); const Products = lazy(() => import('../pages/Products/Products')); const ProductPage = lazy(() => import('../pages/Products/ProductPage')); + const TermsOfService = lazy( () => import('../pages/TermsOfService/TermsOfService') ); diff --git a/src/store/CollectionSlice.js b/src/store/CollectionSlice.js new file mode 100644 index 00000000..6eb55743 --- /dev/null +++ b/src/store/CollectionSlice.js @@ -0,0 +1,69 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { getCollectionById } from '../api/collectionService'; + +// Async thunk to fetch a collection by ID/slug +export const fetchCollectionById = createAsyncThunk( + 'collections/fetchById', + async (collectionId, { rejectWithValue }) => { + try { + const data = await getCollectionById(collectionId); + return data; + } catch (error) { + return rejectWithValue( + error.response?.data?.message || 'Failed to fetch collection' + ); + } + } +); + +const collectionSlice = createSlice({ + name: 'collections', + initialState: { + currentCollection: null, + products: [], + loading: false, + error: null, + pagination: { + page: 1, + limit: 50, + total: 0, + }, + }, + reducers: { + clearCurrentCollection: (state) => { + state.currentCollection = null; + state.products = []; + state.error = null; + state.pagination = { + page: 1, + limit: 50, + total: 0, + }; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchCollectionById.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchCollectionById.fulfilled, (state, action) => { + state.loading = false; + state.currentCollection = action.payload.collections; + state.products = action.payload.products || []; + state.pagination = { + page: action.payload.page || 1, + limit: action.payload.limit || 50, + total: action.payload.total || 0, + }; + }) + .addCase(fetchCollectionById.rejected, (state, action) => { + state.loading = false; + state.error = action.payload || 'An error occurred'; + }); + }, +}); + +export const { clearCurrentCollection } = collectionSlice.actions; + +export default collectionSlice.reducer; diff --git a/src/store/index.js b/src/store/index.js index 067b8c77..a68aebfd 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2,12 +2,14 @@ import { configureStore } from '@reduxjs/toolkit'; import productSlice from './productSlice'; import cartSlice from './cartSlice'; import authSlice from './authSlice'; +import collectionSlice from './CollectionSlice'; const store = configureStore({ reducer: { products: productSlice, cart: cartSlice, auth: authSlice, + collections: collectionSlice, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ @@ -17,4 +19,4 @@ const store = configureStore({ }), }); -export default store; \ No newline at end of file +export default store;