Compare commits
7 Commits
feature/we
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66829aeffd | ||
|
|
bce2d19ca3 | ||
|
|
cee3b71454 | ||
|
|
ff629691a5 | ||
|
|
1cdda7db3c | ||
|
|
1dd7e1dfe7 | ||
|
|
054889a44e |
@@ -130,6 +130,8 @@ spec:
|
|||||||
value: "https://rybbit.nodecrew.me"
|
value: "https://rybbit.nodecrew.me"
|
||||||
- name: NEXT_PUBLIC_RYBBIT_SITE_ID
|
- name: NEXT_PUBLIC_RYBBIT_SITE_ID
|
||||||
value: "1"
|
value: "1"
|
||||||
|
- name: RYBBIT_API_KEY
|
||||||
|
value: "rb_NgFoMtHeohWoJULLiKqSEJmdghSrhJajgseSWQLjfxyeUJcFfQvUrfYwdllSTsLx"
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpu: 500m
|
cpu: 500m
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const nextConfig: NextConfig = {
|
|||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
const rybbitHost = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
|
const rybbitHost = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
|
||||||
|
const openpanelUrl = process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api";
|
||||||
|
const openpanelScriptUrl = "https://op.nodecrew.me";
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/api/script.js",
|
source: "/api/script.js",
|
||||||
@@ -28,12 +30,13 @@ 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",
|
||||||
@@ -55,16 +58,8 @@ const nextConfig: NextConfig = {
|
|||||||
hostname: "**.saleor.cloud",
|
hostname: "**.saleor.cloud",
|
||||||
pathname: "/**",
|
pathname: "/**",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "images.unsplash.com",
|
|
||||||
pathname: "/**",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
experimental: {
|
|
||||||
optimizePackageImports: ["lucide-react", "framer-motion"],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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";
|
||||||
|
|
||||||
@@ -68,13 +67,10 @@ 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">
|
||||||
<Image
|
<img
|
||||||
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}
|
||||||
fill
|
className="w-full h-full object-cover"
|
||||||
priority
|
|
||||||
className="object-cover"
|
|
||||||
sizes="100vw"
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/20" />
|
<div className="absolute inset-0 bg-black/20" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default async function LocaleLayout({
|
|||||||
<Script
|
<Script
|
||||||
src="/api/script.js"
|
src="/api/script.js"
|
||||||
data-site-id={RYBBIT_SITE_ID}
|
data-site-id={RYBBIT_SITE_ID}
|
||||||
strategy="lazyOnload"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
|||||||
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
|
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
|
||||||
import { getPageKeywords, getBrandKeywords } from "@/lib/seo/keywords";
|
import { getPageKeywords, getBrandKeywords } from "@/lib/seo/keywords";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
|
||||||
|
|
||||||
@@ -158,12 +157,10 @@ 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">
|
||||||
<Image
|
<img
|
||||||
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}
|
||||||
fill
|
className="w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
sizes="(max-width: 768px) 100vw, 50vw"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
const OPENPANEL_API_URL = "https://op.nodecrew.me/api";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const response = await fetch(`${OPENPANEL_API_URL}/track`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return NextResponse.json(data, { status: response.status });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[OpenPanel] Track error:", error);
|
|
||||||
return NextResponse.json({ error: "Failed to track event" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,4 +21,4 @@ export async function GET(request: Request) {
|
|||||||
console.error("[OpenPanel] Failed to fetch script:", error);
|
console.error("[OpenPanel] Failed to fetch script:", error);
|
||||||
return new NextResponse("/* OpenPanel script unavailable */", { status: 500 });
|
return new NextResponse("/* OpenPanel script unavailable */", { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,8 @@
|
|||||||
--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 variables will be set by next/font in layout.tsx */
|
--font-display: 'DM Sans', sans-serif;
|
||||||
|
--font-body: 'Inter', sans-serif;
|
||||||
|
|
||||||
--transition-fast: 150ms ease;
|
--transition-fast: 150ms ease;
|
||||||
--transition-base: 250ms ease;
|
--transition-base: 250ms ease;
|
||||||
@@ -65,9 +66,26 @@
|
|||||||
--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 {
|
||||||
@@ -248,38 +266,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
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
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
@@ -1,22 +1,9 @@
|
|||||||
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 = {
|
||||||
@@ -52,7 +39,7 @@ export default async function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html suppressHydrationWarning className={`${dmSans.variable} ${inter.variable}`}>
|
<html suppressHydrationWarning>
|
||||||
<body className="antialiased" suppressHydrationWarning>
|
<body className="antialiased" suppressHydrationWarning>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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,23 +23,30 @@ 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 className="absolute inset-0">
|
<div
|
||||||
<Image
|
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||||
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop"
|
style={{
|
||||||
alt=""
|
backgroundImage: `url('https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop')`,
|
||||||
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 - Visible immediately, animations are enhancements */}
|
{/* Content */}
|
||||||
<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">
|
||||||
<div className="max-w-4xl mx-auto animate-fadeSlideUp">
|
<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"
|
||||||
|
>
|
||||||
{/* Social Proof Micro */}
|
{/* Social Proof Micro */}
|
||||||
<div className="flex items-center justify-center gap-2 mb-6 animate-fadeSlideUp" style={{ animationDelay: "0.1s" }}>
|
<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">
|
<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">
|
||||||
@@ -50,30 +57,36 @@ 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>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Main Heading */}
|
{/* Main Heading - Outcome Focused */}
|
||||||
<h1
|
<motion.h1
|
||||||
className="text-4xl md:text-6xl lg:text-7xl font-medium mb-6 tracking-tight leading-tight animate-fadeSlideUp"
|
initial={{ opacity: 0, y: 30 }}
|
||||||
style={{ animationDelay: "0.2s" }}
|
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"
|
||||||
>
|
>
|
||||||
{t("transformHeadline")}
|
{t("transformHeadline")}
|
||||||
<br />
|
<br />
|
||||||
<span className="text-white/90">{t("withNaturalOils")}</span>
|
<span className="text-white/90">{t("withNaturalOils")}</span>
|
||||||
</h1>
|
</motion.h1>
|
||||||
|
|
||||||
{/* Subtitle */}
|
{/* Subtitle - Expands on how */}
|
||||||
<p
|
<motion.p
|
||||||
className="text-lg md:text-xl text-white/80 mb-8 font-light max-w-2xl mx-auto leading-relaxed animate-fadeSlideUp"
|
initial={{ opacity: 0, y: 20 }}
|
||||||
style={{ animationDelay: "0.3s" }}
|
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"
|
||||||
>
|
>
|
||||||
{t("subtitleText")}
|
{t("subtitleText")}
|
||||||
</p>
|
</motion.p>
|
||||||
|
|
||||||
{/* CTA Buttons */}
|
{/* CTA Button - Action verb + value */}
|
||||||
<div
|
<motion.div
|
||||||
className="flex flex-col sm:flex-row items-center justify-center gap-4 animate-fadeSlideUp"
|
initial={{ opacity: 0, y: 20 }}
|
||||||
style={{ animationDelay: "0.4s" }}
|
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"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`${localePath}/products`}
|
href={`${localePath}/products`}
|
||||||
@@ -87,12 +100,14 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
|
|||||||
>
|
>
|
||||||
{t("learnStory")}
|
{t("learnStory")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Trust Indicators */}
|
{/* Trust Indicators */}
|
||||||
<div
|
<motion.div
|
||||||
className="flex flex-wrap items-center justify-center gap-6 mt-12 text-sm text-white/60 animate-fadeSlideUp"
|
initial={{ opacity: 0 }}
|
||||||
style={{ animationDelay: "0.5s" }}
|
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 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">
|
||||||
@@ -112,21 +127,26 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{t("crueltyFree")}</span>
|
<span>{t("crueltyFree")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll Indicator */}
|
{/* Scroll Indicator */}
|
||||||
<button
|
<motion.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 opacity-0 animate-fade-in"
|
className="absolute bottom-10 left-1/2 -translate-x-1/2 text-white/60 hover:text-white transition-colors cursor-pointer"
|
||||||
style={{ animationDelay: "1.5s", animationFillMode: "forwards" }}
|
|
||||||
aria-label="Scroll to content"
|
aria-label="Scroll to content"
|
||||||
>
|
>
|
||||||
<div className="scroll-indicator">
|
<motion.div
|
||||||
|
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} />
|
||||||
</div>
|
</motion.div>
|
||||||
</button>
|
</motion.button>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,11 @@ 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 ? (
|
||||||
<Image
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
alt={localized.name}
|
alt={localized.name}
|
||||||
fill
|
className="w-full h-full object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
|
||||||
className="object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
|
loading="lazy"
|
||||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-[#999999]">
|
<div className="absolute inset-0 flex items-center justify-center text-[#999999]">
|
||||||
@@ -53,7 +52,7 @@ export default function ProductCard({ product, index = 0, locale = "sr" }: Produ
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="absolute inset-x-0 bottom-0 p-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
<div className="absolute inset-x-0 bottom-0 p-4 translate-y-full group-hover:translate-y-0 transition-transform 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) => {
|
||||||
|
|||||||
@@ -245,12 +245,10 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
|||||||
: "border-transparent hover:border-[#999999]"
|
: "border-transparent hover:border-[#999999]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Image
|
<img
|
||||||
src={image.url}
|
src={image.url}
|
||||||
alt={image.alt || localized.name}
|
alt={image.alt || localized.name}
|
||||||
fill
|
className="w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
sizes="100px"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -258,13 +256,10 @@ 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">
|
||||||
<Image
|
<img
|
||||||
src={images[selectedImage].url}
|
src={images[selectedImage].url}
|
||||||
alt={images[selectedImage].alt || localized.name}
|
alt={images[selectedImage].alt || localized.name}
|
||||||
fill
|
className="w-full h-full object-cover"
|
||||||
priority
|
|
||||||
className="object-cover"
|
|
||||||
sizes="(max-width: 768px) 100vw, 50vw"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
@@ -312,15 +307,17 @@ 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"
|
||||||
>
|
>
|
||||||
<div className="min-h-[52px] flex items-center">
|
<motion.div
|
||||||
<div
|
key={urgencyIndex}
|
||||||
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"
|
initial={{ opacity: 0, y: -10 }}
|
||||||
key={urgencyIndex}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
>
|
exit={{ opacity: 0, y: 10 }}
|
||||||
<span className="mr-2">{urgencyMessages[urgencyIndex].icon}</span>
|
transition={{ duration: 0.3 }}
|
||||||
{urgencyMessages[urgencyIndex].text}
|
className="bg-white/80 backdrop-blur-sm text-[#1a1a1a] py-3 rounded-lg mb-4 text-sm font-medium text-left"
|
||||||
</div>
|
>
|
||||||
</div>
|
<span className="mr-2">{urgencyMessages[urgencyIndex].icon}</span>
|
||||||
|
{urgencyMessages[urgencyIndex].text}
|
||||||
|
</motion.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}
|
||||||
|
|||||||
112
src/lib/analytics/core/AnalyticsTracker.ts
Normal file
112
src/lib/analytics/core/AnalyticsTracker.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { AnalyticsEvent, AnalyticsProvider, UserData } from "./types";
|
||||||
|
|
||||||
|
export class AnalyticsTracker {
|
||||||
|
private providers: AnalyticsProvider[] = [];
|
||||||
|
|
||||||
|
addProvider(provider: AnalyticsProvider): void {
|
||||||
|
this.providers.push(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
track(event: AnalyticsEvent): void {
|
||||||
|
for (const provider of this.providers) {
|
||||||
|
try {
|
||||||
|
provider.track(event);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Analytics] ${provider.name} tracking error:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identify(user: UserData): void {
|
||||||
|
for (const provider of this.providers) {
|
||||||
|
if (provider.identify) {
|
||||||
|
try {
|
||||||
|
provider.identify(user);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Analytics] ${provider.name} identify error:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async revenue(amount: number, currency: string, properties?: Record<string, unknown>): Promise<void> {
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
for (const provider of this.providers) {
|
||||||
|
if (provider.revenue) {
|
||||||
|
promises.push(
|
||||||
|
provider.revenue(amount, currency, properties).catch((e) => {
|
||||||
|
console.error(`[Analytics] ${provider.name} revenue error:`, e);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
productViewed(product: { id: string; name: string; price: number; currency: string; category?: string; variant?: string }) {
|
||||||
|
this.track({ type: "product_viewed", product });
|
||||||
|
}
|
||||||
|
|
||||||
|
addToCart(product: { id: string; name: string; price: number; currency: string; quantity: number; variant?: string }) {
|
||||||
|
this.track({ type: "add_to_cart", product });
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFromCart(product: { id: string; name: string; quantity: number }) {
|
||||||
|
this.track({ type: "remove_from_cart", product });
|
||||||
|
}
|
||||||
|
|
||||||
|
cartViewed(cart: { total: number; currency: string; item_count: number }) {
|
||||||
|
this.track({ type: "cart_view", cart });
|
||||||
|
}
|
||||||
|
|
||||||
|
checkoutStarted(cart: { total: number; currency: string; item_count: number; items?: Array<{ id: string; name: string; quantity: number; price: number }> }) {
|
||||||
|
this.track({ type: "checkout_started", cart });
|
||||||
|
}
|
||||||
|
|
||||||
|
checkoutStep(step: string, data?: Record<string, unknown>) {
|
||||||
|
this.track({ type: "checkout_step", step, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
orderCompleted(order: { order_id: string; order_number: string; total: number; currency: string; item_count: number; shipping_cost?: number; coupon_code?: string; customer_email?: string; payment_method?: string }) {
|
||||||
|
this.track({ type: "order_completed", order });
|
||||||
|
this.revenue(order.total, order.currency, {
|
||||||
|
transaction_id: order.order_number,
|
||||||
|
order_id: order.order_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
searchPerformed(query: string, results_count: number) {
|
||||||
|
this.track({ type: "search", query, results_count });
|
||||||
|
}
|
||||||
|
|
||||||
|
externalLinkClicked(url: string, label?: string) {
|
||||||
|
this.track({ type: "external_link_click", url, label });
|
||||||
|
}
|
||||||
|
|
||||||
|
wishlistAdded(product: { id: string; name: string }) {
|
||||||
|
this.track({ type: "wishlist_add", product });
|
||||||
|
}
|
||||||
|
|
||||||
|
userLoggedIn(method: string) {
|
||||||
|
this.track({ type: "user_login", method });
|
||||||
|
}
|
||||||
|
|
||||||
|
userRegistered(method: string) {
|
||||||
|
this.track({ type: "user_register", method });
|
||||||
|
}
|
||||||
|
|
||||||
|
newsletterSignedUp(email: string, source: string) {
|
||||||
|
this.track({ type: "newsletter_signup", email, source });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let trackerInstance: AnalyticsTracker | null = null;
|
||||||
|
|
||||||
|
export function getTracker(): AnalyticsTracker {
|
||||||
|
if (!trackerInstance) {
|
||||||
|
trackerInstance = new AnalyticsTracker();
|
||||||
|
}
|
||||||
|
return trackerInstance;
|
||||||
|
}
|
||||||
62
src/lib/analytics/core/types.ts
Normal file
62
src/lib/analytics/core/types.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export interface ProductData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
category?: string;
|
||||||
|
variant?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartData {
|
||||||
|
total: number;
|
||||||
|
currency: string;
|
||||||
|
item_count: number;
|
||||||
|
items?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderData {
|
||||||
|
order_id: string;
|
||||||
|
order_number: string;
|
||||||
|
total: number;
|
||||||
|
currency: string;
|
||||||
|
item_count: number;
|
||||||
|
shipping_cost?: number;
|
||||||
|
coupon_code?: string;
|
||||||
|
customer_email?: string;
|
||||||
|
payment_method?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserData {
|
||||||
|
profileId: string;
|
||||||
|
email?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnalyticsEvent =
|
||||||
|
| { type: "product_viewed"; product: ProductData }
|
||||||
|
| { type: "add_to_cart"; product: ProductData & { quantity: number } }
|
||||||
|
| { type: "remove_from_cart"; product: { id: string; name: string; quantity: number } }
|
||||||
|
| { type: "cart_view"; cart: CartData }
|
||||||
|
| { type: "checkout_started"; cart: CartData }
|
||||||
|
| { type: "checkout_step"; step: string; data?: Record<string, unknown> }
|
||||||
|
| { type: "order_completed"; order: OrderData }
|
||||||
|
| { type: "search"; query: string; results_count: number }
|
||||||
|
| { type: "external_link_click"; url: string; label?: string }
|
||||||
|
| { type: "wishlist_add"; product: { id: string; name: string } }
|
||||||
|
| { type: "user_login"; method: string }
|
||||||
|
| { type: "user_register"; method: string }
|
||||||
|
| { type: "newsletter_signup"; email: string; source: string };
|
||||||
|
|
||||||
|
export interface AnalyticsProvider {
|
||||||
|
name: string;
|
||||||
|
track(event: AnalyticsEvent): void;
|
||||||
|
identify?(user: UserData): void;
|
||||||
|
revenue?(amount: number, currency: string, properties?: Record<string, unknown>): Promise<void>;
|
||||||
|
isAvailable(): boolean;
|
||||||
|
}
|
||||||
28
src/lib/analytics/hooks/useAnalytics.ts
Normal file
28
src/lib/analytics/hooks/useAnalytics.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useOpenPanel } from "@openpanel/nextjs";
|
||||||
|
import { getTracker, AnalyticsTracker } from "../core/AnalyticsTracker";
|
||||||
|
import { OpenPanelProvider } from "../providers/OpenPanelProvider";
|
||||||
|
import { RybbitProvider } from "../providers/RybbitProvider";
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
export function useAnalytics(): AnalyticsTracker {
|
||||||
|
const op = useOpenPanel();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const trackerRef = useRef<AnalyticsTracker | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialized) {
|
||||||
|
const tracker = getTracker();
|
||||||
|
tracker.addProvider(new OpenPanelProvider(op));
|
||||||
|
tracker.addProvider(new RybbitProvider());
|
||||||
|
trackerRef.current = tracker;
|
||||||
|
initialized = true;
|
||||||
|
setIsReady(true);
|
||||||
|
}
|
||||||
|
}, [op]);
|
||||||
|
|
||||||
|
return trackerRef.current || getTracker();
|
||||||
|
}
|
||||||
5
src/lib/analytics/index.ts
Normal file
5
src/lib/analytics/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { AnalyticsTracker, getTracker } from "./core/AnalyticsTracker";
|
||||||
|
export type { AnalyticsEvent, AnalyticsProvider, ProductData, CartData, OrderData, UserData } from "./core/types";
|
||||||
|
export { OpenPanelProvider } from "./providers/OpenPanelProvider";
|
||||||
|
export { RybbitProvider } from "./providers/RybbitProvider";
|
||||||
|
export { useAnalytics } from "./hooks/useAnalytics";
|
||||||
146
src/lib/analytics/providers/OpenPanelProvider.ts
Normal file
146
src/lib/analytics/providers/OpenPanelProvider.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { AnalyticsEvent, AnalyticsProvider, UserData } from "../core/types";
|
||||||
|
|
||||||
|
export class OpenPanelProvider implements AnalyticsProvider {
|
||||||
|
name = "OpenPanel";
|
||||||
|
private op: ReturnType<typeof import("@openpanel/nextjs").useOpenPanel>;
|
||||||
|
private isClient: boolean;
|
||||||
|
|
||||||
|
constructor(op: ReturnType<typeof import("@openpanel/nextjs").useOpenPanel>) {
|
||||||
|
this.op = op;
|
||||||
|
this.isClient = typeof window !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
isAvailable(): boolean {
|
||||||
|
return this.isClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
track(event: AnalyticsEvent): void {
|
||||||
|
if (!this.isAvailable()) return;
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case "product_viewed":
|
||||||
|
this.op.track("product_viewed", {
|
||||||
|
product_id: event.product.id,
|
||||||
|
product_name: event.product.name,
|
||||||
|
price: event.product.price,
|
||||||
|
currency: event.product.currency,
|
||||||
|
category: event.product.category,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "add_to_cart":
|
||||||
|
this.op.track("add_to_cart", {
|
||||||
|
product_id: event.product.id,
|
||||||
|
product_name: event.product.name,
|
||||||
|
price: event.product.price,
|
||||||
|
currency: event.product.currency,
|
||||||
|
quantity: event.product.quantity,
|
||||||
|
variant: event.product.variant,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "remove_from_cart":
|
||||||
|
this.op.track("remove_from_cart", {
|
||||||
|
product_id: event.product.id,
|
||||||
|
product_name: event.product.name,
|
||||||
|
quantity: event.product.quantity,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "cart_view":
|
||||||
|
this.op.track("cart_view", {
|
||||||
|
cart_total: event.cart.total,
|
||||||
|
currency: event.cart.currency,
|
||||||
|
item_count: event.cart.item_count,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "checkout_started":
|
||||||
|
this.op.track("checkout_started", {
|
||||||
|
cart_total: event.cart.total,
|
||||||
|
currency: event.cart.currency,
|
||||||
|
item_count: event.cart.item_count,
|
||||||
|
items: event.cart.items,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "checkout_step":
|
||||||
|
this.op.track("checkout_step", {
|
||||||
|
step: event.step,
|
||||||
|
...event.data,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "order_completed":
|
||||||
|
this.op.track("order_completed", {
|
||||||
|
order_id: event.order.order_id,
|
||||||
|
order_number: event.order.order_number,
|
||||||
|
total: event.order.total,
|
||||||
|
currency: event.order.currency,
|
||||||
|
item_count: event.order.item_count,
|
||||||
|
shipping_cost: event.order.shipping_cost,
|
||||||
|
coupon_code: event.order.coupon_code,
|
||||||
|
customer_email: event.order.customer_email,
|
||||||
|
payment_method: event.order.payment_method,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "search":
|
||||||
|
this.op.track("search", {
|
||||||
|
query: event.query,
|
||||||
|
results_count: event.results_count,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "external_link_click":
|
||||||
|
this.op.track("external_link_click", {
|
||||||
|
url: event.url,
|
||||||
|
label: event.label,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "wishlist_add":
|
||||||
|
this.op.track("wishlist_add", {
|
||||||
|
product_id: event.product.id,
|
||||||
|
product_name: event.product.name,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "user_login":
|
||||||
|
this.op.track("user_login", {
|
||||||
|
method: event.method,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "user_register":
|
||||||
|
this.op.track("user_register", {
|
||||||
|
method: event.method,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "newsletter_signup":
|
||||||
|
this.op.track("newsletter_signup", {
|
||||||
|
email: event.email,
|
||||||
|
source: event.source,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identify(user: UserData): void {
|
||||||
|
if (!this.isAvailable()) return;
|
||||||
|
this.op.identify({
|
||||||
|
profileId: user.profileId,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async revenue(amount: number, currency: string, properties?: Record<string, unknown>): Promise<void> {
|
||||||
|
if (!this.isAvailable()) return;
|
||||||
|
await this.op.revenue(amount, { currency, ...properties });
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/lib/analytics/providers/RybbitProvider.ts
Normal file
159
src/lib/analytics/providers/RybbitProvider.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { AnalyticsEvent, AnalyticsProvider, UserData } from "../core/types";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
rybbit?: {
|
||||||
|
event: (eventName: string, eventData?: Record<string, any>) => void;
|
||||||
|
pageview: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RybbitProvider implements AnalyticsProvider {
|
||||||
|
name = "Rybbit";
|
||||||
|
private isClient: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.isClient = typeof window !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
isAvailable(): boolean {
|
||||||
|
return this.isClient && typeof window.rybbit?.event === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackEvent(eventName: string, properties?: Record<string, unknown>): void {
|
||||||
|
if (!this.isAvailable()) {
|
||||||
|
console.warn(`[Rybbit] Not available for event: ${eventName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.rybbit!.event(eventName, properties);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Rybbit] Tracking error for ${eventName}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
track(event: AnalyticsEvent): void {
|
||||||
|
switch (event.type) {
|
||||||
|
case "product_viewed":
|
||||||
|
this.trackEvent("product_view", {
|
||||||
|
product_id: event.product.id,
|
||||||
|
product_name: event.product.name,
|
||||||
|
price: event.product.price,
|
||||||
|
currency: event.product.currency,
|
||||||
|
category: event.product.category,
|
||||||
|
variant: event.product.variant,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "add_to_cart":
|
||||||
|
this.trackEvent("add_to_cart", {
|
||||||
|
product_id: event.product.id,
|
||||||
|
product_name: event.product.name,
|
||||||
|
price: event.product.price,
|
||||||
|
currency: event.product.currency,
|
||||||
|
quantity: event.product.quantity,
|
||||||
|
variant: event.product.variant,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "remove_from_cart":
|
||||||
|
this.trackEvent("remove_from_cart", {
|
||||||
|
product_id: event.product.id,
|
||||||
|
product_name: event.product.name,
|
||||||
|
quantity: event.product.quantity,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "cart_view":
|
||||||
|
this.trackEvent("cart_view", {
|
||||||
|
cart_total: event.cart.total,
|
||||||
|
currency: event.cart.currency,
|
||||||
|
item_count: event.cart.item_count,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "checkout_started":
|
||||||
|
this.trackEvent("checkout_started", {
|
||||||
|
cart_total: event.cart.total,
|
||||||
|
currency: event.cart.currency,
|
||||||
|
item_count: event.cart.item_count,
|
||||||
|
items: event.cart.items,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "checkout_step":
|
||||||
|
this.trackEvent("checkout_step", {
|
||||||
|
step: event.step,
|
||||||
|
...event.data,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "order_completed":
|
||||||
|
this.trackEvent("order_completed", {
|
||||||
|
order_id: event.order.order_id,
|
||||||
|
order_number: event.order.order_number,
|
||||||
|
total: event.order.total,
|
||||||
|
currency: event.order.currency,
|
||||||
|
item_count: event.order.item_count,
|
||||||
|
shipping_cost: event.order.shipping_cost,
|
||||||
|
coupon_code: event.order.coupon_code,
|
||||||
|
customer_email: event.order.customer_email,
|
||||||
|
payment_method: event.order.payment_method,
|
||||||
|
revenue: event.order.total,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "search":
|
||||||
|
this.trackEvent("search", {
|
||||||
|
query: event.query,
|
||||||
|
results_count: event.results_count,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "external_link_click":
|
||||||
|
this.trackEvent("external_link_click", {
|
||||||
|
url: event.url,
|
||||||
|
label: event.label,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "wishlist_add":
|
||||||
|
this.trackEvent("wishlist_add", {
|
||||||
|
product_id: event.product.id,
|
||||||
|
product_name: event.product.name,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "user_login":
|
||||||
|
this.trackEvent("user_login", {
|
||||||
|
method: event.method,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "user_register":
|
||||||
|
this.trackEvent("user_register", {
|
||||||
|
method: event.method,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "newsletter_signup":
|
||||||
|
this.trackEvent("newsletter_signup", {
|
||||||
|
email: event.email,
|
||||||
|
source: event.source,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identify(_user: UserData): void {
|
||||||
|
// Rybbit doesn't have explicit identify - it's handled automatically via cookies
|
||||||
|
}
|
||||||
|
|
||||||
|
revenue?(_amount: number, _currency: string, _properties?: Record<string, unknown>): Promise<void> {
|
||||||
|
// Revenue is tracked via order_completed event
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user