feat(performance): Core Web Vitals optimizations

- Font optimization: Replace @font-face with next/font/google (DM Sans, Inter) for faster font loading and no render-blocking
- Image optimization: Add Unsplash to remotePatterns, configure AVIF/WebP formats, add device/image sizes
- Convert native <img> tags to next/image with proper sizing and priority for LCP images
- Add optimizePackageImports for lucide-react and framer-motion to reduce bundle size
- Fix CLS: Urgency message uses fixed min-height instead of animated height
- Fix CLS: ProductCard quick-add button uses opacity instead of translate for hover
- Convert HeroVideo scroll indicator to CSS animation
- Script loading: Rybbit uses lazyOnload strategy for better INP
This commit is contained in:
Unchained
2026-03-31 12:03:34 +02:00
parent d4039c6e3b
commit 8eb9f24b33
9 changed files with 92 additions and 63 deletions

View File

@@ -2,6 +2,7 @@
import { motion } from "framer-motion";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { ChevronDown } from "lucide-react";
@@ -22,13 +23,16 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
return (
<section className="relative min-h-screen w-full overflow-hidden">
{/* Background Image with Overlay */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url('https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop')`,
}}
>
{/* Background Image with Overlay - Using next/image for LCP optimization */}
<div className="absolute inset-0">
<Image
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop"
alt=""
fill
priority
className="object-cover"
sizes="100vw"
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/40 to-black/70" />
</div>
@@ -132,21 +136,16 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
</div>
{/* Scroll Indicator */}
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.5, duration: 0.8 }}
<button
onClick={scrollToContent}
className="absolute bottom-10 left-1/2 -translate-x-1/2 text-white/60 hover:text-white transition-colors cursor-pointer"
className="absolute bottom-10 left-1/2 -translate-x-1/2 text-white/60 hover:text-white transition-colors cursor-pointer opacity-0 animate-fade-in"
style={{ animationDelay: '1.5s', animationFillMode: 'forwards' }}
aria-label="Scroll to content"
>
<motion.div
animate={{ y: [0, 8, 0] }}
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }}
>
<div className="scroll-indicator">
<ChevronDown className="w-6 h-6" strokeWidth={1.5} />
</motion.div>
</motion.button>
</div>
</button>
</section>
);
}

View File

@@ -32,11 +32,12 @@ export default function ProductCard({ product, index = 0, locale = "sr" }: Produ
<Link href={`/${locale}/products/${localized.slug}`} className="group block">
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
{image ? (
<img
<Image
src={image}
alt={localized.name}
className="w-full h-full object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
loading="lazy"
fill
className="object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-[#999999]">
@@ -52,7 +53,7 @@ export default function ProductCard({ product, index = 0, locale = "sr" }: Produ
</div>
)}
<div className="absolute inset-x-0 bottom-0 p-4 translate-y-full group-hover:translate-y-0 transition-transform duration-300">
<div className="absolute inset-x-0 bottom-0 p-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button
className="w-full py-3 bg-black text-white text-xs uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors"
onClick={(e) => {

View File

@@ -245,10 +245,12 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
: "border-transparent hover:border-[#999999]"
}`}
>
<img
<Image
src={image.url}
alt={image.alt || localized.name}
className="w-full h-full object-cover"
fill
className="object-cover"
sizes="100px"
/>
</button>
))}
@@ -256,10 +258,13 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
)}
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden flex-1">
<img
<Image
src={images[selectedImage].url}
alt={images[selectedImage].alt || localized.name}
className="w-full h-full object-cover"
fill
priority
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/>
{images.length > 1 && (
@@ -307,17 +312,15 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
transition={{ duration: 0.6, delay: 0.2 }}
className="lg:pl-8"
>
<motion.div
key={urgencyIndex}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3 }}
className="bg-white/80 backdrop-blur-sm text-[#1a1a1a] py-3 rounded-lg mb-4 text-sm font-medium text-left"
>
<span className="mr-2">{urgencyMessages[urgencyIndex].icon}</span>
{urgencyMessages[urgencyIndex].text}
</motion.div>
<div className="min-h-[52px] flex items-center">
<div
className="bg-white/80 backdrop-blur-sm text-[#1a1a1a] py-3 px-4 rounded-lg mb-4 text-sm font-medium text-left w-full"
key={urgencyIndex}
>
<span className="mr-2">{urgencyMessages[urgencyIndex].icon}</span>
{urgencyMessages[urgencyIndex].text}
</div>
</div>
<h1 className="text-3xl md:text-4xl font-medium mb-4 tracking-tight">
{localized.name}