Compare commits

..

15 Commits

Author SHA1 Message Date
Unchained
6843d2db36 fix(404): add redirects for broken URLs and custom not-found page
Some checks failed
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
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
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
22 changed files with 608 additions and 435 deletions

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,13 +54,12 @@ const nextConfig: NextConfig = {
source: "/api/session-replay/record/:id",
destination: `${rybbitHost}/api/session-replay/record/:id`,
},
{
source: "/api/op/track",
destination: `${openpanelUrl}/track`,
},
];
},
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
remotePatterns: [
{
protocol: "https",
@@ -58,8 +81,16 @@ const nextConfig: NextConfig = {
hostname: "**.saleor.cloud",
pathname: "/**",
},
{
protocol: "https",
hostname: "images.unsplash.com",
pathname: "/**",
},
],
},
experimental: {
optimizePackageImports: ["lucide-react", "framer-motion", "clsx", "motion"],
},
};
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 { getPageKeywords } from "@/lib/seo/keywords";
import { Metadata } from "next";
import Image from "next/image";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
@@ -67,10 +68,13 @@ export default async function AboutPage({ params }: AboutPageProps) {
</div>
<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"
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>

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,13 +49,7 @@ 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}

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} />
</>
);
}

View File

@@ -14,6 +14,9 @@ import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
import { getPageKeywords, getBrandKeywords } from "@/lib/seo/keywords";
import { Metadata } from "next";
import Image from "next/image";
export const revalidate = 3600;
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
@@ -157,10 +160,12 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
</a>
</div>
<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"
alt={metadata.home.productionAlt}
className="w-full h-full object-cover"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
</div>

View File

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

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 });
}
}

View File

@@ -0,0 +1,86 @@
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");
const nextJsIp = request.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",
},
});
}

View File

@@ -53,8 +53,7 @@
--color-cta-hover: #333333;
--color-overlay: rgba(0, 0, 0, 0.4);
--font-display: 'DM Sans', sans-serif;
--font-body: 'Inter', sans-serif;
/* Font variables will be set by next/font in layout.tsx */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
@@ -66,26 +65,9 @@
--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)
Fonts loaded via next/font in layout.tsx
============================================ */
@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
============================================ */

View File

@@ -1,9 +1,22 @@
import "./globals.css";
import type { Metadata, Viewport } from "next";
import { DM_Sans, Inter } from "next/font/google";
import ErrorBoundary from "@/components/providers/ErrorBoundary";
import { SUPPORTED_LOCALES } from "@/lib/i18n/locales";
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";
export const metadata: Metadata = {
@@ -39,7 +52,7 @@ export default async function RootLayout({
children: React.ReactNode;
}) {
return (
<html suppressHydrationWarning>
<html suppressHydrationWarning className={`${dmSans.variable} ${inter.variable}`}>
<body className="antialiased" suppressHydrationWarning>
<ErrorBoundary>
{children}

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>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { ChevronDown } from "lucide-react";
@@ -23,30 +23,23 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
return (
<section className="relative min-h-screen w-full overflow-hidden">
{/* Background Image with Overlay */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url('https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop')`,
}}
>
<div className="absolute inset-0">
<Image
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop"
alt=""
fill
priority
className="object-cover"
sizes="100vw"
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/40 to-black/70" />
</div>
{/* 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">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.3 }}
className="max-w-4xl mx-auto"
>
<div className="max-w-4xl mx-auto animate-fadeSlideUp">
{/* Social Proof Micro */}
<motion.div
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 items-center justify-center gap-2 mb-6 animate-fadeSlideUp" style={{ animationDelay: "0.1s" }}>
<div className="flex">
{[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">
@@ -57,36 +50,30 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
<span className="text-sm text-white/80">
{t("lovedBy")}
</span>
</motion.div>
</div>
{/* Main Heading - Outcome Focused */}
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.5 }}
className="text-4xl md:text-6xl lg:text-7xl font-medium mb-6 tracking-tight leading-tight"
{/* Main Heading */}
<h1
className="text-4xl md:text-6xl lg:text-7xl font-medium mb-6 tracking-tight leading-tight animate-fadeSlideUp"
style={{ animationDelay: "0.2s" }}
>
{t("transformHeadline")}
<br />
<span className="text-white/90">{t("withNaturalOils")}</span>
</motion.h1>
</h1>
{/* Subtitle - Expands on how */}
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
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"
{/* Subtitle */}
<p
className="text-lg md:text-xl text-white/80 mb-8 font-light max-w-2xl mx-auto leading-relaxed animate-fadeSlideUp"
style={{ animationDelay: "0.3s" }}
>
{t("subtitleText")}
</motion.p>
</p>
{/* CTA Button - Action verb + value */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.9 }}
className="flex flex-col sm:flex-row items-center justify-center gap-4"
{/* CTA Buttons */}
<div
className="flex flex-col sm:flex-row items-center justify-center gap-4 animate-fadeSlideUp"
style={{ animationDelay: "0.4s" }}
>
<Link
href={`${localePath}/products`}
@@ -100,14 +87,12 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
>
{t("learnStory")}
</Link>
</motion.div>
</div>
{/* Trust Indicators */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
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 flex-wrap items-center justify-center gap-6 mt-12 text-sm text-white/60 animate-fadeSlideUp"
style={{ animationDelay: "0.5s" }}
>
<div className="flex items-center gap-2">
<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>
<span>{t("crueltyFree")}</span>
</div>
</motion.div>
</motion.div>
</div>
</div>
</div>
{/* Scroll Indicator */}
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.5, duration: 0.8 }}
<button
onClick={scrollToContent}
className="absolute bottom-10 left-1/2 -translate-x-1/2 text-white/60 hover:text-white transition-colors cursor-pointer"
className="absolute bottom-10 left-1/2 -translate-x-1/2 text-white/60 hover:text-white transition-colors cursor-pointer opacity-0 animate-fade-in"
style={{ animationDelay: "1.5s", animationFillMode: "forwards" }}
aria-label="Scroll to content"
>
<motion.div
animate={{ y: [0, 8, 0] }}
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }}
>
<div className="scroll-indicator">
<ChevronDown className="w-6 h-6" strokeWidth={1.5} />
</motion.div>
</motion.button>
</div>
</button>
</section>
);
}

View File

@@ -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>
);
}

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>
);
}

View File

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

View File

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

View File

@@ -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;
}

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."
}
}

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ć."
}
}

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,

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":

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).*)",
],
};