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.
This commit is contained in:
@@ -7,8 +7,6 @@ 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",
|
||||||
@@ -30,10 +28,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: {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,76 +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();
|
|
||||||
|
|
||||||
// Get the real client IP from various headers
|
|
||||||
const clientIp =
|
|
||||||
request.headers.get("cf-connecting-ip") || // Cloudflare
|
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
||||||
request.headers.get("x-real-ip") ||
|
|
||||||
request.ip ||
|
|
||||||
"unknown";
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"openpanel-client-id": process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "",
|
|
||||||
"X-Forwarded-For": clientIp,
|
|
||||||
"X-Real-IP": clientIp,
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,37 +6,57 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
// Get the real client IP from various headers
|
// Get all possible IP sources for debugging
|
||||||
// Cloudflare headers take precedence
|
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 =
|
const clientIp =
|
||||||
request.headers.get("cf-connecting-ip") || // Cloudflare
|
cfConnectingIp || // Cloudflare (most reliable)
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || // First IP in chain
|
xForwardedFor?.split(",")[0]?.trim() || // First IP in chain
|
||||||
request.headers.get("x-real-ip") || // Nginx/Traefik
|
xRealIp || // Nginx/Traefik
|
||||||
request.ip || // Next.js fallback
|
nextJsIp || // Next.js fallback
|
||||||
"unknown";
|
"unknown";
|
||||||
|
|
||||||
const userAgent = request.headers.get("user-agent") || "";
|
const userAgent = request.headers.get("user-agent") || "";
|
||||||
|
|
||||||
// Forward to Rybbit backend with proper headers
|
console.log("[Rybbit Proxy] IP Debug:", {
|
||||||
const response = await fetch(`${RYBBIT_API_URL}/api/track`, {
|
cfConnectingIp,
|
||||||
method: "POST",
|
xForwardedFor,
|
||||||
headers: {
|
xRealIp,
|
||||||
|
nextJsIp,
|
||||||
|
finalIp: clientIp,
|
||||||
|
userAgent: userAgent?.substring(0, 50),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build headers to forward
|
||||||
|
const forwardHeaders: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-Forwarded-For": clientIp,
|
"X-Forwarded-For": clientIp,
|
||||||
"X-Real-IP": clientIp,
|
"X-Real-IP": clientIp,
|
||||||
"User-Agent": userAgent,
|
"User-Agent": userAgent,
|
||||||
// Forward Cloudflare headers if present
|
};
|
||||||
...(request.headers.get("cf-ipcountry") && {
|
|
||||||
"CF-IPCountry": request.headers.get("cf-ipcountry")!,
|
// Forward original CF headers if present
|
||||||
}),
|
const cfCountry = request.headers.get("cf-ipcountry");
|
||||||
...(request.headers.get("cf-ray") && {
|
const cfRay = request.headers.get("cf-ray");
|
||||||
"CF-Ray": 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),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.text();
|
const data = await response.text();
|
||||||
|
console.log("[Rybbit Proxy] Response:", response.status, data.substring(0, 100));
|
||||||
|
|
||||||
return new NextResponse(data, {
|
return new NextResponse(data, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user