From d517a18ec7a0cdae2f54ac7d30a0d2e2b72f0274 Mon Sep 17 00:00:00 2001 From: "open-design-crew[bot]" <299007234+open-design-crew[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:08:05 +0000 Subject: [PATCH] fix(analytics): restore migrated pricing funnel events (#7299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: design pricing plan exposure restoration * fix(analytics): restore migrated pricing funnel events * docs: design authenticated pricing analytics bridge * docs: plan authenticated pricing analytics bridge * feat(analytics): add authenticated pricing bridge contract * fix(analytics): harden pricing bridge validation * fix(analytics): restore complete personal pricing semantics * fix(analytics): require explicit personal audience * fix(analytics): relay authenticated pricing funnel events * test(analytics): cover localized pricing handoff * docs(analytics): correct pricing source handoff * fix(analytics): align migrated pricing compatibility contract --------- Co-authored-by: 张辉华 --- .../_components/enterprise-lead-form.astro | 8 + .../app/_lib/pricing-analytics-bridge.ts | 434 +++++++++++++++++ .../app/_lib/pricing-compat-analytics.ts | 252 ++++++++++ apps/landing-page/app/_lib/pricing.ts | 2 +- .../app/pages/pricing/index.astro | 131 +++++- .../tests/pricing-analytics-bridge.test.ts | 387 ++++++++++++++++ .../tests/pricing-analytics-browser.test.ts | 355 ++++++++++++++ .../tests/pricing-compat-analytics.test.ts | 437 ++++++++++++++++++ .../tests/pricing-contract.test.ts | 30 ++ ...8-23-restore-migrated-pricing-analytics.md | 250 ++++++++++ ...026-08-23-vela-pricing-analytics-bridge.md | 251 ++++++++++ ...23-restore-pricing-plan-exposure-design.md | 215 +++++++++ 12 files changed, 2750 insertions(+), 2 deletions(-) create mode 100644 apps/landing-page/app/_lib/pricing-analytics-bridge.ts create mode 100644 apps/landing-page/app/_lib/pricing-compat-analytics.ts create mode 100644 apps/landing-page/tests/pricing-analytics-bridge.test.ts create mode 100644 apps/landing-page/tests/pricing-analytics-browser.test.ts create mode 100644 apps/landing-page/tests/pricing-compat-analytics.test.ts create mode 100644 docs/superpowers/plans/2026-08-23-restore-migrated-pricing-analytics.md create mode 100644 docs/superpowers/plans/2026-08-23-vela-pricing-analytics-bridge.md create mode 100644 docs/superpowers/specs/2026-08-23-restore-pricing-plan-exposure-design.md diff --git a/apps/landing-page/app/_components/enterprise-lead-form.astro b/apps/landing-page/app/_components/enterprise-lead-form.astro index ffb9da4cee..9074ac20e2 100644 --- a/apps/landing-page/app/_components/enterprise-lead-form.astro +++ b/apps/landing-page/app/_components/enterprise-lead-form.astro @@ -467,6 +467,14 @@ const countryOptions = buildCountryOptions(locale); form.addEventListener('submit', async (event) => { event.preventDefault(); + // Pricing's legacy compatibility funnel counted submit intent, including + // attempts rejected by client validation. Keep this synchronous and + // payload-free so analytics can never delay or leak lead fields. + if (leadSource === 'pricing_team') { + form.dispatchEvent(new CustomEvent('pricing:enterprise-submit', { + bubbles: true, + })); + } if (errorEl) errorEl.hidden = true; let firstInvalid = null; diff --git a/apps/landing-page/app/_lib/pricing-analytics-bridge.ts b/apps/landing-page/app/_lib/pricing-analytics-bridge.ts new file mode 100644 index 0000000000..24bd600bc8 --- /dev/null +++ b/apps/landing-page/app/_lib/pricing-analytics-bridge.ts @@ -0,0 +1,434 @@ +import { + GO_PLAN, + HOSTED_CLOUD_CONSOLE_DOMAINS, + PRICING_SNAPSHOT, + type BillingInterval, + type PlanTier, + type PlanTierConfig, +} from './pricing'; + +export type PricingBridgeSource = 'wallet' | 'dashboard'; + +export type PlanExposureInput = { + planId: PlanTier; + billingInterval: BillingInterval; + priceUsd: string; + creditsGrantedUsd: string; + deployLimit: number; + introOfferApplied: boolean; + firstMonthEligible: boolean; + isCurrentPlan: boolean; + isRecommended: boolean; +}; + +type ChangeIntervalClickInput = { + element: 'change_interval'; + currentPlanId: PlanTier | null; + currentBillingInterval: BillingInterval; + targetBillingInterval: BillingInterval; +}; + +type PlanCtaClickFields = { + currentBillingInterval: BillingInterval | null; + targetPlanId: PlanTier; + targetBillingInterval: BillingInterval; + priceUsd: string; + creditsGrantedUsd: string; + introOfferApplied: boolean; + isCurrentPlan: boolean; + isRecommended: boolean; +}; + +type EnterpriseClickContext = { + currentPlanId: PlanTier | null; + currentBillingInterval: BillingInterval | null; +}; + +type EnterpriseClickInput = + | (EnterpriseClickContext & { element: 'request_team_access' }) + | (EnterpriseClickContext & { element: 'team_lead_submit' }); + +export type PricingClickInput = + | (PlanCtaClickFields & { + element: 'subscribe_now'; + currentPlanId: null; + }) + | (PlanCtaClickFields & { + element: 'upgrade_now'; + currentPlanId: PlanTier; + }) + | ChangeIntervalClickInput + | EnterpriseClickInput; + +export type PricingBridgeEvent = + | { + kind: 'plan_exposure'; + eventId: string; + eventTime: string; + payload: PlanExposureInput; + } + | { + kind: 'pricing_click'; + eventId: string; + eventTime: string; + payload: PricingClickInput; + }; + +const goTier: PlanTierConfig = { + tier: GO_PLAN.tier, + rank: 0, + recommended: false, + monthly: { + priceUsd: GO_PLAN.monthly.priceUsd, + introPriceUsd: GO_PLAN.monthly.introPriceUsd, + grantUsd: 0, + }, + yearly: { + priceUsd: GO_PLAN.yearly.priceUsd, + discountPct: 50, + grantUsd: 0, + }, + deployLimit: 0, +}; + +export const PERSONAL_PRICING_TIERS: readonly PlanTierConfig[] = [ + goTier, + ...PRICING_SNAPSHOT.tiers, +]; + +const sourceOverrideKeys = [ + 'sourceSurface', + 'source_surface', + 'workspaceTab', + 'workspace_tab', + 'pricingSource', + 'pricing_source', + 'source', + 'od_entry_source', +] as const; + +const sourceByPath: Readonly> = { + '/wallet': 'wallet', + '/dashboard': 'dashboard', + '/cloud/wallet': 'wallet', + '/cloud/dashboard': 'dashboard', +}; + +function isTrustedHostedUrl(url: URL): boolean { + return ( + url.protocol === 'https:' && + url.port.length === 0 && + HOSTED_CLOUD_CONSOLE_DOMAINS.some( + (domain) => + url.hostname === domain || url.hostname.endsWith(`.${domain}`), + ) + ); +} + +function isTrustedLoopbackUrl(url: URL): boolean { + return ( + url.protocol === 'http:' && + (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && + url.port.length > 0 + ); +} + +/** Resolve only canonical Vela routes; query state never creates a surface. */ +export function resolvePricingBridgeSource(input: { + search: URLSearchParams; + referrer: string; +}): PricingBridgeSource | null { + if (sourceOverrideKeys.some((key) => input.search.has(key))) return null; + if (!input.referrer) return null; + + try { + const referrer = new URL(input.referrer); + if ( + referrer.href !== input.referrer || + referrer.username || + referrer.password || + referrer.hash || + (!isTrustedHostedUrl(referrer) && !isTrustedLoopbackUrl(referrer)) + ) { + return null; + } + return sourceByPath[referrer.pathname] ?? null; + } catch { + return null; + } +} + +const idMaxLength = 128; +const maxEventsPerRequest = 8; +const transportTimeoutMs = 3_000; +const usdAmountPattern = /^(?:0|[1-9][0-9]{0,8})\.[0-9]{2}$/u; +// Mirrors Zod 3.25 `z.string().datetime()` used by Vela: real calendar date, +// UTC Z suffix, optional seconds, and arbitrary fractional-second precision. +const velaDateTimePattern = new RegExp( + '^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d+)?)?Z$', + 'u', +); + +function isBoundedId(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= idMaxLength && + value.trim() === value + ); +} + +function isPlanTier(value: unknown): value is PlanTier { + return ( + value === 'go' || + value === 'plus' || + value === 'pro' || + value === 'max' + ); +} + +function isBillingInterval(value: unknown): value is BillingInterval { + return value === 'monthly' || value === 'yearly'; +} + +function isUsdAmount(value: unknown): value is string { + return typeof value === 'string' && usdAmountPattern.test(value); +} + +function isBoolean(value: unknown): value is boolean { + return typeof value === 'boolean'; +} + +function isVelaDateTime(value: unknown): value is string { + return typeof value === 'string' && velaDateTimePattern.test(value); +} + +function sanitizedPlanPayload(value: unknown): PlanExposureInput | null { + if (!value || typeof value !== 'object') return null; + const payload = value as Record; + if ( + !isPlanTier(payload.planId) || + !isBillingInterval(payload.billingInterval) || + !isUsdAmount(payload.priceUsd) || + !isUsdAmount(payload.creditsGrantedUsd) || + !Number.isSafeInteger(payload.deployLimit) || + Number(payload.deployLimit) < 0 || + !isBoolean(payload.introOfferApplied) || + !isBoolean(payload.firstMonthEligible) || + !isBoolean(payload.isCurrentPlan) || + !isBoolean(payload.isRecommended) + ) { + return null; + } + return { + planId: payload.planId, + billingInterval: payload.billingInterval, + priceUsd: payload.priceUsd, + creditsGrantedUsd: payload.creditsGrantedUsd, + deployLimit: Number(payload.deployLimit), + introOfferApplied: payload.introOfferApplied, + firstMonthEligible: payload.firstMonthEligible, + isCurrentPlan: payload.isCurrentPlan, + isRecommended: payload.isRecommended, + }; +} + +function sanitizedPlanCtaPayload( + payload: Record, +): PlanCtaClickFields | null { + if ( + (payload.currentBillingInterval !== null && + !isBillingInterval(payload.currentBillingInterval)) || + !isPlanTier(payload.targetPlanId) || + !isBillingInterval(payload.targetBillingInterval) || + !isUsdAmount(payload.priceUsd) || + !isUsdAmount(payload.creditsGrantedUsd) || + !isBoolean(payload.introOfferApplied) || + !isBoolean(payload.isCurrentPlan) || + !isBoolean(payload.isRecommended) + ) { + return null; + } + return { + currentBillingInterval: payload.currentBillingInterval, + targetPlanId: payload.targetPlanId, + targetBillingInterval: payload.targetBillingInterval, + priceUsd: payload.priceUsd, + creditsGrantedUsd: payload.creditsGrantedUsd, + introOfferApplied: payload.introOfferApplied, + isCurrentPlan: payload.isCurrentPlan, + isRecommended: payload.isRecommended, + }; +} + +function sanitizedClickPayload(value: unknown): PricingClickInput | null { + if (!value || typeof value !== 'object') return null; + const payload = value as Record; + switch (payload.element) { + case 'change_interval': + if ( + (payload.currentPlanId !== null && !isPlanTier(payload.currentPlanId)) || + !isBillingInterval(payload.currentBillingInterval) || + !isBillingInterval(payload.targetBillingInterval) + ) { + return null; + } + return { + element: 'change_interval', + currentPlanId: payload.currentPlanId, + currentBillingInterval: payload.currentBillingInterval, + targetBillingInterval: payload.targetBillingInterval, + }; + case 'subscribe_now': { + const fields = sanitizedPlanCtaPayload(payload); + if (payload.currentPlanId !== null || !fields) return null; + return { element: 'subscribe_now', currentPlanId: null, ...fields }; + } + case 'upgrade_now': { + const fields = sanitizedPlanCtaPayload(payload); + if (!isPlanTier(payload.currentPlanId) || !fields) return null; + return { + element: 'upgrade_now', + currentPlanId: payload.currentPlanId, + ...fields, + }; + } + case 'request_team_access': + if ( + (payload.currentPlanId !== null && !isPlanTier(payload.currentPlanId)) || + (payload.currentBillingInterval !== null && + !isBillingInterval(payload.currentBillingInterval)) + ) { + return null; + } + return { + element: 'request_team_access', + currentPlanId: payload.currentPlanId, + currentBillingInterval: payload.currentBillingInterval, + }; + case 'team_lead_submit': + if ( + (payload.currentPlanId !== null && !isPlanTier(payload.currentPlanId)) || + (payload.currentBillingInterval !== null && + !isBillingInterval(payload.currentBillingInterval)) + ) { + return null; + } + return { + element: 'team_lead_submit', + currentPlanId: payload.currentPlanId, + currentBillingInterval: payload.currentBillingInterval, + }; + default: + return null; + } +} + +function sanitizedEvent(event: PricingBridgeEvent): PricingBridgeEvent | null { + if ( + !isBoundedId(event.eventId) || + !isVelaDateTime(event.eventTime) + ) { + return null; + } + if (event.kind === 'plan_exposure') { + const payload = sanitizedPlanPayload(event.payload); + return payload + ? { + kind: event.kind, + eventId: event.eventId, + eventTime: event.eventTime, + payload, + } + : null; + } + if (event.kind === 'pricing_click') { + const payload = sanitizedClickPayload(event.payload); + return payload + ? { + kind: event.kind, + eventId: event.eventId, + eventTime: event.eventTime, + payload, + } + : null; + } + return null; +} + +function resolveApiBase(rawValue: string): URL | null { + const value = rawValue.trim(); + if (!value) return null; + try { + const url = new URL(value.endsWith('/') ? value : `${value}/`); + if (url.username || url.password || url.search || url.hash) return null; + const hosted = isTrustedHostedUrl(url) && url.pathname.endsWith('/'); + const loopback = isTrustedLoopbackUrl(url) && url.pathname === '/'; + return hosted || loopback ? url : null; + } catch { + return null; + } +} + +/** Best-effort authenticated transport. Invalid input and failures never throw. */ +export async function postPricingBridgeEvents(input: { + apiOrigin: string; + sourceSurface: PricingBridgeSource; + sessionId: string; + events: readonly PricingBridgeEvent[]; + fetcher?: typeof fetch; +}): Promise { + let timeout: ReturnType | undefined; + try { + const apiBase = resolveApiBase(input.apiOrigin); + if ( + !apiBase || + (input.sourceSurface !== 'wallet' && + input.sourceSurface !== 'dashboard') || + !isBoundedId(input.sessionId) || + !Array.isArray(input.events) || + input.events.length < 1 || + input.events.length > maxEventsPerRequest + ) { + return false; + } + + const events: PricingBridgeEvent[] = []; + const eventIds = new Set(); + for (const event of input.events) { + const sanitized = sanitizedEvent(event); + if (!sanitized || eventIds.has(sanitized.eventId)) return false; + eventIds.add(sanitized.eventId); + events.push(sanitized); + } + + const abortController = new AbortController(); + timeout = setTimeout( + () => abortController.abort(), + transportTimeoutMs, + ); + const response = await (input.fetcher ?? fetch)( + new URL('api/v1/analytics/pricing-events', apiBase), + { + method: 'POST', + credentials: 'include', + keepalive: true, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + sourceSurface: input.sourceSurface, + sessionId: input.sessionId, + events, + }), + signal: abortController.signal, + }, + ); + return response.ok; + } catch { + return false; + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} diff --git a/apps/landing-page/app/_lib/pricing-compat-analytics.ts b/apps/landing-page/app/_lib/pricing-compat-analytics.ts new file mode 100644 index 0000000000..eeb3bc00af --- /dev/null +++ b/apps/landing-page/app/_lib/pricing-compat-analytics.ts @@ -0,0 +1,252 @@ +import { + PERSONAL_PRICING_TIERS, + postPricingBridgeEvents, + type PlanExposureInput, + type PricingBridgeEvent, + type PricingBridgeSource, + type PricingClickInput, +} from './pricing-analytics-bridge'; +import type { + BillingInterval, + PlanTier, + PlanTierConfig, +} from './pricing'; + +export type ResolvedPricingContext = { + authenticated: true; + sourceSurface: PricingBridgeSource; + currentPlanId: PlanTier | null; + currentBillingInterval: BillingInterval | null; + firstMonthEligible: boolean; +}; + +type PersonalExposureInput = { + audience: 'creator' | 'team'; + interval: BillingInterval; +}; + +type IntervalChangeInput = Omit & { + currentInterval: BillingInterval; + targetInterval: BillingInterval; + userInitiated: boolean; +}; + +type PlanClickInput = { + audience: 'creator' | 'team'; + planId: string; + interval: BillingInterval; + enabled: boolean; +}; + +type CompatibilityTransport = typeof postPricingBridgeEvents; + +type PricingCompatibilityOptions = { + apiOrigin?: string; + sessionId?: string; + tiers?: readonly PlanTierConfig[]; + postEvents?: CompatibilityTransport; + now?: () => Date; + createEventId?: () => string; +}; + +let fallbackEventSequence = 0; + +function defaultEventId(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + fallbackEventSequence += 1; + return `pricing-${Date.now()}-${fallbackEventSequence}`; +} + +function personalPlanFacts( + tier: PlanTierConfig, + interval: BillingInterval, + firstMonthEligible: boolean, +) { + const introOfferApplied = firstMonthEligible && interval === 'monthly'; + const priceUsd = interval === 'monthly' + ? introOfferApplied + ? tier.monthly.introPriceUsd + : tier.monthly.priceUsd + : tier.yearly.priceUsd; + const creditsGrantedUsd = interval === 'monthly' + ? tier.monthly.grantUsd + : tier.yearly.grantUsd / 12; + + return { + priceUsd: priceUsd.toFixed(2), + creditsGrantedUsd: creditsGrantedUsd.toFixed(2), + introOfferApplied, + } as const; +} + +function recommendedPlan(currentPlanId: PlanTier | null): PlanTier | null { + if (currentPlanId === 'max') return null; + return currentPlanId === 'pro' ? 'max' : 'pro'; +} + +export function createPricingCompatibilityAnalytics({ + apiOrigin = '', + sessionId = '', + tiers = PERSONAL_PRICING_TIERS, + postEvents = postPricingBridgeEvents, + now = () => new Date(), + createEventId = defaultEventId, +}: PricingCompatibilityOptions) { + let context: ResolvedPricingContext | null = null; + let lastExposureSignature: string | null = null; + + const createEvent = ( + kind: T, + payload: T extends 'plan_exposure' ? PlanExposureInput : PricingClickInput, + ): Extract => ({ + kind, + eventId: createEventId(), + eventTime: now().toISOString(), + payload, + }) as Extract; + + const emit = (bridgeEvents: readonly PricingBridgeEvent[]) => { + if (!context || bridgeEvents.length === 0) return; + try { + void Promise.resolve(postEvents({ + apiOrigin, + sourceSurface: context.sourceSurface, + sessionId, + events: bridgeEvents, + })).catch(() => undefined); + } catch { + // Compatibility delivery is best effort and must not block the action. + } + }; + + const exposureEvents = ( + input: PersonalExposureInput, + ): PricingBridgeEvent[] => { + const resolved = context; + if (!resolved) return []; + if (input.audience === 'team') { + lastExposureSignature = null; + return []; + } + + const signature = JSON.stringify([ + input.audience, + input.interval, + resolved.firstMonthEligible, + resolved.currentPlanId, + resolved.currentBillingInterval, + ]); + if (lastExposureSignature === signature) return []; + lastExposureSignature = signature; + + return tiers.map((tier) => { + const facts = personalPlanFacts( + tier, + input.interval, + resolved.firstMonthEligible, + ); + return createEvent('plan_exposure', { + planId: tier.tier, + billingInterval: input.interval, + priceUsd: facts.priceUsd, + creditsGrantedUsd: facts.creditsGrantedUsd, + deployLimit: tier.deployLimit, + introOfferApplied: facts.introOfferApplied, + firstMonthEligible: resolved.firstMonthEligible, + isCurrentPlan: + resolved.currentPlanId === tier.tier && + resolved.currentBillingInterval === input.interval, + isRecommended: tier.tier === recommendedPlan(resolved.currentPlanId), + }); + }); + }; + + const exposePlans = (input: PersonalExposureInput) => { + emit(exposureEvents(input)); + }; + + const changeInterval = (input: IntervalChangeInput) => { + if (!context) return; + const bridgeEvents: PricingBridgeEvent[] = []; + if ( + input.audience === 'creator' && + input.userInitiated && + input.currentInterval !== input.targetInterval + ) { + bridgeEvents.push(createEvent('pricing_click', { + element: 'change_interval', + currentPlanId: context.currentPlanId, + currentBillingInterval: input.currentInterval, + targetBillingInterval: input.targetInterval, + })); + } + bridgeEvents.push(...exposureEvents({ + audience: input.audience, + interval: input.targetInterval, + })); + emit(bridgeEvents); + }; + + const clickPlan = (input: PlanClickInput) => { + if (!context || input.audience !== 'creator' || !input.enabled) return; + const tier = tiers.find((candidate) => candidate.tier === input.planId); + if (!tier) return; + + const facts = personalPlanFacts( + tier, + input.interval, + context.firstMonthEligible, + ); + const common = { + currentBillingInterval: context.currentBillingInterval, + targetPlanId: tier.tier, + targetBillingInterval: input.interval, + priceUsd: facts.priceUsd, + creditsGrantedUsd: facts.creditsGrantedUsd, + introOfferApplied: facts.introOfferApplied, + isCurrentPlan: + context.currentPlanId === tier.tier && + context.currentBillingInterval === input.interval, + isRecommended: tier.tier === recommendedPlan(context.currentPlanId), + } as const; + const payload: PricingClickInput = context.currentPlanId === null + ? { + element: 'subscribe_now', + currentPlanId: null, + ...common, + } + : { + element: 'upgrade_now', + currentPlanId: context.currentPlanId, + ...common, + }; + emit([createEvent('pricing_click', payload)]); + }; + + const enterpriseClick = ( + element: 'request_team_access' | 'team_lead_submit', + ) => { + if (!context) return; + const enterpriseContext = { + currentPlanId: context.currentPlanId, + currentBillingInterval: context.currentBillingInterval, + } as const; + const payload: PricingClickInput = element === 'request_team_access' + ? { element: 'request_team_access', ...enterpriseContext } + : { element: 'team_lead_submit', ...enterpriseContext }; + emit([createEvent('pricing_click', payload)]); + }; + + return { + resolveContext(resolved: ResolvedPricingContext) { + if (resolved?.authenticated === true) context = resolved; + }, + exposePlans, + changeInterval, + clickPlan, + openEnterpriseLead: () => enterpriseClick('request_team_access'), + submitEnterpriseLead: () => enterpriseClick('team_lead_submit'), + }; +} diff --git a/apps/landing-page/app/_lib/pricing.ts b/apps/landing-page/app/_lib/pricing.ts index a54a9bc702..d36f2decc3 100644 --- a/apps/landing-page/app/_lib/pricing.ts +++ b/apps/landing-page/app/_lib/pricing.ts @@ -10,7 +10,7 @@ * static landing-page contract. */ -export type PlanTier = 'plus' | 'pro' | 'max'; +export type PlanTier = 'go' | 'plus' | 'pro' | 'max'; export type TeamPlanTier = | 'team_basic' | 'team_plus' diff --git a/apps/landing-page/app/pages/pricing/index.astro b/apps/landing-page/app/pages/pricing/index.astro index a60ba374b4..f07fd0b08f 100644 --- a/apps/landing-page/app/pages/pricing/index.astro +++ b/apps/landing-page/app/pages/pricing/index.astro @@ -747,6 +747,9 @@ const jsonLd = [ }); } trackCampaignBenefit(audience); + root.dispatchEvent(new CustomEvent('pricing:audience-changed', { + detail: { audience }, + })); }; for (const button of root.querySelectorAll('[data-audience-btn]')) { button.addEventListener('click', () => { @@ -789,6 +792,9 @@ const jsonLd = [ // One entry point so user clicks and subscription-state initialization // share the same rendering path without misreporting initialization as a click. const activateInterval = (interval, via, shouldTrack = true) => { + const previousInterval = root.getAttribute('data-interval') === 'monthly' + ? 'monthly' + : 'yearly'; root.setAttribute('data-interval', interval); for (const b of root.querySelectorAll('[data-interval-btn]')) { const on = b.getAttribute('data-interval-btn') === interval; @@ -797,7 +803,9 @@ const jsonLd = [ } updateTeamPlan(); syncCtas(); - root.dispatchEvent(new CustomEvent('pricing:interval-changed', { detail: { interval } })); + root.dispatchEvent(new CustomEvent('pricing:interval-changed', { + detail: { interval, previousInterval, shouldTrack }, + })); if (shouldTrack) { track('ui_click', { area: 'pricing_controls', @@ -1079,6 +1087,7 @@ const jsonLd = [ for (const btn of document.querySelectorAll('[data-open-lead-modal]')) { btn.addEventListener('click', () => { track('team_lead_open', { area: 'enterprise_card' }); + root.dispatchEvent(new CustomEvent('pricing:enterprise-open')); openModal(); }); } @@ -1167,6 +1176,14 @@ const jsonLd = [ const interval: PersonalBillingInterval = pricingRoot.getAttribute('data-interval') === 'monthly' ? 'monthly' : 'yearly'; + pricingRoot.setAttribute( + 'data-current-personal-plan-id', + pricingContext.current?.tier ?? '', + ); + pricingRoot.setAttribute( + 'data-current-personal-billing-interval', + pricingContext.current?.interval ?? '', + ); for (const cta of pricingRoot.querySelectorAll('[data-pricing-cta]')) { const tier = cta.getAttribute('data-tier') || ''; if (!personalTiers.has(tier)) continue; @@ -1243,9 +1260,121 @@ const jsonLd = [ } else { applyPersonalActions(); } + (pricingRoot as HTMLElement & { + __personalPricingContext?: PersonalPricingContext; + }).__personalPricingContext = context; + pricingRoot.setAttribute('data-personal-pricing-context-resolved', 'true'); + pricingRoot.dispatchEvent(new CustomEvent('pricing:personal-context-resolved', { + detail: { authenticated: true, context }, + })); }); } +