Skip to content

Commit 6ee75ef

Browse files
ak--47claude
andauthored
feat: load Mixpanel from the snippet with a custom lib URL + visual_experiments (#75)
The main Next.js app bundled `mixpanel-browser` from npm, so MIXPANEL_CUSTOM_LIB_URL had no effect. Only the loader snippet reads that global. Switch the app to the snippet so we can pin the visual-experiments build, and add `visual_experiments: true` to init. - lib/mixpanel-snippet.ts: official loader + MIXPANEL_CUSTOM_LIB_URL pointing at .../libs/mixpanel-2.83.0-ve-alpha-1.min.js - app/layout.tsx: inject it with next/script strategy="beforeInteractive" - lib/analytics.ts: export `mixpanel` as a Proxy over window.mixpanel so all importing components keep working; add visual_experiments: true; keep every other init option and the whole console lifecycle intact `mixpanel-browser` stays in package.json for its TypeScript types. The snippet defines window.mixpanel on every page, which broke four things that used its presence as a signal: - waitForMixpanel() polled for the global and resolved on the bare stub. It now resolves from init()'s own `loaded` callback, and clears its timeout on settle so it can no longer log a false "UN-READY" error. - ClientLayout used the global to detect client-side navigation, so the landing page hit an infinite cleanup-and-reload loop. It now calls isMixpanelInitialized(). - The stub has no get_property, so useMixpanelDeviceId threw a TypeError. It now waits via the new whenMixpanelLoaded(), which does NOT trigger init -- the Header and Footer render on the landing page, where we never track. - The stub has no `flags`, so ChatbotWidget and DynamicCTAButton silently skipped their flag reads. The Proxy now supplies a deferred `flags` that forwards once the library loads, and rejects on timeout so callers' .catch() fallbacks still run. initMixpanelOnce() gains one explicit guard: if the inline snippet never ran we log a clear error and disable analytics rather than white-screening the demo. Tests asserted `window.mixpanel` was absent on the landing page, which is no longer a meaningful check. They now use the existing (previously unused) isMixpanelInitialized() helper, which probes for real instance methods. Verified in dev and production builds across all six verticals: lib loads from the custom URL, get_config('visual_experiments') is true, mixpanel.experiments exists, flags resolve, session recording starts, console lifecycle and window.RESET() intact, zero console errors. Full Playwright run: 75 passed, 1 pre-existing unrelated failure (`RESET clears everything` expects a log string the code has never emitted). Co-authored-by: Claude <noreply@anthropic.com>
1 parent bb967f8 commit 6ee75ef

11 files changed

Lines changed: 260 additions & 186 deletions

‎AGENTS.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ The build command (`npm run build`) uses Next.js static export and includes a po
6666
**Client-Side Only App**: This is a Next.js app configured for static export (`output: "export"`) with all components marked as `"use client"`. Server-side rendering is minimal - only the root layout runs on server. The app is configured for GitHub Pages deployment with appropriate basePath and assetPrefix settings.
6767

6868
**Mixpanel Integration**:
69-
- Initialization happens in `app/ClientLayout.tsx` via `initMixpanel()` from `lib/analytics.ts`
69+
- The library loads from the **snippet**, not the bundled `mixpanel-browser` npm module. `lib/mixpanel-snippet.ts` holds the official loader plus `MIXPANEL_CUSTOM_LIB_URL`, and `app/layout.tsx` injects it with `next/script` `strategy="beforeInteractive"`. Only the snippet honors a custom lib URL, which is how we pin the visual-experiments build. `mixpanel-browser` stays in `package.json` for its TypeScript types.
70+
- `lib/analytics.ts` exports `mixpanel` as a **Proxy over `window.mixpanel`**, so every call reaches the snippet-loaded instance. Do not re-add a module import.
71+
- Init happens in `app/ClientLayout.tsx` via `initMixpanelOnce()` from `lib/analytics.ts`. The landing page (`/`) deliberately never initializes.
72+
- **Never test `window.mixpanel` to detect readiness or init state** — the snippet defines that global on every page before our code runs. Use `isMixpanelInitialized()` for init state, `waitForMixpanel()` to init-and-wait, and `whenMixpanelLoaded()` to wait passively (read-only observers like the Header/Footer device-ID badge, which also render on the untracked landing page). Both waiters resolve from `init()`'s own `loaded` callback; nothing polls.
73+
- The snippet stub queues plain tracking calls but has **no `flags` namespace**. The analytics Proxy substitutes a deferred `flags` that forwards once the library loads, so `mixpanel.flags.get_variant_value(...)` is safe to call immediately.
7074
- Mixpanel is configured with comprehensive auto-capture settings:
7175
- Page views, clicks, form inputs, scrolling, and form submissions
7276
- Session recording enabled at 100% capture rate

‎app/ClientLayout.tsx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@
22

33
import { useEffect } from "react";
44
import { usePathname } from "next/navigation";
5-
import { initMixpanelOnce, cleanupEverything } from "../lib/analytics";
5+
import { initMixpanelOnce, cleanupEverything, isMixpanelInitialized } from "../lib/analytics";
66

77
export default function ClientLayout({ children }: { children: React.ReactNode }) {
88
const pathname = usePathname();
99

1010
useEffect(() => {
1111
// LANDING PAGE: Clean everything and force hard refresh
1212
if (pathname === "/") {
13-
// If Mixpanel exists, we client-side navigated here from a microsite
14-
// Clean everything, then force a hard reload for a fresh start
15-
if (typeof window !== 'undefined' && window.mixpanel) {
13+
// If Mixpanel was initialized, we client-side navigated here from a microsite.
14+
// Clean everything, then force a hard reload for a fresh start.
15+
// Do not test window.mixpanel here: the snippet defines it on every page.
16+
if (isMixpanelInitialized()) {
1617
console.log("[CLIENT LAYOUT]: Client-side navigation to landing detected - cleaning and reloading");
1718
cleanupEverything();
1819

‎app/checkout/ChatbotWidget.tsx‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import React, { useState, useEffect, useRef } from "react";
44
import { motion, AnimatePresence } from "framer-motion";
55
import { MessageCircle, X, Send, Bot, User } from "lucide-react";
6-
import { initMixpanelOnce } from "@/lib/analytics";
6+
import { initMixpanelOnce, mixpanel } from "@/lib/analytics";
77
import { products } from "./products";
88

99
// @ts-ignore
@@ -122,21 +122,21 @@ export function ChatbotWidget() {
122122
useEffect(() => {
123123
initMixpanelOnce();
124124

125-
// Check if feature flag is enabled
126-
if (window.mixpanel?.flags) {
127-
window.mixpanel.flags
128-
.is_enabled('we_buy_chatbot', false)
129-
.then((enabled: boolean) => {
130-
setShouldShow(enabled);
131-
if (enabled) {
132-
console.log('[MIXPANEL]: Chatbot feature flag enabled');
133-
window.mixpanel.track('Chatbot Loaded');
134-
}
135-
})
136-
.catch((error: any) => {
137-
console.error('[MIXPANEL]: Error checking chatbot flag:', error);
138-
});
139-
}
125+
// Check if feature flag is enabled.
126+
// The analytics proxy holds this call until the Mixpanel lib loads, so we
127+
// must not gate on window.mixpanel?.flags - the snippet stub has no flags.
128+
mixpanel.flags
129+
.is_enabled('we_buy_chatbot', false)
130+
.then((enabled: boolean) => {
131+
setShouldShow(enabled);
132+
if (enabled) {
133+
console.log('[MIXPANEL]: Chatbot feature flag enabled');
134+
mixpanel.track('Chatbot Loaded');
135+
}
136+
})
137+
.catch((error: any) => {
138+
console.error('[MIXPANEL]: Error checking chatbot flag:', error);
139+
});
140140
}, []);
141141

142142
// Auto-scroll to bottom when new messages arrive

‎app/checkout/DynamicCTAButton.tsx‎

Lines changed: 46 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import React, { useState, useEffect } from "react";
44
import { motion, AnimatePresence } from "framer-motion";
55
import { Sparkles, X, Gift, Zap, Rocket, AlertCircle } from "lucide-react";
6-
import { initMixpanelOnce } from "@/lib/analytics";
6+
import { initMixpanelOnce, mixpanel } from "@/lib/analytics";
77

88
// @ts-ignore
99
declare global {
@@ -52,59 +52,54 @@ export function DynamicCTAButton() {
5252
useEffect(() => {
5353
initMixpanelOnce();
5454

55-
// Fetch feature flag configuration
56-
if (window.mixpanel?.flags) {
57-
window.mixpanel.flags
58-
.get_variant_value('we_buy_custom_cta', null)
59-
.then((value: any) => {
60-
console.log('[MIXPANEL]: Got CTA config:', value);
55+
// Fetch feature flag configuration.
56+
// The analytics proxy holds this call until the Mixpanel lib loads,
57+
// so we no longer need a "is Mixpanel there yet" guard. The .catch
58+
// below still applies the fallback if the read fails.
59+
mixpanel.flags
60+
.get_variant_value('we_buy_custom_cta', null)
61+
.then((value: any) => {
62+
console.log('[MIXPANEL]: Got CTA config:', value);
6163

62-
if (value && typeof value === 'object' && 'cta' in value) {
63-
setConfig(value as CTAConfig);
64-
// Try to determine variant key for tracking
65-
Object.entries(defaultConfigs).forEach(([key, cfg]) => {
66-
if (cfg.cta === value.cta) {
67-
setVariantKey(key);
68-
}
69-
});
64+
if (value && typeof value === 'object' && 'cta' in value) {
65+
setConfig(value as CTAConfig);
66+
// Try to determine variant key for tracking
67+
Object.entries(defaultConfigs).forEach(([key, cfg]) => {
68+
if (cfg.cta === value.cta) {
69+
setVariantKey(key);
70+
}
71+
});
7072

71-
window.mixpanel.track('Dynamic CTA Loaded', {
72-
variant: variantKey || 'custom',
73-
cta_text: value.cta,
74-
color: value.color
75-
});
76-
} else {
77-
// Fallback to a random default for demo purposes
78-
const keys = Object.keys(defaultConfigs);
79-
const randomKey = keys[Math.floor(Math.random() * keys.length)];
80-
setConfig(defaultConfigs[randomKey]);
81-
setVariantKey(randomKey);
73+
window.mixpanel.track('Dynamic CTA Loaded', {
74+
variant: variantKey || 'custom',
75+
cta_text: value.cta,
76+
color: value.color
77+
});
78+
} else {
79+
// Fallback to a random default for demo purposes
80+
const keys = Object.keys(defaultConfigs);
81+
const randomKey = keys[Math.floor(Math.random() * keys.length)];
82+
setConfig(defaultConfigs[randomKey]);
83+
setVariantKey(randomKey);
8284

83-
console.log('[MIXPANEL]: Using fallback CTA config:', randomKey);
84-
window.mixpanel.track('Dynamic CTA Loaded', {
85-
variant: randomKey,
86-
cta_text: defaultConfigs[randomKey].cta,
87-
color: defaultConfigs[randomKey].color,
88-
is_fallback: true
89-
});
90-
}
91-
setIsLoading(false);
92-
})
93-
.catch((error: any) => {
94-
console.error('[MIXPANEL]: Error fetching CTA config:', error);
95-
// Use fallback on error
96-
const fallbackKey = 'snag';
97-
setConfig(defaultConfigs[fallbackKey]);
98-
setVariantKey(fallbackKey);
99-
setIsLoading(false);
100-
});
101-
} else {
102-
// No Mixpanel, use fallback
103-
const fallbackKey = 'snag';
104-
setConfig(defaultConfigs[fallbackKey]);
105-
setVariantKey(fallbackKey);
106-
setIsLoading(false);
107-
}
85+
console.log('[MIXPANEL]: Using fallback CTA config:', randomKey);
86+
window.mixpanel.track('Dynamic CTA Loaded', {
87+
variant: randomKey,
88+
cta_text: defaultConfigs[randomKey].cta,
89+
color: defaultConfigs[randomKey].color,
90+
is_fallback: true
91+
});
92+
}
93+
setIsLoading(false);
94+
})
95+
.catch((error: any) => {
96+
console.error('[MIXPANEL]: Error fetching CTA config:', error);
97+
// Use fallback on error
98+
const fallbackKey = 'snag';
99+
setConfig(defaultConfigs[fallbackKey]);
100+
setVariantKey(fallbackKey);
101+
setIsLoading(false);
102+
});
108103
}, []);
109104

110105
const handleClick = () => {

‎app/layout.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import "./globals.css";
44
import type { Metadata } from "next";
55
import { Inter } from "next/font/google";
6+
import Script from "next/script";
67
import ClientLayout from './ClientLayout';
8+
import { MIXPANEL_SNIPPET } from "@/lib/mixpanel-snippet";
79

810
const inter = Inter({ subsets: ["latin"] });
911

@@ -16,6 +18,14 @@ export const metadata: Metadata = {
1618
export default function RootLayout({ children }: { children: React.ReactNode }) {
1719
return (
1820
<html lang="en">
21+
<head>
22+
{/* Loads the Mixpanel lib from our custom URL before hydration. */}
23+
<Script
24+
id="mixpanel-snippet"
25+
strategy="beforeInteractive"
26+
dangerouslySetInnerHTML={{ __html: MIXPANEL_SNIPPET }}
27+
/>
28+
</head>
1929
<body className={inter.className}>
2030
<ClientLayout>{children}</ClientLayout>
2131
</body>

0 commit comments

Comments
 (0)