From d79eb037c8328da8b9a460e87cb264e74232e4d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 14:42:10 +0000 Subject: [PATCH] feat(web): local-mode Google connect prompt after three events Fixes #4500 Co-authored-by: Tyler Dane --- .../CompassKit/Generated/ProductEvent.swift | 3 + .../ProductEventNativeCoverage.swift | 3 + .../src/__tests__/utils/state/reset-stores.ts | 2 + .../posthog/anon-calendar-write-count.util.ts | 11 ++ .../posthog/posthog-calendar-grid.util.ts | 1 + .../src/auth/posthog/signup-funnel.ts | 1 + apps/calendar-web/src/auth/posthog/track.ts | 3 + .../src/common/constants/storage.constants.ts | 2 + .../utils/toast/anonymous-save.toast.tsx | 2 +- .../LocalModeConnectPrompt.test.tsx | 181 ++++++++++++++++++ .../LocalModeConnectPrompt.tsx | 131 +++++++++++++ .../local-mode-connect.storage.ts | 18 ++ .../local-mode-connect.store.ts | 102 ++++++++++ .../useLocalModeConnectPromptEligible.ts | 53 +++++ .../src/components/RootShell/RootShell.tsx | 2 + .../mutations/event.mutation.runtime.ts | 11 +- .../local-mode-connect-prompt.spec.ts | 56 ++++++ 17 files changed, 579 insertions(+), 3 deletions(-) create mode 100644 apps/calendar-web/src/auth/posthog/anon-calendar-write-count.util.ts create mode 100644 apps/calendar-web/src/components/LocalModeConnectPrompt/LocalModeConnectPrompt.test.tsx create mode 100644 apps/calendar-web/src/components/LocalModeConnectPrompt/LocalModeConnectPrompt.tsx create mode 100644 apps/calendar-web/src/components/LocalModeConnectPrompt/local-mode-connect.storage.ts create mode 100644 apps/calendar-web/src/components/LocalModeConnectPrompt/local-mode-connect.store.ts create mode 100644 apps/calendar-web/src/components/LocalModeConnectPrompt/useLocalModeConnectPromptEligible.ts create mode 100644 e2e/onboarding/local-mode-connect-prompt.spec.ts diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEvent.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEvent.swift index 49840a87c6..4e17eb580b 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEvent.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEvent.swift @@ -37,6 +37,9 @@ public enum ProductEvent: String, Codable, Hashable, Sendable, CaseIterable { case firstEventPromptCompleted = "first_event_prompt_completed" case firstEventPromptDismissed = "first_event_prompt_dismissed" case connectCalendarPromptDismissed = "connect_calendar_prompt_dismissed" + case connectPromptShown = "connect_prompt_shown" + case connectPromptClicked = "connect_prompt_clicked" + case connectPromptDismissed = "connect_prompt_dismissed" case trialConverted = "trial_converted" case trialCelebrationShown = "trial_celebration_shown" case billingGateShown = "billing_gate_shown" diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEventNativeCoverage.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEventNativeCoverage.swift index 2b53618e59..2a86795be3 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEventNativeCoverage.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEventNativeCoverage.swift @@ -46,6 +46,9 @@ public enum ProductEventNativeCoverage: Sendable { .firstEventPromptCompleted, .firstEventPromptDismissed, .connectCalendarPromptDismissed, + .connectPromptShown, + .connectPromptClicked, + .connectPromptDismissed, .trialConverted, .trialCelebrationShown, .billingGateShown, diff --git a/apps/calendar-web/src/__tests__/utils/state/reset-stores.ts b/apps/calendar-web/src/__tests__/utils/state/reset-stores.ts index 22f8332763..ecea1f1c90 100644 --- a/apps/calendar-web/src/__tests__/utils/state/reset-stores.ts +++ b/apps/calendar-web/src/__tests__/utils/state/reset-stores.ts @@ -33,6 +33,7 @@ import { initialFirstEventPromptState, useFirstEventPromptStore, } from "@web/components/FirstEventPrompt/first-event.store"; +import { resetLocalModeConnectPromptStoreForTests } from "@web/components/LocalModeConnectPrompt/local-mode-connect.store"; import { initialShortcutShowcaseState, useShortcutShowcaseStore, @@ -118,6 +119,7 @@ const storeResets: StoreReset[] = [ () => useShortcutShowcaseStore.setState(initialShortcutShowcaseState, true), () => useFirstEventPromptStore.setState(initialFirstEventPromptState, true), resetConnectCalendarPromptStoreForTests, + resetLocalModeConnectPromptStoreForTests, () => useWelcomeGuideStore.setState(useWelcomeGuideStore.getInitialState(), true), () => useThemeStore.setState(useThemeStore.getInitialState(), true), diff --git a/apps/calendar-web/src/auth/posthog/anon-calendar-write-count.util.ts b/apps/calendar-web/src/auth/posthog/anon-calendar-write-count.util.ts new file mode 100644 index 0000000000..439cd22167 --- /dev/null +++ b/apps/calendar-web/src/auth/posthog/anon-calendar-write-count.util.ts @@ -0,0 +1,11 @@ +import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; +import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; + +export function readAnonymousCalendarWriteCount(): number { + if (!persistentBrowserStore.isAvailable()) return 0; + const raw = persistentBrowserStore.get( + STORAGE_KEYS.ANONYMOUS_CALENDAR_WRITE_COUNT, + ); + const parsed = raw ? Number.parseInt(raw, 10) : 0; + return Number.isFinite(parsed) ? parsed : 0; +} diff --git a/apps/calendar-web/src/auth/posthog/posthog-calendar-grid.util.ts b/apps/calendar-web/src/auth/posthog/posthog-calendar-grid.util.ts index e954ac25d5..d182db4cb1 100644 --- a/apps/calendar-web/src/auth/posthog/posthog-calendar-grid.util.ts +++ b/apps/calendar-web/src/auth/posthog/posthog-calendar-grid.util.ts @@ -28,6 +28,7 @@ const GRID_CHAIN_MARKERS = [...GRID_ELEMENT_IDS, ...GRID_ELEMENT_ATTRS]; export const CALENDAR_GRID_DEAD_CLICK_IGNORE_SELECTORS = [ ...GRID_ELEMENT_IDS.map((id) => `#${id}`), ...GRID_ELEMENT_ATTRS.map((attr) => `[${attr}]`), + "[data-local-mode-connect-prompt]", ]; /** True when autocapture element metadata points at the timed or all-day grid. */ diff --git a/apps/calendar-web/src/auth/posthog/signup-funnel.ts b/apps/calendar-web/src/auth/posthog/signup-funnel.ts index 47e05bfa4d..5f3d271c85 100644 --- a/apps/calendar-web/src/auth/posthog/signup-funnel.ts +++ b/apps/calendar-web/src/auth/posthog/signup-funnel.ts @@ -60,6 +60,7 @@ export type SignupSource = | "welcome_modal" | `welcome_modal_${ProviderKind}` | "anon_nudge" + | "local_mode_connect_prompt" | "command_palette" | "connect_chooser" | "shortcut_showcase" diff --git a/apps/calendar-web/src/auth/posthog/track.ts b/apps/calendar-web/src/auth/posthog/track.ts index b834a8005e..150c928940 100644 --- a/apps/calendar-web/src/auth/posthog/track.ts +++ b/apps/calendar-web/src/auth/posthog/track.ts @@ -54,6 +54,9 @@ export type ProductEvent = | "first_event_prompt_completed" | "first_event_prompt_dismissed" | "connect_calendar_prompt_dismissed" + | "connect_prompt_shown" + | "connect_prompt_clicked" + | "connect_prompt_dismissed" | "trial_converted" | "trial_celebration_shown" | "billing_gate_shown" diff --git a/apps/calendar-web/src/common/constants/storage.constants.ts b/apps/calendar-web/src/common/constants/storage.constants.ts index 1b504f3011..45c3a9f2f8 100644 --- a/apps/calendar-web/src/common/constants/storage.constants.ts +++ b/apps/calendar-web/src/common/constants/storage.constants.ts @@ -14,6 +14,8 @@ export const STORAGE_KEYS = { "compass.onboarding.has-seen-anonymous-save-toast", ANONYMOUS_CALENDAR_WRITE_COUNT: "compass.onboarding.anonymous-calendar-write-count", + HAS_DISMISSED_LOCAL_MODE_CONNECT_PROMPT: + "compass.onboarding.has-dismissed-local-mode-connect-prompt", HAS_DISMISSED_DEMO_EVENTS_BANNER: "compass.onboarding.has-dismissed-demo-events-banner", HAS_DISMISSED_TASKS_REMOVAL_NOTICE: diff --git a/apps/calendar-web/src/common/utils/toast/anonymous-save.toast.tsx b/apps/calendar-web/src/common/utils/toast/anonymous-save.toast.tsx index 6532af6032..8bc04c142b 100644 --- a/apps/calendar-web/src/common/utils/toast/anonymous-save.toast.tsx +++ b/apps/calendar-web/src/common/utils/toast/anonymous-save.toast.tsx @@ -55,7 +55,7 @@ function markAnonymousSaveToastSeen(): void { ); } -function incrementAnonymousCalendarWriteCount(): number { +export function incrementAnonymousCalendarWriteCount(): number { if (!persistentBrowserStore.isAvailable()) return 0; const raw = persistentBrowserStore.get( STORAGE_KEYS.ANONYMOUS_CALENDAR_WRITE_COUNT, diff --git a/apps/calendar-web/src/components/LocalModeConnectPrompt/LocalModeConnectPrompt.test.tsx b/apps/calendar-web/src/components/LocalModeConnectPrompt/LocalModeConnectPrompt.test.tsx new file mode 100644 index 0000000000..f795c2fd7e --- /dev/null +++ b/apps/calendar-web/src/components/LocalModeConnectPrompt/LocalModeConnectPrompt.test.tsx @@ -0,0 +1,181 @@ +import "@testing-library/jest-dom"; +import { HotkeysProvider } from "@tanstack/react-hotkeys"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { pressKey } from "@web/__tests__/utils/keyboard.test.util"; +import { mockModuleForFile } from "@web/__tests__/utils/mock-module.test.util"; +import { SessionContext } from "@web/auth/compass/session/session.context"; +import * as realSignupFunnel from "@web/auth/posthog/signup-funnel"; +import * as realTrack from "@web/auth/posthog/track"; +import * as realProviderAvailability from "@web/auth/providers/useIsProviderAvailable"; +import * as realSignInProviders from "@web/auth/providers/useSignInProviders"; +import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; +import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; +import { AuthModalContext } from "@web/components/AuthModal/hooks/useAuthModal"; +import { + LOCAL_MODE_CONNECT_PROMPT_CONNECT_LABEL, + LOCAL_MODE_CONNECT_PROMPT_MESSAGE, + LocalModeConnectPrompt, +} from "@web/components/LocalModeConnectPrompt/LocalModeConnectPrompt"; +import { resetLocalModeConnectPromptStoreForTests } from "@web/components/LocalModeConnectPrompt/local-mode-connect.store"; +import { + initialShortcutShowcaseState, + useShortcutShowcaseStore, +} from "@web/components/ShortcutShowcase/showcase.store"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +const mockStartSignIn = mock(); +const trackSpy = mock(); +const trackSignupStartedAtClickSpy = mock(); + +mockModuleForFile( + "@web/auth/providers/useSignInProviders", + realSignInProviders, + { + useSignInProviders: () => ({ + available: ["google"], + startSignIn: mockStartSignIn, + isLoading: false, + loadingKind: null, + byKind: {}, + }), + }, +); + +mockModuleForFile( + "@web/auth/providers/useIsProviderAvailable", + realProviderAvailability, + { + useIsProviderAvailable: () => true, + }, +); + +mockModuleForFile("@web/auth/posthog/track", realTrack, { + track: trackSpy, +}); + +mockModuleForFile("@web/auth/posthog/signup-funnel", realSignupFunnel, { + trackSignupStartedAtClick: trackSignupStartedAtClickSpy, +}); + +const renderPrompt = () => + render( + + + + + + + , + ); + +describe("LocalModeConnectPrompt", () => { + beforeEach(() => { + localStorage.clear(); + mockStartSignIn.mockClear(); + trackSpy.mockClear(); + trackSignupStartedAtClickSpy.mockClear(); + useShortcutShowcaseStore.setState(initialShortcutShowcaseState); + persistentBrowserStore.set(STORAGE_KEYS.HAS_SEEN_WELCOME, "true"); + persistentBrowserStore.set( + STORAGE_KEYS.ANONYMOUS_CALENDAR_WRITE_COUNT, + "3", + ); + resetLocalModeConnectPromptStoreForTests(); + }); + + it("does not render before the write threshold", () => { + persistentBrowserStore.set( + STORAGE_KEYS.ANONYMOUS_CALENDAR_WRITE_COUNT, + "2", + ); + resetLocalModeConnectPromptStoreForTests(); + renderPrompt(); + expect( + screen.queryByRole("complementary", { name: "Connect Google" }), + ).toBeNull(); + }); + + it("renders after three anonymous writes with connect copy", () => { + renderPrompt(); + expect( + screen.getByRole("complementary", { name: "Connect Google" }), + ).toBeInTheDocument(); + expect( + screen.getByText(LOCAL_MODE_CONNECT_PROMPT_MESSAGE), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: LOCAL_MODE_CONNECT_PROMPT_CONNECT_LABEL, + }), + ).toBeInTheDocument(); + }); + + it("fires connect_prompt_shown once with event count", async () => { + renderPrompt(); + await waitFor(() => { + expect(trackSpy).toHaveBeenCalledWith("connect_prompt_shown", { + events_created_count: 3, + provider: "google", + }); + }); + expect( + trackSpy.mock.calls.filter(([event]) => event === "connect_prompt_shown"), + ).toHaveLength(1); + }); + + it("starts Google signup and tracks click", async () => { + const user = userEvent.setup(); + renderPrompt(); + await user.click( + screen.getByRole("button", { + name: LOCAL_MODE_CONNECT_PROMPT_CONNECT_LABEL, + }), + ); + + expect(trackSpy).toHaveBeenCalledWith("connect_prompt_clicked", { + events_created_count: 3, + provider: "google", + }); + expect(trackSignupStartedAtClickSpy).toHaveBeenCalledWith( + "local_mode_connect_prompt", + ); + expect(mockStartSignIn).toHaveBeenCalledWith("google"); + }); + + it("dismisses on Escape and persists", async () => { + renderPrompt(); + pressKey("Escape"); + + await waitFor(() => { + expect( + screen.queryByRole("complementary", { name: "Connect Google" }), + ).toBeNull(); + }); + expect(trackSpy).toHaveBeenCalledWith("connect_prompt_dismissed", { + events_created_count: 3, + provider: "google", + }); + expect( + persistentBrowserStore.get( + STORAGE_KEYS.HAS_DISMISSED_LOCAL_MODE_CONNECT_PROMPT, + ), + ).toBe("true"); + }); + + it("connects on Enter when focus is not in a field", async () => { + renderPrompt(); + pressKey("Enter"); + expect(mockStartSignIn).toHaveBeenCalledWith("google"); + }); +}); diff --git a/apps/calendar-web/src/components/LocalModeConnectPrompt/LocalModeConnectPrompt.tsx b/apps/calendar-web/src/components/LocalModeConnectPrompt/LocalModeConnectPrompt.tsx new file mode 100644 index 0000000000..f8e0d6cc48 --- /dev/null +++ b/apps/calendar-web/src/components/LocalModeConnectPrompt/LocalModeConnectPrompt.tsx @@ -0,0 +1,131 @@ +import { XIcon } from "@phosphor-icons/react/dist/csr/X"; +import { type FC, useCallback, useContext, useEffect, useRef } from "react"; +import { useSignInProviders } from "@web/auth/providers/useSignInProviders"; +import { BANNER_DISMISS_MS } from "@web/common/constants/motion.constants"; +import { Z_INDEX_TOOLTIP } from "@web/common/constants/web.constants"; +import { useDismissTransition } from "@web/common/hooks/useDismissTransition"; +import { AuthModalContext } from "@web/components/AuthModal/hooks/useAuthModal"; +import IconButton from "@web/components/IconButton/IconButton"; +import { + localModeConnectPromptActions, + selectLocalModeConnectPromptWriteCount, + useLocalModeConnectPromptStore, +} from "@web/components/LocalModeConnectPrompt/local-mode-connect.store"; +import { useLocalModeConnectPromptEligible } from "@web/components/LocalModeConnectPrompt/useLocalModeConnectPromptEligible"; +import { useAppShortcut } from "@web/shortcuts/useAppShortcut"; + +export const LOCAL_MODE_CONNECT_PROMPT_ARIA_LABEL = "Connect Google"; + +export const LOCAL_MODE_CONNECT_PROMPT_MESSAGE = + "Connect Google to sync your events and start your free trial."; + +export const LOCAL_MODE_CONNECT_PROMPT_CONNECT_LABEL = "Connect Google"; + +export const LOCAL_MODE_CONNECT_PROMPT_PH_CLASS = "ph-no-rageclick"; + +const shouldDelegateEnterToConnect = ( + active: Element | null, + connectRef: HTMLButtonElement | null, +): boolean => { + if (!connectRef) return false; + if (active === connectRef) return true; + if (active instanceof HTMLElement && active.isContentEditable) return false; + const tag = active?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return false; + return true; +}; + +export const LocalModeConnectPrompt: FC = () => { + const { isOpen: isAuthModalOpen } = useContext(AuthModalContext); + const isLive = useLocalModeConnectPromptEligible(isAuthModalOpen); + const writeCount = useLocalModeConnectPromptStore( + selectLocalModeConnectPromptWriteCount, + ); + const { closing, beginDismiss } = useDismissTransition(BANNER_DISMISS_MS); + const connectRef = useRef(null); + const { startSignIn, isLoading } = useSignInProviders({ signupFlow: true }); + + const dismiss = useCallback(() => { + beginDismiss(() => localModeConnectPromptActions.dismiss(writeCount)); + }, [beginDismiss, writeCount]); + + const connect = useCallback(() => { + localModeConnectPromptActions.connect(writeCount); + startSignIn("google"); + }, [startSignIn, writeCount]); + + useEffect(() => { + localModeConnectPromptActions.syncFromStorage(); + }, []); + + useEffect(() => { + if (!isLive) return; + localModeConnectPromptActions.markShown(writeCount); + }, [isLive, writeCount]); + + useAppShortcut("Escape", dismiss, { + enabled: isLive && !closing, + ignoreAppLock: true, + preventDefault: true, + }); + + useAppShortcut( + "Enter", + (event) => { + if (event.repeat) return; + if ( + !shouldDelegateEnterToConnect( + document.activeElement, + connectRef.current, + ) + ) { + return; + } + connect(); + }, + { + enabled: isLive && !closing, + ignoreAppLock: true, + preventDefault: true, + }, + ); + + if (!isLive) return null; + + return ( + + ); +}; diff --git a/apps/calendar-web/src/components/LocalModeConnectPrompt/local-mode-connect.storage.ts b/apps/calendar-web/src/components/LocalModeConnectPrompt/local-mode-connect.storage.ts new file mode 100644 index 0000000000..b0acf191d5 --- /dev/null +++ b/apps/calendar-web/src/components/LocalModeConnectPrompt/local-mode-connect.storage.ts @@ -0,0 +1,18 @@ +import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; +import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; + +export function isLocalModeConnectPromptDismissed(): boolean { + if (!persistentBrowserStore.isAvailable()) return true; + return ( + persistentBrowserStore.get( + STORAGE_KEYS.HAS_DISMISSED_LOCAL_MODE_CONNECT_PROMPT, + ) === "true" + ); +} + +export function markLocalModeConnectPromptDismissed(): void { + persistentBrowserStore.set( + STORAGE_KEYS.HAS_DISMISSED_LOCAL_MODE_CONNECT_PROMPT, + "true", + ); +} diff --git a/apps/calendar-web/src/components/LocalModeConnectPrompt/local-mode-connect.store.ts b/apps/calendar-web/src/components/LocalModeConnectPrompt/local-mode-connect.store.ts new file mode 100644 index 0000000000..acbab29c2b --- /dev/null +++ b/apps/calendar-web/src/components/LocalModeConnectPrompt/local-mode-connect.store.ts @@ -0,0 +1,102 @@ +import { create } from "zustand"; +import { readAnonymousCalendarWriteCount } from "@web/auth/posthog/anon-calendar-write-count.util"; +import { trackSignupStartedAtClick } from "@web/auth/posthog/signup-funnel"; +import { track } from "@web/auth/posthog/track"; +import { + isLocalModeConnectPromptDismissed, + markLocalModeConnectPromptDismissed, +} from "@web/components/LocalModeConnectPrompt/local-mode-connect.storage"; + +export type LocalModeConnectPromptState = { + writeCount: number; + isDismissed: boolean; +}; + +export const initialLocalModeConnectPromptState: LocalModeConnectPromptState = { + writeCount: readAnonymousCalendarWriteCount(), + isDismissed: isLocalModeConnectPromptDismissed(), +}; + +export const useLocalModeConnectPromptStore = + create(() => ({ + ...initialLocalModeConnectPromptState, + })); + +let hasTrackedShown = false; + +export const localModeConnectPromptActions = { + syncFromStorage: () => { + useLocalModeConnectPromptStore.setState({ + writeCount: readAnonymousCalendarWriteCount(), + isDismissed: isLocalModeConnectPromptDismissed(), + }); + }, + markShown: (eventsCreatedCount: number) => { + if (hasTrackedShown) return; + hasTrackedShown = true; + track("connect_prompt_shown", { + events_created_count: eventsCreatedCount, + provider: "google", + }); + }, + connect: (eventsCreatedCount: number) => { + track("connect_prompt_clicked", { + events_created_count: eventsCreatedCount, + provider: "google", + }); + void trackSignupStartedAtClick("local_mode_connect_prompt"); + }, + dismiss: (eventsCreatedCount: number) => { + track("connect_prompt_dismissed", { + events_created_count: eventsCreatedCount, + provider: "google", + }); + markLocalModeConnectPromptDismissed(); + useLocalModeConnectPromptStore.setState({ isDismissed: true }); + }, +}; + +export const resetLocalModeConnectPromptStoreForTests = (): void => { + hasTrackedShown = false; + useLocalModeConnectPromptStore.setState( + { + writeCount: readAnonymousCalendarWriteCount(), + isDismissed: isLocalModeConnectPromptDismissed(), + }, + true, + ); +}; + +export const selectLocalModeConnectPromptWriteCount = ( + state: LocalModeConnectPromptState, +): number => state.writeCount; + +export const selectLocalModeConnectPromptDismissed = ( + state: LocalModeConnectPromptState, +): boolean => state.isDismissed; + +export type LocalModeConnectPromptEligibility = { + authenticated: boolean; + hasUserEverAuthenticated: boolean; + writeCount: number; + isDismissed: boolean; + storageAvailable: boolean; + isAuthModalOpen: boolean; + isShowcaseActive: boolean; + isWelcomeBlocking: boolean; + googleSignInAvailable: boolean; + threshold: number; +}; + +export const selectLocalModeConnectPromptEligible = ( + input: LocalModeConnectPromptEligibility, +): boolean => + !input.authenticated && + !input.hasUserEverAuthenticated && + input.storageAvailable && + !input.isDismissed && + input.writeCount >= input.threshold && + !input.isAuthModalOpen && + !input.isShowcaseActive && + !input.isWelcomeBlocking && + input.googleSignInAvailable; diff --git a/apps/calendar-web/src/components/LocalModeConnectPrompt/useLocalModeConnectPromptEligible.ts b/apps/calendar-web/src/components/LocalModeConnectPrompt/useLocalModeConnectPromptEligible.ts new file mode 100644 index 0000000000..54ff3f3337 --- /dev/null +++ b/apps/calendar-web/src/components/LocalModeConnectPrompt/useLocalModeConnectPromptEligible.ts @@ -0,0 +1,53 @@ +import { useContext } from "react"; +import { SessionContext } from "@web/auth/compass/session/session.context"; +import { hasUserEverAuthenticated } from "@web/auth/compass/state/auth.state.util"; +import { useIsProviderAvailable } from "@web/auth/providers/useIsProviderAvailable"; +import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; +import { + selectLocalModeConnectPromptDismissed, + selectLocalModeConnectPromptEligible, + selectLocalModeConnectPromptWriteCount, + useLocalModeConnectPromptStore, +} from "@web/components/LocalModeConnectPrompt/local-mode-connect.store"; +import { + selectShowcaseActive, + useShortcutShowcaseStore, +} from "@web/components/ShortcutShowcase/showcase.store"; +import { + selectWelcomeFirstVisitOpen, + useWelcomeGuideStore, +} from "@web/components/WelcomeModal/welcome.guide.store"; +import { hasSeenWelcome } from "@web/components/WelcomeModal/welcome.modal.util"; +import { LOCAL_MODE_CONNECT_PROMPT_AFTER_EVENT_COUNT } from "@web/events/mutations/event.mutation.runtime"; + +export function useLocalModeConnectPromptEligible( + isAuthModalOpen: boolean, +): boolean { + const { authenticated } = useContext(SessionContext); + const writeCount = useLocalModeConnectPromptStore( + selectLocalModeConnectPromptWriteCount, + ); + const isDismissed = useLocalModeConnectPromptStore( + selectLocalModeConnectPromptDismissed, + ); + const isShowcaseActive = useShortcutShowcaseStore(selectShowcaseActive); + const isWelcomeFirstVisitOpen = useWelcomeGuideStore( + selectWelcomeFirstVisitOpen, + ); + const googleSignInAvailable = useIsProviderAvailable("google", "signIn"); + + const isWelcomeBlocking = !hasSeenWelcome() || isWelcomeFirstVisitOpen; + + return selectLocalModeConnectPromptEligible({ + authenticated, + hasUserEverAuthenticated: hasUserEverAuthenticated(), + writeCount, + isDismissed, + storageAvailable: persistentBrowserStore.isAvailable(), + isAuthModalOpen, + isShowcaseActive, + isWelcomeBlocking, + googleSignInAvailable, + threshold: LOCAL_MODE_CONNECT_PROMPT_AFTER_EVENT_COUNT, + }); +} diff --git a/apps/calendar-web/src/components/RootShell/RootShell.tsx b/apps/calendar-web/src/components/RootShell/RootShell.tsx index 113bff0966..1be8da911f 100644 --- a/apps/calendar-web/src/components/RootShell/RootShell.tsx +++ b/apps/calendar-web/src/components/RootShell/RootShell.tsx @@ -29,6 +29,7 @@ import { ConnectCalendarPromptGate } from "@web/components/ConnectCalendarPrompt import { useConnectCalendarPromptSurfaceEligible } from "@web/components/ConnectCalendarPrompt/useConnectCalendarPromptSurfaceEligible"; import { FirstEventPrompt } from "@web/components/FirstEventPrompt/FirstEventPrompt"; import { useFirstEventPromptSurfaceEligible } from "@web/components/FirstEventPrompt/useFirstEventPromptSurfaceEligible"; +import { LocalModeConnectPrompt } from "@web/components/LocalModeConnectPrompt/LocalModeConnectPrompt"; import { PointerHint } from "@web/components/PointerHint/PointerHint"; import { type OnboardingSurfaceFlags, @@ -223,6 +224,7 @@ export function RootShell() { )} {activeOnboardingSurface === "firstEventPrompt" && } {activeOnboardingSurface === "pointerHint" && } + ); } diff --git a/apps/calendar-web/src/events/mutations/event.mutation.runtime.ts b/apps/calendar-web/src/events/mutations/event.mutation.runtime.ts index b1656d4fa5..28cfcb9554 100644 --- a/apps/calendar-web/src/events/mutations/event.mutation.runtime.ts +++ b/apps/calendar-web/src/events/mutations/event.mutation.runtime.ts @@ -5,11 +5,15 @@ import { markAnonymousCalendarChangeForSignUpPrompt, } from "@web/auth/compass/state/auth.state.util"; import { maybeShowAnonymousSaveToast } from "@web/common/utils/toast/anonymous-save.toast"; +import { localModeConnectPromptActions } from "@web/components/LocalModeConnectPrompt/local-mode-connect.store"; +import { eventMutationKeys } from "./event.mutation.keys"; /** Founder decision (Trial v2 WP-03): nudge after this many anonymous calendar writes. */ export const ANONYMOUS_SAVE_TOAST_AFTER_SAVE_COUNT = 3; -import { eventMutationKeys } from "./event.mutation.keys"; +/** Local-mode Google connect prompt uses the same threshold as the save toast. */ +export const LOCAL_MODE_CONNECT_PROMPT_AFTER_EVENT_COUNT = + ANONYMOUS_SAVE_TOAST_AFTER_SAVE_COUNT; const ifAnonymousGuest = async (action: () => void): Promise => { if (await session.doesSessionExist()) return; @@ -21,7 +25,10 @@ export const markAnonymousEventWrite = () => ifAnonymousGuest(markAnonymousCalendarChangeForSignUpPrompt); export const showAnonymousSaveToastIfEligible = () => - ifAnonymousGuest(maybeShowAnonymousSaveToast); + ifAnonymousGuest(() => { + maybeShowAnonymousSaveToast(); + localModeConnectPromptActions.syncFromStorage(); + }); type AnyMutation = Mutation; diff --git a/e2e/onboarding/local-mode-connect-prompt.spec.ts b/e2e/onboarding/local-mode-connect-prompt.spec.ts new file mode 100644 index 0000000000..77cd96f165 --- /dev/null +++ b/e2e/onboarding/local-mode-connect-prompt.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from "@playwright/test"; +import { E2E_APP_CONFIG_VERSION } from "../utils/test-constants"; + +test.use({ storageState: { cookies: [], origins: [] } }); +test.use({ viewport: { width: 1600, height: 900 } }); + +test("shows a Google connect prompt after three anonymous writes and dismisses permanently", async ({ + page, +}) => { + await page.route("**/api/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + version: E2E_APP_CONFIG_VERSION, + providers: { + google: { signIn: true, connect: true }, + microsoft: { signIn: false, connect: false }, + apple: { signIn: false, connect: false }, + }, + }), + }); + }); + + await page.addInitScript( + (keys) => { + localStorage.setItem(keys.hasSeenWelcome, "true"); + localStorage.setItem(keys.firstEventDone, "completed"); + localStorage.setItem(keys.writeCount, "3"); + localStorage.removeItem(keys.dismissed); + }, + { + hasSeenWelcome: "compass.onboarding.has-seen-welcome", + firstEventDone: "compass.onboarding.first-event-done", + writeCount: "compass.onboarding.anonymous-calendar-write-count", + dismissed: "compass.onboarding.has-dismissed-local-mode-connect-prompt", + }, + ); + + await page.goto("/week", { waitUntil: "domcontentloaded" }); + + const prompt = page.getByRole("complementary", { name: "Connect Google" }); + await expect(prompt).toBeVisible(); + await expect(prompt).toContainText( + "Connect Google to sync your events and start your free trial.", + ); + await page.screenshot({ + path: "/opt/cursor/artifacts/local-mode-connect-prompt-demo.png", + }); + + await prompt.getByRole("button", { name: "Dismiss" }).click(); + await expect(prompt).toHaveCount(0); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(prompt).toHaveCount(0); +});