8 Commits

Author SHA1 Message Date
Unchained
e87c655a5b Merge branch 'feature/web-vitals-optimization' into dev
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 13:22:45 +02:00
Unchained
66829aeffd refactor(analytics): abstract analytics into provider pattern
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Add type-safe AnalyticsEvent union types
- Create AnalyticsProvider interface for pluggable analytics backends
- Implement OpenPanelProvider and RybbitProvider adapters
- Create AnalyticsTracker that fans out events to all providers
- Simplifies adding new analytics platforms in the future
2026-03-31 07:45:21 +02:00
Unchained
bce2d19ca3 fix(analytics): fix OpenPanel apiUrl to not include /track
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 07:23:03 +02:00
Unchained
cee3b71454 fix(analytics): use route handler for OpenPanel script to fix query param issue
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 07:19:14 +02:00
Unchained
ff629691a5 fix(analytics): fix OpenPanel script rewrite URL
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 07:11:59 +02:00
Unchained
1cdda7db3c fix(analytics): use rewrites instead of route handler for OpenPanel proxy
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 06:37:17 +02:00
Unchained
1dd7e1dfe7 fix(analytics): use local proxy for OpenPanel to avoid ad blockers
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 06:31:48 +02:00
Unchained
054889a44e feat(analytics): add RYBBIT_API_KEY for server-side tracking
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 06:05:47 +02:00
10 changed files with 521 additions and 23 deletions

View File

@@ -130,6 +130,8 @@ spec:
value: "https://rybbit.nodecrew.me"
- name: NEXT_PUBLIC_RYBBIT_SITE_ID
value: "1"
- name: RYBBIT_API_KEY
value: "rb_NgFoMtHeohWoJULLiKqSEJmdghSrhJajgseSWQLjfxyeUJcFfQvUrfYwdllSTsLx"
resources:
limits:
cpu: 500m

View File

@@ -7,6 +7,8 @@ const nextConfig: NextConfig = {
output: 'standalone',
async rewrites() {
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 [
{
source: "/api/script.js",
@@ -28,6 +30,10 @@ const nextConfig: NextConfig = {
source: "/api/session-replay/record/:id",
destination: `${rybbitHost}/api/session-replay/record/:id`,
},
{
source: "/api/op/track",
destination: `${openpanelUrl}/track`,
},
];
},
images: {

View File

@@ -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 });
}
}

View File

@@ -21,4 +21,4 @@ export async function GET(request: Request) {
console.error("[OpenPanel] Failed to fetch script:", error);
return new NextResponse("/* OpenPanel script unavailable */", { status: 500 });
}
}
}

View 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;
}

View 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;
}

View 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();
}

View 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";

View 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 });
}
}

View 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();
}
}