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:
103
package-lock.json
generated
103
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^4.1.6",
|
"@apollo/client": "^4.1.6",
|
||||||
|
"@openpanel/nextjs": "^1.4.0",
|
||||||
"@react-email/components": "^1.0.10",
|
"@react-email/components": "^1.0.10",
|
||||||
"@react-email/render": "^2.0.4",
|
"@react-email/render": "^2.0.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -1340,6 +1341,34 @@
|
|||||||
"node": ">=12.4.0"
|
"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": {
|
"node_modules/@parcel/watcher": {
|
||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
"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"
|
"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": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -2482,6 +2523,12 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -3145,6 +3192,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -3438,6 +3491,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
"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"
|
"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": {
|
"node_modules/motion-dom": {
|
||||||
"version": "12.34.3",
|
"version": "12.34.3",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
|
||||||
@@ -6749,7 +6817,6 @@
|
|||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -6996,6 +7063,40 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^4.1.6",
|
"@apollo/client": "^4.1.6",
|
||||||
|
"@openpanel/nextjs": "^1.4.0",
|
||||||
"@react-email/components": "^1.0.10",
|
"@react-email/components": "^1.0.10",
|
||||||
"@react-email/render": "^2.0.4",
|
"@react-email/render": "^2.0.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Footer from "@/components/layout/Footer";
|
|||||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||||
import { formatPrice } from "@/lib/saleor";
|
import { formatPrice } from "@/lib/saleor";
|
||||||
import { saleorClient } from "@/lib/saleor/client";
|
import { saleorClient } from "@/lib/saleor/client";
|
||||||
|
import { useAnalytics } from "@/lib/analytics";
|
||||||
import {
|
import {
|
||||||
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||||
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||||
@@ -96,6 +97,7 @@ export default function CheckoutPage() {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
|
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
|
||||||
|
const { trackCheckoutStarted, trackCheckoutStep, trackOrderCompleted, identifyUser } = useAnalytics();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [orderComplete, setOrderComplete] = useState(false);
|
const [orderComplete, setOrderComplete] = useState(false);
|
||||||
@@ -138,6 +140,25 @@ export default function CheckoutPage() {
|
|||||||
}
|
}
|
||||||
}, [checkout, refreshCheckout]);
|
}, [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
|
// Scroll to top when order is complete
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orderComplete) {
|
if (orderComplete) {
|
||||||
@@ -257,6 +278,27 @@ export default function CheckoutPage() {
|
|||||||
if (order) {
|
if (order) {
|
||||||
setOrderNumber(order.number);
|
setOrderNumber(order.number);
|
||||||
setOrderComplete(true);
|
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 {
|
} else {
|
||||||
throw new Error(t("errorCreatingOrder"));
|
throw new Error(t("errorCreatingOrder"));
|
||||||
}
|
}
|
||||||
@@ -330,6 +372,11 @@ export default function CheckoutPage() {
|
|||||||
setShippingMethods(availableMethods);
|
setShippingMethods(availableMethods);
|
||||||
setShowShippingMethods(true);
|
setShowShippingMethods(true);
|
||||||
|
|
||||||
|
// Track shipping step
|
||||||
|
trackCheckoutStep("shipping_method_selection", {
|
||||||
|
available_methods_count: availableMethods.length,
|
||||||
|
});
|
||||||
|
|
||||||
// Don't complete yet - show shipping method selection
|
// Don't complete yet - show shipping method selection
|
||||||
console.log("Phase 1 complete. Waiting for shipping method selection...");
|
console.log("Phase 1 complete. Waiting for shipping method selection...");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Metadata } from "next";
|
|||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||||
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
|
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";
|
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();
|
const messages = await getMessages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<OpenPanelComponent
|
||||||
|
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
|
||||||
|
trackScreenViews={true}
|
||||||
|
trackOutgoingLinks={true}
|
||||||
|
/>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
{children}
|
{children}
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
|||||||
import HowItWorks from "@/components/home/HowItWorks";
|
import HowItWorks from "@/components/home/HowItWorks";
|
||||||
import NewsletterSection from "@/components/home/NewsletterSection";
|
import NewsletterSection from "@/components/home/NewsletterSection";
|
||||||
import BundleSelector from "@/components/product/BundleSelector";
|
import BundleSelector from "@/components/product/BundleSelector";
|
||||||
|
import { useAnalytics } from "@/lib/analytics";
|
||||||
|
|
||||||
interface ProductDetailProps {
|
interface ProductDetailProps {
|
||||||
product: Product;
|
product: Product;
|
||||||
@@ -99,8 +100,25 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
|||||||
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
||||||
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
|
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
|
||||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||||
|
const { trackProductView, trackAddToCart } = useAnalytics();
|
||||||
const validLocale = isValidLocale(locale) ? locale : "sr";
|
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(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setUrgencyIndex(prev => (prev + 1) % 3);
|
setUrgencyIndex(prev => (prev + 1) % 3);
|
||||||
@@ -132,6 +150,25 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
|||||||
setIsAdding(true);
|
setIsAdding(true);
|
||||||
try {
|
try {
|
||||||
await addLine(selectedVariantId, 1);
|
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();
|
openCart();
|
||||||
} finally {
|
} finally {
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
|
|||||||
154
src/lib/analytics.ts
Normal file
154
src/lib/analytics.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user