Compare commits

..

13 Commits

Author SHA1 Message Date
Unchained 93b239bc5a Merge branch 'dev'
Build and Deploy / build (push) Has been cancelled
2026-04-03 16:12:02 +02:00
Unchained 1ed6cac647 fix(k8s): use NodePort with externalTrafficPolicy Local to preserve client IP
Build and Deploy / build (push) Has been cancelled
Change storefront service from ClusterIP to NodePort with externalTrafficPolicy: Local.
This preserves the real client source IP instead of NATing to the node IP.

Fixes analytics tracking showing Hetzner IP (138.201.11.251) instead of real visitor IPs.
Same fix previously applied to Rybbit backend service.

Note: On single-node clusters, this works seamlessly. Traefik routes directly
to the node where the pod is running, preserving the original source IP.
2026-04-03 06:55:42 +02:00
Unchained e476bc9fc4 fix(k8s): add HTTP to HTTPS redirect for manoonoils.com
Build and Deploy / build (push) Has been cancelled
- Create redirect-https middleware for permanent redirect (301)
- Split IngressRoute: HTTP route redirects to HTTPS, HTTPS route serves app
- Fixes Google Search Console 404 error on HTTP version
- No application code changes, only routing configuration
2026-04-02 22:50:43 +02:00
Unchained f4f23aa7f3 fix(k8s): add HTTP to HTTPS redirect for manoonoils.com
- Create redirect-https middleware for permanent redirect (301)
- Split IngressRoute: HTTP route redirects to HTTPS, HTTPS route serves app
- Fixes Google Search Console 404 error on HTTP version
- No application code changes, only routing configuration
2026-04-02 22:49:26 +02:00
Unchained 9124eeedc1 fix: add ts-ignore for request.ip runtime property
Build and Deploy / build (push) Has been cancelled
2026-04-01 10:47:09 +02:00
Unchained 6843d2db36 fix(404): add redirects for broken URLs and custom not-found page
Build and Deploy / build (push) Has been cancelled
- Add permanent redirects for /products/manoon to /products
- Strip malformed /contact suffix from product URLs
- Create custom branded 404 page with product navigation
- Add NotFound translations for en and sr locales
2026-04-01 10:24:09 +02:00
Unchained 0b9ddeedc8 fix(analytics): properly forward client IPs to Rybbit and OpenPanel
- Create new API route /api/rybbit/track to proxy Rybbit tracking requests
- Extract real client IP from Cloudflare headers (cf-connecting-ip)
- Forward X-Forwarded-For and X-Real-IP headers to analytics backends
- Update OpenPanel proxy to also forward client IP
- Update next.config.ts rewrite to use internal API route

This fixes geo-location issues where all traffic appeared to come from
Cloudflare edge locations instead of actual visitor countries.
2026-04-01 10:24:09 +02:00
Unchained a3873bb50d fix(analytics): properly forward client IPs to Rybbit and OpenPanel
- Create new API route /api/rybbit/track to proxy Rybbit tracking requests
- Extract real client IP from Cloudflare headers (cf-connecting-ip)
- Forward X-Forwarded-For and X-Real-IP headers to analytics backends
- Update OpenPanel proxy to also forward client IP
- Update next.config.ts rewrite to use internal API route

This fixes geo-location issues where all traffic appeared to come from
Cloudflare edge locations instead of actual visitor countries.
2026-04-01 07:42:34 +02:00
Unchained 3c9c091c46 fix: revert HeroVideo aspect-ratio, fix ProblemSection scroll animation with useEffect
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'
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
20 changed files with 478 additions and 407 deletions
+23 -7
View File
@@ -5,13 +5,29 @@ metadata:
namespace: manoonoils
spec:
entryPoints:
- web
- websecure
- web
routes:
- match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
kind: Rule
services:
- name: storefront
port: 3000
- kind: Rule
match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
middlewares:
- name: redirect-https
services:
- name: storefront
port: 3000
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: storefront-secure
namespace: manoonoils
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
services:
- name: storefront
port: 3000
tls:
certResolver: letsencrypt
+1
View File
@@ -3,6 +3,7 @@ kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- middleware.yaml
- ingress.yaml
images:
- name: ghcr.io/unchainedio/manoon-headless
+9
View File
@@ -0,0 +1,9 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: redirect-https
namespace: manoonoils
spec:
redirectScheme:
scheme: https
permanent: true
+5 -1
View File
@@ -4,9 +4,13 @@ metadata:
name: storefront
namespace: manoonoils
spec:
# Use NodePort with externalTrafficPolicy: Local to preserve client source IP
# This is required for proper client IP detection in analytics (Rybbit, etc.)
type: NodePort
externalTrafficPolicy: Local
selector:
app: storefront
ports:
- port: 3000
targetPort: 3000
type: ClusterIP
# Let Kubernetes assign a NodePort automatically
+28 -8
View File
@@ -5,10 +5,34 @@ const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
output: 'standalone',
async redirects() {
return [
// Fix malformed URLs with /contact appended to product slugs
{
source: '/:locale(en|sr)/products/:slug*/contact',
destination: '/:locale/products/:slug*',
permanent: true,
},
{
source: '/products/:slug*/contact',
destination: '/products/:slug*',
permanent: true,
},
// Redirect old/removed product "manoon" to products listing
{
source: '/:locale(en|sr)/products/manoon',
destination: '/:locale/products',
permanent: true,
},
{
source: '/products/manoon',
destination: '/products',
permanent: true,
},
];
},
async rewrites() {
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 [
{
source: "/api/script.js",
@@ -16,7 +40,7 @@ const nextConfig: NextConfig = {
},
{
source: "/api/track",
destination: `${rybbitHost}/api/track`,
destination: "/api/rybbit/track",
},
{
source: "/api/site/tracking-config/:id",
@@ -30,10 +54,6 @@ const nextConfig: NextConfig = {
source: "/api/session-replay/record/:id",
destination: `${rybbitHost}/api/session-replay/record/:id`,
},
{
source: "/api/op/track",
destination: `${openpanelUrl}/track`,
},
];
},
images: {
@@ -69,7 +89,7 @@ const nextConfig: NextConfig = {
],
},
experimental: {
optimizePackageImports: ["lucide-react", "framer-motion"],
optimizePackageImports: ["lucide-react", "framer-motion", "clsx", "motion"],
},
};
+3 -10
View File
@@ -2,10 +2,9 @@ import { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
import { OpenPanelComponent } from "@openpanel/nextjs";
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 baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
@@ -50,17 +49,11 @@ export default async function LocaleLayout({
return (
<>
<OpenPanelComponent
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
trackScreenViews={true}
trackOutgoingLinks={true}
apiUrl="/api/op"
scriptUrl="/api/op1"
/>
<AnalyticsProvider clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""} />
<Script
src="/api/script.js"
data-site-id={RYBBIT_SITE_ID}
strategy="lazyOnload"
strategy="afterInteractive"
/>
<NextIntlClientProvider messages={messages}>
{children}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useTranslations, useLocale } from "next-intl";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import Link from "next/link";
import { Home, Search, Package } from "lucide-react";
export default function NotFoundPage() {
const t = useTranslations("NotFound");
const locale = useLocale();
const basePath = `/${locale}`;
return (
<>
<Header locale={locale} />
<main className="min-h-screen bg-white">
<div className="pt-[180px] lg:pt-[200px] pb-20 px-4">
<div className="max-w-2xl mx-auto text-center">
{/* 404 Code */}
<div className="text-[120px] lg:text-[180px] font-light text-black/5 leading-none select-none mb-4">
404
</div>
<h1 className="text-2xl lg:text-3xl font-medium mb-4">
{t("title")}
</h1>
<p className="text-[#666666] mb-10 max-w-md mx-auto">
{t("description")}
</p>
{/* Quick Links */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12">
<Link
href={`${basePath}/products`}
className="flex items-center gap-2 px-6 py-3 bg-black text-white text-sm uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors w-full sm:w-auto justify-center"
>
<Package className="w-4 h-4" />
{t("browseProducts")}
</Link>
<Link
href={basePath}
className="flex items-center gap-2 px-6 py-3 border border-black text-black text-sm uppercase tracking-[0.1em] hover:bg-black hover:text-white transition-colors w-full sm:w-auto justify-center"
>
<Home className="w-4 h-4" />
{t("goHome")}
</Link>
</div>
{/* Search Suggestion */}
<div className="p-6 bg-[#f8f8f8] rounded-sm">
<div className="flex items-center gap-3 mb-3 text-[#666666]">
<Search className="w-5 h-5" />
<span className="text-sm font-medium uppercase tracking-[0.1em]">
{t("lookingFor")}
</span>
</div>
<p className="text-sm text-[#666666]">
{t("searchSuggestion")}
</p>
</div>
</div>
</div>
</main>
<Footer locale={locale} />
</>
);
}
-65
View File
@@ -1,65 +0,0 @@
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
View File
@@ -1,24 +0,0 @@
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 });
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
const RYBBIT_API_URL = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Get all possible IP sources for debugging
const cfConnectingIp = request.headers.get("cf-connecting-ip");
const xForwardedFor = request.headers.get("x-forwarded-for");
const xRealIp = request.headers.get("x-real-ip");
// @ts-ignore - ip exists at runtime but not in types
const nextJsIp = (request as any).ip;
// Use the first available IP in priority order
const clientIp =
cfConnectingIp || // Cloudflare (most reliable)
xForwardedFor?.split(",")[0]?.trim() || // First IP in chain
xRealIp || // Nginx/Traefik
nextJsIp || // Next.js fallback
"unknown";
const userAgent = request.headers.get("user-agent") || "";
console.log("[Rybbit Proxy] IP Debug:", {
cfConnectingIp,
xForwardedFor,
xRealIp,
nextJsIp,
finalIp: clientIp,
userAgent: userAgent?.substring(0, 50),
});
// Build headers to forward
const forwardHeaders: Record<string, string> = {
"Content-Type": "application/json",
"X-Forwarded-For": clientIp,
"X-Real-IP": clientIp,
"User-Agent": userAgent,
};
// Forward original CF headers if present
const cfCountry = request.headers.get("cf-ipcountry");
const cfRay = request.headers.get("cf-ray");
if (cfCountry) forwardHeaders["CF-IPCountry"] = cfCountry;
if (cfRay) forwardHeaders["CF-Ray"] = cfRay;
console.log("[Rybbit Proxy] Forwarding to Rybbit with headers:", Object.keys(forwardHeaders));
const response = await fetch(`${RYBBIT_API_URL}/api/track`, {
method: "POST",
headers: forwardHeaders,
body: JSON.stringify(body),
});
const data = await response.text();
console.log("[Rybbit Proxy] Response:", response.status, data.substring(0, 100));
return new NextResponse(data, {
status: response.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
console.error("[Rybbit Proxy] Error:", error);
return new NextResponse(
JSON.stringify({ error: "Proxy error" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
}
// Handle CORS preflight
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}
+24 -30
View File
@@ -1,6 +1,5 @@
"use client";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
const mediaLogos = [
@@ -40,15 +39,9 @@ export default function AsSeenIn() {
return (
<section className="py-12 bg-[#1a1a1a] overflow-hidden border-y border-white/10">
<div className="container mx-auto px-4 mb-8">
<motion.p
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 }}
>
<p className="text-center text-[10px] uppercase tracking-[0.4em] text-[#c9a962] font-bold animate-fade-in">
{t("title")}
</motion.p>
</p>
</div>
<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="flex overflow-hidden">
<motion.div
className="flex items-center gap-16"
animate={{
x: [0, -50 + "%"],
}}
transition={{
x: {
repeat: Infinity,
repeatType: "loop",
duration: 30,
ease: "linear",
},
}}
>
{mediaLogos.map((logo, index) => (
<LogoItem key={`first-${index}`} name={logo.name} />
<div className="flex items-center gap-16 animate-marquee">
{[...mediaLogos, ...mediaLogos].map((logo, index) => (
<LogoItem key={`${logo.name}-${index}`} name={logo.name} />
))}
{mediaLogos.map((logo, index) => (
<LogoItem key={`second-${index}`} name={logo.name} />
))}
</motion.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>
);
}
}
+48 -19
View File
@@ -1,22 +1,36 @@
"use client";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { useEffect, useRef } from "react";
export default function ProblemSection() {
const t = useTranslations("ProblemSection");
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 (
<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">
<motion.div
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 }}
>
<div className="max-w-3xl mx-auto text-center animate-on-scroll">
<span className="text-xs uppercase tracking-[0.3em] text-[#c9a962] mb-4 block font-medium">
{t("title")}
</span>
@@ -27,18 +41,14 @@ export default function ProblemSection() {
{t("description")}
</p>
<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">
{problems.map((item, index) => (
<motion.div
<div
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"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ y: -5 }}
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"
style={{ animationDelay: `${index * 100}ms` }}
>
<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>
<h3 className="text-lg font-semibold text-[#1a1a1a] mb-3">{item.problem}</h3>
<p className="text-sm text-[#666666] leading-relaxed">{item.description}</p>
</motion.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>
);
}
}
+29 -47
View File
@@ -1,6 +1,5 @@
"use client";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
export default function TrustBadges() {
@@ -9,21 +8,8 @@ export default function TrustBadges() {
return (
<section className="py-16 bg-gradient-to-b from-[#fefcfb] to-[#faf9f7]">
<div className="container mx-auto px-4">
<motion.div
className="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6"
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="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" }}>
<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">
<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">
{t("basedOnReviews")}
</p>
</motion.div>
</div>
<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.1 }}
whileHover={{ y: -3 }}
>
<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" }}>
<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">
<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">
{t("worldwide")}
</p>
</motion.div>
</div>
<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.2 }}
whileHover={{ y: -3 }}
>
<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" }}>
<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">
<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">
{t("noAdditives")}
</p>
</motion.div>
</div>
<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.3 }}
whileHover={{ y: -3 }}
>
<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" }}>
<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">
<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>
</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">
@@ -110,9 +75,26 @@ export default function TrustBadges() {
<p className="text-xs text-[#888888] mt-0.5">
{t("ordersOver")}
</p>
</motion.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>
);
}
}
+1
View File
@@ -38,6 +38,7 @@ export default function ProductCard({ product, index = 0, locale = "sr" }: Produ
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"
loading={index < 4 ? "eager" : "lazy"}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-[#999999]">
@@ -0,0 +1,13 @@
"use client";
// AnalyticsProvider - placeholder for future analytics integrations
// Currently only Rybbit is used via the script tag in layout.tsx
interface AnalyticsProviderProps {
clientId?: string;
}
export default function AnalyticsProvider({ clientId }: AnalyticsProviderProps) {
// No-op component - Rybbit is loaded via next/script in layout.tsx
return null;
}
+8
View File
@@ -464,5 +464,13 @@
"description": "Pay via bank transfer",
"comingSoon": "Coming soon"
}
},
"NotFound": {
"title": "Page Not Found",
"description": "The page you're looking for doesn't exist or has been moved.",
"browseProducts": "Browse Products",
"goHome": "Go Home",
"lookingFor": "Can't find what you're looking for?",
"searchSuggestion": "Try browsing our product collection or contact us for assistance."
}
}
+8
View File
@@ -463,5 +463,13 @@
"description": "Platite putem bankovnog transfera",
"comingSoon": "Uskoro dostupno"
}
},
"NotFound": {
"title": "Stranica Nije Pronađena",
"description": "Stranica koju tražite ne postoji ili je premeštena.",
"browseProducts": "Pregledaj Proizvode",
"goHome": "Početna Strana",
"lookingFor": "Ne možete da pronađete ono što tražite?",
"searchSuggestion": "Pokušajte da pregledate našu kolekciju proizvoda ili nas kontaktirajte za pomoć."
}
}
+42 -189
View File
@@ -1,6 +1,5 @@
"use client";
import { useOpenPanel } from "@openpanel/nextjs";
import { useCallback } from "react";
import {
trackRybbitProductView,
@@ -19,106 +18,6 @@ import {
} from "@/lib/services/RybbitService";
export function useAnalytics() {
const op = useOpenPanel();
// Helper to track with both OpenPanel and Rybbit
const trackDual = useCallback((
eventName: string,
openPanelData: Record<string, any>
) => {
// OpenPanel tracking
try {
op.track(eventName, openPanelData);
} catch (e) {
console.error("[OpenPanel] Tracking error:", e);
}
// Rybbit tracking (fire-and-forget)
try {
switch (eventName) {
case "product_viewed":
trackRybbitProductView({
id: openPanelData.product_id,
name: openPanelData.product_name,
price: openPanelData.price,
currency: openPanelData.currency,
category: openPanelData.category,
});
break;
case "add_to_cart":
trackRybbitAddToCart({
id: openPanelData.product_id,
name: openPanelData.product_name,
price: openPanelData.price,
currency: openPanelData.currency,
quantity: openPanelData.quantity,
variant: openPanelData.variant,
});
break;
case "remove_from_cart":
trackRybbitRemoveFromCart({
id: openPanelData.product_id,
name: openPanelData.product_name,
quantity: openPanelData.quantity,
});
break;
case "cart_view":
trackRybbitCartView({
total: openPanelData.cart_total,
currency: openPanelData.currency,
item_count: openPanelData.item_count,
});
break;
case "checkout_started":
trackRybbitCheckoutStarted({
total: openPanelData.cart_total,
currency: openPanelData.currency,
item_count: openPanelData.item_count,
items: openPanelData.items,
});
break;
case "checkout_step":
trackRybbitCheckoutStep(openPanelData.step, openPanelData);
break;
case "order_completed":
trackRybbitOrderCompleted({
order_id: openPanelData.order_id,
order_number: openPanelData.order_number,
total: openPanelData.total,
currency: openPanelData.currency,
item_count: openPanelData.item_count,
shipping_cost: openPanelData.shipping_cost,
customer_email: openPanelData.customer_email,
payment_method: openPanelData.payment_method,
});
break;
case "search":
trackRybbitSearch(openPanelData.query, openPanelData.results_count);
break;
case "external_link_click":
trackRybbitExternalLink(openPanelData.url, openPanelData.label);
break;
case "wishlist_add":
trackRybbitWishlistAdd({
id: openPanelData.product_id,
name: openPanelData.product_name,
});
break;
case "user_login":
trackRybbitUserLogin(openPanelData.method);
break;
case "user_register":
trackRybbitUserRegister(openPanelData.method);
break;
case "newsletter_signup":
trackRybbitNewsletterSignup(openPanelData.email, openPanelData.source);
break;
}
} catch (e) {
console.warn("[Rybbit] Tracking error:", e);
}
}, [op]);
const trackProductView = useCallback((product: {
id: string;
name: string;
@@ -126,15 +25,14 @@ export function useAnalytics() {
currency: string;
category?: string;
}) => {
trackDual("product_viewed", {
product_id: product.id,
product_name: product.name,
trackRybbitProductView({
id: product.id,
name: product.name,
price: product.price,
currency: product.currency,
category: product.category,
source: "client",
});
}, [trackDual]);
}, []);
const trackAddToCart = useCallback((product: {
id: string;
@@ -144,42 +42,39 @@ export function useAnalytics() {
quantity: number;
variant?: string;
}) => {
trackDual("add_to_cart", {
product_id: product.id,
product_name: product.name,
trackRybbitAddToCart({
id: product.id,
name: product.name,
price: product.price,
currency: product.currency,
quantity: product.quantity,
variant: product.variant,
source: "client",
});
}, [trackDual]);
}, []);
const trackRemoveFromCart = useCallback((product: {
id: string;
name: string;
quantity: number;
}) => {
trackDual("remove_from_cart", {
product_id: product.id,
product_name: product.name,
trackRybbitRemoveFromCart({
id: product.id,
name: product.name,
quantity: product.quantity,
source: "client",
});
}, [trackDual]);
}, []);
const trackCartView = useCallback((cart: {
total: number;
currency: string;
item_count: number;
}) => {
trackDual("cart_view", {
cart_total: cart.total,
trackRybbitCartView({
total: cart.total,
currency: cart.currency,
item_count: cart.item_count,
source: "client",
});
}, [trackDual]);
}, []);
const trackCheckoutStarted = useCallback((cart: {
total: number;
@@ -192,22 +87,17 @@ export function useAnalytics() {
price: number;
}>;
}) => {
trackDual("checkout_started", {
cart_total: cart.total,
trackRybbitCheckoutStarted({
total: cart.total,
currency: cart.currency,
item_count: cart.item_count,
items: cart.items,
source: "client",
});
}, [trackDual]);
}, []);
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
trackDual("checkout_step", {
step,
...data,
source: "client",
});
}, [trackDual]);
trackRybbitCheckoutStep(step, data);
}, []);
const trackOrderCompleted = useCallback(async (order: {
order_id: string;
@@ -221,8 +111,8 @@ export function useAnalytics() {
}) => {
console.log("[Analytics] Tracking order:", order.order_number);
// Track with both OpenPanel and Rybbit
trackDual("order_completed", {
// Rybbit tracking
trackRybbitOrderCompleted({
order_id: order.order_id,
order_number: order.order_number,
total: order.total,
@@ -231,20 +121,8 @@ export function useAnalytics() {
shipping_cost: order.shipping_cost,
customer_email: order.customer_email,
payment_method: order.payment_method,
source: "client",
});
// OpenPanel revenue tracking
try {
op.revenue(order.total, {
currency: order.currency,
transaction_id: order.order_number,
source: "client",
});
} catch (e) {
console.error("[OpenPanel] Revenue tracking error:", e);
}
// Server-side tracking for reliability
try {
const response = await fetch("/api/analytics/track-order", {
@@ -268,73 +146,48 @@ export function useAnalytics() {
} catch (e) {
console.error("[Server Analytics] API call failed:", e);
}
}, [op, trackDual]);
}, []);
const trackSearch = useCallback((query: string, results_count: number) => {
trackDual("search", {
query,
results_count,
source: "client",
});
}, [trackDual]);
trackRybbitSearch(query, results_count);
}, []);
const trackExternalLink = useCallback((url: string, label?: string) => {
trackDual("external_link_click", {
url,
label,
source: "client",
});
}, [trackDual]);
trackRybbitExternalLink(url, label);
}, []);
const trackWishlistAdd = useCallback((product: {
id: string;
name: string;
}) => {
trackDual("wishlist_add", {
product_id: product.id,
product_name: product.name,
source: "client",
trackRybbitWishlistAdd({
id: product.id,
name: product.name,
});
}, [trackDual]);
}, []);
const trackUserLogin = useCallback((method: string) => {
trackDual("user_login", {
method,
source: "client",
});
}, [trackDual]);
trackRybbitUserLogin(method);
}, []);
const trackUserRegister = useCallback((method: string) => {
trackDual("user_register", {
method,
source: "client",
});
}, [trackDual]);
trackRybbitUserRegister(method);
}, []);
const trackNewsletterSignup = useCallback((email: string, source: string) => {
trackDual("newsletter_signup", {
email,
source,
});
}, [trackDual]);
trackRybbitNewsletterSignup(email, source);
}, []);
const identifyUser = useCallback((user: {
// No-op placeholder for identifyUser (OpenPanel removed)
const identifyUser = useCallback((_user: {
profileId: string;
email?: string;
firstName?: string;
lastName?: string;
}) => {
try {
op.identify({
profileId: user.profileId,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
});
} catch (e) {
console.error("[OpenPanel] Identify error:", e);
}
}, [op]);
// OpenPanel was removed - this is now a no-op
// User identification is handled by Rybbit automatically via cookies
}, []);
return {
trackProductView,
+79 -5
View File
@@ -11,30 +11,104 @@ declare global {
}
}
type QueuedEvent = {
eventName: string;
properties?: Record<string, unknown>;
};
export class RybbitProvider implements AnalyticsProvider {
name = "Rybbit";
private isClient: boolean;
private eventQueue: QueuedEvent[] = [];
private flushInterval: ReturnType<typeof setInterval> | null = null;
private initialized = false;
constructor() {
this.isClient = typeof window !== "undefined";
if (this.isClient) {
console.log("[RybbitProvider] Constructor called");
// Start checking for rybbit availability
this.startFlushInterval();
// Also try to flush immediately in case script is already loaded
setTimeout(() => this.tryFlushQueue(), 100);
}
}
private startFlushInterval() {
// Check every 500ms for up to 15 seconds
let attempts = 0;
const maxAttempts = 30;
this.flushInterval = setInterval(() => {
attempts++;
const available = this.isAvailable();
if (available && !this.initialized) {
console.log("[RybbitProvider] Script became available, flushing queue");
this.initialized = true;
}
this.tryFlushQueue();
if (available || attempts >= maxAttempts) {
this.stopFlushInterval();
if (attempts >= maxAttempts && !available) {
console.warn("[RybbitProvider] Max attempts reached, script not loaded. Queue size:", this.eventQueue.length);
}
}
}, 500);
}
private stopFlushInterval() {
if (this.flushInterval) {
clearInterval(this.flushInterval);
this.flushInterval = null;
}
}
private tryFlushQueue() {
if (!this.isAvailable() || this.eventQueue.length === 0) {
return;
}
console.log(`[RybbitProvider] Flushing ${this.eventQueue.length} queued events`);
// Flush all queued events
while (this.eventQueue.length > 0) {
const event = this.eventQueue.shift();
if (event) {
this.sendEvent(event.eventName, event.properties);
}
}
}
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;
}
private sendEvent(eventName: string, properties?: Record<string, unknown>): void {
try {
window.rybbit!.event(eventName, properties);
console.log(`[Rybbit] Event sent: ${eventName}`);
} catch (e) {
console.warn(`[Rybbit] Tracking error for ${eventName}:`, e);
}
}
private trackEvent(eventName: string, properties?: Record<string, unknown>): void {
if (!this.isClient) return;
if (this.isAvailable()) {
this.sendEvent(eventName, properties);
} else {
// Queue the event for later
this.eventQueue.push({ eventName, properties });
console.log(`[Rybbit] Queued event: ${eventName}, queue size: ${this.eventQueue.length}`);
}
}
track(event: AnalyticsEvent): void {
switch (event.type) {
case "product_viewed":
+2 -2
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const url = request.nextUrl.pathname;
@@ -32,4 +32,4 @@ export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|icon.png|robots.txt|sitemap.xml).*)",
],
};
};