feat: integrate Rybbit analytics alongside OpenPanel
Some checks failed
Build and Deploy / build (push) Has been cancelled
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Add RybbitService for tracking e-commerce events - Update useAnalytics hook to track with both OpenPanel and Rybbit - Add Rybbit script to layout for page view tracking - Track all applicable store events: product views, cart, checkout, orders, search, etc.
This commit is contained in:
@@ -3,6 +3,8 @@ import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
|
||||
import { OpenPanelComponent } from "@openpanel/nextjs";
|
||||
import Script from "next/script";
|
||||
import { RYBBIT_HOST, RYBBIT_SITE_ID } from "@/lib/services/RybbitService";
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||
|
||||
@@ -46,13 +48,18 @@ export default async function LocaleLayout({
|
||||
|
||||
return (
|
||||
<>
|
||||
<OpenPanelComponent
|
||||
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
|
||||
trackScreenViews={true}
|
||||
trackOutgoingLinks={true}
|
||||
apiUrl="https://op.nodecrew.me/api"
|
||||
scriptUrl="https://op.nodecrew.me/op1.js"
|
||||
/>
|
||||
<OpenPanelComponent
|
||||
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
|
||||
trackScreenViews={true}
|
||||
trackOutgoingLinks={true}
|
||||
apiUrl="https://op.nodecrew.me/api"
|
||||
scriptUrl="https://op.nodecrew.me/op1.js"
|
||||
/>
|
||||
<Script
|
||||
src={`${RYBBIT_HOST}/api/script.js`}
|
||||
data-site-id={RYBBIT_SITE_ID}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
|
||||
@@ -2,11 +2,119 @@
|
||||
|
||||
import { useOpenPanel } from "@openpanel/nextjs";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
trackRybbitProductView,
|
||||
trackRybbitAddToCart,
|
||||
trackRybbitRemoveFromCart,
|
||||
trackRybbitCheckoutStarted,
|
||||
trackRybbitCheckoutStep,
|
||||
trackRybbitOrderCompleted,
|
||||
trackRybbitSearch,
|
||||
trackRybbitExternalLink,
|
||||
trackRybbitCartView,
|
||||
trackRybbitWishlistAdd,
|
||||
trackRybbitUserLogin,
|
||||
trackRybbitUserRegister,
|
||||
} from "@/lib/services/RybbitService";
|
||||
|
||||
export function useAnalytics() {
|
||||
const op = useOpenPanel();
|
||||
|
||||
// Client-side tracking for user behavior
|
||||
// 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;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[Rybbit] Tracking error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
|
||||
const trackProductView = useCallback((product: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -14,19 +122,15 @@ export function useAnalytics() {
|
||||
currency: string;
|
||||
category?: string;
|
||||
}) => {
|
||||
try {
|
||||
op.track("product_viewed", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
category: product.category,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Product view error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
trackDual("product_viewed", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
category: product.category,
|
||||
source: "client",
|
||||
});
|
||||
}, [trackDual]);
|
||||
|
||||
const trackAddToCart = useCallback((product: {
|
||||
id: string;
|
||||
@@ -36,37 +140,42 @@ export function useAnalytics() {
|
||||
quantity: number;
|
||||
variant?: string;
|
||||
}) => {
|
||||
try {
|
||||
op.track("add_to_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
quantity: product.quantity,
|
||||
variant: product.variant,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Add to cart error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
trackDual("add_to_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
quantity: product.quantity,
|
||||
variant: product.variant,
|
||||
source: "client",
|
||||
});
|
||||
}, [trackDual]);
|
||||
|
||||
const trackRemoveFromCart = useCallback((product: {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
}) => {
|
||||
try {
|
||||
op.track("remove_from_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
quantity: product.quantity,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Remove from cart error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
trackDual("remove_from_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
quantity: product.quantity,
|
||||
source: "client",
|
||||
});
|
||||
}, [trackDual]);
|
||||
|
||||
const trackCartView = useCallback((cart: {
|
||||
total: number;
|
||||
currency: string;
|
||||
item_count: number;
|
||||
}) => {
|
||||
trackDual("cart_view", {
|
||||
cart_total: cart.total,
|
||||
currency: cart.currency,
|
||||
item_count: cart.item_count,
|
||||
source: "client",
|
||||
});
|
||||
}, [trackDual]);
|
||||
|
||||
const trackCheckoutStarted = useCallback((cart: {
|
||||
total: number;
|
||||
@@ -79,36 +188,23 @@ export function useAnalytics() {
|
||||
price: number;
|
||||
}>;
|
||||
}) => {
|
||||
try {
|
||||
op.track("checkout_started", {
|
||||
cart_total: cart.total,
|
||||
currency: cart.currency,
|
||||
item_count: cart.item_count,
|
||||
items: cart.items,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Checkout started error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
trackDual("checkout_started", {
|
||||
cart_total: cart.total,
|
||||
currency: cart.currency,
|
||||
item_count: cart.item_count,
|
||||
items: cart.items,
|
||||
source: "client",
|
||||
});
|
||||
}, [trackDual]);
|
||||
|
||||
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
|
||||
try {
|
||||
op.track("checkout_step", {
|
||||
step,
|
||||
...data,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Checkout step error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
trackDual("checkout_step", {
|
||||
step,
|
||||
...data,
|
||||
source: "client",
|
||||
});
|
||||
}, [trackDual]);
|
||||
|
||||
/**
|
||||
* DUAL TRACKING: Order completion
|
||||
* 1. Track client-side (immediate, captures user session)
|
||||
* 2. Call server-side API (reliable, can't be blocked)
|
||||
*/
|
||||
const trackOrderCompleted = useCallback(async (order: {
|
||||
order_id: string;
|
||||
order_number: string;
|
||||
@@ -119,37 +215,34 @@ export function useAnalytics() {
|
||||
customer_email?: string;
|
||||
payment_method?: string;
|
||||
}) => {
|
||||
console.log("[Dual Analytics] Tracking order:", order.order_number, "Total:", order.total);
|
||||
console.log("[Analytics] Tracking order:", order.order_number);
|
||||
|
||||
// CLIENT-SIDE: Track immediately for user session data
|
||||
// Track with both OpenPanel and Rybbit
|
||||
trackDual("order_completed", {
|
||||
order_id: order.order_id,
|
||||
order_number: order.order_number,
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
item_count: order.item_count,
|
||||
shipping_cost: order.shipping_cost,
|
||||
customer_email: order.customer_email,
|
||||
payment_method: order.payment_method,
|
||||
source: "client",
|
||||
});
|
||||
|
||||
// OpenPanel revenue tracking
|
||||
try {
|
||||
op.track("order_completed", {
|
||||
order_id: order.order_id,
|
||||
order_number: order.order_number,
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
item_count: order.item_count,
|
||||
shipping_cost: order.shipping_cost,
|
||||
customer_email: order.customer_email,
|
||||
payment_method: order.payment_method,
|
||||
source: "client",
|
||||
});
|
||||
|
||||
op.revenue(order.total, {
|
||||
currency: order.currency,
|
||||
transaction_id: order.order_number,
|
||||
source: "client",
|
||||
});
|
||||
|
||||
console.log("[Client Analytics] Order tracked");
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Order tracking error:", e);
|
||||
console.error("[OpenPanel] Revenue tracking error:", e);
|
||||
}
|
||||
|
||||
// SERVER-SIDE: Call API for reliable tracking
|
||||
// Server-side tracking for reliability
|
||||
try {
|
||||
console.log("[Server Analytics] Calling server-side tracking API...");
|
||||
|
||||
const response = await fetch("/api/analytics/track-order", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -165,39 +258,54 @@ export function useAnalytics() {
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log("[Server Analytics] Order tracked successfully");
|
||||
} else {
|
||||
if (!response.ok) {
|
||||
console.error("[Server Analytics] Failed:", await response.text());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Server Analytics] API call failed:", e);
|
||||
}
|
||||
}, [op]);
|
||||
}, [op, trackDual]);
|
||||
|
||||
const trackSearch = useCallback((query: string, results_count: number) => {
|
||||
try {
|
||||
op.track("search", {
|
||||
query,
|
||||
results_count,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Search error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
trackDual("search", {
|
||||
query,
|
||||
results_count,
|
||||
source: "client",
|
||||
});
|
||||
}, [trackDual]);
|
||||
|
||||
const trackExternalLink = useCallback((url: string, label?: string) => {
|
||||
try {
|
||||
op.track("external_link_click", {
|
||||
url,
|
||||
label,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] External link error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
trackDual("external_link_click", {
|
||||
url,
|
||||
label,
|
||||
source: "client",
|
||||
});
|
||||
}, [trackDual]);
|
||||
|
||||
const trackWishlistAdd = useCallback((product: {
|
||||
id: string;
|
||||
name: string;
|
||||
}) => {
|
||||
trackDual("wishlist_add", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
source: "client",
|
||||
});
|
||||
}, [trackDual]);
|
||||
|
||||
const trackUserLogin = useCallback((method: string) => {
|
||||
trackDual("user_login", {
|
||||
method,
|
||||
source: "client",
|
||||
});
|
||||
}, [trackDual]);
|
||||
|
||||
const trackUserRegister = useCallback((method: string) => {
|
||||
trackDual("user_register", {
|
||||
method,
|
||||
source: "client",
|
||||
});
|
||||
}, [trackDual]);
|
||||
|
||||
const identifyUser = useCallback((user: {
|
||||
profileId: string;
|
||||
@@ -213,7 +321,7 @@ export function useAnalytics() {
|
||||
email: user.email,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Identify error:", e);
|
||||
console.error("[OpenPanel] Identify error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
|
||||
@@ -221,11 +329,15 @@ export function useAnalytics() {
|
||||
trackProductView,
|
||||
trackAddToCart,
|
||||
trackRemoveFromCart,
|
||||
trackCartView,
|
||||
trackCheckoutStarted,
|
||||
trackCheckoutStep,
|
||||
trackOrderCompleted,
|
||||
trackSearch,
|
||||
trackExternalLink,
|
||||
trackWishlistAdd,
|
||||
trackUserLogin,
|
||||
trackUserRegister,
|
||||
identifyUser,
|
||||
};
|
||||
}
|
||||
|
||||
209
src/lib/services/RybbitService.ts
Normal file
209
src/lib/services/RybbitService.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
// Rybbit Analytics Service
|
||||
// Self-hosted instance at rybbit.nodecrew.me
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
rybbit?: {
|
||||
event: (eventName: string, eventData?: Record<string, any>) => void;
|
||||
pageview: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const RYBBIT_HOST = "https://rybbit.nodecrew.me";
|
||||
export const RYBBIT_SITE_ID = "1";
|
||||
|
||||
/**
|
||||
* Check if Rybbit is loaded and available
|
||||
*/
|
||||
export function isRybbitAvailable(): boolean {
|
||||
return typeof window !== "undefined" &&
|
||||
!!window.rybbit &&
|
||||
typeof window.rybbit.event === "function";
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a custom event with Rybbit
|
||||
*/
|
||||
export function trackRybbitEvent(
|
||||
eventName: string,
|
||||
eventData?: Record<string, any>
|
||||
): void {
|
||||
if (isRybbitAvailable()) {
|
||||
try {
|
||||
window.rybbit!.event(eventName, eventData);
|
||||
} catch (e) {
|
||||
console.warn("[Rybbit] Event tracking error:", e);
|
||||
}
|
||||
} else {
|
||||
console.warn("[Rybbit] Not available for event:", eventName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track page view manually (usually auto-tracked by Rybbit script)
|
||||
*/
|
||||
export function trackRybbitPageview(): void {
|
||||
if (isRybbitAvailable()) {
|
||||
try {
|
||||
window.rybbit!.pageview();
|
||||
} catch (e) {
|
||||
console.warn("[Rybbit] Pageview error:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// E-commerce Event Tracking Functions
|
||||
|
||||
export function trackRybbitProductView(product: {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
category?: string;
|
||||
variant?: string;
|
||||
}): void {
|
||||
trackRybbitEvent("product_view", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
category: product.category,
|
||||
variant: product.variant,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackRybbitAddToCart(product: {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
quantity: number;
|
||||
variant?: string;
|
||||
}): void {
|
||||
trackRybbitEvent("add_to_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
quantity: product.quantity,
|
||||
variant: product.variant,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackRybbitRemoveFromCart(product: {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
}): void {
|
||||
trackRybbitEvent("remove_from_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
quantity: product.quantity,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackRybbitCartView(cart: {
|
||||
total: number;
|
||||
currency: string;
|
||||
item_count: number;
|
||||
}): void {
|
||||
trackRybbitEvent("cart_view", {
|
||||
cart_total: cart.total,
|
||||
currency: cart.currency,
|
||||
item_count: cart.item_count,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackRybbitCheckoutStarted(cart: {
|
||||
total: number;
|
||||
currency: string;
|
||||
item_count: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
}>;
|
||||
}): void {
|
||||
trackRybbitEvent("checkout_started", {
|
||||
cart_total: cart.total,
|
||||
currency: cart.currency,
|
||||
item_count: cart.item_count,
|
||||
items: cart.items,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackRybbitCheckoutStep(step: string, data?: Record<string, unknown>): void {
|
||||
trackRybbitEvent("checkout_step", {
|
||||
step,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackRybbitOrderCompleted(order: {
|
||||
order_id: string;
|
||||
order_number: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
item_count: number;
|
||||
shipping_cost?: number;
|
||||
customer_email?: string;
|
||||
payment_method?: string;
|
||||
}): void {
|
||||
trackRybbitEvent("order_completed", {
|
||||
order_id: order.order_id,
|
||||
order_number: order.order_number,
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
item_count: order.item_count,
|
||||
shipping_cost: order.shipping_cost,
|
||||
customer_email: order.customer_email,
|
||||
payment_method: order.payment_method,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackRybbitSearch(query: string, results_count: number): void {
|
||||
trackRybbitEvent("search", {
|
||||
query,
|
||||
results_count,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackRybbitExternalLink(url: string, label?: string): void {
|
||||
trackRybbitEvent("external_link_click", {
|
||||
url,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackRybbitNewsletterSignup(email: string, source: string): void {
|
||||
trackRybbitEvent("newsletter_signup", {
|
||||
email,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackRybbitWishlistAdd(product: {
|
||||
id: string;
|
||||
name: string;
|
||||
}): void {
|
||||
trackRybbitEvent("wishlist_add", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackRybbitUserLogin(method: string): void {
|
||||
trackRybbitEvent("user_login", {
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackRybbitUserRegister(method: string): void {
|
||||
trackRybbitEvent("user_register", {
|
||||
method,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user