diff --git a/apps/web/.cross-domain-allowlist.json b/apps/web/.cross-domain-allowlist.json index bf864dc21fc..28447b58e09 100644 --- a/apps/web/.cross-domain-allowlist.json +++ b/apps/web/.cross-domain-allowlist.json @@ -26,7 +26,6 @@ "src/domains/chat/components/chat-route-content.tsx": [ "interactions", "messaging", - "nudges", "subagents" ], "src/domains/chat/components/mobile-subagent-detail-overlay.tsx": [ @@ -47,9 +46,6 @@ "src/domains/chat/components/voice-input-button.tsx": [ "voice" ], - "src/domains/chat/hooks/use-app-nudges.ts": [ - "nudges" - ], "src/domains/chat/hooks/use-assistant-lifecycle.ts": [ "onboarding" ], @@ -190,21 +186,9 @@ "src/domains/logs/components/usage-tab.tsx": [ "chat" ], - "src/domains/onboarding/pages/pre-chat-flow.tsx": [ - "nudges" - ], - "src/domains/onboarding/screens/get-ios-app-screen.tsx": [ - "nudges" - ], - "src/domains/onboarding/screens/get-macos-app-screen.tsx": [ - "nudges" - ], "src/domains/settings/ai/ai-page.tsx": [ "voice" ], - "src/domains/settings/components/ios-app-card.tsx": [ - "nudges" - ], "src/domains/settings/components/panels/assistant-terminal-panel.tsx": [ "terminal" ], @@ -226,9 +210,6 @@ "src/domains/settings/pages/archive-page.tsx": [ "chat" ], - "src/domains/settings/pages/community-page.tsx": [ - "nudges" - ], "src/domains/settings/pages/voice-page.tsx": [ "voice" ], diff --git a/apps/web/src/domains/nudges/components/discord-nudge-banner.tsx b/apps/web/src/components/nudges/discord-nudge-banner.tsx similarity index 93% rename from apps/web/src/domains/nudges/components/discord-nudge-banner.tsx rename to apps/web/src/components/nudges/discord-nudge-banner.tsx index 5375e369796..70ce6aeed1b 100644 --- a/apps/web/src/domains/nudges/components/discord-nudge-banner.tsx +++ b/apps/web/src/components/nudges/discord-nudge-banner.tsx @@ -1,6 +1,6 @@ import { DiscordLogo } from "@/components/icons/discord-logo.js"; -import { NudgeChatBanner } from "@/domains/nudges/components/nudge-chat-banner.js"; +import { NudgeChatBanner } from "@/components/nudges/nudge-chat-banner.js"; interface DiscordNudgeBannerProps { onJoin: () => void; diff --git a/apps/web/src/domains/nudges/components/github-nudge-banner.tsx b/apps/web/src/components/nudges/github-nudge-banner.tsx similarity index 94% rename from apps/web/src/domains/nudges/components/github-nudge-banner.tsx rename to apps/web/src/components/nudges/github-nudge-banner.tsx index 19ad4884533..8a09c04b827 100644 --- a/apps/web/src/domains/nudges/components/github-nudge-banner.tsx +++ b/apps/web/src/components/nudges/github-nudge-banner.tsx @@ -1,7 +1,7 @@ import { Star } from "lucide-react"; -import { NudgeChatBanner } from "@/domains/nudges/components/nudge-chat-banner.js"; +import { NudgeChatBanner } from "@/components/nudges/nudge-chat-banner.js"; function GitHubIcon({ size = 16 }: { size?: number }) { return ( diff --git a/apps/web/src/domains/nudges/components/ios-app-banner.tsx b/apps/web/src/components/nudges/ios-app-banner.tsx similarity index 88% rename from apps/web/src/domains/nudges/components/ios-app-banner.tsx rename to apps/web/src/components/nudges/ios-app-banner.tsx index 4f16f9e7662..1212c4a25c1 100644 --- a/apps/web/src/domains/nudges/components/ios-app-banner.tsx +++ b/apps/web/src/components/nudges/ios-app-banner.tsx @@ -1,7 +1,7 @@ import { Smartphone } from "lucide-react"; -import { NudgeChatBanner } from "@/domains/nudges/components/nudge-chat-banner.js"; +import { NudgeChatBanner } from "@/components/nudges/nudge-chat-banner.js"; interface IOSAppBannerProps { onDownload: () => void; diff --git a/apps/web/src/domains/nudges/components/macos-app-banner.tsx b/apps/web/src/components/nudges/macos-app-banner.tsx similarity index 89% rename from apps/web/src/domains/nudges/components/macos-app-banner.tsx rename to apps/web/src/components/nudges/macos-app-banner.tsx index 3e179ae14c9..1ae786b3f4f 100644 --- a/apps/web/src/domains/nudges/components/macos-app-banner.tsx +++ b/apps/web/src/components/nudges/macos-app-banner.tsx @@ -2,7 +2,7 @@ import { Download } from "lucide-react"; import { AppleLogo } from "@/components/icons/apple-logo.js"; -import { NudgeChatBanner } from "@/domains/nudges/components/nudge-chat-banner.js"; +import { NudgeChatBanner } from "@/components/nudges/nudge-chat-banner.js"; interface MacOSAppBannerProps { onDownload: () => void; diff --git a/apps/web/src/domains/nudges/components/nudge-chat-banner.tsx b/apps/web/src/components/nudges/nudge-chat-banner.tsx similarity index 100% rename from apps/web/src/domains/nudges/components/nudge-chat-banner.tsx rename to apps/web/src/components/nudges/nudge-chat-banner.tsx diff --git a/apps/web/src/domains/chat/components/chat-route-content.tsx b/apps/web/src/domains/chat/components/chat-route-content.tsx index 69f0eb51a18..806b00f53bc 100644 --- a/apps/web/src/domains/chat/components/chat-route-content.tsx +++ b/apps/web/src/domains/chat/components/chat-route-content.tsx @@ -42,10 +42,10 @@ import { useChatAttachmentDropZone } from "@/domains/chat/components/chat-attach import type { ChatAttachment } from "@/domains/chat/components/chat-attachments/use-chat-attachments.js"; import type { ChatEmptyStateProps } from "@/domains/chat/components/chat-empty-state.js"; import { CreditsExhaustedBanner } from "@/domains/chat/components/credits-exhausted-banner.js"; -import { DiscordNudgeBanner } from "@/domains/nudges/components/discord-nudge-banner.js"; -import { GitHubNudgeBanner } from "@/domains/nudges/components/github-nudge-banner.js"; -import { IOSAppBanner } from "@/domains/nudges/components/ios-app-banner.js"; -import { MacOSAppBanner } from "@/domains/nudges/components/macos-app-banner.js"; +import { DiscordNudgeBanner } from "@/components/nudges/discord-nudge-banner.js"; +import { GitHubNudgeBanner } from "@/components/nudges/github-nudge-banner.js"; +import { IOSAppBanner } from "@/components/nudges/ios-app-banner.js"; +import { MacOSAppBanner } from "@/components/nudges/macos-app-banner.js"; import { Loader2 } from "lucide-react"; import { Button, Notice, ResizablePanel } from "@vellum/design-library"; import { ProviderBillingBanner } from "@/domains/chat/components/provider-billing-banner.js"; diff --git a/apps/web/src/domains/chat/hooks/use-app-nudges.ts b/apps/web/src/domains/chat/hooks/use-app-nudges.ts index d33d1ed4715..3332e594823 100644 --- a/apps/web/src/domains/chat/hooks/use-app-nudges.ts +++ b/apps/web/src/domains/chat/hooks/use-app-nudges.ts @@ -1,24 +1,21 @@ import { type MutableRefObject, useEffect, useState } from "react"; import type { DisplayMessage } from "@/domains/chat/utils/reconcile.js"; -import { useIsIOSWeb } from "@/domains/nudges/ios-app-platform.js"; +import { useIsIOSWeb, useIsMacOSWeb } from "@/utils/platform-detection.js"; import { readIOSAssistantTurnsSeen, incrementIOSAssistantTurnsSeen, useIOSNudgeState, -} from "@/domains/nudges/ios-app-prefs.js"; -import { IOS_APP_BANNER_MIN_TURNS } from "@/domains/nudges/ios-app-constants.js"; -import { useIsMacOSWeb } from "@/domains/nudges/mac-app-platform.js"; + IOS_APP_BANNER_MIN_TURNS, +} from "@/hooks/use-ios-app-nudge.js"; import { readMacOsAssistantTurnsSeen, incrementMacOsAssistantTurnsSeen, useMacOsNudgeState, -} from "@/domains/nudges/mac-app-prefs.js"; -import { MAC_APP_BANNER_MIN_TURNS } from "@/domains/nudges/mac-app-constants.js"; -import { useGitHubNudgeState } from "@/domains/nudges/github-prefs.js"; -import type { GitHubNudgeState } from "@/domains/nudges/github-prefs.js"; -import { useDiscordNudgeState, ensureFirstSeenAt } from "@/domains/nudges/discord-prefs.js"; -import type { DiscordNudgeState } from "@/domains/nudges/discord-prefs.js"; + MAC_APP_BANNER_MIN_TURNS, +} from "@/hooks/use-macos-app-nudge.js"; +import { useGitHubNudgeState, type GitHubNudgeState } from "@/hooks/use-github-nudge.js"; +import { useDiscordNudgeState, ensureFirstSeenAt, type DiscordNudgeState } from "@/hooks/use-discord-nudge.js"; // --------------------------------------------------------------------------- // Types diff --git a/apps/web/src/domains/nudges/discord-constants.ts b/apps/web/src/domains/nudges/discord-constants.ts deleted file mode 100644 index c48fc2e59db..00000000000 --- a/apps/web/src/domains/nudges/discord-constants.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** localStorage key: user clicked "Join Discord" on any nudge surface. */ -export const KEY_DISCORD_NUDGE_JOINED = "app.discordNudge.joined"; - -/** localStorage key: user dismissed the in-chat floating banner. */ -export const KEY_DISCORD_NUDGE_BANNER_DISMISSED = - "app.discordNudge.bannerDismissed"; - -/** - * localStorage key: epoch-ms timestamp of the first page load observed - * by the Discord nudge module. Used to derive "account age" without a - * network call — on first visit we record `Date.now()`. - */ -export const KEY_DISCORD_NUDGE_FIRST_SEEN_AT = - "app.discordNudge.firstSeenAt"; - -/** Public Discord invite URL for the Vellum community. */ -export const DISCORD_INVITE_URL = "https://discord.gg/ZABd9V2zM8"; - -/** - * Minimum number of conversations (sidebar threads) the user must have - * before the Discord nudge becomes eligible. Aggressive: 2. - */ -export const DISCORD_MIN_CONVERSATION_COUNT = 2; - -/** - * Minimum account age (milliseconds since `firstSeenAt`) before the - * Discord nudge becomes eligible. 0 = no minimum age gate. - */ -export const DISCORD_MIN_ACCOUNT_AGE_MS = 0; - -/** - * Cooldown (milliseconds) after the GitHub nudge banner is dismissed - * before the Discord nudge can surface. 24 hours. - */ -export const DISCORD_GITHUB_DISMISS_COOLDOWN_MS = 24 * 60 * 60 * 1000; diff --git a/apps/web/src/domains/nudges/github-constants.ts b/apps/web/src/domains/nudges/github-constants.ts deleted file mode 100644 index dae882ee79c..00000000000 --- a/apps/web/src/domains/nudges/github-constants.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** localStorage key: user clicked "Star on GitHub" on any nudge surface. */ -export const KEY_GITHUB_NUDGE_STARRED = "app.githubNudge.starred"; - -/** localStorage key: user dismissed the in-chat floating banner. */ -export const KEY_GITHUB_NUDGE_BANNER_DISMISSED = - "app.githubNudge.bannerDismissed"; - -/** - * localStorage key: epoch-ms timestamp of the last time the user - * dismissed the GitHub nudge banner. Used by the Discord nudge module - * to enforce a cooldown period before surfacing. - */ -export const KEY_GITHUB_NUDGE_BANNER_DISMISSED_AT = - "app.githubNudge.bannerDismissedAt"; - -/** Public GitHub repository for Vellum Assistant. */ -export const GITHUB_REPO_URL = - "https://github.com/vellum-ai/vellum-assistant"; diff --git a/apps/web/src/domains/nudges/ios-app-constants.ts b/apps/web/src/domains/nudges/ios-app-constants.ts deleted file mode 100644 index ed3ba654484..00000000000 --- a/apps/web/src/domains/nudges/ios-app-constants.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** localStorage key: user tapped "Download" on any iOS nudge surface. */ -export const KEY_IOS_APP_DOWNLOADED = "app.iosNudge.downloaded"; - -/** localStorage key: user dismissed the in-chat floating banner. */ -export const KEY_IOS_APP_BANNER_DISMISSED = "app.iosNudge.bannerDismissed"; - -/** localStorage key: cumulative completed assistant turns observed on web. */ -export const KEY_IOS_APP_ASSISTANT_TURNS_SEEN = - "app.iosNudge.assistantTurnsSeen"; - -export const IOS_APP_BANNER_MIN_TURNS = 5; - -/** App Store listing for Vellum Assistant (id6759934423). */ -export const IOS_APP_STORE_URL = - "https://apps.apple.com/us/app/vellum-assistant/id6759934423"; diff --git a/apps/web/src/domains/nudges/mac-app-constants.ts b/apps/web/src/domains/nudges/mac-app-constants.ts deleted file mode 100644 index 30cae78b568..00000000000 --- a/apps/web/src/domains/nudges/mac-app-constants.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** localStorage key: user clicked "Download" on any nudge surface. */ -export const KEY_MAC_APP_DOWNLOADED = "app.macOsNudge.downloaded"; - -/** localStorage key: user dismissed the in-chat floating banner. */ -export const KEY_MAC_APP_BANNER_DISMISSED = "app.macOsNudge.bannerDismissed"; - -/** localStorage key: cumulative completed assistant turns observed on web. */ -export const KEY_MAC_APP_ASSISTANT_TURNS_SEEN = - "app.macOsNudge.assistantTurnsSeen"; - -export const MAC_APP_BANNER_MIN_TURNS = 5; - -/** - * macOS app download URL. Replace with the canonical CDN or marketing - * page URL before shipping. - */ -export const MACOS_DOWNLOAD_URL = "https://vellum.ai/download"; diff --git a/apps/web/src/domains/nudges/mac-app-platform.ts b/apps/web/src/domains/nudges/mac-app-platform.ts deleted file mode 100644 index a971003d34f..00000000000 --- a/apps/web/src/domains/nudges/mac-app-platform.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useSyncExternalStore } from "react"; - -import { isNativePlatform } from "@/runtime/native-auth.js"; -import { isIOSBrowser } from "@/domains/nudges/ios-app-platform.js"; - -/** - * Returns true when the current browser is running on macOS (not iOS). - * Uses `navigator.userAgentData` where available (Chrome/Edge), falls back - * to `navigator.platform` for Safari and Firefox. - * - * iPadOS 13+ sends a macOS user agent by default, so this function - * explicitly excludes iOS devices (detected via `isIOSBrowser()`) to - * prevent iPads from seeing the macOS download nudge. - * - * Always returns `false` during SSR (no `navigator`). - */ -export function isMacOSBrowser(): boolean { - if (typeof navigator === "undefined") return false; - if (isIOSBrowser()) return false; - const uaData = ( - navigator as Navigator & { - userAgentData?: { platform?: string }; - } - ).userAgentData; - if (uaData?.platform) { - return uaData.platform.toLowerCase().includes("mac"); - } - return navigator.platform.toLowerCase().includes("mac"); -} - -function isMacOSWeb(): boolean { - return isMacOSBrowser() && !isNativePlatform(); -} - -const noop = () => () => {}; - -export function useIsMacOSWeb(): boolean { - return useSyncExternalStore(noop, isMacOSWeb, () => false); -} diff --git a/apps/web/src/domains/onboarding/pages/pre-chat-flow.tsx b/apps/web/src/domains/onboarding/pages/pre-chat-flow.tsx index 0335dc27189..9ccf39b7258 100644 --- a/apps/web/src/domains/onboarding/pages/pre-chat-flow.tsx +++ b/apps/web/src/domains/onboarding/pages/pre-chat-flow.tsx @@ -3,10 +3,9 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useNavigate } from "react-router"; -import { useIsIOSWeb } from "@/domains/nudges/ios-app-platform.js"; -import { readIOSAppDownloaded } from "@/domains/nudges/ios-app-prefs.js"; -import { useIsMacOSWeb } from "@/domains/nudges/mac-app-platform.js"; -import { readMacOsAppDownloaded } from "@/domains/nudges/mac-app-prefs.js"; +import { useIsIOSWeb, useIsMacOSWeb } from "@/utils/platform-detection.js"; +import { readIOSAppDownloaded } from "@/hooks/use-ios-app-nudge.js"; +import { readMacOsAppDownloaded } from "@/hooks/use-macos-app-nudge.js"; import { persistContentAutomationPreChatHandoff } from "@/domains/onboarding/content-automation.js"; import { GetIOSAppScreen } from "@/domains/onboarding/screens/get-ios-app-screen.js"; import { GetMacOSAppScreen } from "@/domains/onboarding/screens/get-macos-app-screen.js"; diff --git a/apps/web/src/domains/onboarding/screens/get-ios-app-screen.tsx b/apps/web/src/domains/onboarding/screens/get-ios-app-screen.tsx index 5b2e52dc5ca..26b8cd6a4c2 100644 --- a/apps/web/src/domains/onboarding/screens/get-ios-app-screen.tsx +++ b/apps/web/src/domains/onboarding/screens/get-ios-app-screen.tsx @@ -5,7 +5,7 @@ import { OnboardingLayout } from "@/domains/onboarding/components/onboarding-lay import { writeIOSAppDownloaded, openIOSAppStore, -} from "@/domains/nudges/ios-app-prefs.js"; +} from "@/hooks/use-ios-app-nudge.js"; interface GetIOSAppScreenProps { onComplete: () => void; diff --git a/apps/web/src/domains/onboarding/screens/get-macos-app-screen.tsx b/apps/web/src/domains/onboarding/screens/get-macos-app-screen.tsx index 0f718adacb1..9accfc18c40 100644 --- a/apps/web/src/domains/onboarding/screens/get-macos-app-screen.tsx +++ b/apps/web/src/domains/onboarding/screens/get-macos-app-screen.tsx @@ -6,7 +6,7 @@ import { OnboardingLayout } from "@/domains/onboarding/components/onboarding-lay import { writeMacOsAppDownloaded, openMacOsDownload, -} from "@/domains/nudges/mac-app-prefs.js"; +} from "@/hooks/use-macos-app-nudge.js"; interface GetMacOSAppScreenProps { onComplete: () => void; diff --git a/apps/web/src/domains/settings/components/ios-app-card.tsx b/apps/web/src/domains/settings/components/ios-app-card.tsx index a330b05901c..c4d271c710f 100644 --- a/apps/web/src/domains/settings/components/ios-app-card.tsx +++ b/apps/web/src/domains/settings/components/ios-app-card.tsx @@ -1,11 +1,11 @@ import { Bell, Fingerprint, Smartphone, Vibrate } from "lucide-react"; import { NudgeSettingsCard } from "@/domains/settings/components/nudge-settings-card.js"; -import { useIsIOSWeb } from "@/domains/nudges/ios-app-platform.js"; +import { useIsIOSWeb } from "@/utils/platform-detection.js"; import { openIOSAppStore, writeIOSAppDownloaded, -} from "@/domains/nudges/ios-app-prefs.js"; +} from "@/hooks/use-ios-app-nudge.js"; export function IOSAppCard() { const isIOSWeb = useIsIOSWeb(); diff --git a/apps/web/src/domains/settings/pages/community-page.tsx b/apps/web/src/domains/settings/pages/community-page.tsx index 7a99fe84f0a..a18f0d9a02f 100644 --- a/apps/web/src/domains/settings/pages/community-page.tsx +++ b/apps/web/src/domains/settings/pages/community-page.tsx @@ -19,9 +19,8 @@ import { DiscordLogo } from "@/components/icons/discord-logo.js"; import { GitHubLogo } from "@/components/icons/github-logo.js"; import { YouTubeLogo } from "@/components/icons/youtube-logo.js"; import { XLogo } from "@/components/icons/x-logo.js"; -import { GITHUB_REPO_URL } from "@/domains/nudges/github-constants.js"; -import { useGitHubNudgeState } from "@/domains/nudges/github-prefs.js"; -import { joinDiscord } from "@/domains/nudges/discord-prefs.js"; +import { GITHUB_REPO_URL, useGitHubNudgeState } from "@/hooks/use-github-nudge.js"; +import { joinDiscord } from "@/hooks/use-discord-nudge.js"; function HeroBanner() { return ( diff --git a/apps/web/src/domains/nudges/discord-prefs.ts b/apps/web/src/hooks/use-discord-nudge.ts similarity index 81% rename from apps/web/src/domains/nudges/discord-prefs.ts rename to apps/web/src/hooks/use-discord-nudge.ts index f8ad2700fea..6b78be28800 100644 --- a/apps/web/src/domains/nudges/discord-prefs.ts +++ b/apps/web/src/hooks/use-discord-nudge.ts @@ -8,17 +8,36 @@ import { useCallback } from "react"; -import { useNudgeStore } from "@/domains/nudges/nudge-store.js"; +import { useNudgeStore } from "@/stores/nudge-store.js"; import { readGitHubNudgeStarred, readGitHubBannerDismissedAt, -} from "@/domains/nudges/github-prefs.js"; -import { - DISCORD_INVITE_URL, - DISCORD_MIN_CONVERSATION_COUNT, - DISCORD_MIN_ACCOUNT_AGE_MS, - DISCORD_GITHUB_DISMISS_COOLDOWN_MS, -} from "@/domains/nudges/discord-constants.js"; +} from "@/hooks/use-github-nudge.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Public Discord invite URL for the Vellum community. */ +export const DISCORD_INVITE_URL = "https://discord.gg/ZABd9V2zM8"; + +/** + * Minimum number of conversations (sidebar threads) the user must have + * before the Discord nudge becomes eligible. Aggressive: 2. + */ +export const DISCORD_MIN_CONVERSATION_COUNT = 2; + +/** + * Minimum account age (milliseconds since `firstSeenAt`) before the + * Discord nudge becomes eligible. 0 = no minimum age gate. + */ +export const DISCORD_MIN_ACCOUNT_AGE_MS = 0; + +/** + * Cooldown (milliseconds) after the GitHub nudge banner is dismissed + * before the Discord nudge can surface. 24 hours. + */ +export const DISCORD_GITHUB_DISMISS_COOLDOWN_MS = 24 * 60 * 60 * 1000; // --------------------------------------------------------------------------- // First-seen timestamp diff --git a/apps/web/src/domains/nudges/github-prefs.ts b/apps/web/src/hooks/use-github-nudge.ts similarity index 82% rename from apps/web/src/domains/nudges/github-prefs.ts rename to apps/web/src/hooks/use-github-nudge.ts index b2b5b127db5..8b9a3928cf4 100644 --- a/apps/web/src/domains/nudges/github-prefs.ts +++ b/apps/web/src/hooks/use-github-nudge.ts @@ -1,15 +1,22 @@ /** * GitHub-nudge public API. * - * Backed by `useNudgeStore`; this file just exposes the GitHub-specific + * Backed by `useNudgeStore`; this file exposes the GitHub-specific * derived state, click handlers, and a few non-React readers used by the * Discord-nudge prerequisite checks. */ import { useCallback } from "react"; -import { useNudgeStore } from "@/domains/nudges/nudge-store.js"; -import { GITHUB_REPO_URL } from "@/domains/nudges/github-constants.js"; +import { useNudgeStore } from "@/stores/nudge-store.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Public GitHub repository for Vellum Assistant. */ +export const GITHUB_REPO_URL = + "https://github.com/vellum-ai/vellum-assistant"; // --------------------------------------------------------------------------- // Public readers (non-React, for cross-module prerequisite checks) diff --git a/apps/web/src/domains/nudges/ios-app-prefs.ts b/apps/web/src/hooks/use-ios-app-nudge.ts similarity index 72% rename from apps/web/src/domains/nudges/ios-app-prefs.ts rename to apps/web/src/hooks/use-ios-app-nudge.ts index a8ae83c87c1..b0e5de848ce 100644 --- a/apps/web/src/domains/nudges/ios-app-prefs.ts +++ b/apps/web/src/hooks/use-ios-app-nudge.ts @@ -1,3 +1,11 @@ +/** + * iOS app-download nudge module. + * + * Manages whether the user has downloaded the iOS app, banner + * dismissal state, and assistant turn counting for the minimum-turn + * threshold before the nudge banner surfaces. + */ + import { useCallback, useEffect, useState } from "react"; import { @@ -5,14 +13,27 @@ import { writeBooleanPref, readNumberPref, writeNumberPref, -} from "@/domains/nudges/nudge-prefs.js"; +} from "@/utils/nudge-prefs.js"; -import { - KEY_IOS_APP_DOWNLOADED, - KEY_IOS_APP_BANNER_DISMISSED, - KEY_IOS_APP_ASSISTANT_TURNS_SEEN, - IOS_APP_STORE_URL, -} from "@/domains/nudges/ios-app-constants.js"; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** localStorage key: user tapped "Download" on any iOS nudge surface. */ +export const KEY_IOS_APP_DOWNLOADED = "app.iosNudge.downloaded"; + +/** localStorage key: user dismissed the in-chat floating banner. */ +export const KEY_IOS_APP_BANNER_DISMISSED = "app.iosNudge.bannerDismissed"; + +/** localStorage key: cumulative completed assistant turns observed on web. */ +export const KEY_IOS_APP_ASSISTANT_TURNS_SEEN = + "app.iosNudge.assistantTurnsSeen"; + +export const IOS_APP_BANNER_MIN_TURNS = 5; + +/** App Store listing for Vellum Assistant (id6759934423). */ +export const IOS_APP_STORE_URL = + "https://apps.apple.com/us/app/vellum-assistant/id6759934423"; // --------------------------------------------------------------------------- // Public readers / writers @@ -45,7 +66,7 @@ export function incrementIOSAssistantTurnsSeen(delta = 1): void { } // --------------------------------------------------------------------------- -// Hooks +// Hook // --------------------------------------------------------------------------- export function useIOSNudgeState(): { @@ -86,16 +107,3 @@ export function useIOSNudgeState(): { export function openIOSAppStore(): void { window.open(IOS_APP_STORE_URL, "_blank", "noopener,noreferrer"); } - -// --------------------------------------------------------------------------- -// Internals exported for tests only. Not part of the public API. -// --------------------------------------------------------------------------- - -export const __testing = { - readBooleanPref, - writeBooleanPref, - readNumberPref, - writeNumberPref, - readIOSAppBannerDismissed, - writeIOSAppBannerDismissed, -}; diff --git a/apps/web/src/domains/nudges/mac-app-prefs.ts b/apps/web/src/hooks/use-macos-app-nudge.ts similarity index 72% rename from apps/web/src/domains/nudges/mac-app-prefs.ts rename to apps/web/src/hooks/use-macos-app-nudge.ts index 2d7ac304ba8..c2813143773 100644 --- a/apps/web/src/domains/nudges/mac-app-prefs.ts +++ b/apps/web/src/hooks/use-macos-app-nudge.ts @@ -1,3 +1,11 @@ +/** + * macOS app-download nudge module. + * + * Manages whether the user has downloaded the macOS app, banner + * dismissal state, and assistant turn counting for the minimum-turn + * threshold before the nudge banner surfaces. + */ + import { useCallback, useEffect, useState } from "react"; import { @@ -5,14 +13,29 @@ import { writeBooleanPref, readNumberPref, writeNumberPref, -} from "@/domains/nudges/nudge-prefs.js"; +} from "@/utils/nudge-prefs.js"; -import { - KEY_MAC_APP_DOWNLOADED, - KEY_MAC_APP_BANNER_DISMISSED, - KEY_MAC_APP_ASSISTANT_TURNS_SEEN, - MACOS_DOWNLOAD_URL, -} from "@/domains/nudges/mac-app-constants.js"; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** localStorage key: user clicked "Download" on any nudge surface. */ +export const KEY_MAC_APP_DOWNLOADED = "app.macOsNudge.downloaded"; + +/** localStorage key: user dismissed the in-chat floating banner. */ +export const KEY_MAC_APP_BANNER_DISMISSED = "app.macOsNudge.bannerDismissed"; + +/** localStorage key: cumulative completed assistant turns observed on web. */ +export const KEY_MAC_APP_ASSISTANT_TURNS_SEEN = + "app.macOsNudge.assistantTurnsSeen"; + +export const MAC_APP_BANNER_MIN_TURNS = 5; + +/** + * macOS app download URL. Replace with the canonical CDN or marketing + * page URL before shipping. + */ +export const MACOS_DOWNLOAD_URL = "https://vellum.ai/download"; // --------------------------------------------------------------------------- // Public readers / writers @@ -45,7 +68,7 @@ export function incrementMacOsAssistantTurnsSeen(delta = 1): void { } // --------------------------------------------------------------------------- -// Hooks +// Hook // --------------------------------------------------------------------------- export function useMacOsNudgeState(): { @@ -86,16 +109,3 @@ export function useMacOsNudgeState(): { export function openMacOsDownload(): void { window.open(MACOS_DOWNLOAD_URL, "_blank", "noopener,noreferrer"); } - -// --------------------------------------------------------------------------- -// Internals exported for tests only. Not part of the public API. -// --------------------------------------------------------------------------- - -export const __testing = { - readBooleanPref, - writeBooleanPref, - readNumberPref, - writeNumberPref, - readMacOsAppBannerDismissed, - writeMacOsAppBannerDismissed, -}; diff --git a/apps/web/src/domains/nudges/nudge-store.ts b/apps/web/src/stores/nudge-store.ts similarity index 74% rename from apps/web/src/domains/nudges/nudge-store.ts rename to apps/web/src/stores/nudge-store.ts index c14f69be70c..5632d2588b6 100644 --- a/apps/web/src/domains/nudges/nudge-store.ts +++ b/apps/web/src/stores/nudge-store.ts @@ -2,9 +2,8 @@ * Zustand store for GitHub + Discord nudge prefs. * * Owns whether each nudge has been actioned (starred, joined) or - * dismissed (banner) and when. `github-prefs.ts` and - * `discord-prefs.ts` expose thin selector hooks (`useGitHubNudgeState`, - * `useDiscordNudgeState`) backed by this store. + * dismissed (banner) and when. `use-github-nudge.ts` and + * `use-discord-nudge.ts` expose thin selector hooks backed by this store. * * **Storage model:** * @@ -24,17 +23,6 @@ import { persist, createJSONStorage } from "zustand/middleware"; import { createSelectors } from "@/utils/create-selectors.js"; -import { - KEY_GITHUB_NUDGE_STARRED, - KEY_GITHUB_NUDGE_BANNER_DISMISSED, - KEY_GITHUB_NUDGE_BANNER_DISMISSED_AT, -} from "@/domains/nudges/github-constants.js"; -import { - KEY_DISCORD_NUDGE_JOINED, - KEY_DISCORD_NUDGE_BANNER_DISMISSED, - KEY_DISCORD_NUDGE_FIRST_SEEN_AT, -} from "@/domains/nudges/discord-constants.js"; - // --------------------------------------------------------------------------- // State + Actions // --------------------------------------------------------------------------- @@ -102,7 +90,6 @@ const useNudgeStoreBase = create()( { name: NUDGE_STORE_KEY, storage: createJSONStorage(() => localStorage), - // Only persist state, not action functions. partialize: (state) => ({ githubStarred: state.githubStarred, githubBannerDismissed: state.githubBannerDismissed, @@ -121,9 +108,6 @@ export const useNudgeStore = createSelectors(useNudgeStoreBase); // Cross-tab sync // --------------------------------------------------------------------------- -// `localStorage.setItem` fires a native `storage` event in *other* tabs. -// Persist middleware doesn't subscribe to it on its own, so wire a listener -// that rehydrates this store whenever `vellum:nudge-prefs` changes elsewhere. if (typeof window !== "undefined") { window.addEventListener("storage", (event) => { if (event.key === NUDGE_STORE_KEY) { @@ -136,28 +120,20 @@ if (typeof window !== "undefined") { // One-shot legacy cleanup // --------------------------------------------------------------------------- -// One-time cleanup of legacy per-key localStorage entries from an -// older nudge-state shape. The persist middleware now owns the state -// under `vellum:nudge-prefs`; the old keys are orphaned bytes on -// every user's device and get removed on first load. const LEGACY_CLEANUP_FLAG = "app.nudgeLegacy.cleaned"; const LEGACY_KEYS = [ - KEY_GITHUB_NUDGE_STARRED, - KEY_GITHUB_NUDGE_BANNER_DISMISSED, - KEY_GITHUB_NUDGE_BANNER_DISMISSED_AT, - KEY_DISCORD_NUDGE_JOINED, - KEY_DISCORD_NUDGE_BANNER_DISMISSED, - KEY_DISCORD_NUDGE_FIRST_SEEN_AT, + "app.githubNudge.starred", + "app.githubNudge.bannerDismissed", + "app.githubNudge.bannerDismissedAt", + "app.discordNudge.joined", + "app.discordNudge.bannerDismissed", + "app.discordNudge.firstSeenAt", ]; if (typeof window !== "undefined") { try { if (localStorage.getItem(LEGACY_CLEANUP_FLAG) !== "true") { - // Calling localStorage.removeItem directly (rather than going through - // `removeLocalSetting` in domains/settings/) keeps this cleanup - // self-contained — nothing listens for the `vellum:pref-changed` - // event on these specific legacy keys. for (const key of LEGACY_KEYS) { localStorage.removeItem(key); } diff --git a/apps/web/src/domains/nudges/nudge-prefs.ts b/apps/web/src/utils/nudge-prefs.ts similarity index 95% rename from apps/web/src/domains/nudges/nudge-prefs.ts rename to apps/web/src/utils/nudge-prefs.ts index 3ad4fd1539b..bf9c1eda82e 100644 --- a/apps/web/src/domains/nudges/nudge-prefs.ts +++ b/apps/web/src/utils/nudge-prefs.ts @@ -1,7 +1,7 @@ /** * Shared localStorage helpers for platform-specific app-nudge modules. * - * Both `mac-app-nudge/prefs.ts` and `ios-app-nudge/prefs.ts` use these + * Both `use-ios-app-nudge.ts` and `use-macos-app-nudge.ts` use these * to read/write boolean and number preferences. Extracting them avoids * duplicating identical helpers across nudge modules. */ diff --git a/apps/web/src/domains/nudges/ios-app-platform.ts b/apps/web/src/utils/platform-detection.ts similarity index 55% rename from apps/web/src/domains/nudges/ios-app-platform.ts rename to apps/web/src/utils/platform-detection.ts index a0023b1ed63..47985bebb3a 100644 --- a/apps/web/src/domains/nudges/ios-app-platform.ts +++ b/apps/web/src/utils/platform-detection.ts @@ -52,20 +52,56 @@ export function isSafariBrowser(): boolean { } /** - * iOS web user who should see our custom nudge surfaces. + * Returns true when the current browser is running on macOS (not iOS). + * Uses `navigator.userAgentData` where available (Chrome/Edge), falls back + * to `navigator.platform` for Safari and Firefox. * - * Excludes Safari because Safari users already see the native Smart App Banner - * (via the tag in layout.tsx), which provides a - * better, Apple-native download experience. Our custom nudge surfaces only - * target non-Safari iOS browsers (Chrome, Firefox, Edge, etc.) that don't - * support the Smart App Banner. + * iPadOS 13+ sends a macOS user agent by default, so this function + * explicitly excludes iOS devices (detected via `isIOSBrowser()`) to + * prevent iPads from seeing the macOS download nudge. + * + * Always returns `false` during SSR (no `navigator`). */ -function isIOSWeb(): boolean { - return isIOSBrowser() && !isNativePlatform() && !isSafariBrowser(); +export function isMacOSBrowser(): boolean { + if (typeof navigator === "undefined") return false; + if (isIOSBrowser()) return false; + const uaData = ( + navigator as Navigator & { + userAgentData?: { platform?: string }; + } + ).userAgentData; + if (uaData?.platform) { + return uaData.platform.toLowerCase().includes("mac"); + } + return navigator.platform.toLowerCase().includes("mac"); } +// --------------------------------------------------------------------------- +// React hooks — safe thin wrappers for use in component render bodies +// --------------------------------------------------------------------------- + const noop = () => () => {}; +/** + * iOS web user who should see custom nudge surfaces. + * + * Excludes Safari because Safari users already see the native Smart App Banner + * (via the `` tag), which provides a better, + * Apple-native download experience. Custom nudge surfaces only target + * non-Safari iOS browsers (Chrome, Firefox, Edge, etc.). + */ export function useIsIOSWeb(): boolean { - return useSyncExternalStore(noop, isIOSWeb, () => false); + return useSyncExternalStore( + noop, + () => isIOSBrowser() && !isNativePlatform() && !isSafariBrowser(), + () => false, + ); +} + +export function useIsMacOSWeb(): boolean { + return useSyncExternalStore( + noop, + () => isMacOSBrowser() && !isNativePlatform(), + () => false, + ); }