Compare commits

...

20 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
Unchained 3697a5d8ea fix: rename middleware to proxy for Next.js 16 compatibility
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
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)
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
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
26 changed files with 647 additions and 443 deletions
+18 -2
View File
@@ -6,10 +6,26 @@ metadata:
spec: spec:
entryPoints: entryPoints:
- web - web
routes:
- 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 - websecure
routes: routes:
- match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`) - kind: Rule
kind: Rule match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
services: services:
- name: storefront - name: storefront
port: 3000 port: 3000
+1
View File
@@ -3,6 +3,7 @@ kind: Kustomization
resources: resources:
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- middleware.yaml
- ingress.yaml - ingress.yaml
images: images:
- name: ghcr.io/unchainedio/manoon-headless - 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 name: storefront
namespace: manoonoils namespace: manoonoils
spec: 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: selector:
app: storefront app: storefront
ports: ports:
- port: 3000 - port: 3000
targetPort: 3000 targetPort: 3000
type: ClusterIP # Let Kubernetes assign a NodePort automatically
+38 -7
View File
@@ -5,10 +5,34 @@ const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', 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() { 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",
@@ -16,7 +40,7 @@ const nextConfig: NextConfig = {
}, },
{ {
source: "/api/track", source: "/api/track",
destination: `${rybbitHost}/api/track`, destination: "/api/rybbit/track",
}, },
{ {
source: "/api/site/tracking-config/:id", source: "/api/site/tracking-config/:id",
@@ -30,13 +54,12 @@ 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: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
@@ -58,8 +81,16 @@ const nextConfig: NextConfig = {
hostname: "**.saleor.cloud", hostname: "**.saleor.cloud",
pathname: "/**", pathname: "/**",
}, },
{
protocol: "https",
hostname: "images.unsplash.com",
pathname: "/**",
},
], ],
}, },
experimental: {
optimizePackageImports: ["lucide-react", "framer-motion", "clsx", "motion"],
},
}; };
export default withNextIntl(nextConfig); export default withNextIntl(nextConfig);
+6 -2
View File
@@ -5,6 +5,7 @@ import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales"; import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
import { getPageKeywords } from "@/lib/seo/keywords"; import { getPageKeywords } from "@/lib/seo/keywords";
import { Metadata } from "next"; import { Metadata } from "next";
import Image from "next/image";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com"; const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
@@ -67,10 +68,13 @@ export default async function AboutPage({ params }: AboutPageProps) {
</div> </div>
<div className="relative h-[400px] md:h-[500px] overflow-hidden"> <div className="relative h-[400px] md:h-[500px] overflow-hidden">
<img <Image
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop" src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
alt={metadata.about.productionAlt} alt={metadata.about.productionAlt}
className="w-full h-full object-cover" fill
priority
className="object-cover"
sizes="100vw"
/> />
<div className="absolute inset-0 bg-black/20" /> <div className="absolute inset-0 bg-black/20" />
</div> </div>
+2 -9
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}
+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} />
</>
);
}
+7 -2
View File
@@ -14,6 +14,9 @@ import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales"; import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
import { getPageKeywords, getBrandKeywords } from "@/lib/seo/keywords"; import { getPageKeywords, getBrandKeywords } from "@/lib/seo/keywords";
import { Metadata } from "next"; import { Metadata } from "next";
import Image from "next/image";
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";
@@ -157,10 +160,12 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
</a> </a>
</div> </div>
<div className="relative aspect-[4/3] bg-[#e8f0f5] rounded-lg overflow-hidden"> <div className="relative aspect-[4/3] bg-[#e8f0f5] rounded-lg overflow-hidden">
<img <Image
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=800&auto=format&fit=crop" src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=800&auto=format&fit=crop"
alt={metadata.home.productionAlt} alt={metadata.home.productionAlt}
className="w-full h-full object-cover" fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/> />
</div> </div>
</div> </div>
+2
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 {
-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",
},
});
}
+34 -20
View File
@@ -53,8 +53,7 @@
--color-cta-hover: #333333; --color-cta-hover: #333333;
--color-overlay: rgba(0, 0, 0, 0.4); --color-overlay: rgba(0, 0, 0, 0.4);
--font-display: 'DM Sans', sans-serif; /* Font variables will be set by next/font in layout.tsx */
--font-body: 'Inter', sans-serif;
--transition-fast: 150ms ease; --transition-fast: 150ms ease;
--transition-base: 250ms ease; --transition-base: 250ms ease;
@@ -66,26 +65,9 @@
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1); --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
} }
/* ============================================
FONT IMPORTS
============================================ */
@font-face {
font-family: 'DM Sans';
src: url('https://fonts.gstatic.com/s/dmsans/v15/rP2tp2ywxg089UriI5-g4vlH9VoD8CmcqZG40F9JadbnoEwAopxhS2f3ZGMZpg.woff2') format('woff2');
font-weight: 400 700;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hjp-Ek-_EeA.woff2') format('woff2');
font-weight: 400 700;
font-display: swap;
}
/* ============================================ /* ============================================
BASE STYLES (in Tailwind base layer) BASE STYLES (in Tailwind base layer)
Fonts loaded via next/font in layout.tsx
============================================ */ ============================================ */
@layer base { @layer base {
@@ -266,6 +248,38 @@
} }
} }
/* ============================================
SCROLL INDICATOR ANIMATION
============================================ */
@keyframes scrollBounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(8px); }
}
.scroll-indicator {
animation: scrollBounce 1.5s ease-in-out infinite;
}
/* ============================================
FADE SLIDE UP ANIMATION
============================================ */
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeSlideUp {
animation: fadeSlideUp 0.6s ease-out both;
}
/* ============================================ /* ============================================
UTILITIES UTILITIES
============================================ */ ============================================ */
+14 -1
View File
@@ -1,9 +1,22 @@
import "./globals.css"; import "./globals.css";
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { DM_Sans, Inter } from "next/font/google";
import ErrorBoundary from "@/components/providers/ErrorBoundary"; import ErrorBoundary from "@/components/providers/ErrorBoundary";
import { SUPPORTED_LOCALES } from "@/lib/i18n/locales"; import { SUPPORTED_LOCALES } from "@/lib/i18n/locales";
import { OrganizationSchema } from "@/components/seo"; import { OrganizationSchema } from "@/components/seo";
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-display",
display: "swap",
});
const inter = Inter({
subsets: ["latin"],
variable: "--font-body",
display: "swap",
});
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com"; const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -39,7 +52,7 @@ export default async function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html suppressHydrationWarning> <html suppressHydrationWarning className={`${dmSans.variable} ${inter.variable}`}>
<body className="antialiased" suppressHydrationWarning> <body className="antialiased" suppressHydrationWarning>
<ErrorBoundary> <ErrorBoundary>
{children} {children}
+23 -29
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) => (
<LogoItem key={`second-${index}`} name={logo.name} />
))}
</motion.div>
</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>
); );
} }
+40 -60
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { motion } from "framer-motion";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
@@ -23,30 +23,23 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
return ( return (
<section className="relative min-h-screen w-full overflow-hidden"> <section className="relative min-h-screen w-full overflow-hidden">
{/* Background Image with Overlay */} {/* Background Image with Overlay */}
<div <div className="absolute inset-0">
className="absolute inset-0 bg-cover bg-center bg-no-repeat" <Image
style={{ src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop"
backgroundImage: `url('https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop')`, alt=""
}} fill
> priority
className="object-cover"
sizes="100vw"
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/40 to-black/70" /> <div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/40 to-black/70" />
</div> </div>
{/* Content */} {/* Content - Visible immediately, animations are enhancements */}
<div className="relative z-10 min-h-screen flex flex-col items-center justify-center text-center text-white px-4 py-20"> <div className="relative z-10 min-h-screen flex flex-col items-center justify-center text-center text-white px-4 py-20">
<motion.div <div className="max-w-4xl mx-auto animate-fadeSlideUp">
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.3 }}
className="max-w-4xl mx-auto"
>
{/* Social Proof Micro */} {/* Social Proof Micro */}
<motion.div <div className="flex items-center justify-center gap-2 mb-6 animate-fadeSlideUp" style={{ animationDelay: "0.1s" }}>
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="flex items-center justify-center gap-2 mb-6"
>
<div className="flex"> <div className="flex">
{[1, 2, 3, 4, 5].map((star) => ( {[1, 2, 3, 4, 5].map((star) => (
<svg key={star} className="w-4 h-4 fill-yellow-400 text-yellow-400" viewBox="0 0 24 24"> <svg key={star} className="w-4 h-4 fill-yellow-400 text-yellow-400" viewBox="0 0 24 24">
@@ -57,36 +50,30 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
<span className="text-sm text-white/80"> <span className="text-sm text-white/80">
{t("lovedBy")} {t("lovedBy")}
</span> </span>
</motion.div> </div>
{/* Main Heading - Outcome Focused */} {/* Main Heading */}
<motion.h1 <h1
initial={{ opacity: 0, y: 30 }} className="text-4xl md:text-6xl lg:text-7xl font-medium mb-6 tracking-tight leading-tight animate-fadeSlideUp"
animate={{ opacity: 1, y: 0 }} style={{ animationDelay: "0.2s" }}
transition={{ duration: 0.8, delay: 0.5 }}
className="text-4xl md:text-6xl lg:text-7xl font-medium mb-6 tracking-tight leading-tight"
> >
{t("transformHeadline")} {t("transformHeadline")}
<br /> <br />
<span className="text-white/90">{t("withNaturalOils")}</span> <span className="text-white/90">{t("withNaturalOils")}</span>
</motion.h1> </h1>
{/* Subtitle - Expands on how */} {/* Subtitle */}
<motion.p <p
initial={{ opacity: 0, y: 20 }} className="text-lg md:text-xl text-white/80 mb-8 font-light max-w-2xl mx-auto leading-relaxed animate-fadeSlideUp"
animate={{ opacity: 1, y: 0 }} style={{ animationDelay: "0.3s" }}
transition={{ duration: 0.6, delay: 0.7 }}
className="text-lg md:text-xl text-white/80 mb-8 font-light max-w-2xl mx-auto leading-relaxed"
> >
{t("subtitleText")} {t("subtitleText")}
</motion.p> </p>
{/* CTA Button - Action verb + value */} {/* CTA Buttons */}
<motion.div <div
initial={{ opacity: 0, y: 20 }} className="flex flex-col sm:flex-row items-center justify-center gap-4 animate-fadeSlideUp"
animate={{ opacity: 1, y: 0 }} style={{ animationDelay: "0.4s" }}
transition={{ duration: 0.6, delay: 0.9 }}
className="flex flex-col sm:flex-row items-center justify-center gap-4"
> >
<Link <Link
href={`${localePath}/products`} href={`${localePath}/products`}
@@ -100,14 +87,12 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
> >
{t("learnStory")} {t("learnStory")}
</Link> </Link>
</motion.div> </div>
{/* Trust Indicators */} {/* Trust Indicators */}
<motion.div <div
initial={{ opacity: 0 }} className="flex flex-wrap items-center justify-center gap-6 mt-12 text-sm text-white/60 animate-fadeSlideUp"
animate={{ opacity: 1 }} style={{ animationDelay: "0.5s" }}
transition={{ delay: 1.2, duration: 0.8 }}
className="flex flex-wrap items-center justify-center gap-6 mt-12 text-sm text-white/60"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -127,26 +112,21 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
</svg> </svg>
<span>{t("crueltyFree")}</span> <span>{t("crueltyFree")}</span>
</div> </div>
</motion.div> </div>
</motion.div> </div>
</div> </div>
{/* Scroll Indicator */} {/* Scroll Indicator */}
<motion.button <button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.5, duration: 0.8 }}
onClick={scrollToContent} onClick={scrollToContent}
className="absolute bottom-10 left-1/2 -translate-x-1/2 text-white/60 hover:text-white transition-colors cursor-pointer" className="absolute bottom-10 left-1/2 -translate-x-1/2 text-white/60 hover:text-white transition-colors cursor-pointer opacity-0 animate-fade-in"
style={{ animationDelay: "1.5s", animationFillMode: "forwards" }}
aria-label="Scroll to content" aria-label="Scroll to content"
> >
<motion.div <div className="scroll-indicator">
animate={{ y: [0, 8, 0] }}
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }}
>
<ChevronDown className="w-6 h-6" strokeWidth={1.5} /> <ChevronDown className="w-6 h-6" strokeWidth={1.5} />
</motion.div> </div>
</motion.button> </button>
</section> </section>
); );
} }
+47 -18
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>
); );
} }
+28 -46
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>
</motion.div>
</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>
); );
} }
+6 -4
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"> <Link href={`/${locale}/products/${localized.slug}`} className="group block">
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4"> <div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
{image ? ( {image ? (
<img <Image
src={image} src={image}
alt={localized.name} alt={localized.name}
className="w-full h-full object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105" fill
loading="lazy" className="object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
loading={index < 4 ? "eager" : "lazy"}
/> />
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center text-[#999999]"> <div className="absolute inset-0 flex items-center justify-center text-[#999999]">
@@ -52,7 +54,7 @@ export default function ProductCard({ product, index = 0, locale = "sr" }: Produ
</div> </div>
)} )}
<div className="absolute inset-x-0 bottom-0 p-4 translate-y-full group-hover:translate-y-0 transition-transform duration-300"> <div className="absolute inset-x-0 bottom-0 p-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button <button
className="w-full py-3 bg-black text-white text-xs uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors" className="w-full py-3 bg-black text-white text-xs uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors"
onClick={(e) => { onClick={(e) => {
+14 -11
View File
@@ -245,10 +245,12 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
: "border-transparent hover:border-[#999999]" : "border-transparent hover:border-[#999999]"
}`} }`}
> >
<img <Image
src={image.url} src={image.url}
alt={image.alt || localized.name} alt={image.alt || localized.name}
className="w-full h-full object-cover" fill
className="object-cover"
sizes="100px"
/> />
</button> </button>
))} ))}
@@ -256,10 +258,13 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
)} )}
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden flex-1"> <div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden flex-1">
<img <Image
src={images[selectedImage].url} src={images[selectedImage].url}
alt={images[selectedImage].alt || localized.name} alt={images[selectedImage].alt || localized.name}
className="w-full h-full object-cover" fill
priority
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/> />
{images.length > 1 && ( {images.length > 1 && (
@@ -307,17 +312,15 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
transition={{ duration: 0.6, delay: 0.2 }} transition={{ duration: 0.6, delay: 0.2 }}
className="lg:pl-8" className="lg:pl-8"
> >
<motion.div <div className="min-h-[52px] flex items-center">
<div
className="bg-white/80 backdrop-blur-sm text-[#1a1a1a] py-3 px-4 rounded-lg mb-4 text-sm font-medium text-left w-full"
key={urgencyIndex} key={urgencyIndex}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3 }}
className="bg-white/80 backdrop-blur-sm text-[#1a1a1a] py-3 rounded-lg mb-4 text-sm font-medium text-left"
> >
<span className="mr-2">{urgencyMessages[urgencyIndex].icon}</span> <span className="mr-2">{urgencyMessages[urgencyIndex].icon}</span>
{urgencyMessages[urgencyIndex].text} {urgencyMessages[urgencyIndex].text}
</motion.div> </div>
</div>
<h1 className="text-3xl md:text-4xl font-medium mb-4 tracking-tight"> <h1 className="text-3xl md:text-4xl font-medium mb-4 tracking-tight">
{localized.name} {localized.name}
@@ -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", "description": "Pay via bank transfer",
"comingSoon": "Coming soon" "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", "description": "Platite putem bankovnog transfera",
"comingSoon": "Uskoro dostupno" "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"; "use client";
import { useOpenPanel } from "@openpanel/nextjs";
import { useCallback } from "react"; import { useCallback } from "react";
import { import {
trackRybbitProductView, trackRybbitProductView,
@@ -19,106 +18,6 @@ import {
} from "@/lib/services/RybbitService"; } from "@/lib/services/RybbitService";
export function useAnalytics() { 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: { const trackProductView = useCallback((product: {
id: string; id: string;
name: string; name: string;
@@ -126,15 +25,14 @@ export function useAnalytics() {
currency: string; currency: string;
category?: string; category?: string;
}) => { }) => {
trackDual("product_viewed", { trackRybbitProductView({
product_id: product.id, id: product.id,
product_name: product.name, name: product.name,
price: product.price, price: product.price,
currency: product.currency, currency: product.currency,
category: product.category, category: product.category,
source: "client",
}); });
}, [trackDual]); }, []);
const trackAddToCart = useCallback((product: { const trackAddToCart = useCallback((product: {
id: string; id: string;
@@ -144,42 +42,39 @@ export function useAnalytics() {
quantity: number; quantity: number;
variant?: string; variant?: string;
}) => { }) => {
trackDual("add_to_cart", { trackRybbitAddToCart({
product_id: product.id, id: product.id,
product_name: product.name, name: product.name,
price: product.price, price: product.price,
currency: product.currency, currency: product.currency,
quantity: product.quantity, quantity: product.quantity,
variant: product.variant, variant: product.variant,
source: "client",
}); });
}, [trackDual]); }, []);
const trackRemoveFromCart = useCallback((product: { const trackRemoveFromCart = useCallback((product: {
id: string; id: string;
name: string; name: string;
quantity: number; quantity: number;
}) => { }) => {
trackDual("remove_from_cart", { trackRybbitRemoveFromCart({
product_id: product.id, id: product.id,
product_name: product.name, name: product.name,
quantity: product.quantity, quantity: product.quantity,
source: "client",
}); });
}, [trackDual]); }, []);
const trackCartView = useCallback((cart: { const trackCartView = useCallback((cart: {
total: number; total: number;
currency: string; currency: string;
item_count: number; item_count: number;
}) => { }) => {
trackDual("cart_view", { trackRybbitCartView({
cart_total: cart.total, total: cart.total,
currency: cart.currency, currency: cart.currency,
item_count: cart.item_count, item_count: cart.item_count,
source: "client",
}); });
}, [trackDual]); }, []);
const trackCheckoutStarted = useCallback((cart: { const trackCheckoutStarted = useCallback((cart: {
total: number; total: number;
@@ -192,22 +87,17 @@ export function useAnalytics() {
price: number; price: number;
}>; }>;
}) => { }) => {
trackDual("checkout_started", { trackRybbitCheckoutStarted({
cart_total: cart.total, total: cart.total,
currency: cart.currency, currency: cart.currency,
item_count: cart.item_count, item_count: cart.item_count,
items: cart.items, items: cart.items,
source: "client",
}); });
}, [trackDual]); }, []);
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => { const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
trackDual("checkout_step", { trackRybbitCheckoutStep(step, data);
step, }, []);
...data,
source: "client",
});
}, [trackDual]);
const trackOrderCompleted = useCallback(async (order: { const trackOrderCompleted = useCallback(async (order: {
order_id: string; order_id: string;
@@ -221,8 +111,8 @@ export function useAnalytics() {
}) => { }) => {
console.log("[Analytics] Tracking order:", order.order_number); console.log("[Analytics] Tracking order:", order.order_number);
// Track with both OpenPanel and Rybbit // Rybbit tracking
trackDual("order_completed", { trackRybbitOrderCompleted({
order_id: order.order_id, order_id: order.order_id,
order_number: order.order_number, order_number: order.order_number,
total: order.total, total: order.total,
@@ -231,20 +121,8 @@ export function useAnalytics() {
shipping_cost: order.shipping_cost, shipping_cost: order.shipping_cost,
customer_email: order.customer_email, customer_email: order.customer_email,
payment_method: order.payment_method, 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 // Server-side tracking for reliability
try { try {
const response = await fetch("/api/analytics/track-order", { const response = await fetch("/api/analytics/track-order", {
@@ -268,73 +146,48 @@ export function useAnalytics() {
} catch (e) { } catch (e) {
console.error("[Server Analytics] API call failed:", e); console.error("[Server Analytics] API call failed:", e);
} }
}, [op, trackDual]); }, []);
const trackSearch = useCallback((query: string, results_count: number) => { const trackSearch = useCallback((query: string, results_count: number) => {
trackDual("search", { trackRybbitSearch(query, results_count);
query, }, []);
results_count,
source: "client",
});
}, [trackDual]);
const trackExternalLink = useCallback((url: string, label?: string) => { const trackExternalLink = useCallback((url: string, label?: string) => {
trackDual("external_link_click", { trackRybbitExternalLink(url, label);
url, }, []);
label,
source: "client",
});
}, [trackDual]);
const trackWishlistAdd = useCallback((product: { const trackWishlistAdd = useCallback((product: {
id: string; id: string;
name: string; name: string;
}) => { }) => {
trackDual("wishlist_add", { trackRybbitWishlistAdd({
product_id: product.id, id: product.id,
product_name: product.name, name: product.name,
source: "client",
}); });
}, [trackDual]); }, []);
const trackUserLogin = useCallback((method: string) => { const trackUserLogin = useCallback((method: string) => {
trackDual("user_login", { trackRybbitUserLogin(method);
method, }, []);
source: "client",
});
}, [trackDual]);
const trackUserRegister = useCallback((method: string) => { const trackUserRegister = useCallback((method: string) => {
trackDual("user_register", { trackRybbitUserRegister(method);
method, }, []);
source: "client",
});
}, [trackDual]);
const trackNewsletterSignup = useCallback((email: string, source: string) => { const trackNewsletterSignup = useCallback((email: string, source: string) => {
trackDual("newsletter_signup", { trackRybbitNewsletterSignup(email, source);
email, }, []);
source,
});
}, [trackDual]);
const identifyUser = useCallback((user: { // No-op placeholder for identifyUser (OpenPanel removed)
const identifyUser = useCallback((_user: {
profileId: string; profileId: string;
email?: string; email?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
}) => { }) => {
try { // OpenPanel was removed - this is now a no-op
op.identify({ // User identification is handled by Rybbit automatically via cookies
profileId: user.profileId, }, []);
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
});
} catch (e) {
console.error("[OpenPanel] Identify error:", e);
}
}, [op]);
return { return {
trackProductView, trackProductView,
+79 -5
View File
@@ -11,30 +11,104 @@ declare global {
} }
} }
type QueuedEvent = {
eventName: string;
properties?: Record<string, unknown>;
};
export class RybbitProvider implements AnalyticsProvider { export class RybbitProvider implements AnalyticsProvider {
name = "Rybbit"; name = "Rybbit";
private isClient: boolean; private isClient: boolean;
private eventQueue: QueuedEvent[] = [];
private flushInterval: ReturnType<typeof setInterval> | null = null;
private initialized = false;
constructor() { constructor() {
this.isClient = typeof window !== "undefined"; 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 { isAvailable(): boolean {
return this.isClient && typeof window.rybbit?.event === "function"; return this.isClient && typeof window.rybbit?.event === "function";
} }
private trackEvent(eventName: string, properties?: Record<string, unknown>): void { private sendEvent(eventName: string, properties?: Record<string, unknown>): void {
if (!this.isAvailable()) {
console.warn(`[Rybbit] Not available for event: ${eventName}`);
return;
}
try { try {
window.rybbit!.event(eventName, properties); window.rybbit!.event(eventName, properties);
console.log(`[Rybbit] Event sent: ${eventName}`);
} catch (e) { } catch (e) {
console.warn(`[Rybbit] Tracking error for ${eventName}:`, 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 { track(event: AnalyticsEvent): void {
switch (event.type) { switch (event.type) {
case "product_viewed": case "product_viewed":
+35
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).*)",
],
};