Compare commits

...

17 Commits

Author SHA1 Message Date
Unchained
3c9c091c46 fix: revert HeroVideo aspect-ratio, fix ProblemSection scroll animation with useEffect
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-01 06:43:43 +02:00
Unchained
27af03ba3a feat(performance): optimize Core Web Vitals with CSS animations and lazy analytics
- Replace framer-motion with CSS animations in TrustBadges, AsSeenIn, ProblemSection
- Create AnalyticsProvider client component for OpenPanel lazy-loading
- Fix HeroVideo CLS with explicit aspect-ratio (4/3)
- Remove deprecated swcMinify from next.config (enabled by default)
- Add optimizePackageImports for better tree-shaking
2026-04-01 06:14:49 +02:00
Unchained
ad20ffe588 Merge branch 'dev'
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-01 05:17:48 +02:00
Unchained
13301dca12 fix: use middleware.ts instead of proxy.ts for build compatibility 2026-04-01 05:17:36 +02:00
Unchained
e57169a807 fix: revert proxy back to middleware for Next.js build compatibility 2026-04-01 05:15:42 +02:00
Unchained
3697a5d8ea fix: rename middleware to proxy for Next.js 16 compatibility
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-01 04:19:07 +02:00
Unchained
edd5c1582b feat(performance): add ISR and Cloudflare cache headers
- Add revalidate=3600 to homepage and products page (1hr ISR)
- Add middleware to set cache headers for HTML pages
- Bypass cache for checkout and cart pages
2026-03-31 20:08:56 +02:00
Unchained
dff78b28a5 fix(analytics): restore OpenPanel proxy routes
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 13:47:14 +02:00
Unchained
b4905ce4ee chore: remove OpenPanel proxy routes (keeping core vitals changes)
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 13:44:53 +02:00
Unchained
e87c655a5b Merge branch 'feature/web-vitals-optimization' into dev
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 13:22:45 +02:00
Unchained
66829aeffd refactor(analytics): abstract analytics into provider pattern
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Add type-safe AnalyticsEvent union types
- Create AnalyticsProvider interface for pluggable analytics backends
- Implement OpenPanelProvider and RybbitProvider adapters
- Create AnalyticsTracker that fans out events to all providers
- Simplifies adding new analytics platforms in the future
2026-03-31 07:45:21 +02:00
Unchained
bce2d19ca3 fix(analytics): fix OpenPanel apiUrl to not include /track
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 07:23:03 +02:00
Unchained
cee3b71454 fix(analytics): use route handler for OpenPanel script to fix query param issue
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 07:19:14 +02:00
Unchained
ff629691a5 fix(analytics): fix OpenPanel script rewrite URL
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 07:11:59 +02:00
Unchained
1cdda7db3c fix(analytics): use rewrites instead of route handler for OpenPanel proxy
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 06:37:17 +02:00
Unchained
1dd7e1dfe7 fix(analytics): use local proxy for OpenPanel to avoid ad blockers
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 06:31:48 +02:00
Unchained
054889a44e feat(analytics): add RYBBIT_API_KEY for server-side tracking
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 06:05:47 +02:00
19 changed files with 776 additions and 111 deletions

View File

@@ -130,6 +130,8 @@ spec:
value: "https://rybbit.nodecrew.me" value: "https://rybbit.nodecrew.me"
- name: NEXT_PUBLIC_RYBBIT_SITE_ID - name: NEXT_PUBLIC_RYBBIT_SITE_ID
value: "1" value: "1"
- name: RYBBIT_API_KEY
value: "rb_NgFoMtHeohWoJULLiKqSEJmdghSrhJajgseSWQLjfxyeUJcFfQvUrfYwdllSTsLx"
resources: resources:
limits: limits:
cpu: 500m cpu: 500m

View File

@@ -7,6 +7,8 @@ const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
async rewrites() { async rewrites() {
const rybbitHost = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me"; const rybbitHost = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
const openpanelUrl = process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api";
const openpanelScriptUrl = "https://op.nodecrew.me";
return [ return [
{ {
source: "/api/script.js", source: "/api/script.js",
@@ -28,6 +30,10 @@ const nextConfig: NextConfig = {
source: "/api/session-replay/record/:id", source: "/api/session-replay/record/:id",
destination: `${rybbitHost}/api/session-replay/record/:id`, destination: `${rybbitHost}/api/session-replay/record/:id`,
}, },
{
source: "/api/op/track",
destination: `${openpanelUrl}/track`,
},
]; ];
}, },
images: { images: {
@@ -63,7 +69,7 @@ const nextConfig: NextConfig = {
], ],
}, },
experimental: { experimental: {
optimizePackageImports: ["lucide-react", "framer-motion"], optimizePackageImports: ["lucide-react", "framer-motion", "clsx", "motion"],
}, },
}; };

View File

@@ -2,10 +2,9 @@ import { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server"; import { getMessages, setRequestLocale } from "next-intl/server";
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales"; import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
import { OpenPanelComponent } from "@openpanel/nextjs";
import Script from "next/script"; import Script from "next/script";
import AnalyticsProvider from "@/components/providers/AnalyticsProvider";
// Rybbit configuration
const RYBBIT_SITE_ID = process.env.NEXT_PUBLIC_RYBBIT_SITE_ID || "1"; const RYBBIT_SITE_ID = process.env.NEXT_PUBLIC_RYBBIT_SITE_ID || "1";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com"; const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
@@ -50,13 +49,7 @@ export default async function LocaleLayout({
return ( return (
<> <>
<OpenPanelComponent <AnalyticsProvider clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""} />
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
trackScreenViews={true}
trackOutgoingLinks={true}
apiUrl="/api/op"
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}

View File

@@ -16,6 +16,8 @@ import { getPageKeywords, getBrandKeywords } from "@/lib/seo/keywords";
import { Metadata } from "next"; import { Metadata } from "next";
import Image from "next/image"; import Image from "next/image";
export const revalidate = 3600;
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com"; const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {

View File

@@ -9,6 +9,8 @@ import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/l
import { getPageKeywords } from "@/lib/seo/keywords"; import { getPageKeywords } from "@/lib/seo/keywords";
import { Metadata } from "next"; import { Metadata } from "next";
export const revalidate = 3600;
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com"; const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
interface ProductsPageProps { interface ProductsPageProps {

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,65 @@
import { NextRequest, NextResponse } from "next/server";
const OPENPANEL_API_URL = process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api";
export async function POST(request: NextRequest) {
try {
const body = await request.text();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"openpanel-client-id": process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "",
};
if (process.env.OPENPANEL_CLIENT_SECRET) {
headers["openpanel-client-secret"] = process.env.OPENPANEL_CLIENT_SECRET;
}
const response = await fetch(`${OPENPANEL_API_URL}/track`, {
method: "POST",
headers,
body,
});
const data = await response.text();
return new NextResponse(data, {
status: response.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
console.error("[OpenPanel Proxy] Error:", error);
return new NextResponse(JSON.stringify({ error: "Proxy error" }), {
status: 500,
});
}
}
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const path = url.searchParams.get("path") || "";
try {
const response = await fetch(`${OPENPANEL_API_URL}/track/${path}`, {
method: "GET",
headers: {
"openpanel-client-id": process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "",
},
});
const data = await response.text();
return new NextResponse(data, {
status: response.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
console.error("[OpenPanel Proxy] Error:", error);
return new NextResponse(JSON.stringify({ error: "Proxy error" }), {
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

@@ -1,6 +1,5 @@
"use client"; "use client";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
const mediaLogos = [ const mediaLogos = [
@@ -40,15 +39,9 @@ export default function AsSeenIn() {
return ( return (
<section className="py-12 bg-[#1a1a1a] overflow-hidden border-y border-white/10"> <section className="py-12 bg-[#1a1a1a] overflow-hidden border-y border-white/10">
<div className="container mx-auto px-4 mb-8"> <div className="container mx-auto px-4 mb-8">
<motion.p <p className="text-center text-[10px] uppercase tracking-[0.4em] text-[#c9a962] font-bold animate-fade-in">
className="text-center text-[10px] uppercase tracking-[0.4em] text-[#c9a962] font-bold"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
{t("title")} {t("title")}
</motion.p> </p>
</div> </div>
<div className="relative"> <div className="relative">
@@ -56,29 +49,30 @@ export default function AsSeenIn() {
<div className="absolute right-0 top-0 bottom-0 w-32 bg-gradient-to-l from-[#1a1a1a] to-transparent z-10 pointer-events-none" /> <div className="absolute right-0 top-0 bottom-0 w-32 bg-gradient-to-l from-[#1a1a1a] to-transparent z-10 pointer-events-none" />
<div className="flex overflow-hidden"> <div className="flex overflow-hidden">
<motion.div <div className="flex items-center gap-16 animate-marquee">
className="flex items-center gap-16" {[...mediaLogos, ...mediaLogos].map((logo, index) => (
animate={{ <LogoItem key={`${logo.name}-${index}`} name={logo.name} />
x: [0, -50 + "%"],
}}
transition={{
x: {
repeat: Infinity,
repeatType: "loop",
duration: 30,
ease: "linear",
},
}}
>
{mediaLogos.map((logo, index) => (
<LogoItem key={`first-${index}`} name={logo.name} />
))} ))}
{mediaLogos.map((logo, index) => ( </div>
<LogoItem key={`second-${index}`} name={logo.name} />
))}
</motion.div>
</div> </div>
</div> </div>
<style>{`
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.animate-marquee {
animation: marquee 30s linear infinite;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-in {
animation: fade-in 0.6s ease-out forwards;
}
`}</style>
</section> </section>
); );
} }

View File

@@ -1,22 +1,36 @@
"use client"; "use client";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useRef } from "react";
export default function ProblemSection() { export default function ProblemSection() {
const t = useTranslations("ProblemSection"); const t = useTranslations("ProblemSection");
const problems = t.raw("problems") as Array<{ problem: string; description: string }>; const problems = t.raw("problems") as Array<{ problem: string; description: string }>;
const sectionRef = useRef<HTMLElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("animate-visible");
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
const animatedElements = sectionRef.current?.querySelectorAll(".animate-on-scroll");
animatedElements?.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
return ( return (
<section className="py-24 bg-gradient-to-b from-[#fefcfb] to-[#faf9f7]"> <section ref={sectionRef} className="py-24 bg-gradient-to-b from-[#fefcfb] to-[#faf9f7]">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<motion.div <div className="max-w-3xl mx-auto text-center animate-on-scroll">
className="max-w-3xl mx-auto text-center"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<span className="text-xs uppercase tracking-[0.3em] text-[#c9a962] mb-4 block font-medium"> <span className="text-xs uppercase tracking-[0.3em] text-[#c9a962] mb-4 block font-medium">
{t("title")} {t("title")}
</span> </span>
@@ -27,18 +41,14 @@ export default function ProblemSection() {
{t("description")} {t("description")}
</p> </p>
<div className="w-16 h-1 bg-gradient-to-r from-[#c9a962] to-[#FFD700] mx-auto mt-8 rounded-full" /> <div className="w-16 h-1 bg-gradient-to-r from-[#c9a962] to-[#FFD700] mx-auto mt-8 rounded-full" />
</motion.div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto mt-16"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto mt-16">
{problems.map((item, index) => ( {problems.map((item, index) => (
<motion.div <div
key={index} key={index}
className="relative text-center p-8 bg-white rounded-3xl shadow-lg border border-[#f0ede8] hover:shadow-2xl hover:border-[#c9a962]/30 transition-all duration-500 group" className="relative text-center p-8 bg-white rounded-3xl shadow-lg border border-[#f0ede8] hover:shadow-2xl hover:border-[#c9a962]/30 transition-all duration-500 group animate-on-scroll"
initial={{ opacity: 0, y: 30 }} style={{ animationDelay: `${index * 100}ms` }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ y: -5 }}
> >
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-20 h-1 bg-gradient-to-r from-[#c9a962] to-[#FFD700] rounded-b-full opacity-0 group-hover:opacity-100 transition-opacity duration-300" /> <div className="absolute top-0 left-1/2 -translate-x-1/2 w-20 h-1 bg-gradient-to-r from-[#c9a962] to-[#FFD700] rounded-b-full opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
@@ -61,10 +71,29 @@ export default function ProblemSection() {
</div> </div>
<h3 className="text-lg font-semibold text-[#1a1a1a] mb-3">{item.problem}</h3> <h3 className="text-lg font-semibold text-[#1a1a1a] mb-3">{item.problem}</h3>
<p className="text-sm text-[#666666] leading-relaxed">{item.description}</p> <p className="text-sm text-[#666666] leading-relaxed">{item.description}</p>
</motion.div> </div>
))} ))}
</div> </div>
</div> </div>
<style>{`
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-on-scroll {
opacity: 0;
}
.animate-on-scroll.animate-visible {
animation: fadeInUp 0.5s ease-out forwards;
}
`}</style>
</section> </section>
); );
} }

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
export default function TrustBadges() { export default function TrustBadges() {
@@ -9,21 +8,8 @@ export default function TrustBadges() {
return ( return (
<section className="py-16 bg-gradient-to-b from-[#fefcfb] to-[#faf9f7]"> <section className="py-16 bg-gradient-to-b from-[#fefcfb] to-[#faf9f7]">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<motion.div <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
className="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6" <div className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300 animate-fadeSlideUp" style={{ animationDelay: "0s" }}>
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<motion.div
className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: 0 }}
whileHover={{ y: -3 }}
>
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]"> <div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
<svg className="w-6 h-6 text-yellow-400" viewBox="0 0 24 24" fill="currentColor"> <svg className="w-6 h-6 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /> <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
@@ -38,16 +24,9 @@ export default function TrustBadges() {
<p className="text-xs text-[#888888] mt-0.5"> <p className="text-xs text-[#888888] mt-0.5">
{t("basedOnReviews")} {t("basedOnReviews")}
</p> </p>
</motion.div> </div>
<motion.div <div className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300 animate-fadeSlideUp" style={{ animationDelay: "0.1s" }}>
className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: 0.1 }}
whileHover={{ y: -3 }}
>
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]"> <div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#c9a962" strokeWidth="1.5"> <svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#c9a962" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
@@ -62,16 +41,9 @@ export default function TrustBadges() {
<p className="text-xs text-[#888888] mt-0.5"> <p className="text-xs text-[#888888] mt-0.5">
{t("worldwide")} {t("worldwide")}
</p> </p>
</motion.div> </div>
<motion.div <div className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300 animate-fadeSlideUp" style={{ animationDelay: "0.2s" }}>
className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: 0.2 }}
whileHover={{ y: -3 }}
>
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]"> <div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#7eb89e" strokeWidth="1.5"> <svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#7eb89e" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
@@ -86,19 +58,12 @@ export default function TrustBadges() {
<p className="text-xs text-[#888888] mt-0.5"> <p className="text-xs text-[#888888] mt-0.5">
{t("noAdditives")} {t("noAdditives")}
</p> </p>
</motion.div> </div>
<motion.div <div className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300 animate-fadeSlideUp" style={{ animationDelay: "0.3s" }}>
className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: 0.3 }}
whileHover={{ y: -3 }}
>
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]"> <div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#e8967a" strokeWidth="1.5"> <svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#e8967a" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0v1.875c0 .621-.504 1.125-1.125 1.125H4.125A1.125 1.125 0 013 16.875v-1.875m12-9.375v-6.75m0 4.5v-4.5m0 0h-12" />
</svg> </svg>
</div> </div>
<p className="text-2xl lg:text-3xl font-bold bg-gradient-to-r from-[#1a1a1a] to-[#4a4a4a] bg-clip-text text-transparent tracking-tight"> <p className="text-2xl lg:text-3xl font-bold bg-gradient-to-r from-[#1a1a1a] to-[#4a4a4a] bg-clip-text text-transparent tracking-tight">
@@ -110,9 +75,26 @@ export default function TrustBadges() {
<p className="text-xs text-[#888888] mt-0.5"> <p className="text-xs text-[#888888] mt-0.5">
{t("ordersOver")} {t("ordersOver")}
</p> </p>
</motion.div> </div>
</motion.div> </div>
</div> </div>
<style>{`
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeSlideUp {
opacity: 0;
animation: fadeInUp 0.5s ease-out forwards;
}
`}</style>
</section> </section>
); );
} }

View File

@@ -0,0 +1,24 @@
"use client";
import dynamic from "next/dynamic";
const OpenPanelComponent = dynamic(
() => import("@openpanel/nextjs").then((mod) => mod.OpenPanelComponent),
{ ssr: false }
);
interface AnalyticsProviderProps {
clientId: string;
}
export default function AnalyticsProvider({ clientId }: AnalyticsProviderProps) {
return (
<OpenPanelComponent
clientId={clientId}
trackScreenViews={true}
trackOutgoingLinks={true}
apiUrl="/api/op"
scriptUrl="/api/op1"
/>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import type { AnalyticsEvent, AnalyticsProvider, UserData } from "./types";
export class AnalyticsTracker {
private providers: AnalyticsProvider[] = [];
addProvider(provider: AnalyticsProvider): void {
this.providers.push(provider);
}
track(event: AnalyticsEvent): void {
for (const provider of this.providers) {
try {
provider.track(event);
} catch (e) {
console.error(`[Analytics] ${provider.name} tracking error:`, e);
}
}
}
identify(user: UserData): void {
for (const provider of this.providers) {
if (provider.identify) {
try {
provider.identify(user);
} catch (e) {
console.error(`[Analytics] ${provider.name} identify error:`, e);
}
}
}
}
async revenue(amount: number, currency: string, properties?: Record<string, unknown>): Promise<void> {
const promises: Promise<void>[] = [];
for (const provider of this.providers) {
if (provider.revenue) {
promises.push(
provider.revenue(amount, currency, properties).catch((e) => {
console.error(`[Analytics] ${provider.name} revenue error:`, e);
})
);
}
}
await Promise.all(promises);
}
productViewed(product: { id: string; name: string; price: number; currency: string; category?: string; variant?: string }) {
this.track({ type: "product_viewed", product });
}
addToCart(product: { id: string; name: string; price: number; currency: string; quantity: number; variant?: string }) {
this.track({ type: "add_to_cart", product });
}
removeFromCart(product: { id: string; name: string; quantity: number }) {
this.track({ type: "remove_from_cart", product });
}
cartViewed(cart: { total: number; currency: string; item_count: number }) {
this.track({ type: "cart_view", cart });
}
checkoutStarted(cart: { total: number; currency: string; item_count: number; items?: Array<{ id: string; name: string; quantity: number; price: number }> }) {
this.track({ type: "checkout_started", cart });
}
checkoutStep(step: string, data?: Record<string, unknown>) {
this.track({ type: "checkout_step", step, data });
}
orderCompleted(order: { order_id: string; order_number: string; total: number; currency: string; item_count: number; shipping_cost?: number; coupon_code?: string; customer_email?: string; payment_method?: string }) {
this.track({ type: "order_completed", order });
this.revenue(order.total, order.currency, {
transaction_id: order.order_number,
order_id: order.order_id,
});
}
searchPerformed(query: string, results_count: number) {
this.track({ type: "search", query, results_count });
}
externalLinkClicked(url: string, label?: string) {
this.track({ type: "external_link_click", url, label });
}
wishlistAdded(product: { id: string; name: string }) {
this.track({ type: "wishlist_add", product });
}
userLoggedIn(method: string) {
this.track({ type: "user_login", method });
}
userRegistered(method: string) {
this.track({ type: "user_register", method });
}
newsletterSignedUp(email: string, source: string) {
this.track({ type: "newsletter_signup", email, source });
}
}
let trackerInstance: AnalyticsTracker | null = null;
export function getTracker(): AnalyticsTracker {
if (!trackerInstance) {
trackerInstance = new AnalyticsTracker();
}
return trackerInstance;
}

View File

@@ -0,0 +1,62 @@
export interface ProductData {
id: string;
name: string;
price: number;
currency: string;
category?: string;
variant?: string;
}
export interface CartData {
total: number;
currency: string;
item_count: number;
items?: Array<{
id: string;
name: string;
quantity: number;
price: number;
}>;
}
export interface OrderData {
order_id: string;
order_number: string;
total: number;
currency: string;
item_count: number;
shipping_cost?: number;
coupon_code?: string;
customer_email?: string;
payment_method?: string;
}
export interface UserData {
profileId: string;
email?: string;
firstName?: string;
lastName?: string;
}
export type AnalyticsEvent =
| { type: "product_viewed"; product: ProductData }
| { type: "add_to_cart"; product: ProductData & { quantity: number } }
| { type: "remove_from_cart"; product: { id: string; name: string; quantity: number } }
| { type: "cart_view"; cart: CartData }
| { type: "checkout_started"; cart: CartData }
| { type: "checkout_step"; step: string; data?: Record<string, unknown> }
| { type: "order_completed"; order: OrderData }
| { type: "search"; query: string; results_count: number }
| { type: "external_link_click"; url: string; label?: string }
| { type: "wishlist_add"; product: { id: string; name: string } }
| { type: "user_login"; method: string }
| { type: "user_register"; method: string }
| { type: "newsletter_signup"; email: string; source: string };
export interface AnalyticsProvider {
name: string;
track(event: AnalyticsEvent): void;
identify?(user: UserData): void;
revenue?(amount: number, currency: string, properties?: Record<string, unknown>): Promise<void>;
isAvailable(): boolean;
}

View File

@@ -0,0 +1,28 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useOpenPanel } from "@openpanel/nextjs";
import { getTracker, AnalyticsTracker } from "../core/AnalyticsTracker";
import { OpenPanelProvider } from "../providers/OpenPanelProvider";
import { RybbitProvider } from "../providers/RybbitProvider";
let initialized = false;
export function useAnalytics(): AnalyticsTracker {
const op = useOpenPanel();
const [isReady, setIsReady] = useState(false);
const trackerRef = useRef<AnalyticsTracker | null>(null);
useEffect(() => {
if (!initialized) {
const tracker = getTracker();
tracker.addProvider(new OpenPanelProvider(op));
tracker.addProvider(new RybbitProvider());
trackerRef.current = tracker;
initialized = true;
setIsReady(true);
}
}, [op]);
return trackerRef.current || getTracker();
}

View File

@@ -0,0 +1,5 @@
export { AnalyticsTracker, getTracker } from "./core/AnalyticsTracker";
export type { AnalyticsEvent, AnalyticsProvider, ProductData, CartData, OrderData, UserData } from "./core/types";
export { OpenPanelProvider } from "./providers/OpenPanelProvider";
export { RybbitProvider } from "./providers/RybbitProvider";
export { useAnalytics } from "./hooks/useAnalytics";

View File

@@ -0,0 +1,146 @@
"use client";
import type { AnalyticsEvent, AnalyticsProvider, UserData } from "../core/types";
export class OpenPanelProvider implements AnalyticsProvider {
name = "OpenPanel";
private op: ReturnType<typeof import("@openpanel/nextjs").useOpenPanel>;
private isClient: boolean;
constructor(op: ReturnType<typeof import("@openpanel/nextjs").useOpenPanel>) {
this.op = op;
this.isClient = typeof window !== "undefined";
}
isAvailable(): boolean {
return this.isClient;
}
track(event: AnalyticsEvent): void {
if (!this.isAvailable()) return;
switch (event.type) {
case "product_viewed":
this.op.track("product_viewed", {
product_id: event.product.id,
product_name: event.product.name,
price: event.product.price,
currency: event.product.currency,
category: event.product.category,
});
break;
case "add_to_cart":
this.op.track("add_to_cart", {
product_id: event.product.id,
product_name: event.product.name,
price: event.product.price,
currency: event.product.currency,
quantity: event.product.quantity,
variant: event.product.variant,
});
break;
case "remove_from_cart":
this.op.track("remove_from_cart", {
product_id: event.product.id,
product_name: event.product.name,
quantity: event.product.quantity,
});
break;
case "cart_view":
this.op.track("cart_view", {
cart_total: event.cart.total,
currency: event.cart.currency,
item_count: event.cart.item_count,
});
break;
case "checkout_started":
this.op.track("checkout_started", {
cart_total: event.cart.total,
currency: event.cart.currency,
item_count: event.cart.item_count,
items: event.cart.items,
});
break;
case "checkout_step":
this.op.track("checkout_step", {
step: event.step,
...event.data,
});
break;
case "order_completed":
this.op.track("order_completed", {
order_id: event.order.order_id,
order_number: event.order.order_number,
total: event.order.total,
currency: event.order.currency,
item_count: event.order.item_count,
shipping_cost: event.order.shipping_cost,
coupon_code: event.order.coupon_code,
customer_email: event.order.customer_email,
payment_method: event.order.payment_method,
});
break;
case "search":
this.op.track("search", {
query: event.query,
results_count: event.results_count,
});
break;
case "external_link_click":
this.op.track("external_link_click", {
url: event.url,
label: event.label,
});
break;
case "wishlist_add":
this.op.track("wishlist_add", {
product_id: event.product.id,
product_name: event.product.name,
});
break;
case "user_login":
this.op.track("user_login", {
method: event.method,
});
break;
case "user_register":
this.op.track("user_register", {
method: event.method,
});
break;
case "newsletter_signup":
this.op.track("newsletter_signup", {
email: event.email,
source: event.source,
});
break;
}
}
identify(user: UserData): void {
if (!this.isAvailable()) return;
this.op.identify({
profileId: user.profileId,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
});
}
async revenue(amount: number, currency: string, properties?: Record<string, unknown>): Promise<void> {
if (!this.isAvailable()) return;
await this.op.revenue(amount, { currency, ...properties });
}
}

View File

@@ -0,0 +1,159 @@
"use client";
import type { AnalyticsEvent, AnalyticsProvider, UserData } from "../core/types";
declare global {
interface Window {
rybbit?: {
event: (eventName: string, eventData?: Record<string, any>) => void;
pageview: () => void;
};
}
}
export class RybbitProvider implements AnalyticsProvider {
name = "Rybbit";
private isClient: boolean;
constructor() {
this.isClient = typeof window !== "undefined";
}
isAvailable(): boolean {
return this.isClient && typeof window.rybbit?.event === "function";
}
private trackEvent(eventName: string, properties?: Record<string, unknown>): void {
if (!this.isAvailable()) {
console.warn(`[Rybbit] Not available for event: ${eventName}`);
return;
}
try {
window.rybbit!.event(eventName, properties);
} catch (e) {
console.warn(`[Rybbit] Tracking error for ${eventName}:`, e);
}
}
track(event: AnalyticsEvent): void {
switch (event.type) {
case "product_viewed":
this.trackEvent("product_view", {
product_id: event.product.id,
product_name: event.product.name,
price: event.product.price,
currency: event.product.currency,
category: event.product.category,
variant: event.product.variant,
});
break;
case "add_to_cart":
this.trackEvent("add_to_cart", {
product_id: event.product.id,
product_name: event.product.name,
price: event.product.price,
currency: event.product.currency,
quantity: event.product.quantity,
variant: event.product.variant,
});
break;
case "remove_from_cart":
this.trackEvent("remove_from_cart", {
product_id: event.product.id,
product_name: event.product.name,
quantity: event.product.quantity,
});
break;
case "cart_view":
this.trackEvent("cart_view", {
cart_total: event.cart.total,
currency: event.cart.currency,
item_count: event.cart.item_count,
});
break;
case "checkout_started":
this.trackEvent("checkout_started", {
cart_total: event.cart.total,
currency: event.cart.currency,
item_count: event.cart.item_count,
items: event.cart.items,
});
break;
case "checkout_step":
this.trackEvent("checkout_step", {
step: event.step,
...event.data,
});
break;
case "order_completed":
this.trackEvent("order_completed", {
order_id: event.order.order_id,
order_number: event.order.order_number,
total: event.order.total,
currency: event.order.currency,
item_count: event.order.item_count,
shipping_cost: event.order.shipping_cost,
coupon_code: event.order.coupon_code,
customer_email: event.order.customer_email,
payment_method: event.order.payment_method,
revenue: event.order.total,
});
break;
case "search":
this.trackEvent("search", {
query: event.query,
results_count: event.results_count,
});
break;
case "external_link_click":
this.trackEvent("external_link_click", {
url: event.url,
label: event.label,
});
break;
case "wishlist_add":
this.trackEvent("wishlist_add", {
product_id: event.product.id,
product_name: event.product.name,
});
break;
case "user_login":
this.trackEvent("user_login", {
method: event.method,
});
break;
case "user_register":
this.trackEvent("user_register", {
method: event.method,
});
break;
case "newsletter_signup":
this.trackEvent("newsletter_signup", {
email: event.email,
source: event.source,
});
break;
}
}
identify(_user: UserData): void {
// Rybbit doesn't have explicit identify - it's handled automatically via cookies
}
revenue?(_amount: number, _currency: string, _properties?: Record<string, unknown>): Promise<void> {
// Revenue is tracked via order_completed event
return Promise.resolve();
}
}

35
src/middleware.ts Normal file
View File

@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const url = request.nextUrl.pathname;
if (
url.startsWith("/sr") ||
url.startsWith("/en") ||
url.startsWith("/de") ||
url.startsWith("/fr") ||
url === "/"
) {
if (
!url.includes("/checkout") &&
!url.includes("/cart") &&
!url.includes("/api/")
) {
response.headers.set(
"Cache-Control",
"public, max-age=3600, stale-while-revalidate=86400"
);
}
}
return response;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|icon.png|robots.txt|sitemap.xml).*)",
],
};