diff --git a/src/components/ActionButtons/ActionButtons.jsx b/src/components/ActionButtons/ActionButtons.jsx new file mode 100644 index 0000000..6dce90c --- /dev/null +++ b/src/components/ActionButtons/ActionButtons.jsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import { useCart } from '../../context/context'; +import './ActionButtons.scss'; + +const ActionButtons = ({ + onPlay, + onAddToCart, + onBuyNow, + movie, + variant = 'primary' // primary, secondary, compact +}) => { + const { cart } = useCart(); + const [isAdding, setIsAdding] = useState(false); + + const isInCart = cart.some(item => item.id === movie?.id); + + const handleAddToCart = async () => { + if (isInCart || isAdding) return; + + setIsAdding(true); + await onAddToCart(); + + setTimeout(() => { + setIsAdding(false); + }, 1000); + }; + + const getAddToCartText = () => { + if (isAdding) return '✓ Adding...'; + if (isInCart) return '✓ In Cart'; + return '+ My List'; + }; + + const getAddToCartClass = () => { + let baseClass = `action-buttons__btn action-buttons__btn--${variant}`; + if (isInCart) baseClass += ' action-buttons__btn--added'; + if (isAdding) baseClass += ' action-buttons__btn--adding'; + return baseClass; + }; + + return ( +
+ {onPlay && ( + + )} + + + + {onBuyNow && ( + + )} + + +
+ ); +}; + +export default ActionButtons; \ No newline at end of file diff --git a/src/components/ActionButtons/ActionButtons.scss b/src/components/ActionButtons/ActionButtons.scss new file mode 100644 index 0000000..be5003f --- /dev/null +++ b/src/components/ActionButtons/ActionButtons.scss @@ -0,0 +1,234 @@ +@use '../../styles/variables' as *; + +.action-buttons { + display: flex; + align-items: center; + gap: $spacing-md; + flex-wrap: wrap; + + &__btn { + display: flex; + align-items: center; + gap: $spacing-sm; + padding: $spacing-md $spacing-xl; + border: none; + border-radius: $radius-md; + font-size: $font-size-base; + font-weight: $font-weight-semibold; + font-family: $font-family-primary; + cursor: pointer; + transition: $transition-all; + position: relative; + overflow: hidden; + + // Icon sizing + svg { + width: 20px; + height: 20px; + transition: $transition-all; + } + + // Hover effect + &::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.2), + transparent + ); + transition: left 0.5s; + } + + &:hover::before { + left: 100%; + } + + &:hover { + transform: translateY(-2px); + box-shadow: $shadow-lg; + } + + &:active { + transform: translateY(0); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + + &:hover::before { + left: -100%; + } + } + + // Play button - primary CTA + &--play { + background: $text-primary; + color: #000; + font-weight: $font-weight-bold; + padding: $spacing-md $spacing-2xl; + + .play-icon { + width: 24px; + height: 24px; + } + + &:hover { + background: rgba(255, 255, 255, 0.8); + box-shadow: 0 8px 25px rgba(255, 255, 255, 0.3); + } + } + + // Add to list button + &--primary { + background: rgba(109, 109, 110, 0.7); + color: $text-primary; + border: 1px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); + + &:hover { + background: rgba(109, 109, 110, 0.9); + border-color: rgba(255, 255, 255, 0.5); + } + + &--added { + background: rgba(76, 175, 80, 0.7); + border-color: rgba(76, 175, 80, 0.8); + + &:hover { + background: rgba(76, 175, 80, 0.8); + } + } + + &--adding { + background: rgba(255, 193, 7, 0.7); + border-color: rgba(255, 193, 7, 0.8); + } + } + + // Buy button + &--buy { + background: $netflix-red; + color: $text-primary; + + .buy-icon { + width: 18px; + height: 18px; + } + + &:hover { + background: $netflix-red-dark; + box-shadow: 0 8px 25px rgba(229, 9, 20, 0.4); + } + } + + // More options button + &--more { + background: transparent; + color: $text-secondary; + border: 1px solid rgba(255, 255, 255, 0.3); + padding: $spacing-md; + border-radius: $radius-full; + + .more-icon { + width: 20px; + height: 20px; + } + + &:hover { + background: rgba(255, 255, 255, 0.1); + color: $text-primary; + border-color: rgba(255, 255, 255, 0.5); + } + } + } + + // Compact variant for smaller spaces + &--compact { + gap: $spacing-sm; + + .action-buttons__btn { + padding: $spacing-sm; + border-radius: $radius-full; + + &--play { + padding: $spacing-sm $spacing-lg; + border-radius: $radius-md; + } + + svg { + width: 18px; + height: 18px; + } + } + } + + // Secondary variant with different styling + &--secondary { + .action-buttons__btn { + background: transparent; + border: 1px solid $border-primary; + color: $text-secondary; + + &:hover { + background: $surface-primary; + color: $text-primary; + border-color: $border-secondary; + } + + &--play { + background: $text-primary; + color: #000; + border: none; + + &:hover { + background: rgba(255, 255, 255, 0.8); + } + } + } + } +} + +// Responsive design +@media (max-width: $breakpoint-md) { + .action-buttons { + flex-direction: column; + align-items: stretch; + gap: $spacing-sm; + + &__btn { + justify-content: center; + padding: $spacing-md; + + &--play { + order: -1; // Play button first on mobile + } + } + + &--compact { + flex-direction: row; + justify-content: center; + } + } +} + +// Animation keyframes +@keyframes pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } +} + +.action-buttons__btn--adding { + animation: pulse 1s ease-in-out infinite; +} \ No newline at end of file diff --git a/src/components/ImageCarousel/ImageCarousel.jsx b/src/components/ImageCarousel/ImageCarousel.jsx new file mode 100644 index 0000000..f93c452 --- /dev/null +++ b/src/components/ImageCarousel/ImageCarousel.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { useCarousel } from '../../hooks/useCarousel'; +import './ImageCarousel.scss'; + +const ImageCarousel = ({ images, title, className = '' }) => { + const { currentIndex, goToPrevious, goToNext, goToSlide } = useCarousel(images?.length || 0); + + if (!images || images.length === 0) return null; + + return ( +
+
+

Gallery

+
+ {images.map((_, index) => ( +
+
+ +
+ + +
+
+ {images.map((image, index) => ( +
+ {`Scene +
+ {index + 1} / {images.length} +
+
+ ))} +
+
+ + +
+ +
+ {images.map((image, index) => ( + + ))} +
+
+ ); +}; + +export default ImageCarousel; \ No newline at end of file diff --git a/src/components/ImageCarousel/ImageCarousel.scss b/src/components/ImageCarousel/ImageCarousel.scss new file mode 100644 index 0000000..5c88722 --- /dev/null +++ b/src/components/ImageCarousel/ImageCarousel.scss @@ -0,0 +1,239 @@ +@use '../../styles/variables' as *; + +.image-carousel { + margin: $spacing-3xl 0; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: $spacing-xl; + padding: 0 $spacing-lg; + + @media (max-width: $breakpoint-md) { + flex-direction: column; + gap: $spacing-lg; + align-items: flex-start; + } + } + + &__title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-primary; + margin: 0; + } + + &__indicators { + display: flex; + gap: $spacing-sm; + + .indicator { + width: 12px; + height: 12px; + border-radius: $radius-full; + border: none; + background: $surface-secondary; + cursor: pointer; + transition: $transition-all; + + &:hover { + background: $text-secondary; + transform: scale(1.2); + } + + &.active { + background: $netflix-red; + transform: scale(1.3); + } + } + } + + &__container { + position: relative; + border-radius: $radius-lg; + overflow: hidden; + background: $surface-primary; + box-shadow: $shadow-2xl; + } + + &__viewport { + overflow: hidden; + width: 100%; + height: 400px; + + @media (max-width: $breakpoint-md) { + height: 300px; + } + } + + &__track { + display: flex; + height: 100%; + transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); + } + + &__slide { + min-width: 100%; + height: 100%; + position: relative; + display: flex; + align-items: center; + justify-content: center; + background: $background-secondary; + } + + &__image { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + } + + &__slide-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + padding: $spacing-lg; + color: $text-primary; + + .slide-number { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + background: rgba(0, 0, 0, 0.5); + padding: $spacing-xs $spacing-sm; + border-radius: $radius-sm; + backdrop-filter: blur(5px); + } + } + + &__btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(0, 0, 0, 0.7); + border: none; + color: $text-primary; + width: 50px; + height: 50px; + border-radius: $radius-full; + cursor: pointer; + transition: $transition-all; + z-index: $z-dropdown; + backdrop-filter: blur(10px); + + svg { + width: 24px; + height: 24px; + } + + &:hover { + background: rgba(0, 0, 0, 0.9); + transform: translateY(-50%) scale(1.1); + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + transform: translateY(-50%); + } + + &--prev { + left: $spacing-lg; + } + + &--next { + right: $spacing-lg; + } + } + + &__thumbnails { + display: flex; + gap: $spacing-sm; + padding: $spacing-lg; + overflow-x: auto; + scrollbar-width: thin; + scrollbar-color: $netflix-red $surface-primary; + + &::-webkit-scrollbar { + height: 4px; + } + + &::-webkit-scrollbar-track { + background: $surface-primary; + } + + &::-webkit-scrollbar-thumb { + background: $netflix-red; + border-radius: $radius-sm; + } + + .thumbnail { + flex-shrink: 0; + width: 80px; + height: 60px; + border: 2px solid transparent; + border-radius: $radius-sm; + overflow: hidden; + cursor: pointer; + transition: $transition-all; + background: $surface-secondary; + + img { + width: 100%; + height: 100%; + object-fit: cover; + transition: $transition-all; + } + + &:hover { + border-color: $text-secondary; + transform: scale(1.05); + } + + &.active { + border-color: $netflix-red; + transform: scale(1.1); + box-shadow: 0 0 15px rgba(229, 9, 20, 0.4); + + img { + transform: scale(1.1); + } + } + } + } +} + +// Responsive design +@media (max-width: $breakpoint-md) { + .image-carousel { + &__thumbnails { + padding: $spacing-md; + + .thumbnail { + width: 60px; + height: 45px; + } + } + + &__btn { + width: 40px; + height: 40px; + + svg { + width: 20px; + height: 20px; + } + + &--prev { + left: $spacing-sm; + } + + &--next { + right: $spacing-sm; + } + } + } +} \ No newline at end of file diff --git a/src/components/MovieHero/MovieHero.jsx b/src/components/MovieHero/MovieHero.jsx new file mode 100644 index 0000000..1f55f6d --- /dev/null +++ b/src/components/MovieHero/MovieHero.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useCart } from '../../context/context'; +import { createMovieObject, formatRuntime, getImageWithFallback } from '../../utils/movieUtils'; +import ActionButtons from '../ActionButtons/ActionButtons'; +import './MovieHero.scss'; + +const MovieHero = ({ movieData }) => { + const navigate = useNavigate(); + const { addToCart } = useCart(); + + if (!movieData) return null; + + const movie = createMovieObject(movieData); + const heroImage = getImageWithFallback( + movieData.images?.[0], + movieData.poster + ); + + const handleAddToCart = () => { + addToCart(movie); + }; + + const handleBuyNow = () => { + addToCart(movie); + navigate('/checkout'); + }; + + const handlePlayTrailer = () => { + // In a real app, this would open a trailer modal or navigate to trailer + console.log('Play trailer for:', movieData.title); + }; + + return ( +
+
+ +
+
+ {movieData.rated && ( + {movieData.rated} + )} +
+ +

{movieData.title}

+ +
+ {movieData.year && {movieData.year}} + {movieData.runtime && {formatRuntime(movieData.runtime)}} + {movieData.genre && ( + + {movieData.genre.split(',').slice(0, 3).map(genre => ( + {genre.trim()} + ))} + + )} +
+ +

{movieData.plot}

+ +
+ {movieData.director && ( +
+ Director: + {movieData.director} +
+ )} + {movieData.actors && ( +
+ Cast: + {movieData.actors.split(',').slice(0, 3).join(', ')} +
+ )} +
+ + +
+
+ ); +}; + +export default MovieHero; \ No newline at end of file diff --git a/src/components/MovieHero/MovieHero.scss b/src/components/MovieHero/MovieHero.scss new file mode 100644 index 0000000..b740167 --- /dev/null +++ b/src/components/MovieHero/MovieHero.scss @@ -0,0 +1,182 @@ +@use '../../styles/variables' as *; + +.movie-hero { + position: relative; + height: 80vh; + min-height: 600px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + display: flex; + align-items: center; + color: $text-primary; + overflow: hidden; + + &__overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 77deg, + rgba(0, 0, 0, 0.8) 0%, + rgba(0, 0, 0, 0.6) 40%, + rgba(0, 0, 0, 0.4) 60%, + transparent 100% + ); + } + + &__content { + position: relative; + z-index: 2; + padding: 0 5%; + max-width: 50%; + animation: slideInUp 0.8s ease-out; + + @media (max-width: $breakpoint-lg) { + max-width: 70%; + } + + @media (max-width: $breakpoint-md) { + max-width: 90%; + } + } + + &__badge { + margin-bottom: $spacing-lg; + + .rating-badge { + display: inline-block; + padding: $spacing-xs $spacing-sm; + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: $radius-sm; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + backdrop-filter: blur(10px); + } + } + + &__title { + font-size: clamp($font-size-3xl, 5vw, $font-size-6xl); + font-weight: $font-weight-bold; + margin-bottom: $spacing-xl; + line-height: $line-height-tight; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7); + + // Add a subtle glow effect + filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.1)); + } + + &__meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: $spacing-lg; + margin-bottom: $spacing-xl; + font-size: $font-size-base; + color: $text-secondary; + + .meta-item { + display: flex; + align-items: center; + gap: $spacing-sm; + + &.year { + font-weight: $font-weight-medium; + color: $text-primary; + } + + &.runtime { + &::before { + content: '•'; + color: $text-secondary; + } + } + + &.genres { + display: flex; + gap: $spacing-sm; + + &::before { + content: '•'; + color: $text-secondary; + margin-right: $spacing-sm; + } + + .genre-tag { + background: rgba(255, 255, 255, 0.1); + padding: $spacing-xs $spacing-sm; + border-radius: $radius-xl; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + border: 1px solid rgba(255, 255, 255, 0.2); + backdrop-filter: blur(5px); + } + } + } + } + + &__plot { + font-size: $font-size-lg; + line-height: $line-height-relaxed; + margin-bottom: $spacing-2xl; + max-width: 600px; + color: rgba(255, 255, 255, 0.9); + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7); + } + + &__cast { + margin-bottom: $spacing-2xl; + + .cast-info { + display: flex; + margin-bottom: $spacing-sm; + font-size: $font-size-base; + + .cast-label { + font-weight: $font-weight-medium; + color: $text-secondary; + min-width: 80px; + margin-right: $spacing-md; + } + + .cast-value { + color: $text-primary; + } + } + } +} + +@media (max-width: $breakpoint-lg) { + .movie-hero { + height: 70vh; + min-height: 500px; + + &__content { + max-width: 80%; + } + } +} + +@media (max-width: $breakpoint-md) { + .movie-hero { + height: 60vh; + min-height: 450px; + + &__content { + max-width: 95%; + } + + &__meta { + flex-direction: column; + align-items: flex-start; + gap: $spacing-sm; + + .meta-item.genres { + flex-wrap: wrap; + } + } + } +} \ No newline at end of file diff --git a/src/components/MovieRatings/MovieRatings.jsx b/src/components/MovieRatings/MovieRatings.jsx new file mode 100644 index 0000000..7fdc2d7 --- /dev/null +++ b/src/components/MovieRatings/MovieRatings.jsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { parseRating, getRatingColor } from '../../utils/movieUtils'; +import './MovieRatings.scss'; + +const MovieRatings = ({ movieData }) => { + if (!movieData) return null; + + const ratings = [ + { + source: 'IMDb', + value: movieData.imdbRating, + maxValue: 10, + icon: '⭐' + }, + { + source: 'Metacritic', + value: movieData.metascore, + maxValue: 100, + icon: '🎯' + } + ].filter(rating => rating.value && rating.value !== 'N/A'); + + if (ratings.length === 0) return null; + + const renderRatingBar = (rating) => { + const numericValue = parseRating(rating.value); + if (!numericValue) return null; + + const percentage = (numericValue / rating.maxValue) * 100; + const color = getRatingColor(numericValue); + + return ( +
+
+ {rating.icon} + {rating.source} + + {rating.value} + {rating.maxValue === 10 && '/10'} + {rating.maxValue === 100 && '/100'} + +
+
+
+
+
+ ); + }; + + return ( +
+

Ratings & Reviews

+
+ {ratings.map(renderRatingBar)} +
+ + {movieData.awards && movieData.awards !== 'N/A' && ( +
+
+ 🏆 + Awards +
+

{movieData.awards}

+
+ )} +
+ ); +}; + +export default MovieRatings; \ No newline at end of file diff --git a/src/components/MovieRatings/MovieRatings.scss b/src/components/MovieRatings/MovieRatings.scss new file mode 100644 index 0000000..0f98f58 --- /dev/null +++ b/src/components/MovieRatings/MovieRatings.scss @@ -0,0 +1,149 @@ +@use '../../styles/variables' as *; + +.movie-ratings { + background: $surface-primary; + border-radius: $radius-lg; + padding: $spacing-xl; + border: 1px solid $border-primary; + backdrop-filter: blur(10px); + + &__title { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + color: $text-primary; + margin-bottom: $spacing-xl; + border-bottom: 2px solid $netflix-red; + padding-bottom: $spacing-sm; + display: inline-block; + } + + &__list { + display: flex; + flex-direction: column; + gap: $spacing-lg; + } + + &__item { + .rating-header { + display: flex; + align-items: center; + gap: $spacing-sm; + margin-bottom: $spacing-sm; + + .rating-icon { + font-size: $font-size-lg; + } + + .rating-source { + font-weight: $font-weight-medium; + color: $text-secondary; + flex: 1; + } + + .rating-value { + font-weight: $font-weight-bold; + font-size: $font-size-lg; + transition: $transition-all; + } + } + + .rating-bar { + height: 8px; + background: $surface-secondary; + border-radius: $radius-xl; + overflow: hidden; + position: relative; + + .rating-fill { + height: 100%; + border-radius: $radius-xl; + transition: width 1s ease-out; + position: relative; + + // Add a subtle glow effect + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.3), + transparent + ); + animation: shimmer 2s infinite; + } + } + } + + // Hover effect for rating items + &:hover { + .rating-header .rating-value { + transform: scale(1.1); + } + + .rating-bar .rating-fill { + box-shadow: 0 0 10px currentColor; + } + } + } + + &__awards { + margin-top: $spacing-2xl; + padding-top: $spacing-xl; + border-top: 1px solid $border-primary; + + .awards-header { + display: flex; + align-items: center; + gap: $spacing-sm; + margin-bottom: $spacing-md; + + .awards-icon { + font-size: $font-size-lg; + } + + .awards-title { + font-weight: $font-weight-semibold; + color: $text-primary; + } + } + + .awards-text { + color: $text-secondary; + line-height: $line-height-relaxed; + font-style: italic; + } + } +} + +// Animation for the shimmer effect +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +// Responsive design +@media (max-width: $breakpoint-md) { + .movie-ratings { + padding: $spacing-lg; + + &__item { + .rating-header { + flex-wrap: wrap; + gap: $spacing-xs; + + .rating-source { + min-width: 100px; + } + } + } + } +} \ No newline at end of file diff --git a/src/components/SimilarMovies/SimilarMovies.jsx b/src/components/SimilarMovies/SimilarMovies.jsx new file mode 100644 index 0000000..ddc967c --- /dev/null +++ b/src/components/SimilarMovies/SimilarMovies.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import ActionButtons from '../ActionButtons/ActionButtons'; +import { createMovieObject } from '../../utils/movieUtils'; +import { useCart } from '../../context/context'; +import './SimilarMovies.scss'; + +const SimilarMovies = ({ movies, title = "More Like This" }) => { + const navigate = useNavigate(); + const { addToCart } = useCart(); + + if (!movies || movies.length === 0) return null; + + const handleMovieClick = (movieTitle) => { + navigate(`/details/${movieTitle}`); + }; + + const handleAddToCart = (movieData) => { + const movie = createMovieObject(movieData); + addToCart(movie); + }; + + const handleBuyNow = (movieData) => { + const movie = createMovieObject(movieData); + addToCart(movie); + navigate('/checkout'); + }; + + return ( +
+
+

{title}

+
+ Because you're interested in this genre +
+
+ +
+ {movies.map((movie, index) => ( +
+
+ {movie.title handleMovieClick(movie.title || movie.Title)} + loading="lazy" + /> +
+
+

{movie.title || movie.Title}

+
+ {movie.Year || movie.year} + {movie.Genre || movie.genre} +
+
+ handleAddToCart(movie)} + onBuyNow={() => handleBuyNow(movie)} + movie={createMovieObject(movie)} + variant="compact" + /> +
+
+
+ ))} +
+
+ ); +}; + +export default SimilarMovies; \ No newline at end of file diff --git a/src/components/SimilarMovies/SimilarMovies.scss b/src/components/SimilarMovies/SimilarMovies.scss new file mode 100644 index 0000000..cb08a91 --- /dev/null +++ b/src/components/SimilarMovies/SimilarMovies.scss @@ -0,0 +1,207 @@ +@use '../../styles/variables' as *; + +.similar-movies { + padding: $spacing-3xl 5%; + background: linear-gradient(to bottom, transparent, $background-secondary); + + &__header { + margin-bottom: $spacing-2xl; + text-align: center; + + @media (min-width: $breakpoint-md) { + text-align: left; + } + } + + &__title { + font-size: $font-size-3xl; + font-weight: $font-weight-bold; + color: $text-primary; + margin-bottom: $spacing-sm; + } + + &__subtitle { + font-size: $font-size-base; + color: $text-secondary; + font-style: italic; + } + + &__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: $spacing-xl; + + @media (max-width: $breakpoint-sm) { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: $spacing-lg; + } + } +} + +.similar-movie-card { + position: relative; + border-radius: $radius-lg; + overflow: hidden; + transition: $transition-all; + background: $surface-primary; + box-shadow: $shadow-md; + + &:hover { + transform: translateY(-8px) scale(1.02); + box-shadow: $shadow-2xl; + z-index: 10; + + .similar-movie-card__overlay { + opacity: 1; + visibility: visible; + } + + .similar-movie-card__poster { + transform: scale(1.1); + } + } + + &__poster-container { + position: relative; + aspect-ratio: 2/3; + overflow: hidden; + background: $surface-secondary; + } + + &__poster { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + transition: transform 0.3s ease; + cursor: pointer; + } + + &__overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0.3) 0%, + rgba(0, 0, 0, 0.7) 70%, + rgba(0, 0, 0, 0.9) 100% + ); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: $spacing-lg; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; + backdrop-filter: blur(2px); + + @media (max-width: $breakpoint-md) { + // Always show overlay on mobile + opacity: 1; + visibility: visible; + background: linear-gradient( + to bottom, + transparent 0%, + rgba(0, 0, 0, 0.8) 70%, + rgba(0, 0, 0, 0.95) 100% + ); + } + } + + &__info { + color: $text-primary; + + .movie-title { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + margin-bottom: $spacing-sm; + line-height: $line-height-tight; + cursor: pointer; + transition: $transition-fast; + + &:hover { + color: $netflix-red; + } + } + + .movie-meta { + display: flex; + flex-direction: column; + gap: $spacing-xs; + font-size: $font-size-sm; + color: $text-secondary; + + .year { + font-weight: $font-weight-medium; + } + + .genre { + font-size: $font-size-xs; + opacity: 0.8; + } + } + } + + // Action buttons positioning + .action-buttons { + justify-content: center; + margin-top: $spacing-md; + + @media (max-width: $breakpoint-md) { + flex-direction: row; + gap: $spacing-xs; + } + } +} + +// Loading state for similar movies +.similar-movies--loading { + .similar-movie-card { + background: linear-gradient( + 90deg, + $surface-primary 25%, + $surface-secondary 50%, + $surface-primary 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + + &__poster { + background: $surface-secondary; + border: none; + } + } +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +// Empty state +.similar-movies--empty { + text-align: center; + padding: $spacing-4xl; + color: $text-secondary; + + &::before { + content: '🎬'; + font-size: $font-size-6xl; + display: block; + margin-bottom: $spacing-xl; + opacity: 0.5; + } + + &::after { + content: 'No similar movies found'; + font-size: $font-size-lg; + display: block; + } +} \ No newline at end of file diff --git a/src/hooks/useCarousel.js b/src/hooks/useCarousel.js new file mode 100644 index 0000000..0360091 --- /dev/null +++ b/src/hooks/useCarousel.js @@ -0,0 +1,35 @@ +import { useState, useCallback } from 'react'; + +export const useCarousel = (itemsLength = 0) => { + const [currentIndex, setCurrentIndex] = useState(0); + + const goToPrevious = useCallback(() => { + if (itemsLength === 0) return; + setCurrentIndex(prev => (prev === 0 ? itemsLength - 1 : prev - 1)); + }, [itemsLength]); + + const goToNext = useCallback(() => { + if (itemsLength === 0) return; + setCurrentIndex(prev => (prev === itemsLength - 1 ? 0 : prev + 1)); + }, [itemsLength]); + + const goToSlide = useCallback((index) => { + if (index >= 0 && index < itemsLength) { + setCurrentIndex(index); + } + }, [itemsLength]); + + const reset = useCallback(() => { + setCurrentIndex(0); + }, []); + + return { + currentIndex, + goToPrevious, + goToNext, + goToSlide, + reset, + isFirst: currentIndex === 0, + isLast: currentIndex === itemsLength - 1 + }; +}; \ No newline at end of file diff --git a/src/hooks/useMovieDetails.js b/src/hooks/useMovieDetails.js new file mode 100644 index 0000000..c6bca2b --- /dev/null +++ b/src/hooks/useMovieDetails.js @@ -0,0 +1,77 @@ +import { useState, useEffect } from 'react'; +import movieService from '../services/movieService'; + +export const useMovieDetails = (title) => { + const [movieData, setMovieData] = useState(null); + const [similarMovies, setSimilarMovies] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!title) return; + + const fetchMovieData = async () => { + try { + setIsLoading(true); + setError(null); + + const rawData = await movieService.fetchMovieByTitle(title); + + if (rawData.Response === "False") { + throw new Error(rawData.Error); + } + + const transformedData = movieService.transformMovieData(rawData); + setMovieData(transformedData); + + // Fetch similar movies if genre is available + if (transformedData.genre) { + try { + const similar = await movieService.fetchSimilarMovies( + transformedData.genre, + transformedData.title + ); + setSimilarMovies(similar); + } catch (similarError) { + console.warn('Failed to fetch similar movies:', similarError); + setSimilarMovies([]); + } + } + + } catch (err) { + setError(err.message); + setMovieData(null); + setSimilarMovies([]); + } finally { + setIsLoading(false); + } + }; + + fetchMovieData(); + }, [title]); + + return { + movieData, + similarMovies, + isLoading, + error, + refetch: () => { + if (title) { + const fetchMovieData = async () => { + try { + setIsLoading(true); + setError(null); + const rawData = await movieService.fetchMovieByTitle(title); + const transformedData = movieService.transformMovieData(rawData); + setMovieData(transformedData); + } catch (err) { + setError(err.message); + } finally { + setIsLoading(false); + } + }; + fetchMovieData(); + } + } + }; +}; \ No newline at end of file diff --git a/src/pages/Details/Details.jsx b/src/pages/Details/Details.jsx index f018c0f..fc9c94e 100644 --- a/src/pages/Details/Details.jsx +++ b/src/pages/Details/Details.jsx @@ -1,211 +1,130 @@ -// src/components/Details/Details.jsx - -import React, { useState, useEffect } from 'react'; -import './Details.scss'; +import React from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { useCart } from '../../context/context'; +import { useMovieDetails } from '../../hooks/useMovieDetails'; +import MovieHero from '../../components/MovieHero/MovieHero'; +import MovieRatings from '../../components/MovieRatings/MovieRatings'; +import ImageCarousel from '../../components/ImageCarousel/ImageCarousel'; +import SimilarMovies from '../../components/SimilarMovies/SimilarMovies'; +import './Details.scss'; -// This helper component is no longer needed for ratings, but let's keep it in case the API changes -const Rating = ({ source, value }) => ( -
- {source} - {value} +// Loading component following Single Responsibility Principle +const LoadingState = () => ( +
+
+

Loading movie details...

+

Please wait while we fetch the information

); -function Details() { - const [movieData, setMovieData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const { title } = useParams(); - const { addToCart } = useCart(); - const navigate = useNavigate(); - - const [currentIndex, setCurrentIndex] = useState(0); - - // ... (useEffect for fetching data should be here) ... - - // NEW: Carousel navigation functions - const goToPrevious = () => { - // Check if images exist before trying to access length - if (!movieData || !movieData.Images) return; - const isFirstSlide = currentIndex === 0; - const newIndex = isFirstSlide ? movieData.Images.length - 1 : currentIndex - 1; - setCurrentIndex(newIndex); - }; - - const goToNext = () => { - // Check if images exist - if (!movieData || !movieData.Images) return; - const isLastSlide = currentIndex === movieData.Images.length - 1; - const newIndex = isLastSlide ? 0 : currentIndex + 1; - setCurrentIndex(newIndex); - }; - - // NEW: Cart functionality - const handleAddToCart = () => { - if (!movieData) return; - - const movie = { - id: movieData.imdbID || movieData.title, - title: movieData.title, - poster: movieData.Poster, - year: movieData.year, - genre: movieData.genre, - price: 9.99 // Default price, could be dynamic - }; - - addToCart(movie); - - // Show feedback to user - const button = document.querySelector('.btn-info'); - if (button) { - const originalText = button.textContent; - button.textContent = '✓ Added!'; - button.style.background = '#28a745'; - setTimeout(() => { - button.textContent = originalText; - button.style.background = ''; - }, 2000); - } - }; - - const handleBuyNow = () => { - if (!movieData) return; - - const movie = { - id: movieData.imdbID || movieData.title, - title: movieData.title, - poster: movieData.Poster, - year: movieData.year, - genre: movieData.genre, - price: 9.99 // Default price, could be dynamic - }; - - addToCart(movie); - navigate('/checkout'); - }; +// Error component following Single Responsibility Principle +const ErrorState = ({ error, onRetry, onGoHome }) => ( +
+
🎬
+

Oops! Something went wrong

+

{error}

+
+ + +
+
+); - const API_URL = `https://www.apirequest.in/movie/api/title/${title}`; // Using a mocky URL with your exact data +// Movie info section component +const MovieInfo = ({ movieData }) => { + if (!movieData) return null; - useEffect(() => { - const fetchMovie = async () => { - try { - const response = await fetch(API_URL); - if (!response.ok) { - throw new Error('Network response was not ok'); - } - const data = await response.json(); + const infoItems = [ + { label: 'Genre', value: movieData.genre }, + { label: 'Director', value: movieData.director }, + { label: 'Cast', value: movieData.actors }, + { label: 'Language', value: movieData.language }, + { label: 'Runtime', value: movieData.runtime } + ].filter(item => item.value && item.value !== 'N/A'); - // The API returns an array, we take the first element - const movie = data[0]; + return ( +
+

Movie Information

+
+ {infoItems.map(({ label, value }) => ( +
+
{label}:
+
{value}
+
+ ))} +
+
+ ); +}; - if (movie.Response === "False") { - throw new Error(movie.Error); - } - - setMovieData(movie); - } catch (err) { - setError(err.message); - } finally { - setIsLoading(false); - } - }; +// Main Details component following SOLID principles +function Details() { + const { title } = useParams(); + const navigate = useNavigate(); + const { movieData, similarMovies, isLoading, error, refetch } = useMovieDetails(title); - fetchMovie(); - }, [title]); + // Event handlers following Single Responsibility Principle + const handleRetry = () => refetch(); + const handleGoHome = () => navigate('/'); + // Loading state if (isLoading) { + return ; + } + + // Error state + if (error) { return ( -
-
-

Loading...

-
+ ); } - if (error) { + // No data state + if (!movieData) { return ( -
-

Error: {error}

- -
+ ); } return (
- {movieData && ( - <> - {/* Background Hero Section */} -
-
-
- {/* CHANGE: Properties are capitalized */} -

{movieData.title}

-
- {movieData.year} - {movieData.rated} - {movieData.runtime} - $9.99 -
-

{movieData.plot}

-
- - -
-
-
+ {/* Hero Section */} + + + {/* Content Section */} +
+
+ +
+ +
+ +
+
- {/* Detailed Info Section */} -
-
- {/* CHANGE: Properties are capitalized */} -

Genre: {movieData.genre}

-

Director: {movieData.director}

-

Actors: {movieData.actors}

-

Language: {movieData.language}

- {movieData.Awards !== 'N/A' &&

Awards: {movieData.Awards}

} -
- {/* CHANGE: Displaying ratings from new properties */} -
-

Ratings

- {movieData.imdbRating && } - {movieData.Metascore && } -
-
- - {/* NEW: Image Gallery Section */} - {movieData.Images && movieData.Images.length > 0 && ( -
-

Gallery

-
- -
-
- {movieData.Images.map((image, index) => ( -
- {`Scene -
- ))} -
-
- -
-
- )} - + {/* Image Gallery */} + {movieData.images && movieData.images.length > 0 && ( +
+ +
)} + + {/* Similar Movies */} +
); } diff --git a/src/pages/Details/Details.scss b/src/pages/Details/Details.scss index a9138d0..d1caba2 100644 --- a/src/pages/Details/Details.scss +++ b/src/pages/Details/Details.scss @@ -1,259 +1,245 @@ -/* src/components/Details/Details.scss */ - @use '../../styles/variables' as *; -// --- Loading/Error states --- - -.loading-state, .error-state { - @include flex-center; - height: 100vh; - width: 100%; - background-color: $background-primary; - color: $text-primary; - flex-direction: column; - - h1 { - margin-top: $spacing-xl; - font-size: $font-size-2xl; - } - - .btn-back { - @include button-primary; - margin-top: $spacing-xl; - } -} - -.loading-spinner { - @include loading-spinner; -} - -// --- Main Page Styles --- .details-page { - background-color: $background-primary; + background: $background-primary; color: $text-primary; font-family: $font-family-primary; min-height: 100vh; + position: relative; - // --- Hero Section (Top part with background image) --- - .details-hero { - position: relative; - height: 90vh; - background-size: cover; - background-position: top center; - display: flex; + // Loading state + &__loading { + @include flex-center; flex-direction: column; - justify-content: center; - padding: 0 5%; + height: 100vh; + text-align: center; + animation: fadeIn 0.3s ease-in; - .hero-overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - // Gradient to make text readable over the image - background: linear-gradient(to top, $background-primary 10%, rgba(20, 20, 20, 0.5) 50%, rgba(20, 20, 20, 0.2) 100%); + .loading-spinner { + @include loading-spinner; + margin-bottom: $spacing-xl; } - .hero-content { - position: relative; // To stay on top of the overlay - z-index: 2; - max-width: 50%; + h1 { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + margin-bottom: $spacing-md; + } - .movie-title { - font-size: $font-size-6xl; - font-weight: $font-weight-bold; - margin-bottom: $spacing-xl; - } + p { + color: $text-secondary; + font-size: $font-size-base; + } + } - .movie-meta { - display: flex; - align-items: center; - gap: $spacing-xl; - margin-bottom: $spacing-xl; - color: $text-secondary; - font-weight: $font-weight-medium; - - .rated { - border: 1px solid $text-secondary; - padding: $spacing-xs $spacing-sm; - border-radius: $radius-sm; - } - - .price { - background: $netflix-red; - color: $text-primary; - padding: $spacing-sm $spacing-md; - border-radius: $radius-sm; - font-weight: $font-weight-bold; - } - } + // Error state + &__error { + @include flex-center; + flex-direction: column; + height: 100vh; + text-align: center; + padding: $spacing-2xl; + animation: fadeIn 0.3s ease-in; + + .error-icon { + font-size: $font-size-6xl; + margin-bottom: $spacing-xl; + opacity: 0.6; + } + + h1 { + font-size: $font-size-3xl; + font-weight: $font-weight-bold; + margin-bottom: $spacing-lg; + color: $text-primary; + } + + .error-message { + font-size: $font-size-lg; + color: $text-secondary; + margin-bottom: $spacing-2xl; + max-width: 500px; + line-height: $line-height-relaxed; + } - .movie-plot { - font-size: $font-size-lg; - line-height: $line-height-relaxed; - max-width: 600px; - margin-bottom: $spacing-2xl; + .error-actions { + display: flex; + gap: $spacing-lg; + flex-wrap: wrap; + justify-content: center; + + .btn-retry { + @include button-primary; } - .hero-buttons { - display: flex; - gap: $spacing-lg; - - button { - padding: $spacing-md $spacing-2xl; - border: none; - border-radius: $radius-sm; - font-size: $font-size-lg; - font-weight: $font-weight-bold; - cursor: pointer; - transition: $transition-all; - } - - .btn-play { - background-color: $text-primary; - color: #000; - &:hover { - background-color: rgba(255, 255, 255, 0.8); - transform: translateY(-2px); - } - } - - .btn-info { - background-color: rgba(109, 109, 110, 0.7); - color: $text-primary; - &:hover { - background-color: rgba(109, 109, 110, 0.5); - transform: translateY(-2px); - } - } + .btn-home { + @include button-secondary; } } } - // --- Body Section (Below the hero) --- - .details-body { - padding: $spacing-3xl 5%; - display: flex; + // Main content layout + &__content { + display: grid; + grid-template-columns: 2fr 1fr; gap: $spacing-3xl; + padding: $spacing-3xl 5%; + max-width: 1400px; + margin: 0 auto; - .info-section { - flex: 2; - p { - margin-bottom: $spacing-md; - line-height: $line-height-normal; - font-size: $font-size-base; - strong { - color: $text-secondary; - } - } + @media (max-width: $breakpoint-lg) { + grid-template-columns: 1fr; + gap: $spacing-2xl; + padding: $spacing-2xl 5%; } + } - .ratings-section { - flex: 1; - background-color: $surface-primary; - padding: $spacing-xl; - border-radius: $radius-lg; - - h3 { - margin-top: 0; - margin-bottom: $spacing-xl; - border-bottom: 1px solid $text-secondary; - padding-bottom: $spacing-md; - } - } + &__main { + min-width: 0; // Prevent flex item overflow } -} -// --- Individual Rating Component Style --- -.rating { - display: flex; - justify-content: space-between; - margin-bottom: $spacing-md; - font-size: $font-size-base; - - .rating-source { - color: $text-secondary; + &__sidebar { + @media (max-width: $breakpoint-lg) { + order: -1; // Move ratings above info on mobile + } } - - .rating-value { - font-weight: $font-weight-bold; + + &__gallery { + padding: 0 5%; + margin-bottom: $spacing-3xl; } } -/* src/components/Details/Details.scss */ - -// ... (all your existing SCSS styles up to the gallery) - -// --- Image Carousel Section --- -.carousel-section { - padding: 0 5% $spacing-3xl 5%; - - h2 { - font-size: $font-size-3xl; - margin-bottom: $spacing-xl; - border-bottom: 1px solid $text-secondary; - padding-bottom: $spacing-md; +// Movie Info component styles +.movie-info { + background: linear-gradient(135deg, $surface-primary 0%, $surface-secondary 100%); + border-radius: $radius-xl; + padding: $spacing-2xl; + border: 1px solid $border-primary; + position: relative; + overflow: hidden; + + // Subtle background pattern + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: radial-gradient(circle at 20% 50%, rgba(229, 9, 20, 0.1) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(229, 9, 20, 0.05) 0%, transparent 50%); + pointer-events: none; } - .carousel { + &__title { + font-size: $font-size-2xl; + font-weight: $font-weight-bold; + color: $text-primary; + margin-bottom: $spacing-xl; position: relative; - width: 100%; - margin: auto; + z-index: 1; + + &::after { + content: ''; + position: absolute; + bottom: -$spacing-sm; + left: 0; + width: 60px; + height: 3px; + background: linear-gradient(90deg, $netflix-red, transparent); + border-radius: $radius-sm; + } } - // This container clips the content that overflows - .carousel-inner-container { - overflow: hidden; - width: 100%; - border-radius: $radius-lg; + &__content { + position: relative; + z-index: 1; } - // This is the strip of images that moves - .carousel-inner { + .info-item { display: flex; - transition: transform 0.5s ease-in-out; + padding: $spacing-md 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + transition: $transition-all; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: rgba(255, 255, 255, 0.05); + margin: 0 (-$spacing-md); + padding-left: $spacing-md; + padding-right: $spacing-md; + border-radius: $radius-sm; + } + + .info-label { + font-weight: $font-weight-semibold; + color: $text-secondary; + min-width: 100px; + margin-right: $spacing-lg; + font-size: $font-size-sm; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .info-value { + color: $text-primary; + font-size: $font-size-base; + line-height: $line-height-relaxed; + flex: 1; + } } +} - .carousel-item { - min-width: 100%; - flex-shrink: 0; - - img { - width: 100%; - height: auto; - display: block; +// Responsive design +@media (max-width: $breakpoint-md) { + .details-page { + &__content { + padding: $spacing-xl 3%; + } + + &__gallery { + padding: 0 3%; } } - .carousel-btn { - position: absolute; - top: 50%; - transform: translateY(-50%); - background-color: rgba(0, 0, 0, 0.5); - color: $text-primary; - border: none; - font-size: $font-size-5xl; - cursor: pointer; - z-index: $z-dropdown; - padding: 0 $spacing-lg; - height: 100%; - transition: $transition-normal; + .movie-info { + padding: $spacing-xl; - &:hover { - background-color: rgba(0, 0, 0, 0.8); + .info-item { + flex-direction: column; + gap: $spacing-xs; + + .info-label { + min-width: auto; + margin-right: 0; + } } + } +} - &.prev { - left: 0; - border-top-left-radius: $radius-lg; - border-bottom-left-radius: $radius-lg; +@media (max-width: $breakpoint-sm) { + .details-page { + &__error .error-actions { + flex-direction: column; + align-items: center; + + .btn-retry, + .btn-home { + width: 200px; + } } + } + + .movie-info { + padding: $spacing-lg; + border-radius: $radius-lg; - &.next { - right: 0; - border-top-right-radius: $radius-lg; - border-bottom-right-radius: $radius-lg; + &__title { + font-size: $font-size-xl; } } } \ No newline at end of file diff --git a/src/services/movieService.js b/src/services/movieService.js new file mode 100644 index 0000000..c0a88e2 --- /dev/null +++ b/src/services/movieService.js @@ -0,0 +1,69 @@ +// Movie service for handling all API interactions +class MovieService { + constructor() { + this.baseURL = 'https://www.apirequest.in/movie/api'; + } + + async fetchMovieByTitle(title) { + try { + const response = await fetch(`${this.baseURL}/title/${title}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + return Array.isArray(data) ? data[0] : data; + } catch (error) { + throw new Error(`Failed to fetch movie: ${error.message}`); + } + } + + async fetchSimilarMovies(genre, excludeTitle) { + try { + // This would typically be a real API call + // For now, we'll simulate it with a mock response + const mockSimilarMovies = [ + { + id: 'similar1', + title: 'Similar Movie 1', + Poster: 'https://via.placeholder.com/300x400', + Year: '2023', + Genre: genre + }, + { + id: 'similar2', + title: 'Similar Movie 2', + Poster: 'https://via.placeholder.com/300x400', + Year: '2022', + Genre: genre + } + ]; + return mockSimilarMovies.filter(movie => movie.title !== excludeTitle); + } catch (error) { + throw new Error(`Failed to fetch similar movies: ${error.message}`); + } + } + + // Transform API response to consistent format + transformMovieData(apiData) { + return { + id: apiData.imdbID || apiData.title, + title: apiData.title || apiData.Title, + poster: apiData.Poster, + year: apiData.year || apiData.Year, + rated: apiData.rated || apiData.Rated, + runtime: apiData.runtime || apiData.Runtime, + genre: apiData.genre || apiData.Genre, + director: apiData.director || apiData.Director, + actors: apiData.actors || apiData.Actors, + plot: apiData.plot || apiData.Plot, + language: apiData.language || apiData.Language, + awards: apiData.Awards, + imdbRating: apiData.imdbRating, + metascore: apiData.Metascore, + images: apiData.Images || [], + price: 9.99 // Default price + }; + } +} + +export default new MovieService(); \ No newline at end of file diff --git a/src/utils/movieUtils.js b/src/utils/movieUtils.js new file mode 100644 index 0000000..5dccfb7 --- /dev/null +++ b/src/utils/movieUtils.js @@ -0,0 +1,50 @@ +// Utility functions for movie-related operations +export const createMovieObject = (movieData) => ({ + id: movieData.id || movieData.imdbID || movieData.title, + title: movieData.title, + poster: movieData.poster, + year: movieData.year, + genre: movieData.genre, + price: movieData.price || 9.99 +}); + +export const formatRuntime = (runtime) => { + if (!runtime || runtime === 'N/A') return ''; + // Convert "120 min" to "2h 0m" format + const match = runtime.match(/(\d+)/); + if (!match) return runtime; + + const minutes = parseInt(match[1]); + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + + if (hours === 0) return `${minutes}m`; + return remainingMinutes === 0 ? `${hours}h` : `${hours}h ${remainingMinutes}m`; +}; + +export const parseRating = (rating) => { + if (!rating || rating === 'N/A') return null; + const numericRating = parseFloat(rating); + return isNaN(numericRating) ? null : numericRating; +}; + +export const getRatingColor = (rating) => { + if (!rating) return '#666'; + if (rating >= 8) return '#4CAF50'; // Green + if (rating >= 6) return '#FF9800'; // Orange + return '#f44336'; // Red +}; + +export const truncateText = (text, maxLength = 150) => { + if (!text || text.length <= maxLength) return text; + return text.substring(0, maxLength).trim() + '...'; +}; + +export const formatGenres = (genreString) => { + if (!genreString) return []; + return genreString.split(',').map(genre => genre.trim()); +}; + +export const getImageWithFallback = (primaryImage, fallbackImage) => { + return primaryImage && primaryImage !== 'N/A' ? primaryImage : fallbackImage; +}; \ No newline at end of file