diff --git a/src/components/CartActionsButtons/CartActionsButtons.module.css b/src/components/CartActionsButtons/CartActionsButtons.module.css index b79a26b..efe1520 100644 --- a/src/components/CartActionsButtons/CartActionsButtons.module.css +++ b/src/components/CartActionsButtons/CartActionsButtons.module.css @@ -23,7 +23,7 @@ } .quantityButton { - padding: 0px; + padding: 0; width: 25px; height: 32px; display: flex; @@ -42,7 +42,7 @@ } .removeButton { - padding: 4px 0px; + padding: 4px 0; height: 32px; display: flex; align-items: center; diff --git a/src/components/ImageWithFallback/ImageWithFallback.module.css b/src/components/ImageWithFallback/ImageWithFallback.module.css index cbce225..ebeb490 100644 --- a/src/components/ImageWithFallback/ImageWithFallback.module.css +++ b/src/components/ImageWithFallback/ImageWithFallback.module.css @@ -1,4 +1,11 @@ -/* ImageWithFallback component styles */ +/** + * ImageWithFallback Component Styles + * + * This stylesheet contains all the styles for the ImageWithFallback component, + * including loading states, error states, and animations. + */ + +/* Loading container - used for both the placeholder and the actual loading state */ .loadingContainer { background-color: #f8f9fa; overflow: hidden; @@ -8,52 +15,6 @@ justify-content: center; } -.loadingSpinner { - animation: spin 1s linear infinite; - color: #6c757d; -} - -.loadingSpinner circle:first-child { - animation: dash 1.5s ease-in-out infinite; -} - -.loadingText { - margin-top: 8px; - font-size: 11px; - color: #6c757d; - font-weight: 500; -} - -.placeholderIcon { - color: #6c757d; - opacity: 0.7; -} - -/* Spinner animation */ -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@keyframes dash { - 0% { - stroke-dasharray: 1, 150; - stroke-dashoffset: 0; - } - 50% { - stroke-dasharray: 90, 150; - stroke-dashoffset: -35; - } - 100% { - stroke-dasharray: 90, 150; - stroke-dashoffset: -124; - } -} - /* Loading state transitions */ .loadingState { transition: opacity 0.3s ease-in-out; @@ -61,74 +22,25 @@ z-index: 2; } -/* Error state */ +/* Error state - applied when image fails to load */ .errorState { background-color: #f8d7da; color: #721c24; -} - -/* Success state */ -.successState { - background-color: #d4edda; - color: #155724; -} - -/* Responsive adjustments */ -@media (max-width: 768px) { - .loadingText { - font-size: 10px; - } - - .placeholderIcon { - width: 24px; - height: 24px; - } + border-radius: 4px; } /* Dark theme support */ @media (prefers-color-scheme: dark) { - /* .loadingContainer { + .loadingContainer { background-color: #343a40; - } */ - - .loadingText { - color: #adb5bd; } - .placeholderIcon { - color: #adb5bd; + .loadingState { + background-color: #343a40; } -} -/* HTML:
*/ -.loader { - width: 30px; - aspect-ratio: 1; - display: grid; - border-radius: 50%; - background: - linear-gradient(0deg, rgb(0 0 0/50%) 30%, #0000 0 70%, rgb(0 0 0/100%) 0) - 50%/8% 100%, - linear-gradient(90deg, rgb(0 0 0/25%) 30%, #0000 0 70%, rgb(0 0 0/75%) 0) - 50%/100% 8%; - background-repeat: no-repeat; - animation: l23 1s infinite steps(12); -} -.loader::before, -.loader::after { - content: ""; - grid-area: 1/1; - border-radius: 50%; - background: inherit; - opacity: 0.915; - transform: rotate(30deg); -} -.loader::after { - opacity: 0.83; - transform: rotate(60deg); -} -@keyframes l23 { - 100% { - transform: rotate(1turn); + .errorState { + background-color: #45232a; + color: #f8d7da; } } diff --git a/src/components/ImageWithFallback/ImageWithFallback.tsx b/src/components/ImageWithFallback/ImageWithFallback.tsx index d22e3c0..7fcc643 100644 --- a/src/components/ImageWithFallback/ImageWithFallback.tsx +++ b/src/components/ImageWithFallback/ImageWithFallback.tsx @@ -1,40 +1,31 @@ import { Image, ImageProps, Skeleton } from "antd"; -import { CSSProperties, useCallback, useEffect, useRef, useState } from "react"; +import { + CSSProperties, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import styles from "./ImageWithFallback.module.css"; -interface ImageWithFallbackProps extends ImageProps { - fallbackSrc: string; - borderRadius?: number | string; - priority?: boolean; - lazy?: boolean; - placeholder?: "blur" | "empty"; - blurDataURL?: string; - width?: number | string; - height?: number | string; - loadingContainerStyle?: CSSProperties; -} - -const ImageWithFallback = ({ - fallbackSrc, - src, - borderRadius, - style, - alt, - priority = false, +/** + * Custom hook for handling lazy loading with Intersection Observer + * @param options Configuration options for the hook + * @returns Object containing ref and isInView state + */ +const useLazyLoading = ({ lazy = true, - width, - height, - loadingContainerStyle, - ...rest -}: ImageWithFallbackProps) => { - const [imgSrc, setImgSrc] = useState(src || fallbackSrc); - const [isLoading, setIsLoading] = useState(true); - const [isInView, setIsInView] = useState(false); - const [hasError, setHasError] = useState(false); - const imageRef = useRef(null); + priority = false, +}: { + lazy?: boolean; + priority?: boolean; +}) => { + const [isInView, setIsInView] = useState(!lazy || priority); + const ref = useRef(null); - // Intersection Observer for lazy loading useEffect(() => { + // Skip lazy loading if priority is true or lazy is false if (lazy && !priority) { const observer = new IntersectionObserver( ([entry]) => { @@ -49,39 +40,160 @@ const ImageWithFallback = ({ }, ); - if (imageRef.current) { - observer.observe(imageRef.current); + const currentRef = ref.current; + if (currentRef) { + observer.observe(currentRef); } - return () => observer.disconnect(); + // Cleanup function + return () => { + if (currentRef) { + observer.unobserve(currentRef); + } + observer.disconnect(); + }; } else { setIsInView(true); } }, [lazy, priority]); + return { ref, isInView }; +}; + +/** + * Props for the ImageWithFallback component + * @extends ImageProps from Ant Design's Image component + */ +interface ImageWithFallbackProps extends ImageProps { + /** URL for the fallback image to display when the main image fails to load */ + fallbackSrc: string; + /** Border radius for the image (can be number in pixels or string like '8px' or '50%') */ + borderRadius?: number | string; + /** If true, loads the image immediately without lazy loading */ + priority?: boolean; + /** If true (default), enables lazy loading of the image */ + lazy?: boolean; + /** Type of placeholder to show while loading (not fully implemented) */ + placeholder?: "blur" | "empty"; + /** Base64 data URL for blur placeholder (not fully implemented) */ + blurDataURL?: string; + /** Width of the image (number for pixels, string for other units) */ + width?: number | string; + /** Height of the image (number for pixels, string for other units) */ + height?: number | string; + /** Additional styles for the loading container */ + loadingContainerStyle?: CSSProperties; +} + +/** + * Enhanced image component with fallback support, lazy loading, and loading states + * + * Features: + * - Displays a fallback image when the main image fails to load + * - Supports lazy loading using Intersection Observer API + * - Shows loading skeleton while the image is loading + * - Handles various styling options including border radius + * - Optimized with React.memo to prevent unnecessary re-renders + */ +const ImageWithFallback = ({ + fallbackSrc, + src, + borderRadius, + style, + alt = "Image", // Default alt text for better accessibility + priority = false, + lazy = true, + width, + height, + loadingContainerStyle, + ...rest +}: ImageWithFallbackProps) => { + const [imgSrc, setImgSrc] = useState(src || fallbackSrc); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + // Use the custom lazy loading hook + const { ref: imageRef, isInView } = useLazyLoading({ lazy, priority }); + + // Default width value for consistency + const defaultWidth = 90; + + /** + * Handle image load error + * Sets the fallback image and updates loading/error states + */ const handleError = useCallback(() => { setImgSrc(fallbackSrc); setHasError(true); setIsLoading(false); }, [fallbackSrc]); + /** + * Handle successful image load + * Updates loading and error states + */ const handleLoad = useCallback(() => { setIsLoading(false); setHasError(false); }, []); - // Update imgSrc when src prop changes + // Update imgSrc when src or fallbackSrc props change useEffect(() => { setImgSrc(src || fallbackSrc); setIsLoading(true); setHasError(false); }, [src, fallbackSrc]); - // Combine custom style with borderRadius if provided - const combinedStyle = { - ...style, - ...(borderRadius && { borderRadius }), - }; + // Memoize combined style to prevent unnecessary re-renders + const combinedStyle: CSSProperties = useMemo( + () => ({ + ...style, + ...(borderRadius && { borderRadius }), + }), + [style, borderRadius], + ); + + /** + * Memoized skeleton loader component + */ + const SkeletonLoader = useMemo( + () => ( + + ), + [height, width, defaultWidth, loadingContainerStyle], + ); + + // Memoize loading container styles for better performance + const loadingContainerStyles: CSSProperties = useMemo( + () => ({ + zIndex: 1, + borderRadius: borderRadius || "8px", + height, + width: width || defaultWidth, + textAlign: "center", + ...loadingContainerStyle, + }), + [borderRadius, height, width, defaultWidth, loadingContainerStyle], + ); + + // Memoize image styles for better performance + const imageStyles: CSSProperties = useMemo( + () => ({ + opacity: isLoading ? 0 : 1, + transition: "opacity 0.3s ease-in-out", + objectFit: "cover", + ...combinedStyle, + }), + [isLoading, combinedStyle], + ); // Don't render the image until it's in view (for lazy loading) if (lazy && !priority && !isInView) { @@ -92,41 +204,35 @@ const ImageWithFallback = ({ style={{ ...combinedStyle, height, - width: width || 90, + width: width || defaultWidth, }} + aria-label={`Loading ${alt}`} + role="progressbar" > - + {SkeletonLoader} ); } return ( -
+
+ {/* Loading state */} {isLoading && !hasError && (