feat(analytics): add OpenPanel tracking to storefront

Add comprehensive OpenPanel analytics tracking:
- Install @openpanel/nextjs SDK
- Add OpenPanelComponent to root layout for automatic page views
- Create useAnalytics hook for tracking custom events
- Track checkout funnel: started, shipping step, order completed
- Track product views and add-to-cart events
- Identify users on order completion
- Add NEXT_PUBLIC_OPENPANEL_CLIENT_ID to environment
This commit is contained in:
Unchained
2026-03-25 18:48:13 +02:00
parent bf628f873f
commit 17367024c2
6 changed files with 352 additions and 4 deletions

View File

@@ -10,6 +10,7 @@ import Footer from "@/components/layout/Footer";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { formatPrice } from "@/lib/saleor";
import { saleorClient } from "@/lib/saleor/client";
import { useAnalytics } from "@/lib/analytics";
import {
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
CHECKOUT_BILLING_ADDRESS_UPDATE,
@@ -96,6 +97,7 @@ export default function CheckoutPage() {
const locale = useLocale();
const router = useRouter();
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
const { trackCheckoutStarted, trackCheckoutStep, trackOrderCompleted, identifyUser } = useAnalytics();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [orderComplete, setOrderComplete] = useState(false);
@@ -138,6 +140,25 @@ export default function CheckoutPage() {
}
}, [checkout, refreshCheckout]);
// Track checkout started when page loads
useEffect(() => {
if (checkout) {
const lines = getLines();
const total = getTotal();
trackCheckoutStarted({
total,
currency: "RSD",
item_count: lines.reduce((sum, line) => sum + line.quantity, 0),
items: lines.map(line => ({
id: line.variant.id,
name: line.variant.product.name,
quantity: line.quantity,
price: line.variant.pricing?.price?.gross?.amount || 0,
})),
});
}
}, [checkout]);
// Scroll to top when order is complete
useEffect(() => {
if (orderComplete) {
@@ -257,6 +278,27 @@ export default function CheckoutPage() {
if (order) {
setOrderNumber(order.number);
setOrderComplete(true);
// Track order completion
const lines = getLines();
const total = getTotal();
trackOrderCompleted({
order_id: checkout.id,
order_number: order.number,
total,
currency: "RSD",
item_count: lines.reduce((sum, line) => sum + line.quantity, 0),
shipping_cost: shippingMethods.find(m => m.id === selectedShippingMethod)?.price.amount,
customer_email: shippingAddress.email,
});
// Identify the user
identifyUser({
profileId: shippingAddress.email,
email: shippingAddress.email,
firstName: shippingAddress.firstName,
lastName: shippingAddress.lastName,
});
} else {
throw new Error(t("errorCreatingOrder"));
}
@@ -330,6 +372,11 @@ export default function CheckoutPage() {
setShippingMethods(availableMethods);
setShowShippingMethods(true);
// Track shipping step
trackCheckoutStep("shipping_method_selection", {
available_methods_count: availableMethods.length,
});
// Don't complete yet - show shipping method selection
console.log("Phase 1 complete. Waiting for shipping method selection...");
}

View File

@@ -2,6 +2,7 @@ import { Metadata } from "next";
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";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
@@ -44,8 +45,15 @@ export default async function LocaleLayout({
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
<>
<OpenPanelComponent
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
trackScreenViews={true}
trackOutgoingLinks={true}
/>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</>
);
}

View File

@@ -20,6 +20,7 @@ import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
import HowItWorks from "@/components/home/HowItWorks";
import NewsletterSection from "@/components/home/NewsletterSection";
import BundleSelector from "@/components/product/BundleSelector";
import { useAnalytics } from "@/lib/analytics";
interface ProductDetailProps {
product: Product;
@@ -99,8 +100,25 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
const [urgencyIndex, setUrgencyIndex] = useState(0);
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
const { addLine, openCart } = useSaleorCheckoutStore();
const { trackProductView, trackAddToCart } = useAnalytics();
const validLocale = isValidLocale(locale) ? locale : "sr";
// Track product view on mount
useEffect(() => {
const localized = getLocalizedProduct(product, locale);
const baseVariant = product.variants?.[0];
const price = baseVariant?.pricing?.price?.gross?.amount || 0;
const currency = baseVariant?.pricing?.price?.gross?.currency || "RSD";
trackProductView({
id: product.id,
name: localized.name,
price,
currency,
category: product.category?.name,
});
}, [product, locale]);
useEffect(() => {
const interval = setInterval(() => {
setUrgencyIndex(prev => (prev + 1) % 3);
@@ -132,6 +150,25 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
setIsAdding(true);
try {
await addLine(selectedVariantId, 1);
// Track add to cart
const localized = getLocalizedProduct(product, locale);
const baseVariant = product.variants?.[0];
const selectedVariant = selectedVariantId === baseVariant?.id
? baseVariant
: bundleProducts.find(p => p.variants?.[0]?.id === selectedVariantId)?.variants?.[0];
const price = selectedVariant?.pricing?.price?.gross?.amount || 0;
const currency = selectedVariant?.pricing?.price?.gross?.currency || "RSD";
trackAddToCart({
id: product.id,
name: localized.name,
price,
currency,
quantity: 1,
variant: selectedVariant?.name,
});
openCart();
} finally {
setIsAdding(false);

154
src/lib/analytics.ts Normal file
View File

@@ -0,0 +1,154 @@
"use client";
import { useOpenPanel } from "@openpanel/nextjs";
import { useCallback } from "react";
export function useAnalytics() {
const op = useOpenPanel();
// Page views are tracked automatically by OpenPanelComponent
// but we can track specific events manually
const trackProductView = useCallback((product: {
id: string;
name: string;
price: number;
currency: string;
category?: string;
}) => {
op.track("product_viewed", {
product_id: product.id,
product_name: product.name,
price: product.price,
currency: product.currency,
category: product.category,
});
}, [op]);
const trackAddToCart = useCallback((product: {
id: string;
name: string;
price: number;
currency: string;
quantity: number;
variant?: string;
}) => {
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,
});
}, [op]);
const trackRemoveFromCart = useCallback((product: {
id: string;
name: string;
quantity: number;
}) => {
op.track("remove_from_cart", {
product_id: product.id,
product_name: product.name,
quantity: product.quantity,
});
}, [op]);
const trackCheckoutStarted = useCallback((cart: {
total: number;
currency: string;
item_count: number;
items: Array<{
id: string;
name: string;
quantity: number;
price: number;
}>;
}) => {
op.track("checkout_started", {
cart_total: cart.total,
currency: cart.currency,
item_count: cart.item_count,
items: cart.items,
});
}, [op]);
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
op.track("checkout_step", {
step,
...data,
});
}, [op]);
const trackOrderCompleted = useCallback((order: {
order_id: string;
order_number: string;
total: number;
currency: string;
item_count: number;
shipping_cost?: number;
customer_email?: string;
}) => {
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,
});
// Also track revenue for analytics
op.track("purchase", {
transaction_id: order.order_number,
value: order.total,
currency: order.currency,
});
}, [op]);
const trackSearch = useCallback((query: string, results_count: number) => {
op.track("search", {
query,
results_count,
});
}, [op]);
const trackExternalLink = useCallback((url: string, label?: string) => {
op.track("external_link_click", {
url,
label,
});
}, [op]);
const identifyUser = useCallback((user: {
profileId: string;
email?: string;
firstName?: string;
lastName?: string;
properties?: Record<string, unknown>;
}) => {
op.identify({
profileId: user.profileId,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: user.properties,
});
}, [op]);
return {
trackProductView,
trackAddToCart,
trackRemoveFromCart,
trackCheckoutStarted,
trackCheckoutStep,
trackOrderCompleted,
trackSearch,
trackExternalLink,
identifyUser,
};
}
export default useAnalytics;