Compare commits

...

3 Commits

Author SHA1 Message Date
Unchained
6843d2db36 fix(404): add redirects for broken URLs and custom not-found page
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Add permanent redirects for /products/manoon to /products
- Strip malformed /contact suffix from product URLs
- Create custom branded 404 page with product navigation
- Add NotFound translations for en and sr locales
2026-04-01 10:24:09 +02:00
Unchained
0b9ddeedc8 fix(analytics): properly forward client IPs to Rybbit and OpenPanel
- Create new API route /api/rybbit/track to proxy Rybbit tracking requests
- Extract real client IP from Cloudflare headers (cf-connecting-ip)
- Forward X-Forwarded-For and X-Real-IP headers to analytics backends
- Update OpenPanel proxy to also forward client IP
- Update next.config.ts rewrite to use internal API route

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

This fixes geo-location issues where all traffic appeared to come from
Cloudflare edge locations instead of actual visitor countries.
2026-04-01 07:42:34 +02:00
13 changed files with 326 additions and 308 deletions

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,10 +54,6 @@ 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: {

View File

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

View File

@@ -0,0 +1,68 @@
"use client";
import { useTranslations, useLocale } from "next-intl";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import Link from "next/link";
import { Home, Search, Package } from "lucide-react";
export default function NotFoundPage() {
const t = useTranslations("NotFound");
const locale = useLocale();
const basePath = `/${locale}`;
return (
<>
<Header locale={locale} />
<main className="min-h-screen bg-white">
<div className="pt-[180px] lg:pt-[200px] pb-20 px-4">
<div className="max-w-2xl mx-auto text-center">
{/* 404 Code */}
<div className="text-[120px] lg:text-[180px] font-light text-black/5 leading-none select-none mb-4">
404
</div>
<h1 className="text-2xl lg:text-3xl font-medium mb-4">
{t("title")}
</h1>
<p className="text-[#666666] mb-10 max-w-md mx-auto">
{t("description")}
</p>
{/* Quick Links */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12">
<Link
href={`${basePath}/products`}
className="flex items-center gap-2 px-6 py-3 bg-black text-white text-sm uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors w-full sm:w-auto justify-center"
>
<Package className="w-4 h-4" />
{t("browseProducts")}
</Link>
<Link
href={basePath}
className="flex items-center gap-2 px-6 py-3 border border-black text-black text-sm uppercase tracking-[0.1em] hover:bg-black hover:text-white transition-colors w-full sm:w-auto justify-center"
>
<Home className="w-4 h-4" />
{t("goHome")}
</Link>
</div>
{/* Search Suggestion */}
<div className="p-6 bg-[#f8f8f8] rounded-sm">
<div className="flex items-center gap-3 mb-3 text-[#666666]">
<Search className="w-5 h-5" />
<span className="text-sm font-medium uppercase tracking-[0.1em]">
{t("lookingFor")}
</span>
</div>
<p className="text-sm text-[#666666]">
{t("searchSuggestion")}
</p>
</div>
</div>
</div>
</main>
<Footer locale={locale} />
</>
);
}

View File

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

View File

@@ -1,24 +0,0 @@
import { NextResponse } from "next/server";
const OPENPANEL_SCRIPT_URL = "https://op.nodecrew.me/op1.js";
export async function GET(request: Request) {
const url = new URL(request.url);
const searchParams = url.search;
try {
const response = await fetch(`${OPENPANEL_SCRIPT_URL}${searchParams}`);
const content = await response.text();
return new NextResponse(content, {
status: 200,
headers: {
"Content-Type": "application/javascript",
"Cache-Control": "public, max-age=86400, stale-while-revalidate=86400",
},
});
} catch (error) {
console.error("[OpenPanel] Failed to fetch script:", error);
return new NextResponse("/* OpenPanel script unavailable */", { status: 500 });
}
}

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
const RYBBIT_API_URL = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Get all possible IP sources for debugging
const cfConnectingIp = request.headers.get("cf-connecting-ip");
const xForwardedFor = request.headers.get("x-forwarded-for");
const xRealIp = request.headers.get("x-real-ip");
const nextJsIp = request.ip;
// Use the first available IP in priority order
const clientIp =
cfConnectingIp || // Cloudflare (most reliable)
xForwardedFor?.split(",")[0]?.trim() || // First IP in chain
xRealIp || // Nginx/Traefik
nextJsIp || // Next.js fallback
"unknown";
const userAgent = request.headers.get("user-agent") || "";
console.log("[Rybbit Proxy] IP Debug:", {
cfConnectingIp,
xForwardedFor,
xRealIp,
nextJsIp,
finalIp: clientIp,
userAgent: userAgent?.substring(0, 50),
});
// Build headers to forward
const forwardHeaders: Record<string, string> = {
"Content-Type": "application/json",
"X-Forwarded-For": clientIp,
"X-Real-IP": clientIp,
"User-Agent": userAgent,
};
// Forward original CF headers if present
const cfCountry = request.headers.get("cf-ipcountry");
const cfRay = request.headers.get("cf-ray");
if (cfCountry) forwardHeaders["CF-IPCountry"] = cfCountry;
if (cfRay) forwardHeaders["CF-Ray"] = cfRay;
console.log("[Rybbit Proxy] Forwarding to Rybbit with headers:", Object.keys(forwardHeaders));
const response = await fetch(`${RYBBIT_API_URL}/api/track`, {
method: "POST",
headers: forwardHeaders,
body: JSON.stringify(body),
});
const data = await response.text();
console.log("[Rybbit Proxy] Response:", response.status, data.substring(0, 100));
return new NextResponse(data, {
status: response.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
console.error("[Rybbit Proxy] Error:", error);
return new NextResponse(
JSON.stringify({ error: "Proxy error" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
}
// Handle CORS preflight
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}

View File

@@ -38,6 +38,7 @@ export default function ProductCard({ product, index = 0, locale = "sr" }: Produ
fill fill
className="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"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" 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]">

View File

@@ -1,24 +1,13 @@
"use client"; "use client";
import dynamic from "next/dynamic"; // AnalyticsProvider - placeholder for future analytics integrations
// Currently only Rybbit is used via the script tag in layout.tsx
const OpenPanelComponent = dynamic(
() => import("@openpanel/nextjs").then((mod) => mod.OpenPanelComponent),
{ ssr: false }
);
interface AnalyticsProviderProps { interface AnalyticsProviderProps {
clientId: string; clientId?: string;
} }
export default function AnalyticsProvider({ clientId }: AnalyticsProviderProps) { export default function AnalyticsProvider({ clientId }: AnalyticsProviderProps) {
return ( // No-op component - Rybbit is loaded via next/script in layout.tsx
<OpenPanelComponent return null;
clientId={clientId}
trackScreenViews={true}
trackOutgoingLinks={true}
apiUrl="/api/op"
scriptUrl="/api/op1"
/>
);
} }

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

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

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,

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

View File

@@ -32,4 +32,4 @@ export const config = {
matcher: [ matcher: [
"/((?!_next/static|_next/image|favicon.ico|icon.png|robots.txt|sitemap.xml).*)", "/((?!_next/static|_next/image|favicon.ico|icon.png|robots.txt|sitemap.xml).*)",
], ],
}; };