| import type { AppContext, AppModule } from '@/app/app-context'; |
| import type { ProActivationFlowOptions } from '@/components/ProActivationInterstitial'; |
| import { trackProActivation } from '@/services/analytics'; |
| import { getAuthState, subscribeAuthState } from '@/services/auth-state'; |
| import { |
| getSubscription, |
| onSubscriptionChange, |
| type SubscriptionInfo, |
| } from '@/services/billing'; |
| import { |
| getEntitlementState, |
| hasFeature, |
| onEntitlementChange, |
| type EntitlementState, |
| } from '@/services/entitlements'; |
| import { |
| computeFireOnceRecord, |
| computeMountClaim, |
| computePendingMarker, |
| decideActivationMount, |
| activationFlowRetryDelay, |
| deriveActivationAccountKey, |
| deriveSubscriptionKey, |
| isFireOnceActive, |
| isMountClaimBlocking, |
| isProPlanKey, |
| MOUNT_CLAIM_KEY, |
| MOUNT_CLAIM_TTL_MS, |
| parseMountClaim, |
| parsePendingMarker, |
| PENDING_MARKER_KEY, |
| readScopedFireOnceRecord, |
| writeScopedFireOnceRecord, |
| type ActivationEntitlementSnapshot, |
| type ActivationSubscriptionSnapshot, |
| type FireOnceRecord, |
| type MountClaimRecord, |
| type PendingOnboardingMarker, |
| } from '@/services/pro-activation-state'; |
|
|
| const MOUNT_CLAIM_SETTLE_MS = 75; |
| const MOUNT_CLAIM_RETRY_GRACE_MS = 25; |
|
|
| function readPendingMarker(): PendingOnboardingMarker | null { |
| try { |
| const raw = window.localStorage.getItem(PENDING_MARKER_KEY); |
| const marker = parsePendingMarker(raw); |
| if (marker && raw !== JSON.stringify(marker)) writePendingMarker(marker); |
| return marker; |
| } catch { |
| return null; |
| } |
| } |
|
|
| function writePendingMarker(marker: PendingOnboardingMarker): boolean { |
| try { |
| window.localStorage.setItem(PENDING_MARKER_KEY, JSON.stringify(marker)); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| function clearPendingMarker(): void { |
| try { |
| window.localStorage.removeItem(PENDING_MARKER_KEY); |
| } catch { |
| |
| } |
| } |
|
|
| function readMountClaim(): MountClaimRecord | null { |
| try { |
| return parseMountClaim(window.localStorage.getItem(MOUNT_CLAIM_KEY)); |
| } catch { |
| return null; |
| } |
| } |
|
|
| function writeMountClaim(record: MountClaimRecord): void { |
| try { |
| window.localStorage.setItem(MOUNT_CLAIM_KEY, JSON.stringify(record)); |
| } catch { |
| |
| } |
| } |
|
|
| function clearMountClaim(): void { |
| try { |
| window.localStorage.removeItem(MOUNT_CLAIM_KEY); |
| } catch { |
| |
| } |
| } |
|
|
| function clearMountClaimIfOwned(nonce: string): void { |
| if (readMountClaim()?.nonce === nonce) clearMountClaim(); |
| } |
|
|
| function claimSettle(): Promise<void> { |
| return new Promise((resolve) => window.setTimeout(resolve, MOUNT_CLAIM_SETTLE_MS)); |
| } |
|
|
| function generateMountClaimNonce(): string { |
| try { |
| if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { |
| return crypto.randomUUID(); |
| } |
| } catch { |
| |
| } |
| return `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; |
| } |
|
|
| function toEntitlementSnapshot( |
| state: EntitlementState | null, |
| ): ActivationEntitlementSnapshot | null { |
| if (state === null) return null; |
| return { planKey: state.planKey, validUntil: state.validUntil }; |
| } |
|
|
| function toSubscriptionSnapshot( |
| subscription: SubscriptionInfo | null, |
| ): ActivationSubscriptionSnapshot | null { |
| if (subscription === null) return null; |
| return { |
| activationKey: subscription.activationKey ?? null, |
| planKey: subscription.planKey, |
| currentPeriodEnd: subscription.currentPeriodEnd, |
| activationOnboardingEligible: subscription.activationOnboardingEligible ?? null, |
| }; |
| } |
|
|
| |
| export function markProActivationPending(productId: string | null, now = Date.now()): void { |
| const accountKey = deriveActivationAccountKey(getAuthState().user?.id); |
| writePendingMarker(computePendingMarker(productId, accountKey, now)); |
| } |
|
|
| export interface ProActivationControllerOptions { |
| |
| reloadPending: boolean; |
| |
| openAiAnalyst: () => void; |
| |
| openSearch?: () => void; |
| } |
|
|
| type ChipDecision = 'show' | 'wait' | 'hide'; |
|
|
| |
| |
| |
| |
| export class ProActivationController implements AppModule { |
| private resolved = false; |
| private mounting = false; |
| private retryArmed = false; |
| private retryUnsubscribers: Array<() => void> = []; |
| private mountIdleHandle: number | null = null; |
| private mountTimeoutHandle: number | null = null; |
| private claimRetryHandle: number | null = null; |
| private flowRetryHandle: number | null = null; |
| private flowRetryAttempts = 0; |
| private authUnsubscribe: (() => void) | null = null; |
| private authStateKey: string | null = null; |
| private authGeneration = 0; |
| private readonly mountNonce = generateMountClaimNonce(); |
| private readonly mountSessionStartedAt = Date.now(); |
| private lastDay0SessionStartedAt = this.mountSessionStartedAt; |
|
|
| constructor( |
| private readonly ctx: AppContext, |
| private readonly options: ProActivationControllerOptions, |
| ) {} |
|
|
| init(): void { |
| if (typeof window === 'undefined' || this.options.reloadPending) return; |
| this.authStateKey = this.activationAuthStateKey(); |
| let initialAuthEmission = true; |
| this.authUnsubscribe = subscribeAuthState(() => { |
| const nextAuthStateKey = this.activationAuthStateKey(); |
| if (initialAuthEmission) { |
| initialAuthEmission = false; |
| this.authStateKey = nextAuthStateKey; |
| return; |
| } |
| if (nextAuthStateKey === this.authStateKey) { |
| if (!this.resolved && !this.mounting) queueMicrotask(() => void this.evaluate()); |
| return; |
| } |
| this.authStateKey = nextAuthStateKey; |
| this.resetForAuthTransition(); |
| }); |
|
|
| const run = (): void => { |
| this.mountIdleHandle = null; |
| this.mountTimeoutHandle = null; |
| if (!this.ctx.isDestroyed) void this.evaluate(); |
| }; |
| const idle = (window as typeof window & { |
| requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number; |
| }).requestIdleCallback; |
| if (typeof idle === 'function') this.mountIdleHandle = idle(run, { timeout: 2000 }); |
| else this.mountTimeoutHandle = window.setTimeout(run, 800); |
| } |
|
|
| destroy(): void { |
| this.authGeneration += 1; |
| this.authUnsubscribe?.(); |
| this.authUnsubscribe = null; |
| this.teardownRetry(); |
| this.cancelScheduledMount(); |
| this.clearClaimRetry(); |
| this.clearFlowRetry(); |
| } |
|
|
| private activationAuthStateKey(): string { |
| const auth = getAuthState(); |
| if (auth.isPending) return 'pending'; |
| return deriveActivationAccountKey(auth.user?.id) ?? 'signed-out'; |
| } |
|
|
| private resetForAuthTransition(): void { |
| this.authGeneration += 1; |
| this.resolved = false; |
| this.teardownRetry(); |
| this.clearClaimRetry(); |
| this.clearFlowRetry(); |
| clearMountClaimIfOwned(this.mountNonce); |
| queueMicrotask(() => { |
| if (!this.ctx.isDestroyed) void this.evaluate(); |
| }); |
| } |
|
|
| private async evaluate(): Promise<void> { |
| if (this.resolved || this.mounting || this.ctx.isDestroyed) return; |
|
|
| const now = Date.now(); |
| const auth = getAuthState(); |
| const currentAccountKey = deriveActivationAccountKey(auth.user?.id); |
| let marker = readPendingMarker(); |
| if (marker && !marker.accountKey && currentAccountKey) { |
| marker = { ...marker, accountKey: currentAccountKey }; |
| writePendingMarker(marker); |
| } |
|
|
| const entitlement = toEntitlementSnapshot(getEntitlementState()); |
| const subscription = toSubscriptionSnapshot(getSubscription()); |
| const currentSubscriptionKey = deriveSubscriptionKey(subscription); |
| const fireOnce = readScopedFireOnceRecord( |
| window.localStorage, |
| currentAccountKey, |
| currentSubscriptionKey, |
| now, |
| ); |
| const decision = decideActivationMount({ |
| marker, |
| entitlement, |
| subscription, |
| fireOnce, |
| isDesktop: this.ctx.isDesktopApp, |
| authPending: auth.isPending, |
| currentAccountKey, |
| now, |
| }); |
|
|
| switch (decision.action) { |
| case 'mount': |
| if (auth.user) { |
| await this.mount( |
| decision.subscriptionKey, |
| auth.user.id, |
| now, |
| decision.onlyIfUnactivated, |
| ); |
| } |
| else this.armRetry(); |
| return; |
| case 'clear': |
| clearPendingMarker(); |
| this.finishWithChip(fireOnce, entitlement, subscription, auth.isPending, auth.user?.id ?? null, now); |
| return; |
| case 'none': |
| this.finishWithChip(fireOnce, entitlement, subscription, auth.isPending, auth.user?.id ?? null, now); |
| return; |
| case 'keep': |
| this.armRetry(); |
| return; |
| } |
| } |
|
|
| private async mount( |
| subscriptionKey: string, |
| expectedUserId: string, |
| now: number, |
| onlyIfUnactivated: boolean, |
| ): Promise<void> { |
| const authGeneration = this.authGeneration; |
| if (getAuthState().user?.id !== expectedUserId) { |
| this.armRetry(); |
| return; |
| } |
| |
| |
| |
| |
| this.mounting = true; |
| try { |
| const existingClaim = readMountClaim(); |
| if (isMountClaimBlocking(existingClaim, this.mountNonce, now)) { |
| this.armRetry(); |
| this.scheduleClaimRetry(existingClaim, now); |
| return; |
| } |
|
|
| writeMountClaim(computeMountClaim(this.mountNonce, now)); |
| await claimSettle(); |
| if (this.abortIfStale(authGeneration)) return; |
| if (getAuthState().user?.id !== expectedUserId) { |
| clearMountClaimIfOwned(this.mountNonce); |
| this.armRetry(); |
| return; |
| } |
|
|
| const settledClaim = readMountClaim(); |
| const settledAt = Date.now(); |
| if (isMountClaimBlocking(settledClaim, this.mountNonce, settledAt)) { |
| this.armRetry(); |
| this.scheduleClaimRetry(settledClaim, settledAt); |
| return; |
| } |
|
|
| |
| |
| this.teardownRetry(); |
| this.clearClaimRetry(); |
|
|
| const openResult = await this.openFlow( |
| expectedUserId, |
| subscriptionKey, |
| onlyIfUnactivated, |
| () => !this.ctx.isDestroyed && authGeneration === this.authGeneration, |
| ); |
| if (this.abortIfStale(authGeneration)) return; |
| if (openResult === 'not-eligible') { |
| this.resolved = true; |
| this.clearFlowRetry(); |
| clearMountClaimIfOwned(this.mountNonce); |
| return; |
| } |
| if (openResult !== 'opened') { |
| clearMountClaimIfOwned(this.mountNonce); |
| this.scheduleFlowRetry(); |
| return; |
| } |
| this.resolved = true; |
| this.clearFlowRetry(); |
|
|
| |
| |
| const accountKey = deriveActivationAccountKey(expectedUserId); |
| if ( |
| accountKey && |
| writeScopedFireOnceRecord( |
| window.localStorage, |
| accountKey, |
| computeFireOnceRecord(subscriptionKey, now), |
| ) |
| ) { |
| clearPendingMarker(); |
| } |
| clearMountClaimIfOwned(this.mountNonce); |
| } finally { |
| this.mounting = false; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| private abortIfStale(authGeneration: number): boolean { |
| if (!this.ctx.isDestroyed && authGeneration === this.authGeneration) return false; |
| clearMountClaimIfOwned(this.mountNonce); |
| if (!this.ctx.isDestroyed) queueMicrotask(() => void this.evaluate()); |
| return true; |
| } |
|
|
| private finishWithChip( |
| fireOnce: FireOnceRecord | null, |
| entitlement: ActivationEntitlementSnapshot | null, |
| subscription: ActivationSubscriptionSnapshot | null, |
| authPending: boolean, |
| userId: string | null, |
| now: number, |
| ): void { |
| const decision = this.chipDecision( |
| fireOnce, |
| entitlement, |
| subscription, |
| authPending, |
| userId, |
| now, |
| ); |
| if (decision === 'wait') { |
| this.armRetry(); |
| return; |
| } |
|
|
| this.resolved = true; |
| this.teardownRetry(); |
| this.clearClaimRetry(); |
| this.clearFlowRetry(); |
| if (decision !== 'show') return; |
|
|
| const flowOptions = this.buildFlowOptions(); |
| const subscriptionKey = deriveSubscriptionKey(subscription); |
| if (!flowOptions || subscriptionKey === null) return; |
| void import('@/components/ProActivationChip') |
| .then((module) => |
| module.maybeShowFinishSetupChip({ |
| ...flowOptions, |
| onlyIfUnactivated: false, |
| expectedActivationKey: subscriptionKey, |
| }), |
| ) |
| .catch((error) => console.warn('[pro-activation] finish-setup chip failed to load', error)); |
| } |
|
|
| private chipDecision( |
| fireOnce: FireOnceRecord | null, |
| entitlement: ActivationEntitlementSnapshot | null, |
| subscription: ActivationSubscriptionSnapshot | null, |
| authPending: boolean, |
| userId: string | null, |
| now: number, |
| ): ChipDecision { |
| if (this.ctx.isDesktopApp || !isFireOnceActive(fireOnce, now)) return 'hide'; |
| if (authPending || entitlement === null || subscription === null) return 'wait'; |
| if (userId === null) return 'hide'; |
| if (!isProPlanKey(entitlement.planKey) || entitlement.validUntil < now) return 'hide'; |
| const subscriptionKey = deriveSubscriptionKey(subscription); |
| if (subscriptionKey === null) return 'wait'; |
| return subscriptionKey === fireOnce.subscriptionKey ? 'show' : 'hide'; |
| } |
|
|
| private armRetry(): void { |
| if (this.retryArmed || this.resolved) return; |
| this.retryArmed = true; |
| |
| |
| |
| |
| |
| |
| const skipSyncReplay = (cb: () => void): (() => void) => { |
| let first = true; |
| return () => { |
| if (first) { |
| first = false; |
| return; |
| } |
| cb(); |
| }; |
| }; |
| const reEvaluate = (): void => { |
| queueMicrotask(() => void this.evaluate()); |
| }; |
| this.retryUnsubscribers.push(onEntitlementChange(skipSyncReplay(reEvaluate))); |
| this.retryUnsubscribers.push(onSubscriptionChange(skipSyncReplay(reEvaluate))); |
| } |
|
|
| private teardownRetry(): void { |
| for (const unsubscribe of this.retryUnsubscribers) { |
| try { |
| unsubscribe(); |
| } catch { |
| |
| } |
| } |
| this.retryUnsubscribers = []; |
| this.retryArmed = false; |
| } |
|
|
| private scheduleClaimRetry(claim: MountClaimRecord | null, now: number): void { |
| this.clearClaimRetry(); |
| const remaining = claim |
| ? Math.max(0, MOUNT_CLAIM_TTL_MS - (now - claim.claimedAt)) |
| : MOUNT_CLAIM_TTL_MS; |
| this.claimRetryHandle = window.setTimeout(() => { |
| this.claimRetryHandle = null; |
| void this.evaluate(); |
| }, remaining + MOUNT_CLAIM_RETRY_GRACE_MS); |
| } |
|
|
| private clearClaimRetry(): void { |
| if (this.claimRetryHandle === null) return; |
| window.clearTimeout(this.claimRetryHandle); |
| this.claimRetryHandle = null; |
| } |
|
|
| private scheduleFlowRetry(): void { |
| this.teardownRetry(); |
| this.clearFlowRetry(false); |
| const delay = activationFlowRetryDelay(this.flowRetryAttempts); |
| if (delay === null) { |
| |
| |
| |
| |
| |
| |
| this.flowRetryAttempts = 0; |
| this.armRetry(); |
| return; |
| } |
| this.flowRetryAttempts += 1; |
| this.flowRetryHandle = window.setTimeout(() => { |
| this.flowRetryHandle = null; |
| void this.evaluate(); |
| }, delay); |
| } |
|
|
| private clearFlowRetry(resetAttempts = true): void { |
| if (this.flowRetryHandle !== null) { |
| window.clearTimeout(this.flowRetryHandle); |
| this.flowRetryHandle = null; |
| } |
| if (resetAttempts) this.flowRetryAttempts = 0; |
| } |
|
|
| private cancelScheduledMount(): void { |
| if (this.mountIdleHandle !== null) { |
| const cancelIdle = window.cancelIdleCallback as ((handle: number) => void) | undefined; |
| cancelIdle?.(this.mountIdleHandle); |
| this.mountIdleHandle = null; |
| } |
| if (this.mountTimeoutHandle !== null) { |
| window.clearTimeout(this.mountTimeoutHandle); |
| this.mountTimeoutHandle = null; |
| } |
| } |
|
|
| private async openFlow( |
| expectedUserId: string, |
| subscriptionKey: string, |
| onlyIfUnactivated: boolean, |
| isStillValid: () => boolean, |
| ): Promise<'opened' | 'not-eligible' | 'retry'> { |
| const flowOptions = this.buildFlowOptions(expectedUserId); |
| if (!flowOptions) return 'retry'; |
| try { |
| const module = await import('@/components/ProActivationInterstitial'); |
| const result = await module.openProActivationFlow({ |
| ...flowOptions, |
| onlyIfUnactivated, |
| |
| |
| |
| |
| expectedActivationKey: subscriptionKey, |
| activationClaimNonce: this.mountNonce, |
| activationSessionStartedAt: this.mountSessionStartedAt, |
| }); |
| |
| |
| |
| |
| if (!isStillValid()) { |
| module.closeProActivationInterstitial(); |
| return 'retry'; |
| } |
| return result; |
| } catch (error) { |
| console.warn('[pro-activation] failed to open activation flow', error); |
| return 'retry'; |
| } |
| } |
|
|
| private buildFlowOptions(expectedUserId?: string): ProActivationFlowOptions | null { |
| const user = getAuthState().user; |
| if (!user || (expectedUserId && user.id !== expectedUserId)) return null; |
| const ctx = this.ctx; |
| return { |
| accountUserId: user.id, |
| accountEmail: user.email, |
| createDay0SessionIdentity: () => { |
| const sessionStartedAt = Math.max(Date.now(), this.lastDay0SessionStartedAt + 1); |
| this.lastDay0SessionStartedAt = sessionStartedAt; |
| return { |
| activationClaimNonce: generateMountClaimNonce(), |
| activationSessionStartedAt: sessionStartedAt, |
| }; |
| }, |
| |
| |
| |
| |
| |
| |
| |
| openMcpClients: hasFeature('mcpAccess') |
| ? () => { |
| |
| |
| |
| |
| if (hasFeature('mcpAccess')) ctx.unifiedSettings?.open('mcp-clients'); |
| else ctx.unifiedSettings?.open('settings'); |
| } |
| : undefined, |
| openChannelSettings: () => ctx.unifiedSettings?.open('notifications'), |
| openWidgetBuilder: () => |
| ctx.container.dispatchEvent(new CustomEvent('wm:open-widget-creator', { detail: {} })), |
| openAiAnalyst: this.options.openAiAnalyst, |
| openSearch: this.options.openSearch, |
| onEvent: (event, stepId, exit) => |
| trackProActivation(event, { |
| planKey: getEntitlementState()?.planKey ?? null, |
| step: stepId, |
| completion: exit?.completion, |
| verified: exit?.verified, |
| pending: exit?.pending, |
| failed: exit?.failed, |
| total: exit?.total, |
| }), |
| }; |
| } |
| } |
|
|