- 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.
234 lines
6.4 KiB
TypeScript
234 lines
6.4 KiB
TypeScript
"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;
|
|
};
|
|
}
|
|
}
|
|
|
|
type QueuedEvent = {
|
|
eventName: string;
|
|
properties?: Record<string, unknown>;
|
|
};
|
|
|
|
export class RybbitProvider implements AnalyticsProvider {
|
|
name = "Rybbit";
|
|
private isClient: boolean;
|
|
private eventQueue: QueuedEvent[] = [];
|
|
private flushInterval: ReturnType<typeof setInterval> | null = null;
|
|
private initialized = false;
|
|
|
|
constructor() {
|
|
this.isClient = typeof window !== "undefined";
|
|
|
|
if (this.isClient) {
|
|
console.log("[RybbitProvider] Constructor called");
|
|
// Start checking for rybbit availability
|
|
this.startFlushInterval();
|
|
|
|
// Also try to flush immediately in case script is already loaded
|
|
setTimeout(() => this.tryFlushQueue(), 100);
|
|
}
|
|
}
|
|
|
|
private startFlushInterval() {
|
|
// Check every 500ms for up to 15 seconds
|
|
let attempts = 0;
|
|
const maxAttempts = 30;
|
|
|
|
this.flushInterval = setInterval(() => {
|
|
attempts++;
|
|
const available = this.isAvailable();
|
|
|
|
if (available && !this.initialized) {
|
|
console.log("[RybbitProvider] Script became available, flushing queue");
|
|
this.initialized = true;
|
|
}
|
|
|
|
this.tryFlushQueue();
|
|
|
|
if (available || attempts >= maxAttempts) {
|
|
this.stopFlushInterval();
|
|
if (attempts >= maxAttempts && !available) {
|
|
console.warn("[RybbitProvider] Max attempts reached, script not loaded. Queue size:", this.eventQueue.length);
|
|
}
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
private stopFlushInterval() {
|
|
if (this.flushInterval) {
|
|
clearInterval(this.flushInterval);
|
|
this.flushInterval = null;
|
|
}
|
|
}
|
|
|
|
private tryFlushQueue() {
|
|
if (!this.isAvailable() || this.eventQueue.length === 0) {
|
|
return;
|
|
}
|
|
|
|
console.log(`[RybbitProvider] Flushing ${this.eventQueue.length} queued events`);
|
|
|
|
// Flush all queued events
|
|
while (this.eventQueue.length > 0) {
|
|
const event = this.eventQueue.shift();
|
|
if (event) {
|
|
this.sendEvent(event.eventName, event.properties);
|
|
}
|
|
}
|
|
}
|
|
|
|
isAvailable(): boolean {
|
|
return this.isClient && typeof window.rybbit?.event === "function";
|
|
}
|
|
|
|
private sendEvent(eventName: string, properties?: Record<string, unknown>): void {
|
|
try {
|
|
window.rybbit!.event(eventName, properties);
|
|
console.log(`[Rybbit] Event sent: ${eventName}`);
|
|
} catch (e) {
|
|
console.warn(`[Rybbit] Tracking error for ${eventName}:`, e);
|
|
}
|
|
}
|
|
|
|
private trackEvent(eventName: string, properties?: Record<string, unknown>): void {
|
|
if (!this.isClient) return;
|
|
|
|
if (this.isAvailable()) {
|
|
this.sendEvent(eventName, properties);
|
|
} else {
|
|
// Queue the event for later
|
|
this.eventQueue.push({ eventName, properties });
|
|
console.log(`[Rybbit] Queued event: ${eventName}, queue size: ${this.eventQueue.length}`);
|
|
}
|
|
}
|
|
|
|
track(event: AnalyticsEvent): void {
|
|
switch (event.type) {
|
|
case "product_viewed":
|
|
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();
|
|
}
|
|
}
|