diff --git a/src/lib/analytics/core/AnalyticsTracker.ts b/src/lib/analytics/core/AnalyticsTracker.ts new file mode 100644 index 0000000..8952880 --- /dev/null +++ b/src/lib/analytics/core/AnalyticsTracker.ts @@ -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): Promise { + const promises: Promise[] = []; + 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) { + 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; +} diff --git a/src/lib/analytics/core/types.ts b/src/lib/analytics/core/types.ts new file mode 100644 index 0000000..8ca0edf --- /dev/null +++ b/src/lib/analytics/core/types.ts @@ -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 } + | { 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): Promise; + isAvailable(): boolean; +} diff --git a/src/lib/analytics/hooks/useAnalytics.ts b/src/lib/analytics/hooks/useAnalytics.ts new file mode 100644 index 0000000..d4ba41c --- /dev/null +++ b/src/lib/analytics/hooks/useAnalytics.ts @@ -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(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(); +} diff --git a/src/lib/analytics/index.ts b/src/lib/analytics/index.ts new file mode 100644 index 0000000..6384711 --- /dev/null +++ b/src/lib/analytics/index.ts @@ -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"; diff --git a/src/lib/analytics/providers/OpenPanelProvider.ts b/src/lib/analytics/providers/OpenPanelProvider.ts new file mode 100644 index 0000000..f7bfd2e --- /dev/null +++ b/src/lib/analytics/providers/OpenPanelProvider.ts @@ -0,0 +1,146 @@ +"use client"; + +import type { AnalyticsEvent, AnalyticsProvider, UserData } from "../core/types"; + +export class OpenPanelProvider implements AnalyticsProvider { + name = "OpenPanel"; + private op: ReturnType; + private isClient: boolean; + + constructor(op: ReturnType) { + 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): Promise { + if (!this.isAvailable()) return; + await this.op.revenue(amount, { currency, ...properties }); + } +} diff --git a/src/lib/analytics/providers/RybbitProvider.ts b/src/lib/analytics/providers/RybbitProvider.ts new file mode 100644 index 0000000..6c531ed --- /dev/null +++ b/src/lib/analytics/providers/RybbitProvider.ts @@ -0,0 +1,159 @@ +"use client"; + +import type { AnalyticsEvent, AnalyticsProvider, UserData } from "../core/types"; + +declare global { + interface Window { + rybbit?: { + event: (eventName: string, eventData?: Record) => 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): 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): Promise { + // Revenue is tracked via order_completed event + return Promise.resolve(); + } +}