Skip to content

Commit 58b724e

Browse files
authored
Merge pull request #129 from Anurag-Bansode/prelint
feat(BestOfCorex) : Component Created for BestOfCorex
2 parents a314da5 + 832a84c commit 58b724e

8 files changed

Lines changed: 483 additions & 1 deletion

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
VITE_API_URL=https://corexshoptest.onrender.com/api
2+
3+
#TODO: change this value in prod as unsavory error shouldn't be shown to user
4+
ENVIRONMENT=development
5+
6+

src/components/BestOfCorex.jsx

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import React from 'react';
2+
import { ChevronLeft, ChevronRight } from 'lucide-react';
3+
import { useProductCarousel } from '../hooks/useProductCarousel';
4+
import ProductSkeleton from './Products/ProductSkeleton';
5+
import ProductCarousel from './Products/ProductCarousel';
6+
import { useBestOfCoreX } from '../hooks/useBestOfCoreX';
7+
8+
const BestOfCoreX = () => {
9+
const {
10+
collections,
11+
activeTab,
12+
setActiveTab,
13+
products,
14+
loading,
15+
error,
16+
allCollectionsData
17+
} = useBestOfCoreX();
18+
19+
// Use the custom hook to manage all carousel state and logic
20+
const { scrollContainerRef, currentPage, productPages, scroll, showArrows } =
21+
useProductCarousel({ products, productsPerPage: 6 });
22+
23+
return (
24+
<section className="px-4 sm:px-8 py-12 font-sans">
25+
<div className="container mx-auto">
26+
<div className="flex justify-center items-center gap-4">
27+
<h2
28+
id="why-choose"
29+
className="text-4xl lg:text-heading-xxl font-montserrat text-black leading-none uppercase py-16 section-title"
30+
>
31+
<span className="text-[#000]">BEST </span>
32+
<span>OF</span>
33+
<span className="capitalize text-[#000]"> Core</span>
34+
<span className="text-red-500">X</span>
35+
<span className="text-[#000]"> NUTRITION</span>
36+
</h2>
37+
</div>
38+
39+
{/* Tab Navigation & Carousel Arrows */}
40+
<div className="flex justify-center items-center flex-wrap gap-4 mb-8">
41+
{/* Tab Buttons */}
42+
{collections.map(([name]) => (
43+
<button
44+
key={name}
45+
onClick={() => setActiveTab(name)}
46+
className={`px-4 py-2 text-sm font-semibold rounded-full transition-colors ${
47+
activeTab === name
48+
? 'bg-black text-white'
49+
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
50+
}`}
51+
>
52+
{name}
53+
</button>
54+
))}
55+
56+
{/* Carousel Arrows */}
57+
{showArrows && !loading && (
58+
<div className="flex items-center gap-2">
59+
<button
60+
onClick={() => scroll(-1)}
61+
disabled={currentPage === 0}
62+
className="p-2 rounded-full bg-gray-200 hover:bg-gray-300 transition disabled:opacity-50 disabled:hover:cursor-not-allowed"
63+
>
64+
<ChevronLeft className="h-5 w-5" />
65+
</button>
66+
<button
67+
onClick={() => scroll(1)}
68+
disabled={currentPage >= productPages.length - 1}
69+
className="p-2 rounded-full bg-gray-200 hover:bg-gray-300 transition disabled:opacity-50 disabled:hover:cursor-not-allowed"
70+
>
71+
<ChevronRight className="h-5 w-5" />
72+
</button>
73+
</div>
74+
)}
75+
</div>
76+
77+
{/* Loading and Error States */}
78+
{loading && (
79+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-6">
80+
{Array.from({ length: 6 }).map((_, index) => (
81+
<ProductSkeleton key={index} />
82+
))}
83+
</div>
84+
)}
85+
86+
87+
{/* Product Carousel */}
88+
{!loading && allCollectionsData && (
89+
<ProductCarousel
90+
productPages={productPages}
91+
scrollContainerRef={scrollContainerRef}
92+
/>
93+
)}
94+
</div>
95+
</section>
96+
);
97+
};
98+
99+
export default BestOfCoreX;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import React from 'react';
2+
import { motion } from 'framer-motion'; // need this for motion.div need to updadte linting rules
3+
import ProductCard from './ProductCard';
4+
import PropTypes from 'prop-types';
5+
6+
7+
const cardVariants = {
8+
hidden: { opacity: 0, y: 20 },
9+
visible: { opacity: 1, y: 0 },
10+
};
11+
12+
const ProductCarousel = ({ productPages, scrollContainerRef }) => {
13+
if (!productPages || productPages.flat().length === 0) {
14+
return (
15+
<div className="flex h-64 items-center justify-center text-center text-gray-500">
16+
No products found in this collection.
17+
</div>
18+
);
19+
}
20+
21+
// Will show arrow if more than one page
22+
const showAsGrid = productPages.length > 1;
23+
24+
return (
25+
<div
26+
ref={scrollContainerRef}
27+
className="flex overflow-x-auto snap-x snap-mandatory scroll-smooth"
28+
>
29+
{productPages.map((page, pageIndex) => (
30+
<div
31+
key={pageIndex}
32+
className={`w-full flex-shrink-0 snap-start p-1 md:p-2 ${
33+
showAsGrid
34+
? 'grid grid-cols-1 gap-4 sm:grid-cols-3 xl:grid-cols-6' // sm-lg: 3x2 grid, lg+: 1x6 grid
35+
: 'flex justify-center gap-6'
36+
}`}
37+
>
38+
{page.map((product, index) => (
39+
<motion.div
40+
key={product.id || product._id}
41+
variants={cardVariants}
42+
initial="hidden"
43+
animate="visible"
44+
transition={{ duration: 0.5, delay: index * 0.05 }}
45+
// When not a grid (fewer than a page), we need to define the width of each item
46+
className={!showAsGrid ? 'w-1/2 sm:w-1/3 lg:w-1/6' : ''}
47+
>
48+
<ProductCard product={product} />
49+
</motion.div>
50+
))}
51+
</div>
52+
))}
53+
</div>
54+
);
55+
};
56+
57+
ProductCarousel.propTypes = {
58+
productPages: PropTypes.arrayOf(PropTypes.array).isRequired,
59+
scrollContainerRef: PropTypes.oneOfType([
60+
PropTypes.func,
61+
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
62+
]).isRequired,
63+
};
64+
65+
ProductCarousel.defaultProps = {
66+
productPages: [],
67+
};
68+
69+
export default ProductCarousel;

src/hooks/useBestOfCoreX.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { useState } from 'react';
2+
import { API_ENDPOINTS } from '../routes/apiEndpoints';
3+
import { useFetchnCache } from './useFetchnCache';
4+
5+
/**
6+
* Custom hook to manage the state and data fetching for the BestOfCoreX component.
7+
*
8+
* @returns {object} An object containing collections, active tab state, products for the active tab, and loading/error states.
9+
*/
10+
export const useBestOfCoreX = () => {
11+
const collections = Object.entries(API_ENDPOINTS.COLLECTIONS);
12+
const [activeTab, setActiveTab] = useState(collections[0]?.[0] || '');
13+
14+
// Fetch all collection URLs on component mount using the useFetchnCache hook
15+
const {
16+
data: allCollectionsData,
17+
loading,
18+
error,
19+
errors,
20+
} = useFetchnCache(Object.values(API_ENDPOINTS.COLLECTIONS));
21+
22+
const activeEndpoint = activeTab
23+
? API_ENDPOINTS.COLLECTIONS[activeTab]
24+
: null;
25+
26+
// Select the data for the active tab from the pre-fetched data
27+
const productData = allCollectionsData
28+
? allCollectionsData[activeEndpoint]
29+
: null;
30+
31+
const products = productData?.products || [];
32+
33+
return {
34+
collections,
35+
activeTab,
36+
setActiveTab,
37+
products,
38+
loading,
39+
error,
40+
errors,
41+
allCollectionsData,
42+
};
43+
};

src/hooks/useFetchnCache.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { useState, useEffect, useRef, useMemo } from 'react';
2+
import axiosInstance from '../api/axiosInstance';
3+
4+
// In-memory cache stored outside the component lifecycle
5+
const cache = new Map();
6+
7+
/**
8+
* Invalidates the cache. Can clear the entire cache or a specific entry.
9+
* @param {string | string[]} [urlOrUrls] - The specific URL or URLs to remove from the cache. If not provided, the entire cache is cleared.
10+
*/
11+
export const invalidateCache = (url) => {
12+
if (url) {
13+
cache.delete(url);
14+
} else {
15+
cache.clear();
16+
}
17+
};
18+
19+
/**
20+
* A custom hook to fetch data from a single URL or multiple URLs with in-memory caching.
21+
* It manages loading, error, and data states.
22+
* @param {string | string[] | null} urlOrUrls The URL or array of URLs to fetch data from.
23+
* @returns {{
24+
* data: any | null,
25+
* loading: boolean,
26+
* error: Error | null,
27+
* errors: Error[],
28+
* refetch: () => void
29+
* }} An object containing the fetched data, loading state, error details, and a refetch function.
30+
*/
31+
export const useFetchnCache = (urlOrUrls) => {
32+
const [data, setData] = useState(null);
33+
const [loading, setLoading] = useState(false);
34+
const [error, setError] = useState(null);
35+
const [errors, setErrors] = useState([]);
36+
const [forceRefetch, setForceRefetch] = useState(0);
37+
// Use a ref to track the current URL to prevent race conditions
38+
const requestRef = useRef(urlOrUrls);
39+
requestRef.current = urlOrUrls;
40+
41+
// Memoize the stringified URL(s) to use as a stable dependency for useEffect.
42+
const urlsKey = useMemo(() => {
43+
return urlOrUrls ? JSON.stringify(urlOrUrls) : null;
44+
}, [urlOrUrls]);
45+
46+
useEffect(() => {
47+
const isArray = Array.isArray(urlOrUrls);
48+
if (!urlOrUrls || (isArray && urlOrUrls.length === 0)) {
49+
setData(null);
50+
setLoading(false);
51+
return;
52+
}
53+
54+
const abortController = new AbortController();
55+
const fetchData = async () => {
56+
setLoading(true);
57+
setError(null);
58+
setErrors([]);
59+
60+
try {
61+
let resultData;
62+
if (isArray) {
63+
// Used Promise.allSettled to handle partial failures
64+
const settledResults = await Promise.allSettled(
65+
urlOrUrls.map(async (u) => {
66+
if (cache.has(u)) return { url: u, data: cache.get(u) };
67+
const response = await axiosInstance.get(u, { signal: abortController.signal });
68+
cache.set(u, response.data);
69+
return { url: u, data: response.data };
70+
})
71+
);
72+
73+
const successfulData = {};
74+
const failedRequests = [];
75+
settledResults.forEach(result => {
76+
if (result.status === 'fulfilled') {
77+
successfulData[result.value.url] = result.value.data;
78+
} else {
79+
failedRequests.push(result.reason);
80+
}
81+
});
82+
resultData = successfulData;
83+
if (failedRequests.length > 0) {
84+
setErrors(failedRequests);
85+
// Set the primary error to the first one for convenience.
86+
setError(failedRequests[0]);
87+
}
88+
} else {
89+
// Handle single URL
90+
const singleUrl = urlOrUrls;
91+
if (cache.has(singleUrl)) {
92+
resultData = cache.get(singleUrl);
93+
} else {
94+
const response = await axiosInstance.get(singleUrl, { signal: abortController.signal });
95+
cache.set(singleUrl, response.data);
96+
resultData = response.data;
97+
}
98+
}
99+
100+
if (urlsKey === JSON.stringify(requestRef.current)) {
101+
setData(resultData);
102+
}
103+
} catch (err) {
104+
//catch error from url(s)
105+
if (err.name !== 'CanceledError' && urlsKey === JSON.stringify(requestRef.current)) {
106+
setError(err);
107+
setErrors([err]);
108+
}
109+
} finally {
110+
if (urlsKey === JSON.stringify(requestRef.current)) {
111+
setLoading(false);
112+
}
113+
}
114+
};
115+
116+
fetchData();
117+
return () => abortController.abort();
118+
}, [urlsKey, forceRefetch]); //intentional
119+
120+
const refetch = () => {
121+
if (Array.isArray(urlOrUrls)) {
122+
urlOrUrls.forEach(u => invalidateCache(u));
123+
} else if (urlOrUrls) {
124+
invalidateCache(urlOrUrls);
125+
}
126+
setForceRefetch(Date.now()); // Trigger a re-run of the effect
127+
};
128+
129+
return { data, loading, error, errors, refetch };
130+
};
131+
132+
export default useFetchnCache;

0 commit comments

Comments
 (0)