medusa-storefront-analytics 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "name": "medusa-storefront-analytics",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Layered storefront analytics (GTM dataLayer) with reusable domain events for reviews, forms, and more.",
5
5
  "author": "SmartByte Labs",
6
6
  "license": "MIT",
7
- "keywords": ["medusa", "storefront", "analytics", "gtm", "dataLayer", "reviews"],
7
+ "keywords": [
8
+ "medusa",
9
+ "storefront",
10
+ "analytics",
11
+ "gtm",
12
+ "dataLayer",
13
+ "reviews"
14
+ ],
8
15
  "repository": {
9
16
  "type": "git",
10
17
  "url": "https://github.com/SmartByteLabs/medusa-ui-components.git",
@@ -14,7 +21,9 @@
14
21
  "access": "public"
15
22
  },
16
23
  "type": "module",
17
- "sideEffects": ["dist/**/*.js"],
24
+ "sideEffects": [
25
+ "dist/**/*.js"
26
+ ],
18
27
  "main": "./dist/ui-library.umd.cjs",
19
28
  "module": "./dist/ui-library.js",
20
29
  "types": "./dist/index.d.ts",
@@ -29,9 +38,25 @@
29
38
  "types": "./dist/providers/gtm.d.ts",
30
39
  "import": "./dist/ui-library.js",
31
40
  "default": "./dist/ui-library.js"
41
+ },
42
+ "./api/meta-event": {
43
+ "types": "./src/api/meta-event.ts",
44
+ "import": "./src/api/meta-event.ts",
45
+ "default": "./src/api/meta-event.ts"
46
+ }
47
+ },
48
+ "files": [
49
+ "dist",
50
+ "src"
51
+ ],
52
+ "peerDependencies": {
53
+ "next": ">=14.0.0"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "next": {
57
+ "optional": true
32
58
  }
33
59
  },
34
- "files": ["dist"],
35
60
  "scripts": {
36
61
  "build": "tsc && vite build",
37
62
  "dev": "vite build --watch"
@@ -0,0 +1,88 @@
1
+ import { NextRequest, NextResponse } from "next/server"
2
+
3
+ export type MetaCapiEventBody = {
4
+ event_name?: string
5
+ event?: string
6
+ action_source?: string
7
+ event_source_url?: string
8
+ event_id?: string
9
+ custom_data?: Record<string, unknown>
10
+ user_data?: Record<string, unknown>
11
+ }
12
+
13
+ function getMetaConfig() {
14
+ const pixelId = process.env.META_PIXEL_ID || process.env.FB_PIXEL_ID
15
+ const accessToken = process.env.META_ACCESS_TOKEN || process.env.FB_ACCESS_TOKEN
16
+ return { pixelId, accessToken }
17
+ }
18
+
19
+ /** Next.js App Router handler for `POST /api/meta/event` (Meta Conversions API). */
20
+ export async function POST(req: NextRequest) {
21
+ try {
22
+ const body = (await req.json()) as MetaCapiEventBody
23
+ const { pixelId, accessToken } = getMetaConfig()
24
+
25
+ if (!pixelId || !accessToken) {
26
+ return NextResponse.json(
27
+ { success: false, error: "Configuration missing" },
28
+ { status: 500 }
29
+ )
30
+ }
31
+
32
+ const {
33
+ event_name = body.event || "Lead",
34
+ action_source = "website",
35
+ event_source_url,
36
+ event_id,
37
+ custom_data = {},
38
+ user_data = {},
39
+ } = body
40
+
41
+ const forwardedFor = req.headers.get("x-forwarded-for")
42
+ const clientIp =
43
+ forwardedFor?.split(",")[0]?.trim() ||
44
+ req.headers.get("x-real-ip") ||
45
+ ""
46
+
47
+ const payload = {
48
+ data: [
49
+ {
50
+ event_name,
51
+ event_time: Math.floor(Date.now() / 1000),
52
+ action_source,
53
+ event_id,
54
+ event_source_url: event_source_url || req.headers.get("referer") || "",
55
+ user_data: {
56
+ client_ip_address: clientIp,
57
+ client_user_agent: req.headers.get("user-agent") || "",
58
+ ...user_data,
59
+ },
60
+ custom_data,
61
+ },
62
+ ],
63
+ }
64
+
65
+ const response = await fetch(
66
+ `https://graph.facebook.com/v21.0/${pixelId}/events?access_token=${accessToken}`,
67
+ {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/json" },
70
+ body: JSON.stringify(payload),
71
+ }
72
+ )
73
+
74
+ const result = await response.json()
75
+
76
+ if (response.ok) {
77
+ return NextResponse.json({ success: true, data: result })
78
+ }
79
+
80
+ return NextResponse.json(
81
+ { success: false, error: result },
82
+ { status: response.status }
83
+ )
84
+ } catch (error: unknown) {
85
+ const message = error instanceof Error ? error.message : "Unknown error"
86
+ return NextResponse.json({ success: false, error: message }, { status: 500 })
87
+ }
88
+ }
@@ -0,0 +1,24 @@
1
+ import type { FormSubmittedPayload, ReviewSubmittedPayload, StorefrontAnalyticsAdapter } from "./types";
2
+
3
+ /**
4
+ * Fan-out to multiple adapters (e.g. GTM + custom logger).
5
+ */
6
+ export function composeAnalyticsAdapters(
7
+ adapters: StorefrontAnalyticsAdapter[]
8
+ ): StorefrontAnalyticsAdapter {
9
+ const list = adapters.filter(Boolean);
10
+ return {
11
+ trackReviewSubmitted(payload: ReviewSubmittedPayload) {
12
+ list.forEach((a) => a.trackReviewSubmitted(payload));
13
+ },
14
+ trackReviewUpdated(payload: ReviewSubmittedPayload) {
15
+ list.forEach((a) => a.trackReviewUpdated?.(payload));
16
+ },
17
+ trackFormSubmitted(payload: FormSubmittedPayload) {
18
+ list.forEach((a) => a.trackFormSubmitted?.(payload));
19
+ },
20
+ trackEvent(eventName: string, data?: Record<string, unknown>) {
21
+ list.forEach((a) => a.trackEvent?.(eventName, data));
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,8 @@
1
+ import type { StorefrontAnalyticsAdapter } from "./types";
2
+
3
+ export const noopAnalyticsAdapter: StorefrontAnalyticsAdapter = {
4
+ trackReviewSubmitted: () => {},
5
+ trackReviewUpdated: () => {},
6
+ trackFormSubmitted: () => {},
7
+ trackEvent: () => {},
8
+ };
@@ -0,0 +1,35 @@
1
+ /** Shared product reference for ecommerce / UGC events */
2
+ export interface ProductRef {
3
+ productId: string;
4
+ productName: string;
5
+ }
6
+
7
+ export interface ReviewSubmittedPayload extends ProductRef {
8
+ rating: number;
9
+ /** Review title entered by the customer */
10
+ reviewTitle?: string;
11
+ /** True when an existing review was updated */
12
+ isUpdate: boolean;
13
+ }
14
+
15
+ export interface FormSubmittedPayload {
16
+ form_type: string;
17
+ formType?: string;
18
+ [key: string]: unknown;
19
+ }
20
+
21
+ /**
22
+ * Port implemented by each site (GTM, Meta, custom). Domain helpers call these methods.
23
+ */
24
+ export interface StorefrontAnalyticsAdapter {
25
+ trackReviewSubmitted(payload: ReviewSubmittedPayload): void;
26
+ trackReviewUpdated?(payload: ReviewSubmittedPayload): void;
27
+ trackFormSubmitted?(payload: FormSubmittedPayload): void;
28
+ trackEvent?(eventName: string, data?: Record<string, unknown>): void;
29
+ }
30
+
31
+ export type AnalyticsEventMap = {
32
+ review_submitted: ReviewSubmittedPayload;
33
+ review_updated: ReviewSubmittedPayload;
34
+ form_submit: FormSubmittedPayload;
35
+ };
@@ -0,0 +1,261 @@
1
+ /**
2
+ * GA4 dataLayer ecommerce helpers + Meta Pixel (explicit fbq).
3
+ */
4
+
5
+ import {
6
+ fireMetaEcommerceEvent,
7
+ fireMetaPixelEvent,
8
+ fireMetaPurchase,
9
+ } from "../providers/meta-pixel";
10
+ import {
11
+ META_STANDARD_EVENTS,
12
+ type EcommercePayload,
13
+ type GA4Item,
14
+ type MetaStandardEventName,
15
+ } from "../events/meta-events";
16
+
17
+ export {
18
+ META_STANDARD_EVENTS,
19
+ type EcommercePayload,
20
+ type GA4Item,
21
+ type MetaStandardEventName,
22
+ };
23
+
24
+ const GA4_TO_META_EVENT: Record<string, MetaStandardEventName> = {
25
+ add_to_cart: META_STANDARD_EVENTS.AddToCart,
26
+ view_item: META_STANDARD_EVENTS.ViewContent,
27
+ view_cart: META_STANDARD_EVENTS.ViewContent,
28
+ begin_checkout: META_STANDARD_EVENTS.InitiateCheckout,
29
+ add_payment_info: META_STANDARD_EVENTS.AddPaymentInfo,
30
+ purchase: META_STANDARD_EVENTS.Purchase,
31
+ };
32
+
33
+ type CartLineLike = {
34
+ id: string;
35
+ variant_id?: string;
36
+ title?: string;
37
+ product_title?: string;
38
+ quantity: number;
39
+ total?: number;
40
+ };
41
+
42
+ type CartLike = {
43
+ items?: CartLineLike[];
44
+ total?: number;
45
+ currency_code?: string;
46
+ region?: { currency_code?: string };
47
+ };
48
+
49
+ function pushToDataLayer(obj: Record<string, unknown>) {
50
+ if (typeof window === "undefined") return;
51
+ const w = window as Window & { dataLayer?: unknown[] };
52
+ w.dataLayer = w.dataLayer || [];
53
+ w.dataLayer.push({ ecommerce: null });
54
+ w.dataLayer.push(obj);
55
+ }
56
+
57
+ function normalizeCurrency(currency?: string) {
58
+ return (currency ?? "INR").toUpperCase();
59
+ }
60
+
61
+ function buildMetaFields(payload: EcommercePayload) {
62
+ const items = payload.items ?? [];
63
+ const numItems = items.reduce((sum, i) => sum + (i.quantity ?? 1), 0);
64
+ return {
65
+ currency: normalizeCurrency(payload.currency),
66
+ value: payload.value,
67
+ content_ids: items.map((i) => String(i.item_id)),
68
+ content_type: "product",
69
+ contents: items.map((i) => ({
70
+ id: String(i.item_id),
71
+ quantity: i.quantity ?? 1,
72
+ item_price: i.price ?? 0,
73
+ })),
74
+ num_items: numItems,
75
+ };
76
+ }
77
+
78
+ function pushEcommerceEvent(
79
+ event: string,
80
+ payload: EcommercePayload,
81
+ extra?: Record<string, unknown>
82
+ ) {
83
+ const currency = normalizeCurrency(payload.currency);
84
+ const ecommerce = { ...payload, currency };
85
+ const metaFields = buildMetaFields({ ...payload, currency });
86
+
87
+ pushToDataLayer({
88
+ event,
89
+ ecommerce,
90
+ ...metaFields,
91
+ ...extra,
92
+ });
93
+
94
+ const metaEvent = GA4_TO_META_EVENT[event];
95
+ if (metaEvent) {
96
+ if (event === "purchase" && extra?.transaction_id) {
97
+ fireMetaPurchase({
98
+ ...payload,
99
+ currency,
100
+ transaction_id: String(extra.transaction_id),
101
+ });
102
+ } else {
103
+ fireMetaEcommerceEvent(metaEvent, { ...payload, currency }, extra);
104
+ }
105
+ }
106
+ }
107
+
108
+ export function trackMetaStandardEvent(
109
+ metaEvent: MetaStandardEventName,
110
+ params?: Record<string, unknown>
111
+ ) {
112
+ fireMetaPixelEvent(metaEvent, params ?? {});
113
+ }
114
+
115
+ export function cartToEcommercePayload(cart: CartLike | null): EcommercePayload | null {
116
+ if (!cart?.items?.length) return null;
117
+
118
+ const currency = normalizeCurrency(cart.currency_code ?? cart.region?.currency_code);
119
+ const value = (cart.total ?? 0) / 100;
120
+ const items = cart.items.map((item, index) => {
121
+ const unitTotal = item.total && item.quantity ? item.total / item.quantity : 0;
122
+ return {
123
+ item_id: item.variant_id ?? item.id,
124
+ item_name: item.title ?? item.product_title ?? "",
125
+ price: unitTotal / 100,
126
+ quantity: item.quantity,
127
+ index,
128
+ };
129
+ });
130
+
131
+ return { currency, value, items };
132
+ }
133
+
134
+ export function trackAddToCart(payload: EcommercePayload) {
135
+ pushEcommerceEvent("add_to_cart", payload);
136
+ }
137
+
138
+ export function trackRemoveFromCart(payload: EcommercePayload) {
139
+ pushEcommerceEvent("remove_from_cart", payload);
140
+ }
141
+
142
+ export function trackViewCart(payload: EcommercePayload) {
143
+ pushEcommerceEvent("view_cart", payload);
144
+ }
145
+
146
+ export function trackViewItem(payload: EcommercePayload) {
147
+ pushEcommerceEvent("view_item", payload);
148
+ }
149
+
150
+ export function trackViewItemList(payload: {
151
+ list_id?: string;
152
+ list_name?: string;
153
+ items: GA4Item[];
154
+ }) {
155
+ const meta = buildMetaFields({
156
+ currency: "INR",
157
+ value: 0,
158
+ items: payload.items,
159
+ });
160
+ pushToDataLayer({
161
+ event: "view_item_list",
162
+ ecommerce: payload,
163
+ ...meta,
164
+ });
165
+ fireMetaEcommerceEvent(META_STANDARD_EVENTS.ViewContent, {
166
+ currency: "INR",
167
+ value: 0,
168
+ items: payload.items,
169
+ });
170
+ }
171
+
172
+ const INITIATE_CHECKOUT_KEY_PREFIX = "meta_initiate_checkout_";
173
+
174
+ export function markInitiateCheckoutFired(cartId: string) {
175
+ if (typeof window === "undefined") return;
176
+ sessionStorage.setItem(`${INITIATE_CHECKOUT_KEY_PREFIX}${cartId}`, String(Date.now()));
177
+ }
178
+
179
+ export function wasInitiateCheckoutFired(cartId: string, maxAgeMs = 60_000) {
180
+ if (typeof window === "undefined") return false;
181
+ const raw = sessionStorage.getItem(`${INITIATE_CHECKOUT_KEY_PREFIX}${cartId}`);
182
+ if (!raw) return false;
183
+ return Date.now() - Number(raw) < maxAgeMs;
184
+ }
185
+
186
+ export function trackInitiateCheckoutFromCart(payload: EcommercePayload, cartId?: string) {
187
+ trackBeginCheckout(payload);
188
+ if (cartId) markInitiateCheckoutFired(cartId);
189
+ }
190
+
191
+ export function trackBeginCheckout(payload: EcommercePayload) {
192
+ pushEcommerceEvent("begin_checkout", payload);
193
+ }
194
+
195
+ export function trackAddShippingInfo(payload: EcommercePayload) {
196
+ pushEcommerceEvent("add_shipping_info", payload);
197
+ }
198
+
199
+ export function trackAddPaymentInfo(payload: EcommercePayload, extra?: Record<string, unknown>) {
200
+ pushEcommerceEvent("add_payment_info", payload, extra);
201
+ }
202
+
203
+ export function trackPurchase(payload: {
204
+ transaction_id: string;
205
+ currency: string;
206
+ value: number;
207
+ tax?: number;
208
+ shipping?: number;
209
+ items: GA4Item[];
210
+ }) {
211
+ const { transaction_id, ...rest } = payload;
212
+ pushEcommerceEvent("purchase", rest, {
213
+ transaction_id,
214
+ order_id: transaction_id,
215
+ });
216
+ }
217
+
218
+ export function trackAddToWishlist(params: {
219
+ content_ids: string[];
220
+ currency?: string;
221
+ value?: number;
222
+ }) {
223
+ trackMetaStandardEvent(META_STANDARD_EVENTS.AddToWishlist, {
224
+ content_ids: params.content_ids,
225
+ content_type: "product",
226
+ currency: normalizeCurrency(params.currency),
227
+ value: params.value ?? 0,
228
+ });
229
+ }
230
+
231
+ export function trackCompleteRegistration(params?: { status?: string; method?: string }) {
232
+ trackMetaStandardEvent(META_STANDARD_EVENTS.CompleteRegistration, params);
233
+ }
234
+
235
+ export function trackContact(params?: { form_type?: string }) {
236
+ trackMetaStandardEvent(META_STANDARD_EVENTS.Contact, params);
237
+ }
238
+
239
+ export function trackLead(params?: { form_type?: string }) {
240
+ trackMetaStandardEvent(META_STANDARD_EVENTS.Lead, params);
241
+ }
242
+
243
+ export function trackSearch(searchString: string) {
244
+ trackMetaStandardEvent(META_STANDARD_EVENTS.Search, {
245
+ search_string: searchString,
246
+ });
247
+ }
248
+
249
+ export function trackHeroCtaClick(params: { label?: string; link?: string }) {
250
+ trackMetaStandardEvent(META_STANDARD_EVENTS.ViewContent, {
251
+ content_type: "cta",
252
+ content_name: params.label || "shop_now",
253
+ content_category: "hero_banner",
254
+ ...(params.link ? { content_url: params.link } : {}),
255
+ });
256
+ }
257
+
258
+ export function trackCartNavClick(cart: CartLike | null) {
259
+ const payload = cartToEcommercePayload(cart);
260
+ if (payload) trackViewCart(payload);
261
+ }
@@ -0,0 +1,34 @@
1
+ import type { FormSubmittedPayload, StorefrontAnalyticsAdapter } from "../core/types";
2
+
3
+ export function emitFormSubmitted(
4
+ analytics: StorefrontAnalyticsAdapter | undefined,
5
+ payload: FormSubmittedPayload
6
+ ): void {
7
+ analytics?.trackFormSubmitted?.(payload);
8
+ analytics?.trackEvent?.("form_submit", payload);
9
+ }
10
+
11
+ export function emitContactFormSubmitted(
12
+ analytics: StorefrontAnalyticsAdapter | undefined,
13
+ params?: { formType?: string; [key: string]: unknown }
14
+ ): void {
15
+ emitFormSubmitted(analytics, {
16
+ form_type: params?.formType ?? "contact_us_form",
17
+ ...params,
18
+ });
19
+ }
20
+
21
+ export function emitLeadCaptured(
22
+ analytics: StorefrontAnalyticsAdapter | undefined,
23
+ params?: { formType?: string; [key: string]: unknown }
24
+ ): void {
25
+ if (!analytics) return;
26
+ analytics.trackEvent?.("Lead", {
27
+ form_type: params?.formType ?? "newsletter_signup",
28
+ ...params,
29
+ });
30
+ emitFormSubmitted(analytics, {
31
+ form_type: params?.formType ?? "lead",
32
+ ...params,
33
+ });
34
+ }
@@ -0,0 +1,31 @@
1
+ /** Meta Pixel standard event names (fbq second argument) */
2
+ export const META_STANDARD_EVENTS = {
3
+ AddPaymentInfo: "AddPaymentInfo",
4
+ AddToCart: "AddToCart",
5
+ AddToWishlist: "AddToWishlist",
6
+ CompleteRegistration: "CompleteRegistration",
7
+ Contact: "Contact",
8
+ InitiateCheckout: "InitiateCheckout",
9
+ Lead: "Lead",
10
+ Purchase: "Purchase",
11
+ Search: "Search",
12
+ ViewContent: "ViewContent",
13
+ } as const;
14
+
15
+ export type MetaStandardEventName =
16
+ (typeof META_STANDARD_EVENTS)[keyof typeof META_STANDARD_EVENTS];
17
+
18
+ export type GA4Item = {
19
+ item_id: string;
20
+ item_name: string;
21
+ price?: number;
22
+ quantity?: number;
23
+ index?: number;
24
+ item_variant?: string;
25
+ };
26
+
27
+ export type EcommercePayload = {
28
+ currency: string;
29
+ value: number;
30
+ items: GA4Item[];
31
+ };
@@ -0,0 +1,25 @@
1
+ import type { ReviewSubmittedPayload, StorefrontAnalyticsAdapter } from "../core/types";
2
+
3
+ /**
4
+ * Domain helper: call after a review is successfully saved.
5
+ */
6
+ export function emitReviewSubmitted(
7
+ analytics: StorefrontAnalyticsAdapter | undefined,
8
+ payload: Omit<ReviewSubmittedPayload, "isUpdate"> & { isUpdate?: boolean }
9
+ ): void {
10
+ if (!analytics) return;
11
+
12
+ const full: ReviewSubmittedPayload = {
13
+ productId: payload.productId,
14
+ productName: payload.productName,
15
+ rating: payload.rating,
16
+ reviewTitle: payload.reviewTitle,
17
+ isUpdate: payload.isUpdate ?? false,
18
+ };
19
+
20
+ if (full.isUpdate && analytics.trackReviewUpdated) {
21
+ analytics.trackReviewUpdated(full);
22
+ } else {
23
+ analytics.trackReviewSubmitted(full);
24
+ }
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,74 @@
1
+ export type {
2
+ AnalyticsEventMap,
3
+ FormSubmittedPayload,
4
+ ProductRef,
5
+ ReviewSubmittedPayload,
6
+ StorefrontAnalyticsAdapter,
7
+ } from "./core/types";
8
+
9
+ export { noopAnalyticsAdapter } from "./core/noop-adapter";
10
+ export { composeAnalyticsAdapters } from "./core/compose";
11
+
12
+ export {
13
+ createGtmAnalyticsAdapter,
14
+ defaultStorefrontAnalytics,
15
+ pushToDataLayer,
16
+ trackGtmEvent,
17
+ trackEvent,
18
+ initGTM,
19
+ type GtmAnalyticsAdapterOptions,
20
+ } from "./providers/gtm";
21
+
22
+ export { emitReviewSubmitted } from "./events/reviews";
23
+ export {
24
+ emitContactFormSubmitted,
25
+ emitFormSubmitted,
26
+ emitLeadCaptured,
27
+ } from "./events/forms";
28
+
29
+ export {
30
+ META_STANDARD_EVENTS,
31
+ type EcommercePayload,
32
+ type GA4Item,
33
+ type MetaStandardEventName,
34
+ } from "./events/meta-events";
35
+
36
+ export {
37
+ META_PIXEL_DATALAYER_EVENT,
38
+ fireMetaPixelEvent,
39
+ fireMetaEcommerceEvent,
40
+ fireMetaPurchase,
41
+ } from "./providers/meta-pixel";
42
+
43
+ export {
44
+ META_EXPLICIT_KEY,
45
+ disableMetaPixelAutoConfig,
46
+ installMetaPixelGuard,
47
+ } from "./providers/meta-pixel-guard";
48
+
49
+ export { POST as metaCapiEventHandler } from "./api/meta-event";
50
+ export type { MetaCapiEventBody } from "./api/meta-event";
51
+
52
+ export {
53
+ cartToEcommercePayload,
54
+ markInitiateCheckoutFired,
55
+ trackAddPaymentInfo,
56
+ trackAddShippingInfo,
57
+ trackAddToCart,
58
+ trackAddToWishlist,
59
+ trackBeginCheckout,
60
+ trackCartNavClick,
61
+ trackCompleteRegistration,
62
+ trackContact,
63
+ trackHeroCtaClick,
64
+ trackInitiateCheckoutFromCart,
65
+ trackLead,
66
+ trackMetaStandardEvent,
67
+ trackPurchase,
68
+ trackRemoveFromCart,
69
+ trackSearch,
70
+ trackViewCart,
71
+ trackViewItem,
72
+ trackViewItemList,
73
+ wasInitiateCheckoutFired,
74
+ } from "./ecommerce/ga4-ecommerce";
@@ -0,0 +1,97 @@
1
+ import type {
2
+ FormSubmittedPayload,
3
+ ReviewSubmittedPayload,
4
+ StorefrontAnalyticsAdapter,
5
+ } from "../core/types";
6
+
7
+ declare global {
8
+ interface Window {
9
+ dataLayer?: Record<string, unknown>[];
10
+ }
11
+ }
12
+
13
+ export function pushToDataLayer(data: Record<string, unknown>): void {
14
+ if (typeof window === "undefined") return;
15
+ window.dataLayer = window.dataLayer || [];
16
+ window.dataLayer.push(data);
17
+ }
18
+
19
+ export function trackGtmEvent(eventName: string, params?: Record<string, unknown>): void {
20
+ pushToDataLayer({
21
+ event: eventName,
22
+ ...params,
23
+ });
24
+ }
25
+
26
+ export interface GtmAnalyticsAdapterOptions {
27
+ /** Also push legacy `form_submit` events (for existing GTM triggers) */
28
+ legacyFormSubmit?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Default GTM / dataLayer adapter for Medusa storefronts.
33
+ */
34
+ export function createGtmAnalyticsAdapter(
35
+ options: GtmAnalyticsAdapterOptions = {}
36
+ ): StorefrontAnalyticsAdapter {
37
+ const { legacyFormSubmit = true } = options;
38
+
39
+ const pushReview = (payload: ReviewSubmittedPayload, eventName: "review_submitted" | "review_updated") => {
40
+ trackGtmEvent(eventName, {
41
+ product_id: payload.productId,
42
+ product_name: payload.productName,
43
+ rating: payload.rating,
44
+ review_title: payload.reviewTitle,
45
+ is_update: payload.isUpdate,
46
+ });
47
+
48
+ if (legacyFormSubmit) {
49
+ trackGtmEvent("form_submit", {
50
+ form_type:
51
+ eventName === "review_updated"
52
+ ? "product_review_update"
53
+ : "product_review_submit",
54
+ product_id: payload.productId,
55
+ product_name: payload.productName,
56
+ rating: payload.rating,
57
+ });
58
+ }
59
+ };
60
+
61
+ return {
62
+ trackReviewSubmitted(payload) {
63
+ pushReview({ ...payload, isUpdate: false }, "review_submitted");
64
+ },
65
+ trackReviewUpdated(payload) {
66
+ pushReview({ ...payload, isUpdate: true }, "review_updated");
67
+ },
68
+ trackFormSubmitted(payload: FormSubmittedPayload) {
69
+ trackGtmEvent("form_submit", payload);
70
+ },
71
+ trackEvent: trackGtmEvent,
72
+ };
73
+ }
74
+
75
+ /** Default GTM adapter (legacy form_submit + review events). */
76
+ export const defaultStorefrontAnalytics = createGtmAnalyticsAdapter({
77
+ legacyFormSubmit: true,
78
+ });
79
+
80
+ /** @deprecated Use createGtmAnalyticsAdapter */
81
+ export const initGTM = (id: string) => {
82
+ if (typeof window === "undefined") return;
83
+ pushToDataLayer({
84
+ "gtm.start": new Date().getTime(),
85
+ event: "gtm.js",
86
+ });
87
+ const firstScript = document.getElementsByTagName("script")[0];
88
+ const script = document.createElement("script") as HTMLScriptElement;
89
+ script.async = true;
90
+ script.src = `https://www.googletagmanager.com/gtm.js?id=${id}`;
91
+ if (firstScript?.parentNode) {
92
+ firstScript.parentNode.insertBefore(script, firstScript);
93
+ }
94
+ };
95
+
96
+ /** @deprecated Use trackGtmEvent */
97
+ export const trackEvent = trackGtmEvent;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Blocks Meta automatic mis-classification (e.g. Subscribe on non-newsletter clicks).
3
+ * Only events fired via fireMetaPixelEvent() include __cm_explicit and pass through.
4
+ */
5
+
6
+ export const META_EXPLICIT_KEY = "__cm_explicit";
7
+
8
+ /** Disable Meta pixel automatic event configuration (button scraping, etc.) */
9
+ export function disableMetaPixelAutoConfig() {
10
+ if (typeof window === "undefined") return false;
11
+ const fbq = (window as Window & { fbq?: (...args: unknown[]) => void }).fbq;
12
+ if (typeof fbq !== "function") return false;
13
+
14
+ try {
15
+ fbq("set", "autoConfig", false);
16
+ } catch {
17
+ // Pixel may not be fully initialized yet
18
+ }
19
+ return true;
20
+ }
21
+
22
+ function shouldBlockMetaTrack(eventName: unknown, params: unknown): boolean {
23
+ if (typeof eventName !== "string") return false;
24
+ if (eventName === "Subscribe") {
25
+ const p = params as Record<string, unknown> | undefined;
26
+ return !p?.[META_EXPLICIT_KEY];
27
+ }
28
+ return false;
29
+ }
30
+
31
+ function patchFbq() {
32
+ if (typeof window === "undefined") return false;
33
+ const w = window as Window & {
34
+ fbq?: (...args: unknown[]) => void;
35
+ _fbq?: (...args: unknown[]) => void;
36
+ __cmFbqPatched?: boolean;
37
+ };
38
+ const fbq = w.fbq;
39
+ if (typeof fbq !== "function" || w.__cmFbqPatched) return true;
40
+
41
+ const original = fbq;
42
+ const patched = function (this: unknown, ...args: unknown[]) {
43
+ if (args[0] === "track" && shouldBlockMetaTrack(args[1], args[2])) {
44
+ return;
45
+ }
46
+ return original.apply(this, args);
47
+ };
48
+ Object.assign(patched, original);
49
+ w.fbq = patched;
50
+ if (w._fbq) w._fbq = patched;
51
+ w.__cmFbqPatched = true;
52
+ return true;
53
+ }
54
+
55
+ function patchDataLayer() {
56
+ if (typeof window === "undefined") return false;
57
+ const w = window as Window & {
58
+ dataLayer?: unknown[];
59
+ __cmDataLayerPatched?: boolean;
60
+ };
61
+ if (!w.dataLayer || w.__cmDataLayerPatched) return true;
62
+
63
+ const originalPush = w.dataLayer.push.bind(w.dataLayer);
64
+ w.dataLayer.push = function (...items: unknown[]) {
65
+ const filtered = items.filter((item) => {
66
+ if (!item || typeof item !== "object") return true;
67
+ const obj = item as Record<string, unknown>;
68
+ const metaEvent = obj.meta_event;
69
+ if (metaEvent === "Subscribe" && !obj[META_EXPLICIT_KEY]) return false;
70
+ if (obj.event === "subscribe" && !obj[META_EXPLICIT_KEY]) return false;
71
+ return true;
72
+ });
73
+ return originalPush(...filtered);
74
+ };
75
+ w.__cmDataLayerPatched = true;
76
+ return true;
77
+ }
78
+
79
+ /** Call once on app load; retries until GTM/pixel has initialized fbq */
80
+ export function installMetaPixelGuard() {
81
+ if (typeof window === "undefined") return;
82
+
83
+ patchDataLayer();
84
+
85
+ let attempts = 0;
86
+ const maxAttempts = 60;
87
+ const tick = () => {
88
+ patchDataLayer();
89
+ const fbqReady = patchFbq();
90
+ if (fbqReady) disableMetaPixelAutoConfig();
91
+ if (!fbqReady && ++attempts < maxAttempts) {
92
+ setTimeout(tick, 250);
93
+ }
94
+ };
95
+ tick();
96
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Meta Pixel standard events — explicit fbq + dedicated dataLayer pushes.
3
+ */
4
+
5
+ import {
6
+ META_STANDARD_EVENTS,
7
+ type EcommercePayload,
8
+ type MetaStandardEventName,
9
+ } from "../events/meta-events";
10
+ import { META_EXPLICIT_KEY } from "./meta-pixel-guard";
11
+
12
+ export { META_STANDARD_EVENTS, type MetaStandardEventName, type EcommercePayload };
13
+
14
+ export const META_PIXEL_DATALAYER_EVENT = "meta_pixel_track";
15
+
16
+ type FbqFn = (...args: unknown[]) => void;
17
+
18
+ function normalizeCurrency(currency?: string) {
19
+ return (currency ?? "INR").toUpperCase();
20
+ }
21
+
22
+ function buildMetaParams(payload: EcommercePayload) {
23
+ const items = payload.items ?? [];
24
+ const numItems = items.reduce((sum, i) => sum + (i.quantity ?? 1), 0);
25
+ return {
26
+ currency: normalizeCurrency(payload.currency),
27
+ value: payload.value,
28
+ content_ids: items.map((i) => String(i.item_id)),
29
+ content_type: "product",
30
+ contents: items.map((i) => ({
31
+ id: String(i.item_id),
32
+ quantity: i.quantity ?? 1,
33
+ item_price: i.price ?? 0,
34
+ })),
35
+ num_items: numItems,
36
+ };
37
+ }
38
+
39
+ function pushMetaDataLayer(metaEvent: MetaStandardEventName, params: Record<string, unknown>) {
40
+ if (typeof window === "undefined") return;
41
+ const w = window as Window & { dataLayer?: unknown[] };
42
+ w.dataLayer = w.dataLayer || [];
43
+ w.dataLayer.push({
44
+ event: META_PIXEL_DATALAYER_EVENT,
45
+ meta_event: metaEvent,
46
+ [META_EXPLICIT_KEY]: true,
47
+ ...params,
48
+ });
49
+ }
50
+
51
+ export function fireMetaPixelEvent(
52
+ metaEvent: MetaStandardEventName,
53
+ params: Record<string, unknown> = {}
54
+ ) {
55
+ pushMetaDataLayer(metaEvent, params);
56
+
57
+ if (typeof window === "undefined") return;
58
+ const fbq = (window as Window & { fbq?: FbqFn }).fbq;
59
+ if (typeof fbq === "function") {
60
+ fbq("track", metaEvent, { ...params, [META_EXPLICIT_KEY]: true });
61
+ }
62
+ }
63
+
64
+ export function fireMetaEcommerceEvent(
65
+ metaEvent: MetaStandardEventName,
66
+ payload: EcommercePayload,
67
+ extra?: Record<string, unknown>
68
+ ) {
69
+ fireMetaPixelEvent(metaEvent, { ...buildMetaParams(payload), ...extra });
70
+ }
71
+
72
+ export function fireMetaPurchase(payload: EcommercePayload & { transaction_id: string }) {
73
+ const { transaction_id, ...ecommerce } = payload;
74
+ fireMetaEcommerceEvent(META_STANDARD_EVENTS.Purchase, ecommerce, {
75
+ order_id: transaction_id,
76
+ });
77
+ }