3 Commits

Author SHA1 Message Date
Unchained
2b9d8fa7d5 fix: add OpenPanel proxy routes for script and tracking 2026-03-31 13:34:37 +02:00
Unchained
1c5ec1a271 fix: remove framer-motion from HeroVideo for instant content visibility 2026-03-31 13:22:45 +02:00
Unchained
8eb9f24b33 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
2026-03-31 12:03:34 +02:00
12 changed files with 183 additions and 113 deletions

View File

@@ -31,6 +31,9 @@ const nextConfig: NextConfig = {
]; ];
}, },
images: { images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
@@ -52,8 +55,16 @@ const nextConfig: NextConfig = {
hostname: "**.saleor.cloud", hostname: "**.saleor.cloud",
pathname: "/**", pathname: "/**",
}, },
{
protocol: "https",
hostname: "images.unsplash.com",
pathname: "/**",
},
], ],
}, },
experimental: {
optimizePackageImports: ["lucide-react", "framer-motion"],
},
}; };
export default withNextIntl(nextConfig); export default withNextIntl(nextConfig);

View File

@@ -5,6 +5,7 @@ import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales"; import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
import { getPageKeywords } from "@/lib/seo/keywords"; import { getPageKeywords } from "@/lib/seo/keywords";
import { Metadata } from "next"; import { Metadata } from "next";
import Image from "next/image";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com"; const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
@@ -67,10 +68,13 @@ export default async function AboutPage({ params }: AboutPageProps) {
</div> </div>
<div className="relative h-[400px] md:h-[500px] overflow-hidden"> <div className="relative h-[400px] md:h-[500px] overflow-hidden">
<img <Image
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop" src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
alt={metadata.about.productionAlt} alt={metadata.about.productionAlt}
className="w-full h-full object-cover" fill
priority
className="object-cover"
sizes="100vw"
/> />
<div className="absolute inset-0 bg-black/20" /> <div className="absolute inset-0 bg-black/20" />
</div> </div>

View File

@@ -54,13 +54,13 @@ export default async function LocaleLayout({
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""} clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
trackScreenViews={true} trackScreenViews={true}
trackOutgoingLinks={true} trackOutgoingLinks={true}
apiUrl="https://op.nodecrew.me/api" apiUrl="/api/op"
scriptUrl="https://op.nodecrew.me/op1.js" scriptUrl="/api/op1"
/> />
<Script <Script
src="/api/script.js" src="/api/script.js"
data-site-id={RYBBIT_SITE_ID} data-site-id={RYBBIT_SITE_ID}
strategy="afterInteractive" strategy="lazyOnload"
/> />
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
{children} {children}

View File

@@ -14,6 +14,7 @@ import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales"; import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
import { getPageKeywords, getBrandKeywords } from "@/lib/seo/keywords"; import { getPageKeywords, getBrandKeywords } from "@/lib/seo/keywords";
import { Metadata } from "next"; import { Metadata } from "next";
import Image from "next/image";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com"; const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
@@ -157,10 +158,12 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
</a> </a>
</div> </div>
<div className="relative aspect-[4/3] bg-[#e8f0f5] rounded-lg overflow-hidden"> <div className="relative aspect-[4/3] bg-[#e8f0f5] rounded-lg overflow-hidden">
<img <Image
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=800&auto=format&fit=crop" src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=800&auto=format&fit=crop"
alt={metadata.home.productionAlt} alt={metadata.home.productionAlt}
className="w-full h-full object-cover" fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/> />
</div> </div>
</div> </div>

View File

@@ -1,5 +0,0 @@
import { createRouteHandler } from "@openpanel/nextjs/server";
export const { GET, POST } = createRouteHandler({
apiUrl: process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api",
});

View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
const OPENPANEL_API_URL = "https://op.nodecrew.me/api";
export async function POST(request: Request) {
try {
const body = await request.json();
const response = await fetch(`${OPENPANEL_API_URL}/track`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error("[OpenPanel] Track error:", error);
return NextResponse.json({ error: "Failed to track event" }, { status: 500 });
}
}

24
src/app/api/op1/route.ts Normal file
View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
const OPENPANEL_SCRIPT_URL = "https://op.nodecrew.me/op1.js";
export async function GET(request: Request) {
const url = new URL(request.url);
const searchParams = url.search;
try {
const response = await fetch(`${OPENPANEL_SCRIPT_URL}${searchParams}`);
const content = await response.text();
return new NextResponse(content, {
status: 200,
headers: {
"Content-Type": "application/javascript",
"Cache-Control": "public, max-age=86400, stale-while-revalidate=86400",
},
});
} catch (error) {
console.error("[OpenPanel] Failed to fetch script:", error);
return new NextResponse("/* OpenPanel script unavailable */", { status: 500 });
}
}

View File

@@ -53,8 +53,7 @@
--color-cta-hover: #333333; --color-cta-hover: #333333;
--color-overlay: rgba(0, 0, 0, 0.4); --color-overlay: rgba(0, 0, 0, 0.4);
--font-display: 'DM Sans', sans-serif; /* Font variables will be set by next/font in layout.tsx */
--font-body: 'Inter', sans-serif;
--transition-fast: 150ms ease; --transition-fast: 150ms ease;
--transition-base: 250ms ease; --transition-base: 250ms ease;
@@ -66,26 +65,9 @@
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1); --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
} }
/* ============================================
FONT IMPORTS
============================================ */
@font-face {
font-family: 'DM Sans';
src: url('https://fonts.gstatic.com/s/dmsans/v15/rP2tp2ywxg089UriI5-g4vlH9VoD8CmcqZG40F9JadbnoEwAopxhS2f3ZGMZpg.woff2') format('woff2');
font-weight: 400 700;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hjp-Ek-_EeA.woff2') format('woff2');
font-weight: 400 700;
font-display: swap;
}
/* ============================================ /* ============================================
BASE STYLES (in Tailwind base layer) BASE STYLES (in Tailwind base layer)
Fonts loaded via next/font in layout.tsx
============================================ */ ============================================ */
@layer base { @layer base {
@@ -266,6 +248,38 @@
} }
} }
/* ============================================
SCROLL INDICATOR ANIMATION
============================================ */
@keyframes scrollBounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(8px); }
}
.scroll-indicator {
animation: scrollBounce 1.5s ease-in-out infinite;
}
/* ============================================
FADE SLIDE UP ANIMATION
============================================ */
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeSlideUp {
animation: fadeSlideUp 0.6s ease-out both;
}
/* ============================================ /* ============================================
UTILITIES UTILITIES
============================================ */ ============================================ */

View File

@@ -1,9 +1,22 @@
import "./globals.css"; import "./globals.css";
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { DM_Sans, Inter } from "next/font/google";
import ErrorBoundary from "@/components/providers/ErrorBoundary"; import ErrorBoundary from "@/components/providers/ErrorBoundary";
import { SUPPORTED_LOCALES } from "@/lib/i18n/locales"; import { SUPPORTED_LOCALES } from "@/lib/i18n/locales";
import { OrganizationSchema } from "@/components/seo"; import { OrganizationSchema } from "@/components/seo";
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-display",
display: "swap",
});
const inter = Inter({
subsets: ["latin"],
variable: "--font-body",
display: "swap",
});
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com"; const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -39,7 +52,7 @@ export default async function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html suppressHydrationWarning> <html suppressHydrationWarning className={`${dmSans.variable} ${inter.variable}`}>
<body className="antialiased" suppressHydrationWarning> <body className="antialiased" suppressHydrationWarning>
<ErrorBoundary> <ErrorBoundary>
{children} {children}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { motion } from "framer-motion";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
@@ -23,30 +23,23 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
return ( return (
<section className="relative min-h-screen w-full overflow-hidden"> <section className="relative min-h-screen w-full overflow-hidden">
{/* Background Image with Overlay */} {/* Background Image with Overlay */}
<div <div className="absolute inset-0">
className="absolute inset-0 bg-cover bg-center bg-no-repeat" <Image
style={{ src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop"
backgroundImage: `url('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 className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/40 to-black/70" />
</div> </div>
{/* Content */} {/* Content - Visible immediately, animations are enhancements */}
<div className="relative z-10 min-h-screen flex flex-col items-center justify-center text-center text-white px-4 py-20"> <div className="relative z-10 min-h-screen flex flex-col items-center justify-center text-center text-white px-4 py-20">
<motion.div <div className="max-w-4xl mx-auto animate-fadeSlideUp">
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.3 }}
className="max-w-4xl mx-auto"
>
{/* Social Proof Micro */} {/* Social Proof Micro */}
<motion.div <div className="flex items-center justify-center gap-2 mb-6 animate-fadeSlideUp" style={{ animationDelay: "0.1s" }}>
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="flex items-center justify-center gap-2 mb-6"
>
<div className="flex"> <div className="flex">
{[1, 2, 3, 4, 5].map((star) => ( {[1, 2, 3, 4, 5].map((star) => (
<svg key={star} className="w-4 h-4 fill-yellow-400 text-yellow-400" viewBox="0 0 24 24"> <svg key={star} className="w-4 h-4 fill-yellow-400 text-yellow-400" viewBox="0 0 24 24">
@@ -57,36 +50,30 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
<span className="text-sm text-white/80"> <span className="text-sm text-white/80">
{t("lovedBy")} {t("lovedBy")}
</span> </span>
</motion.div> </div>
{/* Main Heading - Outcome Focused */} {/* Main Heading */}
<motion.h1 <h1
initial={{ opacity: 0, y: 30 }} className="text-4xl md:text-6xl lg:text-7xl font-medium mb-6 tracking-tight leading-tight animate-fadeSlideUp"
animate={{ opacity: 1, y: 0 }} style={{ animationDelay: "0.2s" }}
transition={{ duration: 0.8, delay: 0.5 }}
className="text-4xl md:text-6xl lg:text-7xl font-medium mb-6 tracking-tight leading-tight"
> >
{t("transformHeadline")} {t("transformHeadline")}
<br /> <br />
<span className="text-white/90">{t("withNaturalOils")}</span> <span className="text-white/90">{t("withNaturalOils")}</span>
</motion.h1> </h1>
{/* Subtitle - Expands on how */} {/* Subtitle */}
<motion.p <p
initial={{ opacity: 0, y: 20 }} className="text-lg md:text-xl text-white/80 mb-8 font-light max-w-2xl mx-auto leading-relaxed animate-fadeSlideUp"
animate={{ opacity: 1, y: 0 }} style={{ animationDelay: "0.3s" }}
transition={{ duration: 0.6, delay: 0.7 }}
className="text-lg md:text-xl text-white/80 mb-8 font-light max-w-2xl mx-auto leading-relaxed"
> >
{t("subtitleText")} {t("subtitleText")}
</motion.p> </p>
{/* CTA Button - Action verb + value */} {/* CTA Buttons */}
<motion.div <div
initial={{ opacity: 0, y: 20 }} className="flex flex-col sm:flex-row items-center justify-center gap-4 animate-fadeSlideUp"
animate={{ opacity: 1, y: 0 }} style={{ animationDelay: "0.4s" }}
transition={{ duration: 0.6, delay: 0.9 }}
className="flex flex-col sm:flex-row items-center justify-center gap-4"
> >
<Link <Link
href={`${localePath}/products`} href={`${localePath}/products`}
@@ -100,14 +87,12 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
> >
{t("learnStory")} {t("learnStory")}
</Link> </Link>
</motion.div> </div>
{/* Trust Indicators */} {/* Trust Indicators */}
<motion.div <div
initial={{ opacity: 0 }} className="flex flex-wrap items-center justify-center gap-6 mt-12 text-sm text-white/60 animate-fadeSlideUp"
animate={{ opacity: 1 }} style={{ animationDelay: "0.5s" }}
transition={{ delay: 1.2, duration: 0.8 }}
className="flex flex-wrap items-center justify-center gap-6 mt-12 text-sm text-white/60"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -127,26 +112,21 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
</svg> </svg>
<span>{t("crueltyFree")}</span> <span>{t("crueltyFree")}</span>
</div> </div>
</motion.div> </div>
</motion.div> </div>
</div> </div>
{/* Scroll Indicator */} {/* Scroll Indicator */}
<motion.button <button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.5, duration: 0.8 }}
onClick={scrollToContent} 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" aria-label="Scroll to content"
> >
<motion.div <div className="scroll-indicator">
animate={{ y: [0, 8, 0] }}
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }}
>
<ChevronDown className="w-6 h-6" strokeWidth={1.5} /> <ChevronDown className="w-6 h-6" strokeWidth={1.5} />
</motion.div> </div>
</motion.button> </button>
</section> </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"> <Link href={`/${locale}/products/${localized.slug}`} className="group block">
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4"> <div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
{image ? ( {image ? (
<img <Image
src={image} src={image}
alt={localized.name} alt={localized.name}
className="w-full h-full object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105" fill
loading="lazy" 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]"> <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>
)} )}
<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 <button
className="w-full py-3 bg-black text-white text-xs uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors" className="w-full py-3 bg-black text-white text-xs uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors"
onClick={(e) => { onClick={(e) => {

View File

@@ -245,10 +245,12 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
: "border-transparent hover:border-[#999999]" : "border-transparent hover:border-[#999999]"
}`} }`}
> >
<img <Image
src={image.url} src={image.url}
alt={image.alt || localized.name} alt={image.alt || localized.name}
className="w-full h-full object-cover" fill
className="object-cover"
sizes="100px"
/> />
</button> </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"> <div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden flex-1">
<img <Image
src={images[selectedImage].url} src={images[selectedImage].url}
alt={images[selectedImage].alt || localized.name} 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 && ( {images.length > 1 && (
@@ -307,17 +312,15 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
transition={{ duration: 0.6, delay: 0.2 }} transition={{ duration: 0.6, delay: 0.2 }}
className="lg:pl-8" className="lg:pl-8"
> >
<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} 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> <span className="mr-2">{urgencyMessages[urgencyIndex].icon}</span>
{urgencyMessages[urgencyIndex].text} {urgencyMessages[urgencyIndex].text}
</motion.div> </div>
</div>
<h1 className="text-3xl md:text-4xl font-medium mb-4 tracking-tight"> <h1 className="text-3xl md:text-4xl font-medium mb-4 tracking-tight">
{localized.name} {localized.name}