From bf81701b3b7ccf2c3b616c1d02082aec56b927dd Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sun, 5 Jul 2026 12:42:15 +0300 Subject: [PATCH 1/3] add .playwright-mcp dir into gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1fd0c02..486af9a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ # Agent E2E run reports (the specs are tracked; the generated run logs are not) agent-tests/_runs/ +.playwright-mcp \ No newline at end of file From 5f49c7ca5fdb6a47fd44d125bcd4085144896cc5 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Wed, 8 Jul 2026 21:17:39 +0200 Subject: [PATCH 2/3] feat: wire account_created, first-save & pricing-page analytics + enrich Stripe lifecycle events #86exkh107 Close the Step 5 instrumentation gaps against the metrics framework (codebridger/subturtle-docs). Adds the activation-funnel and trial-to-paid events that were still missing, using the canonical [object]_[action] naming convention: - account_created (server-truth) on the new-user branch of both Google OAuth paths; props signup_provider, oauth_timezone, referrer. - phrase_saved_first-time (server-truth) on a user's first-ever phrase in createPhrase; props source_platform, language_pair. - pricing-page_viewed (client) on the subscription page with from_surface, threaded from the cap/limit banner entry points. - Enrich the Stripe lifecycle events: trial_started +currency; subscription_started +currency +subscription_id; subscription_canceled +tier +lifetime_value_cents (summed from payment records) +cancel_reason. Out of scope (per decisions): extension_installed (extension repo) and fluent_waitlist_signed_up (no dashboard trigger yet). Mixpanel funnels and the L1 Forgot-rate aggregate are defined for build-out once these deploy. Co-Authored-By: Claude Opus 4.8 --- frontend/components/FeatureLocked.vue | 4 +- frontend/components/TextChatCounter.vue | 2 +- frontend/components/VoiceCapBanner.vue | 2 +- .../freemium_alerts/LimitationModal.vue | 2 +- .../freemium_alerts/UsageCapBanner.vue | 2 +- frontend/constants/analyticsEvents.ts | 3 ++ frontend/pages/settings/subscription.vue | 6 +++ server/src/modules/auth/router.ts | 17 +++++- .../gateway/__tests__/stripe.adapter.test.ts | 52 ++++++++++++++++++- .../gateway/adapters/stripe.adapter.ts | 47 +++++++++++++++-- .../__tests__/createPhrase.test.ts | 47 +++++++++++++++++ server/src/modules/phrase_bundle/functions.ts | 40 ++++++++++++++ server/src/modules/subscription/service.ts | 3 ++ server/src/utils/analytics.ts | 3 ++ 14 files changed, 218 insertions(+), 12 deletions(-) diff --git a/frontend/components/FeatureLocked.vue b/frontend/components/FeatureLocked.vue index bd7540c..989ea88 100644 --- a/frontend/components/FeatureLocked.vue +++ b/frontend/components/FeatureLocked.vue @@ -63,9 +63,9 @@ // Primary CTA pre-selects the required tier on the pricing page; secondary lists all. function goUpgrade() { - router.push(`/settings/subscription?suggest=${props.requiredTier}`); + router.push(`/settings/subscription?suggest=${props.requiredTier}&from=cap-hit-banner`); } function goToPlans() { - router.push('/settings/subscription'); + router.push('/settings/subscription?from=cap-hit-banner'); } diff --git a/frontend/components/TextChatCounter.vue b/frontend/components/TextChatCounter.vue index 81f929f..8cc1274 100644 --- a/frontend/components/TextChatCounter.vue +++ b/frontend/components/TextChatCounter.vue @@ -41,6 +41,6 @@ // At the cap, point the user at Learner's unlimited text chat. function findOutMore() { - router.push('/settings/subscription?suggest=learner'); + router.push('/settings/subscription?suggest=learner&from=cap-hit-banner'); } diff --git a/frontend/components/VoiceCapBanner.vue b/frontend/components/VoiceCapBanner.vue index dfffbd9..e865c21 100644 --- a/frontend/components/VoiceCapBanner.vue +++ b/frontend/components/VoiceCapBanner.vue @@ -55,6 +55,6 @@ // Learner's "Upgrade plan" highlights Coach; Coach has no tier above (button hidden). function goUpgrade() { - router.push('/settings/subscription?from=voice-banner&suggest=coach'); + router.push('/settings/subscription?from=cap-hit-banner&suggest=coach'); } diff --git a/frontend/components/freemium_alerts/LimitationModal.vue b/frontend/components/freemium_alerts/LimitationModal.vue index 52eb584..b40586d 100644 --- a/frontend/components/freemium_alerts/LimitationModal.vue +++ b/frontend/components/freemium_alerts/LimitationModal.vue @@ -119,7 +119,7 @@ function handlePrimaryAction(toggleModal?: (value: boolean) => void) { // Default behavior: redirect to subscription page (can be overridden by parent) if (props.autoRedirectOnUpgrade) { - router.push('/settings/subscription'); + router.push('/settings/subscription?from=cap-hit-banner'); analytic.track('upgrade-cta_clicked'); } } diff --git a/frontend/components/freemium_alerts/UsageCapBanner.vue b/frontend/components/freemium_alerts/UsageCapBanner.vue index d8f3868..63d0a49 100644 --- a/frontend/components/freemium_alerts/UsageCapBanner.vue +++ b/frontend/components/freemium_alerts/UsageCapBanner.vue @@ -37,6 +37,6 @@ const state = computed<'hidden' | 'warning' | 'paused'>(() => { }); function goToPlans() { - router.push('/settings/subscription'); + router.push('/settings/subscription?from=cap-hit-banner'); } diff --git a/frontend/constants/analyticsEvents.ts b/frontend/constants/analyticsEvents.ts index 790f931..02a1445 100644 --- a/frontend/constants/analyticsEvents.ts +++ b/frontend/constants/analyticsEvents.ts @@ -5,8 +5,11 @@ export const ANALYTICS_EVENTS = { CAP_HIT: 'cap_hit', // props: { cap: 'save_words' | 'ai_taste' } CHECKOUT_OPENED: 'checkout_opened', // props: { tier, cadence, currency } — checkout panel opened + PRICING_PAGE_VIEWED: 'pricing-page_viewed', // props: { from_surface } — subscription page opened FLASHCARD_REVIEW_STARTED: 'flashcard_review_started', // props: { deck_type } USER_LOGGED_IN: 'user_logged-in', // explicit login only, not session restore + ACCOUNT_CREATED: 'account_created', // server-fired (auth: first OAuth exchange for a new account) + PHRASE_SAVED_FIRST_TIME: 'phrase_saved_first-time', // server-fired (first-ever phrase save) TRIAL_STARTED: 'trial_started', // server-fired (Stripe webhook: subscription created as trialing) SUBSCRIPTION_STARTED: 'subscription_started', // server-fired (trial converted, or direct paid start) SUBSCRIPTION_CANCELED: 'subscription_canceled', // server-fired, props: { was_trialing } diff --git a/frontend/pages/settings/subscription.vue b/frontend/pages/settings/subscription.vue index 8d72377..3ac7031 100644 --- a/frontend/pages/settings/subscription.vue +++ b/frontend/pages/settings/subscription.vue @@ -505,6 +505,12 @@ async function probeLocalCurrency() { } onMounted(async () => { + // Trial-to-paid funnel entry. The auto page-view can't carry attribution, so + // fire an explicit event with the surface the user arrived from (?from=...); + // default to 'settings' for direct/nav visits. + analytic.track(ANALYTICS_EVENTS.PRICING_PAGE_VIEWED, { + from_surface: (route.query.from as string) || 'settings', + }); await fetchPlans(); probeLocalCurrency(); // Returning from a portal plan change (?plan_changed=1): the diff --git a/server/src/modules/auth/router.ts b/server/src/modules/auth/router.ts index 3de1064..a8c36ae 100644 --- a/server/src/modules/auth/router.ts +++ b/server/src/modules/auth/router.ts @@ -3,6 +3,7 @@ import { reply, userManager } from "@modular-rest/server"; import { google } from "googleapis"; import { updateUserProfile } from "../profile/service"; import { LeitnerService } from "../leitner_box/service"; +import { trackServerEvent, SERVER_ANALYTICS_EVENTS } from "../../utils/analytics"; const name = "auth"; const auth = new Router(); @@ -122,6 +123,13 @@ auth.get("/google/code-login", async (ctx) => { if (!registeredUser) { try { userId = await userManager.registerUser({ email }); + // Activation funnel entry: first successful OAuth exchange for a brand-new + // account. Server-truth so it fires once, on the genuine new-user branch. + trackServerEvent(SERVER_ANALYTICS_EVENTS.ACCOUNT_CREATED, userId as string, { + signup_provider: "google", + oauth_timezone: timeZone || undefined, + referrer: redirectUrl || undefined, + }); } catch (error) { ctx.throw( 500, @@ -187,7 +195,14 @@ auth.get("/google/access-token-login", async (ctx) => { if (!registeredUser) { try { - await userManager.registerUser({ email: googleEmail }); + const newUserId = await userManager.registerUser({ email: googleEmail }); + // Activation funnel entry for accounts created via the extension's Google + // token. No timezone/referrer on this path, so only the provider is known. + trackServerEvent( + SERVER_ANALYTICS_EVENTS.ACCOUNT_CREATED, + newUserId as string, + { signup_provider: "google" } + ); } catch (error) { ctx.throw( 500, diff --git a/server/src/modules/gateway/__tests__/stripe.adapter.test.ts b/server/src/modules/gateway/__tests__/stripe.adapter.test.ts index 0aa660a..8670075 100644 --- a/server/src/modules/gateway/__tests__/stripe.adapter.test.ts +++ b/server/src/modules/gateway/__tests__/stripe.adapter.test.ts @@ -70,6 +70,7 @@ function createdEvent() { customer: "cus_1", id: "sub_1", status: "trialing", + currency: "gbp", trial_end: 1234, items: { data: [ @@ -92,6 +93,7 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { jest.spyOn(console, "error").mockImplementation(() => {}); (getCollection as any).mockReturnValue({ findOne: jest.fn(async () => ({ user_id: "u1" })), // stripe_customer -> userId + find: jest.fn(async () => []), // payment records for lifetime-value (default: none) }); adapter = new StripeAdapter("sk_test_dummy"); }); @@ -131,6 +133,7 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { expect(trackServerEvent).toHaveBeenCalledWith("trial_started", "u1", { cadence: "monthly", tier: "learner", + currency: "gbp", }); }); @@ -144,7 +147,7 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { expect(trackServerEvent).toHaveBeenCalledWith( "subscription_canceled", "u1", - { was_trialing: false } + { was_trialing: false, lifetime_value_cents: 0 } ); }); @@ -164,6 +167,7 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { customer: "cus_1", id: "sub_1", status: "active", + currency: "gbp", cancel_at_period_end: false, trial_end: null, items: { @@ -184,7 +188,13 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { expect(trackServerEvent).toHaveBeenCalledWith( "subscription_started", "u1", - { cadence: "monthly", tier: "learner", via_trial: true } + { + cadence: "monthly", + tier: "learner", + currency: "gbp", + subscription_id: "sub_1", + via_trial: true, + } ); }); @@ -238,6 +248,8 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { expect(trackServerEvent).toHaveBeenCalledWith("subscription_started", "u1", { cadence: "monthly", tier: "learner", + currency: "gbp", + subscription_id: "sub_1", via_trial: false, }); }); @@ -277,6 +289,7 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { customer: "cus_1", id: "sub_1", status: "active", + currency: "gbp", cancel_at_period_end: false, trial_end: null, items: { @@ -296,6 +309,8 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { expect(trackServerEvent).toHaveBeenCalledWith("subscription_started", "u1", { cadence: "monthly", tier: "learner", + currency: "gbp", + subscription_id: "sub_1", via_trial: false, }); }); @@ -313,6 +328,39 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { await adapter.handleWebhook(event); expect(trackServerEvent).toHaveBeenCalledWith("subscription_canceled", "u1", { was_trialing: true, + lifetime_value_cents: 0, + }); + }); + + it("enriches subscription_canceled with tier, lifetime value (cents), and reason", async () => { + (cancelSubscriptionByProviderAndSubscriptionId as any).mockResolvedValueOnce({ + success: true, + message: "ok", + wasTrialing: false, + tier: "learner", + }); + // Two succeeded payments at 4.49 + 4.49 (major units) -> 898 cents. + (getCollection as any).mockReturnValue({ + findOne: jest.fn(async () => ({ user_id: "u1" })), + find: jest.fn(async () => [{ amount: 4.49 }, { amount: 4.49 }]), + }); + const event = { + type: "customer.subscription.deleted", + data: { + object: { + customer: "cus_1", + id: "sub_1", + status: "canceled", + cancellation_details: { reason: "cancellation_requested" }, + }, + }, + }; + await adapter.handleWebhook(event); + expect(trackServerEvent).toHaveBeenCalledWith("subscription_canceled", "u1", { + was_trialing: false, + tier: "learner", + lifetime_value_cents: 898, + cancel_reason: "cancellation_requested", }); }); diff --git a/server/src/modules/gateway/adapters/stripe.adapter.ts b/server/src/modules/gateway/adapters/stripe.adapter.ts index f474e4e..2cbb67c 100644 --- a/server/src/modules/gateway/adapters/stripe.adapter.ts +++ b/server/src/modules/gateway/adapters/stripe.adapter.ts @@ -518,12 +518,19 @@ export class StripeAdapter implements PaymentAdapter { trackServerEvent(SERVER_ANALYTICS_EVENTS.TRIAL_STARTED, userId, { cadence, tier: entitlements.tierId, + currency: subscription.currency, }); } else if (subscription.status === "active") { trackServerEvent( SERVER_ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, userId, - { cadence, tier: entitlements.tierId, via_trial: false } + { + cadence, + tier: entitlements.tierId, + currency: subscription.currency, + subscription_id: subscription.id, + via_trial: false, + } ); } } @@ -538,7 +545,7 @@ export class StripeAdapter implements PaymentAdapter { const subscription = event.data.object as Stripe.Subscription; try { - const { success, message, wasTrialing } = + const { success, message, wasTrialing, tier } = await cancelSubscriptionByProviderAndSubscriptionId({ provider: this.provider, subscriptionId: subscription.id, @@ -554,10 +561,42 @@ export class StripeAdapter implements PaymentAdapter { subscription.customer as string ); if (userId && success) { + // Lifetime value = sum of the succeeded payments recorded for this + // subscription. Stored `amount` is in major units (see verifyPayment), + // so ×100 → integer cents. Best-effort: a lookup failure must not + // block the churn event. + let lifetimeValueCents = 0; + try { + const paymentCollection = getCollection( + DATABASE, + PAYMENT_COLLECTION + ); + const payments = (await paymentCollection.find({ + "provider_data.subscription_id": subscription.id, + status: "succeeded", + })) as any[]; + const total = (payments || []).reduce( + (sum, p) => sum + (Number(p.amount) || 0), + 0 + ); + lifetimeValueCents = Math.round(total * 100); + } catch (ltvErr) { + console.error( + "[analytics] failed to compute lifetime value for cancel", + ltvErr + ); + } + trackServerEvent( SERVER_ANALYTICS_EVENTS.SUBSCRIPTION_CANCELED, userId, - { was_trialing: !!wasTrialing } + { + was_trialing: !!wasTrialing, + tier, + lifetime_value_cents: lifetimeValueCents, + cancel_reason: + subscription.cancellation_details?.reason || undefined, + } ); } @@ -640,6 +679,8 @@ export class StripeAdapter implements PaymentAdapter { { cadence, tier: entitlements.tierId, + currency: subscription.currency, + subscription_id: subscription.id, via_trial: previousStatus === "trialing", } ); diff --git a/server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts b/server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts index 6a544fc..c3c3eb4 100644 --- a/server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts +++ b/server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts @@ -34,6 +34,14 @@ jest.mock("../../subscription/enforcement", () => ({ jest.mock("../triggers", () => ({ phraseBundleTriggers: [] })); +jest.mock("../../../utils/analytics", () => ({ + trackServerEvent: jest.fn(), + SERVER_ANALYTICS_EVENTS: { + PHRASE_SAVED_FIRST_TIME: "phrase_saved_first-time", + }, +})); + +const { trackServerEvent } = require("../../../utils/analytics"); const { functions } = require("../functions"); const createPhrase = functions.find((f: any) => f.name === "createPhrase"); @@ -83,4 +91,43 @@ describe("createPhrase persistence", () => { expect(doc.chunks).toBeUndefined(); expect(doc.sourceUrl).toBeUndefined(); }); + + it("fires phrase_saved_first-time on the user's first-ever save", async () => { + // countDocuments is called first for bundle verification, then for the + // prior-phrase count. Both zero here -> genuine first save. + countDocuments.mockResolvedValueOnce(0).mockResolvedValueOnce(0); + + await createPhrase.callback({ + phrase: "we had to decide quickly", + translation: "...", + translation_language: "fa", + bundleIds: [], + refId: "user-1", + type: "linguistic", + language_info: { source: "en", target: "fa" }, + sourceUrl: "https://www.youtube.com/watch?v=abc123", + }); + + expect(trackServerEvent).toHaveBeenCalledWith( + "phrase_saved_first-time", + "user-1", + { source_platform: "youtube", language_pair: "en-fa" } + ); + }); + + it("does NOT fire phrase_saved_first-time when the user already has phrases", async () => { + // Bundle-verify count 0, then a non-zero prior-phrase count -> not the first save. + countDocuments.mockResolvedValueOnce(0).mockResolvedValueOnce(7); + + await createPhrase.callback({ + phrase: "hello", + translation: "...", + translation_language: "fa", + bundleIds: [], + refId: "user-1", + type: "normal", + }); + + expect(trackServerEvent).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/modules/phrase_bundle/functions.ts b/server/src/modules/phrase_bundle/functions.ts index 60a0da7..5e59dde 100644 --- a/server/src/modules/phrase_bundle/functions.ts +++ b/server/src/modules/phrase_bundle/functions.ts @@ -15,8 +15,27 @@ import { PhraseSchema } from "./db"; import { normaliseSourceUrl } from "../translation/url-normalise"; import { openRouter } from "../../utils/openrouter"; import { TRANSLATION_MODELS } from "../../utils/openrouter-models"; +import { trackServerEvent, SERVER_ANALYTICS_EVENTS } from "../../utils/analytics"; import { z } from "zod"; +/** + * Map a saved-from page URL to the coarse `source_platform` bucket the activation + * funnel filters on. Unknown/absent hosts collapse to `other`. + */ +function sourcePlatformFromUrl(sourceUrl?: string): string { + if (!sourceUrl) return "other"; + let host = ""; + try { + host = new URL(sourceUrl).hostname.toLowerCase(); + } catch { + return "other"; + } + if (host.includes("youtube.")) return "youtube"; + if (host.includes("netflix.")) return "netflix"; + if (host.includes("wikipedia.")) return "wikipedia"; + return "other"; +} + interface RemoveBundleParams { _id: string; refId: string; @@ -189,6 +208,10 @@ const createPhrase = defineFunction({ // Use existing phrase phraseId = existingPhrase._id; } else { + // Detect the user's first-ever save BEFORE inserting: zero prior phrase + // docs for this owner means this new save is their activation moment. + const priorPhraseCount = await phraseCollection.countDocuments({ refId }); + // Enforce the saved-words cap BEFORE creating a new phrase (free: 200 per // window; paid tiers: unlimited). Reusing an existing phrase doc above does // not count against the cap, so the check only guards genuinely new saves. @@ -230,6 +253,23 @@ const createPhrase = defineFunction({ phraseId = insertedPhrases[0]._id; isNewPhrase = true; + // Activation funnel: fire once, only when this is the user's first-ever + // phrase. Both the extension and the dashboard save through this RPC, so + // this is the single server-truth point for the "first save" milestone. + if (priorPhraseCount === 0) { + const languagePair = language_info?.source + ? `${language_info.source}-${language_info.target}` + : translation_language?.trim() || undefined; + trackServerEvent( + SERVER_ANALYTICS_EVENTS.PHRASE_SAVED_FIRST_TIME, + refId, + { + source_platform: sourcePlatformFromUrl(sourceUrl), + language_pair: languagePair, + } + ); + } + // Update freemium allocation only for new phrases const user_id = refId; const isFreemium = await isUserOnFreemium(user_id); diff --git a/server/src/modules/subscription/service.ts b/server/src/modules/subscription/service.ts index 11b1c37..ae9129c 100644 --- a/server/src/modules/subscription/service.ts +++ b/server/src/modules/subscription/service.ts @@ -820,12 +820,15 @@ export async function cancelSubscriptionByProviderAndSubscriptionId(props: { success: true, message: "Subscription canceled successfully", wasTrialing, + // Surface the canceled tier for the subscription_canceled analytics event. + tier: existing?.tier, }; } catch (error: any) { return { success: false, message: error.message || "Failed to cancel subscription", wasTrialing: false, + tier: undefined, }; } } diff --git a/server/src/utils/analytics.ts b/server/src/utils/analytics.ts index 425380d..2e12913 100644 --- a/server/src/utils/analytics.ts +++ b/server/src/utils/analytics.ts @@ -18,6 +18,9 @@ if (token) { // Mirrors frontend/constants/analyticsEvents.ts for the server-fired events. // Naming follows docs/metrics/event-naming.md: [object]_[action], `-` inside a part. export const SERVER_ANALYTICS_EVENTS = { + // Activation funnel (server-truth signals the client can't see reliably). + ACCOUNT_CREATED: "account_created", // first successful OAuth exchange for a new account + PHRASE_SAVED_FIRST_TIME: "phrase_saved_first-time", // user's first-ever phrase save (any surface) // Stripe webhook lifecycle (the roadmap's slim instrumentation set). TRIAL_STARTED: "trial_started", // subscription created with status "trialing" SUBSCRIPTION_STARTED: "subscription_started", // trial converted, or direct paid start — props: { via_trial } From 71cf5e0fe5975fce202b583de6168a438ce08dd5 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Wed, 8 Jul 2026 21:31:14 +0200 Subject: [PATCH 3/3] fix: guard pricing-page_viewed track so a token-less Mixpanel can't abort the subscription page mount #86exkh107 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When NUXT_PUBLIC_MIXPANEL_PROJECT_TOKEN is unset (CI/e2e), the Mixpanel plugin skips init and analytic.track() throws on the uninitialised instance. Fired at the top of onMounted, that aborted the mount before fetchPlans(), so the plan cards never rendered (Playwright subscription-ui "four plan cards" failed). Wrap the analytics call in try/catch — instrumentation is best-effort and must never break the page. Co-Authored-By: Claude Opus 4.8 --- frontend/pages/settings/subscription.vue | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/pages/settings/subscription.vue b/frontend/pages/settings/subscription.vue index 3ac7031..c46a1b9 100644 --- a/frontend/pages/settings/subscription.vue +++ b/frontend/pages/settings/subscription.vue @@ -507,10 +507,16 @@ async function probeLocalCurrency() { onMounted(async () => { // Trial-to-paid funnel entry. The auto page-view can't carry attribution, so // fire an explicit event with the surface the user arrived from (?from=...); - // default to 'settings' for direct/nav visits. - analytic.track(ANALYTICS_EVENTS.PRICING_PAGE_VIEWED, { - from_surface: (route.query.from as string) || 'settings', - }); + // default to 'settings' for direct/nav visits. Analytics is best-effort: when + // Mixpanel is not initialised (e.g. no token in CI/e2e), track() can throw — + // it must never abort the mount and block the plan cards from loading. + try { + analytic.track(ANALYTICS_EVENTS.PRICING_PAGE_VIEWED, { + from_surface: (route.query.from as string) || 'settings', + }); + } catch (e) { + console.error('Failed to track pricing-page_viewed:', e); + } await fetchPlans(); probeLocalCurrency(); // Returning from a portal plan change (?plan_changed=1): the