From 17367024c2f28074d037f75e14d25b73108373e8 Mon Sep 17 00:00:00 2001 From: Unchained Date: Wed, 25 Mar 2026 18:48:13 +0200 Subject: [PATCH] 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 --- package-lock.json | 103 ++++++++++++++- package.json | 1 + src/app/[locale]/checkout/page.tsx | 47 +++++++ src/app/[locale]/layout.tsx | 14 ++- src/components/product/ProductDetail.tsx | 37 ++++++ src/lib/analytics.ts | 154 +++++++++++++++++++++++ 6 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 src/lib/analytics.ts diff --git a/package-lock.json b/package-lock.json index 3789226..47b8e11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@apollo/client": "^4.1.6", + "@openpanel/nextjs": "^1.4.0", "@react-email/components": "^1.0.10", "@react-email/render": "^2.0.4", "clsx": "^2.1.1", @@ -1340,6 +1341,34 @@ "node": ">=12.4.0" } }, + "node_modules/@openpanel/nextjs": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@openpanel/nextjs/-/nextjs-1.4.0.tgz", + "integrity": "sha512-usMxqdrgAEmBHMZBkJJDX4NeqNOhcP3s0iSuB+TflsUem8R8rsso97jiysoTjae9ZJA4gUe1YDIbh7DyARJhzg==", + "dependencies": { + "@openpanel/web": "1.3.0" + }, + "peerDependencies": { + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@openpanel/sdk": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.3.0.tgz", + "integrity": "sha512-VK/1oawBjGdxA+oYtqcWlNXlLT1zRJ9tslHoMvqqsqlcLNOhH26ltcHpyGp5RhtIF7uIkCltiicALfFN7fyldw==" + }, + "node_modules/@openpanel/web": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@openpanel/web/-/web-1.3.0.tgz", + "integrity": "sha512-geUPcn35oMqWlBS7rB4ejP6qzKGs4VDAZhoSw9MD3q/UYkD/pfTEx70z1ydGVJMjHREdXoAL1XVhBLdZmu1gsw==", + "dependencies": { + "@openpanel/sdk": "1.3.0", + "@rrweb/types": "2.0.0-alpha.20", + "rrweb": "2.0.0-alpha.20" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -1984,6 +2013,18 @@ "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@rrweb/types": { + "version": "2.0.0-alpha.20", + "resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.20.tgz", + "integrity": "sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==", + "license": "MIT" + }, + "node_modules/@rrweb/utils": { + "version": "2.0.0-alpha.20", + "resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.0.0-alpha.20.tgz", + "integrity": "sha512-MTQOmhPRe39C0fYaCnnVYOufQsyGzwNXpUStKiyFSfGLUJrzuwhbRoUAKR5w6W2j5XuA0bIz3ZDIBztkquOhLw==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2482,6 +2523,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3145,6 +3192,12 @@ "node": ">=8" } }, + "node_modules/@xstate/fsm": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz", + "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3438,6 +3491,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -6178,6 +6240,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/motion-dom": { "version": "12.34.3", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", @@ -6749,7 +6817,6 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -6996,6 +7063,40 @@ "node": ">=0.10.0" } }, + "node_modules/rrdom": { + "version": "2.0.0-alpha.20", + "resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.0.0-alpha.20.tgz", + "integrity": "sha512-hoqjS4662LtBp82qEz9GrqU36UpEmCvTA2Hns3qdF7cklLFFy3G+0Th8hLytJENleHHWxsB5nWJ3eXz5mSRxdQ==", + "license": "MIT", + "dependencies": { + "rrweb-snapshot": "^2.0.0-alpha.20" + } + }, + "node_modules/rrweb": { + "version": "2.0.0-alpha.20", + "resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.0-alpha.20.tgz", + "integrity": "sha512-CZKDlm+j1VA50Ko3gnMbpvguCAleljsTNXPnVk9aeNP8o6T6kolRbISHyDZpqZ4G+bdDLlQOignPP3jEsXs8Gg==", + "license": "MIT", + "dependencies": { + "@rrweb/types": "^2.0.0-alpha.20", + "@rrweb/utils": "^2.0.0-alpha.20", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "mitt": "^3.0.0", + "rrdom": "^2.0.0-alpha.20", + "rrweb-snapshot": "^2.0.0-alpha.20" + } + }, + "node_modules/rrweb-snapshot": { + "version": "2.0.0-alpha.20", + "resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.20.tgz", + "integrity": "sha512-YTNf9YVeaGRo/jxY3FKBge2c/Ojd/KTHmuWloUSB+oyPXuY73ZeeG873qMMmhIpqEn7hn7aBF1eWEQmP7wjf8A==", + "license": "MIT", + "dependencies": { + "postcss": "^8.4.38" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/package.json b/package.json index 118a408..7300730 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@apollo/client": "^4.1.6", + "@openpanel/nextjs": "^1.4.0", "@react-email/components": "^1.0.10", "@react-email/render": "^2.0.4", "clsx": "^2.1.1", diff --git a/src/app/[locale]/checkout/page.tsx b/src/app/[locale]/checkout/page.tsx index 19c8b6a..cc79135 100644 --- a/src/app/[locale]/checkout/page.tsx +++ b/src/app/[locale]/checkout/page.tsx @@ -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(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..."); } diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 0f2ad3b..c06cdf0 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -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 ( - - {children} - + <> + + + {children} + + ); } diff --git a/src/components/product/ProductDetail.tsx b/src/components/product/ProductDetail.tsx index cf901e3..2d88b2c 100644 --- a/src/components/product/ProductDetail.tsx +++ b/src/components/product/ProductDetail.tsx @@ -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(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); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..b4506bd --- /dev/null +++ b/src/lib/analytics.ts @@ -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) => { + 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; + }) => { + 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;