| |
| |
| |
| |
| |
| |
|
|
| import { scheduleAfterFirstPaint } from '@/utils/after-paint'; |
| import { subscribeAuthState, type AuthSession } from './auth-state'; |
| import { onSubscriptionChange, type SubscriptionInfo } from './billing'; |
| import { getClerkUserCreatedAt } from './clerk'; |
| import { DODO_PRODUCT_IDS } from '@/config/product-ids.generated'; |
| import type { ActivationEventName, ActivationStepId } from './pro-activation-state'; |
|
|
| const UMAMI_SCRIPT_SRC = 'https://abacus.worldmonitor.app/script.js'; |
| const UMAMI_IDENTIFY_ENDPOINT = new URL('/api/send', UMAMI_SCRIPT_SRC).href; |
| const UMAMI_WEBSITE_ID = 'e8800335-c853-46a8-8497-c993ed2f58bc'; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const UMAMI_DOMAINS = 'worldmonitor.app,www.worldmonitor.app,happy.worldmonitor.app'; |
| const UMAMI_QUEUE_LIMIT = 50; |
| const UMAMI_LOAD_ATTEMPT_LIMIT = 2; |
| const UMAMI_LOAD_RETRY_DELAY_MS = 5_000; |
| const UMAMI_IDENTIFY_RETRY_LIMIT = 2; |
| const UMAMI_IDENTIFY_RETRY_BASE_DELAY_MS = 1_000; |
|
|
| type QueuedUmamiCall = |
| | { kind: 'track'; event: UmamiEvent; data?: Record<string, unknown> } |
| | { |
| kind: 'identify'; |
| data: Record<string, unknown>; |
| revision: number; |
| retryAttempt: number; |
| }; |
| type IdentifyCall = Extract<QueuedUmamiCall, { kind: 'identify' }>; |
|
|
| const pendingUmamiCalls: QueuedUmamiCall[] = []; |
| let umamiLoadScheduled = false; |
| let umamiLoadStarted = false; |
| let umamiLoadAttempts = 0; |
| let latestIdentityRevision = 0; |
| let identifyRetryTimer: ReturnType<typeof setTimeout> | null = null; |
| let identifyInFlight = false; |
| let pendingIdentityCall: IdentifyCall | null = null; |
| let identifyDeliveryGeneration = 0; |
|
|
| |
| |
| |
| |
|
|
| const EVENTS = { |
| |
| 'search-open': true, |
| 'search-used': true, |
| 'search-result-selected': true, |
| |
| 'country-selected': true, |
| 'country-brief-opened': true, |
| 'map-layer-toggle': true, |
| |
| 'panel-toggle': true, |
| |
| 'settings-open': true, |
| 'variant-switch': true, |
| 'theme-changed': true, |
| 'language-change': true, |
| 'feature-toggle': true, |
| |
| 'news-sort-toggle': true, |
| 'news-summarize': true, |
| 'live-news-fullscreen': true, |
| |
| 'webcam-selected': true, |
| 'webcam-region-filter': true, |
| 'webcam-fullscreen': true, |
| |
| 'download-clicked': true, |
| 'critical-banner': true, |
| |
| 'widget-ai-open': true, |
| 'widget-ai-generate': true, |
| 'widget-ai-success': true, |
| |
| 'analyst-control-action': true, |
| |
| 'mcp-connect-attempt': true, |
| 'mcp-connect-success': true, |
| 'mcp-panel-add': true, |
| |
| 'webmcp-registered': true, |
| 'webmcp-tool-invoked': true, |
| |
| 'route-explorer:opened': true, |
| 'route-explorer:query': true, |
| 'route-explorer:tab-switch': true, |
| 'route-explorer:alternative-selected': true, |
| 'route-explorer:impact-viewed': true, |
| 'route-explorer:share-copied': true, |
| 'route-explorer:free-cta-click': true, |
| 'route-explorer:closed': true, |
| |
| 'sign-in': true, |
| 'sign-up': true, |
| 'sign-out': true, |
| 'gate-hit': true, |
| |
| |
| |
| 'checkout-start': true, |
| 'checkout-success': true, |
| 'checkout-failed': true, |
| |
| |
| |
| |
| |
| 'brief-thread-open': true, |
| |
| |
| |
| |
| |
| |
| |
| 'pro-activation-entered': true, |
| 'pro-activation-step-confirmed': true, |
| 'pro-activation-step-skipped': true, |
| 'pro-activation-step-blocked': true, |
| 'pro-activation-step-failed': true, |
| 'pro-activation-exit': true, |
| } as const; |
|
|
| export type UmamiEvent = keyof typeof EVENTS; |
|
|
| function queueUmamiCall(call: QueuedUmamiCall): void { |
| |
| |
| |
| |
| if (call.kind === 'identify') { |
| for (let index = pendingUmamiCalls.length - 1; index >= 0; index -= 1) { |
| if (pendingUmamiCalls[index]?.kind === 'identify') { |
| pendingUmamiCalls.splice(index, 1); |
| } |
| } |
| } |
| if (pendingUmamiCalls.length >= UMAMI_QUEUE_LIMIT) { |
| pendingUmamiCalls.shift(); |
| } |
| pendingUmamiCalls.push(call); |
| } |
|
|
| function clearScheduledIdentityRetry(): void { |
| if (identifyRetryTimer !== null) { |
| clearTimeout(identifyRetryTimer); |
| identifyRetryTimer = null; |
| } |
| } |
|
|
| function createIdentifyCall(data: Record<string, unknown>): QueuedUmamiCall { |
| latestIdentityRevision += 1; |
| clearScheduledIdentityRetry(); |
| return { |
| kind: 'identify', |
| data, |
| revision: latestIdentityRevision, |
| retryAttempt: 0, |
| }; |
| } |
|
|
| function scheduleIdentityRetry(call: IdentifyCall): void { |
| if (call.revision !== latestIdentityRevision) return; |
| if (call.retryAttempt >= UMAMI_IDENTIFY_RETRY_LIMIT) return; |
|
|
| clearScheduledIdentityRetry(); |
| const generation = identifyDeliveryGeneration; |
| const retryCall = { |
| ...call, |
| retryAttempt: call.retryAttempt + 1, |
| }; |
| const delay = UMAMI_IDENTIFY_RETRY_BASE_DELAY_MS * (2 ** call.retryAttempt); |
| identifyRetryTimer = setTimeout(() => { |
| identifyRetryTimer = null; |
| if (generation !== identifyDeliveryGeneration) return; |
| if (retryCall.revision !== latestIdentityRevision) return; |
| if (!sendUmamiCall(retryCall)) { |
| queueUmamiCall(retryCall); |
| } |
| }, delay); |
| } |
|
|
| function isUmamiIdentifyBeacon(input: RequestInfo | URL, init?: RequestInit): boolean { |
| const url = typeof input === 'string' |
| ? input |
| : input instanceof URL |
| ? input.href |
| : input.url; |
| const method = init?.method ?? (input instanceof Request ? input.method : 'GET'); |
| if (url !== UMAMI_IDENTIFY_ENDPOINT || method.toUpperCase() !== 'POST' || typeof init?.body !== 'string') { |
| return false; |
| } |
| try { |
| return (JSON.parse(init.body) as { type?: unknown }).type === 'identify'; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function identifyWithDeliveryObserver( |
| umami: NonNullable<Window['umami']>, |
| data: Record<string, unknown>, |
| ): unknown { |
| const originalFetch = window.fetch; |
| let observedDelivery: Promise<Response> | undefined; |
| const observedFetch = ((input: RequestInfo | URL, init?: RequestInit) => { |
| if (!isUmamiIdentifyBeacon(input, init)) return originalFetch(input, init); |
| try { |
| const result = originalFetch(input, init); |
| const delivery = Promise.resolve(result).then((response) => { |
| if (!response.ok) throw new Error(`Umami identify collector returned HTTP ${response.status}`); |
| return response; |
| }); |
| |
| |
| |
| void delivery.catch(() => {}); |
| observedDelivery = delivery; |
| return result; |
| } catch (error) { |
| const delivery = Promise.reject<Response>(error); |
| void delivery.catch(() => {}); |
| observedDelivery = delivery; |
| throw error; |
| } |
| }) as typeof window.fetch; |
|
|
| try { |
| window.fetch = observedFetch; |
| } catch { |
| |
| |
| return umami.identify(data); |
| } |
| try { |
| const nativeResult = umami.identify(data); |
| return observedDelivery ?? nativeResult; |
| } finally { |
| window.fetch = originalFetch; |
| } |
| } |
|
|
| function finishIdentityDelivery(call: IdentifyCall, generation: number, failed: boolean): void { |
| if (generation !== identifyDeliveryGeneration) return; |
|
|
| identifyInFlight = false; |
| const nextCall = pendingIdentityCall; |
| pendingIdentityCall = null; |
| if (nextCall) { |
| if (!sendUmamiCall(nextCall)) { |
| queueUmamiCall(nextCall); |
| } |
| return; |
| } |
| if (failed) { |
| scheduleIdentityRetry(call); |
| } |
| } |
|
|
| function sendIdentityCall( |
| call: IdentifyCall, |
| umami: NonNullable<Window['umami']>, |
| ): boolean { |
| |
| |
| |
| |
| if (identifyInFlight) { |
| pendingIdentityCall = call; |
| return true; |
| } |
|
|
| identifyInFlight = true; |
| const generation = identifyDeliveryGeneration; |
| try { |
| const result = identifyWithDeliveryObserver(umami, call.data); |
| if (result && typeof (result as { then?: unknown }).then === 'function') { |
| void Promise.resolve(result).then( |
| () => finishIdentityDelivery(call, generation, false), |
| () => finishIdentityDelivery(call, generation, true), |
| ); |
| } else { |
| finishIdentityDelivery(call, generation, false); |
| } |
| } catch { |
| finishIdentityDelivery(call, generation, true); |
| } |
| return true; |
| } |
|
|
| function sendUmamiCall(call: QueuedUmamiCall): boolean { |
| if (typeof window === 'undefined') return false; |
| const umami = window.umami; |
| if (!umami) return false; |
| if (call.kind === 'identify') { |
| return sendIdentityCall(call, umami); |
| } |
| try { |
| const result: unknown = umami.track(call.event, call.data); |
| |
| |
| |
| if (result && typeof (result as { catch?: unknown }).catch === 'function') { |
| void (result as Promise<unknown>).catch(() => {}); |
| } |
| |
| |
| |
| |
| if (call.kind === 'track' && call.event === 'checkout-success') { |
| clearPendingCheckoutSuccessMarker(); |
| } |
| |
| |
| |
| |
| |
| |
| if (call.kind === 'track' && call.event === 'checkout-start' && call.data?.replayed === true) { |
| clearPendingProFunnelMarker(); |
| } |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| function flushPendingUmamiCalls(): void { |
| if (pendingUmamiCalls.length === 0) return; |
| if (typeof window === 'undefined' || !window.umami) return; |
| const calls = pendingUmamiCalls.splice(0, pendingUmamiCalls.length); |
| for (const call of calls) sendUmamiCall(call); |
| } |
|
|
| function loadUmamiScript(): void { |
| if (umamiLoadStarted || typeof document === 'undefined') return; |
| const existing = document.querySelector<HTMLScriptElement>(`script[src="${UMAMI_SCRIPT_SRC}"]`); |
| if (existing) { |
| |
| |
| |
| |
| |
| umamiLoadStarted = true; |
| if (typeof window !== 'undefined' && window.umami) { |
| flushPendingUmamiCalls(); |
| } else { |
| existing.addEventListener('load', flushPendingUmamiCalls, { once: true }); |
| } |
| return; |
| } |
|
|
| umamiLoadStarted = true; |
| umamiLoadAttempts += 1; |
| const script = document.createElement('script'); |
| script.async = true; |
| script.src = UMAMI_SCRIPT_SRC; |
| script.dataset.websiteId = UMAMI_WEBSITE_ID; |
| script.dataset.domains = UMAMI_DOMAINS; |
| script.addEventListener('load', flushPendingUmamiCalls, { once: true }); |
| script.addEventListener('error', () => { |
| umamiLoadStarted = false; |
| script.remove(); |
| if (umamiLoadAttempts < UMAMI_LOAD_ATTEMPT_LIMIT) { |
| setTimeout(loadUmamiScript, UMAMI_LOAD_RETRY_DELAY_MS); |
| } |
| }, { once: true }); |
| document.head.appendChild(script); |
| } |
|
|
| |
| export function track(event: UmamiEvent, data?: Record<string, unknown>): void { |
| if (!sendUmamiCall({ kind: 'track', event, data })) { |
| queueUmamiCall({ kind: 'track', event, data }); |
| } |
| } |
|
|
| export function initAnalytics(): void { |
| if (umamiLoadScheduled || typeof window === 'undefined' || typeof document === 'undefined') return; |
| umamiLoadScheduled = true; |
| scheduleAfterFirstPaint(loadUmamiScript, 3000); |
| } |
|
|
| |
| |
| |
| |
|
|
| export function identifyUser( |
| userId: string, |
| plan: string, |
| subStatus?: SubscriptionInfo['status'] | null, |
| planKey?: string | null, |
| ): void { |
| const data = { |
| userId, |
| plan, |
| ...(subStatus != null && { subStatus }), |
| ...(planKey != null && { planKey }), |
| }; |
| const call = createIdentifyCall(data); |
| if (!sendUmamiCall(call)) { |
| queueUmamiCall(call); |
| } |
| } |
|
|
| export function clearIdentity(): void { |
| const call = createIdentifyCall({}); |
| if (!sendUmamiCall(call)) { |
| queueUmamiCall(call); |
| } |
| } |
|
|
| let _unsubAuth: (() => void) | null = null; |
| let _unsubBilling: (() => void) | null = null; |
|
|
| |
| let _lastAuth: AuthSession | null = null; |
| let _lastSub: SubscriptionInfo | null = null; |
|
|
| function _syncIdentity(): void { |
| const user = _lastAuth?.user; |
| if (user) { |
| identifyUser(user.id, user.role, _lastSub?.status ?? null, _lastSub?.planKey ?? null); |
| } else { |
| _lastSub = null; |
| clearIdentity(); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export function initAuthAnalytics(): void { |
| if (_unsubAuth) return; |
|
|
| _unsubAuth = subscribeAuthState((state) => { |
| const prevUserId = _lastAuth?.user?.id ?? null; |
| const nextUserId = state.user?.id ?? null; |
| if (prevUserId !== nextUserId) { |
| _lastSub = null; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if ( |
| nextUserId !== null && |
| !hasTrackedSignupInSession(nextUserId) && |
| isLikelyFreshSignup(prevUserId, nextUserId, getClerkUserCreatedAt(), Date.now()) |
| ) { |
| trackSignUp('clerk'); |
| markSignupTrackedInSession(nextUserId); |
| } |
| } |
| _lastAuth = state; |
| _syncIdentity(); |
| }); |
|
|
| _unsubBilling = onSubscriptionChange((sub) => { |
| _lastSub = sub; |
| _syncIdentity(); |
| }); |
| } |
|
|
| |
| export function destroyAuthAnalytics(): void { |
| _unsubAuth?.(); |
| _unsubBilling?.(); |
| _unsubAuth = null; |
| _unsubBilling = null; |
| _lastAuth = null; |
| _lastSub = null; |
| clearIdentity(); |
| } |
|
|
| |
| |
| |
|
|
| export function trackSignIn(method: string): void { |
| track('sign-in', { method }); |
| } |
|
|
| export function trackSignUp(method: string): void { |
| track('sign-up', { method }); |
| } |
|
|
| export function trackAnalystControlAction(actionType: string, status: string, reason?: string): void { |
| track('analyst-control-action', { |
| actionType, |
| status, |
| ...(reason ? { reason } : {}), |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export const FRESH_SIGNUP_WINDOW_MS = 60_000; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const FRESH_SIGNUP_CLOCK_SKEW_MS = 5_000; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const SIGNUP_TRACKED_KEY_PREFIX = 'wm-signup-tracked:'; |
|
|
| export function hasTrackedSignupInSession(userId: string): boolean { |
| try { |
| return window.localStorage.getItem(SIGNUP_TRACKED_KEY_PREFIX + userId) === '1'; |
| } catch { |
| return false; |
| } |
| } |
|
|
| export function markSignupTrackedInSession(userId: string): void { |
| try { |
| window.localStorage.setItem(SIGNUP_TRACKED_KEY_PREFIX + userId, '1'); |
| } catch { |
| |
| |
| } |
| } |
|
|
| export function isLikelyFreshSignup( |
| prevUserId: string | null, |
| nextUserId: string | null, |
| createdAtMs: number | null, |
| nowMs: number, |
| ): boolean { |
| if (prevUserId !== null) return false; |
| if (nextUserId === null) return false; |
| if (createdAtMs === null) return false; |
| const age = nowMs - createdAtMs; |
| |
| |
| |
| return age >= -FRESH_SIGNUP_CLOCK_SKEW_MS && age <= FRESH_SIGNUP_WINDOW_MS; |
| } |
|
|
| export function trackSignOut(): void { |
| track('sign-out'); |
| } |
|
|
| |
| |
| |
| |
| |
| export function resetAnalyticsForTesting(): void { |
| clearScheduledIdentityRetry(); |
| identifyDeliveryGeneration += 1; |
| identifyInFlight = false; |
| pendingIdentityCall = null; |
| pendingUmamiCalls.length = 0; |
| umamiLoadScheduled = false; |
| umamiLoadStarted = false; |
| umamiLoadAttempts = 0; |
| latestIdentityRevision = 0; |
| } |
|
|
| export function trackGateHit(feature: string): void { |
| track('gate-hit', { feature }); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const KNOWN_PRODUCT_IDS = DODO_PRODUCT_IDS; |
|
|
| export function bucketProductIdForAnalytics(productId: string): string { |
| return KNOWN_PRODUCT_IDS.has(productId) ? productId : 'unknown'; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function trackCheckoutStart( |
| productId: string, |
| authed: boolean, |
| surface: 'dashboard' | 'dashboard-resume' = 'dashboard', |
| ): void { |
| track('checkout-start', { productId: bucketProductIdForAnalytics(productId), surface, authed }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const CHECKOUT_SUCCESS_PENDING_KEY = 'wm-checkout-success-pending'; |
|
|
| function clearPendingCheckoutSuccessMarker(): void { |
| try { |
| window.sessionStorage.removeItem(CHECKOUT_SUCCESS_PENDING_KEY); |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export function trackCheckoutSuccess(source: 'url-return' | 'overlay-flag'): void { |
| try { |
| window.sessionStorage.setItem(CHECKOUT_SUCCESS_PENDING_KEY, source); |
| } catch { |
| |
| } |
| track('checkout-success', { source }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function replayPendingCheckoutSuccess(): void { |
| let source: string | null = null; |
| try { |
| source = window.sessionStorage.getItem(CHECKOUT_SUCCESS_PENDING_KEY); |
| } catch { |
| return; |
| } |
| if (!source) return; |
| track('checkout-success', { source, replayed: true }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const PRO_FUNNEL_PENDING_KEY = 'wm-pro-funnel-pending'; |
|
|
| function clearPendingProFunnelMarker(): void { |
| try { |
| window.sessionStorage.removeItem(PRO_FUNNEL_PENDING_KEY); |
| } catch { |
| |
| |
| } |
| } |
|
|
| export function replayPendingProFunnelEvents(): void { |
| let raw: string | null = null; |
| try { |
| raw = window.sessionStorage.getItem(PRO_FUNNEL_PENDING_KEY); |
| } catch { |
| return; |
| } |
| if (!raw) return; |
|
|
| const sanitized: Array<{ productId: string; surface: 'pro-page' | 'pro-resume'; authed: boolean }> = []; |
| try { |
| const items: unknown = JSON.parse(raw); |
| if (Array.isArray(items)) { |
| for (const item of items.slice(0, 10)) { |
| if (!item || typeof item !== 'object') continue; |
| const { event, data } = item as { event?: unknown; data?: unknown }; |
| if (event !== 'checkout-start' || !data || typeof data !== 'object') continue; |
| const d = data as Record<string, unknown>; |
| sanitized.push({ |
| productId: bucketProductIdForAnalytics(String(d.productId ?? '')), |
| surface: d.surface === 'pro-resume' ? 'pro-resume' : 'pro-page', |
| authed: Boolean(d.authed), |
| }); |
| } |
| } |
| } catch { |
| |
| } |
|
|
| if (sanitized.length === 0) { |
| clearPendingProFunnelMarker(); |
| return; |
| } |
|
|
| |
| |
| try { |
| window.sessionStorage.setItem( |
| PRO_FUNNEL_PENDING_KEY, |
| JSON.stringify(sanitized.map((data) => ({ event: 'checkout-start', data }))), |
| ); |
| } catch { |
| |
| |
| } |
| for (const data of sanitized) { |
| track('checkout-start', { ...data, replayed: true }); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| const CHECKOUT_FAILED_STATUSES = new Set(['failed', 'declined', 'cancelled', 'canceled']); |
|
|
| |
| export function trackCheckoutFailed(rawStatus: string): void { |
| const status = CHECKOUT_FAILED_STATUSES.has(rawStatus) ? rawStatus : 'other'; |
| track('checkout-failed', { status }); |
| } |
|
|
| |
| |
| |
|
|
| |
| export type ProActivationEvent = ActivationEventName; |
|
|
| |
| |
| |
| |
| |
| |
| |
| export interface ProActivationEventFields { |
| planKey?: string | null; |
| step?: ActivationStepId; |
| completion?: 'complete' | 'partial' | 'none'; |
| verified?: number; |
| pending?: number; |
| failed?: number; |
| total?: number; |
| } |
|
|
| |
| |
| |
| |
| |
| export function trackProActivation( |
| event: ProActivationEvent, |
| fields: ProActivationEventFields = {}, |
| ): void { |
| const data: Record<string, unknown> = {}; |
| if (fields.planKey != null) data.planKey = fields.planKey; |
| if (fields.step != null) data.step = fields.step; |
| if (fields.completion != null) data.completion = fields.completion; |
| if (fields.verified != null) data.verified = fields.verified; |
| if (fields.pending != null) data.pending = fields.pending; |
| if (fields.failed != null) data.failed = fields.failed; |
| if (fields.total != null) data.total = fields.total; |
| track(event, data); |
| } |
|
|
| |
| |
| |
|
|
| export function trackEvent(_name: string, _props?: Record<string, unknown>): void {} |
| export function trackEventBeforeUnload(_name: string, _props?: Record<string, unknown>): void {} |
| export function trackPanelView(_panelId: string): void {} |
| export function trackApiKeysSnapshot(): void {} |
| export function trackUpdateShown(_current: string, _remote: string): void {} |
| export function trackUpdateClicked(_version: string): void {} |
| export function trackUpdateDismissed(_version: string): void {} |
| export function trackDownloadBannerDismissed(): void {} |
|
|
| |
| |
| |
|
|
| export function trackSearchUsed(queryLength: number, resultCount: number): void { |
| track('search-used', { queryLength, resultCount }); |
| } |
|
|
| export function trackSearchResultSelected(resultType: string): void { |
| track('search-result-selected', { type: resultType }); |
| } |
|
|
| |
| |
| |
|
|
| export function trackCountrySelected(code: string, name: string, source: string): void { |
| track('country-selected', { code, name, source }); |
| } |
|
|
| export function trackCountryBriefOpened(countryCode: string): void { |
| track('country-brief-opened', { code: countryCode }); |
| } |
|
|
| |
| |
| |
|
|
| export type BriefThreadOpenSeverity = |
| | 'critical' |
| | 'high' |
| | 'medium' |
| | 'low' |
| | 'info' |
| | null; |
|
|
| export interface BriefThreadOpenProps { |
| |
| country: string | null; |
| |
| followed: boolean; |
| severity: BriefThreadOpenSeverity; |
| |
| source: 'dashboard' | 'magazine'; |
| } |
|
|
| |
| |
| |
| |
| |
| export function trackBriefThreadOpen(props: BriefThreadOpenProps): void { |
| track('brief-thread-open', { |
| country: props.country, |
| followed: props.followed, |
| severity: props.severity, |
| source: props.source, |
| }); |
| } |
|
|
| export function trackMapLayerToggle(layerId: string, enabled: boolean, source: 'user' | 'programmatic'): void { |
| if (source !== 'user') return; |
| track('map-layer-toggle', { layerId, enabled }); |
| } |
|
|
| export function trackMapViewChange(_view: string): void { |
| |
| } |
|
|
| |
| |
| |
|
|
| export function trackPanelToggled(panelId: string, enabled: boolean): void { |
| track('panel-toggle', { panelId, enabled }); |
| } |
|
|
| export function trackPanelResized(_panelId: string, _newSpan: number): void { |
| |
| } |
|
|
| |
| |
| |
|
|
| export function trackVariantSwitch(from: string, to: string): void { |
| track('variant-switch', { from, to }); |
| } |
|
|
| export function trackThemeChanged(theme: string): void { |
| track('theme-changed', { theme }); |
| } |
|
|
| export function trackLanguageChange(language: string): void { |
| track('language-change', { language }); |
| } |
|
|
| export function trackFeatureToggle(featureId: string, enabled: boolean): void { |
| track('feature-toggle', { featureId, enabled }); |
| } |
|
|
| |
| |
| |
|
|
| export function trackLLMUsage(_provider: string, _model: string, _cached: boolean): void { |
| |
| } |
|
|
| export function trackLLMFailure(_lastProvider: string): void { |
| |
| } |
|
|
| |
| |
| |
|
|
| export function trackWebcamSelected(webcamId: string, city: string, viewMode: string): void { |
| track('webcam-selected', { webcamId, city, viewMode }); |
| } |
|
|
| export function trackWebcamRegionFiltered(region: string): void { |
| track('webcam-region-filter', { region }); |
| } |
|
|
| |
| |
| |
|
|
| export function trackDownloadClicked(platform: string): void { |
| track('download-clicked', { platform }); |
| } |
|
|
| export function trackCriticalBannerAction(action: string, theaterId: string): void { |
| track('critical-banner', { action, theaterId }); |
| } |
|
|
| export function trackFindingClicked(_id: string, _source: string, _type: string, _priority: string): void { |
| |
| } |
|
|
| export function trackDeeplinkOpened(_type: string, _target: string): void { |
| |
| } |
|
|