From 6629976c8746cc2068abe737aff86ce55a566edd Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 22 May 2026 22:04:41 -0700 Subject: [PATCH 1/2] Added upgrade badges. Refactored how we do the login wall for askgh. --- packages/shared/src/entitlements.ts | 4 + .../components/defaultSidebar/nav.tsx | 19 +- .../components/settingsSidebar/nav.tsx | 10 + .../(app)/@sidebar/components/sidebarBase.tsx | 6 +- .../components/upsell/upgradeBadge.tsx | 11 + .../upgradeButton.tsx} | 6 +- .../components/{ => upsell}/upsellDialog.tsx | 0 .../[owner]/[repo]/components/landingPage.tsx | 12 +- .../chat/[id]/components/chatThreadPanel.tsx | 3 + packages/web/src/app/(app)/chat/[id]/page.tsx | 1 + .../src/app/(app)/chat/chatLandingPage.tsx | 1 + .../chat/components/landingPageChatBox.tsx | 16 +- .../web/src/app/(app)/settings/layout.tsx | 1 + packages/web/src/app/layout.tsx | 51 ++--- .../auth/identityProvidersProvider.tsx | 21 ++ .../src/features/auth/useIdentityProviders.ts | 9 + packages/web/src/features/chat/actions.ts | 12 -- .../chat/components/chatBox/chatBox.tsx | 193 ++++++++++++------ .../chat/components/chatBox}/loginModal.tsx | 14 +- .../chat/components/chatThread/chatThread.tsx | 68 +----- packages/web/src/features/chat/constants.ts | 1 + .../features/chat/useCreateNewChatThread.ts | 73 ++----- .../features/entitlements/useEntitlements.ts | 9 + 23 files changed, 286 insertions(+), 255 deletions(-) create mode 100644 packages/web/src/app/(app)/@sidebar/components/upsell/upgradeBadge.tsx rename packages/web/src/app/(app)/@sidebar/components/{upsellBadge.tsx => upsell/upgradeButton.tsx} (73%) rename packages/web/src/app/(app)/@sidebar/components/{ => upsell}/upsellDialog.tsx (100%) create mode 100644 packages/web/src/features/auth/identityProvidersProvider.tsx create mode 100644 packages/web/src/features/auth/useIdentityProviders.ts rename packages/web/src/{app/components => features/chat/components/chatBox}/loginModal.tsx (77%) create mode 100644 packages/web/src/features/entitlements/useEntitlements.ts diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index 97d743990..b6da38fc5 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -25,6 +25,9 @@ const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [ 'past_due', ]; +// @WARNING: when adding a new entitlement to this list, make sure +// lighthouse/lambda/entitlements.ts is also updated && deployed +// prior to rolling a new Sourcebot version. // eslint-disable-next-line @typescript-eslint/no-unused-vars const ALL_ENTITLEMENTS = [ "search-contexts", @@ -37,6 +40,7 @@ const ALL_ENTITLEMENTS = [ "chat-sharing", "org-management", "oauth", + "ask" ] as const; export type Entitlement = (typeof ALL_ENTITLEMENTS)[number]; diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx index 775db2bac..ee006baa1 100644 --- a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx @@ -11,6 +11,9 @@ import { HomeView } from "@/hooks/useHomeView"; import { NotificationDot } from "../../../components/notificationDot"; import { useMemo } from "react"; import Link from "next/link"; +import { useEntitlements } from "@/features/entitlements/useEntitlements"; +import { Entitlement } from "@sourcebot/shared"; +import { UpgradeBadge } from "../upsell/upgradeBadge"; interface NavItem { title: string; @@ -18,6 +21,7 @@ interface NavItem { icon: LucideIcon; key: string; requiresAuth?: boolean; + requiredEntitlement?: Entitlement; } interface NavProps { @@ -26,8 +30,13 @@ interface NavProps { homeView: HomeView; } -export function Nav({ isSettingsNotificationVisible, isSignedIn, homeView }: NavProps) { +export function Nav({ + isSettingsNotificationVisible, + isSignedIn, + homeView +}: NavProps) { const pathname = usePathname(); + const entitlements = useEntitlements(); const baseItems = useMemo((): NavItem[] => { @@ -42,7 +51,8 @@ export function Nav({ isSettingsNotificationVisible, isSignedIn, homeView }: Nav title: "Ask", href: "/chat", icon: MessageCircleIcon, - key: "chat" + key: "chat", + requiredEntitlement: "ask" } return [ @@ -101,6 +111,10 @@ export function Nav({ isSettingsNotificationVisible, isSignedIn, homeView }: Nav {baseItems.filter((item) => !item.requiresAuth || isSignedIn).map((item) => { const showNotification = (item.key === "settings" && isSettingsNotificationVisible); + + const showUpgradeBadge = + (item.requiredEntitlement && !entitlements.includes(item.requiredEntitlement)); + return ( @@ -109,6 +123,7 @@ export function Nav({ isSettingsNotificationVisible, isSignedIn, homeView }: Nav > {item.title} + {showUpgradeBadge && } {showNotification && } diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx index 862298a61..5cb44b04e 100644 --- a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx @@ -9,6 +9,8 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; +import { useEntitlements } from "@/features/entitlements/useEntitlements"; +import { Entitlement } from "@sourcebot/shared"; import { ChartAreaIcon, KeyRoundIcon, @@ -23,6 +25,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { UpgradeBadge } from "../upsell/upgradeBadge"; const iconMap = { "link": LinkIcon, @@ -44,6 +47,7 @@ export type NavItem = { title: React.ReactNode; icon?: NavIconName; isNotificationDotVisible?: boolean; + requiredEntitlement?: Entitlement; }; export type NavGroup = { @@ -57,6 +61,7 @@ interface NavProps { export function Nav({ groups }: NavProps) { const pathname = usePathname(); + const entitlements = useEntitlements(); return ( <> @@ -69,6 +74,10 @@ export function Nav({ groups }: NavProps) { const isActive = item.hrefRegex ? new RegExp(item.hrefRegex).test(pathname) : pathname === item.href; + + const showUpgradeBadge = + (item.requiredEntitlement && !entitlements.includes(item.requiredEntitlement)); + const Icon = item.icon ? iconMap[item.icon] : undefined; return ( @@ -76,6 +85,7 @@ export function Nav({ groups }: NavProps) { {Icon && } {item.title} + {showUpgradeBadge && } {item.isNotificationDotVisible && } diff --git a/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx b/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx index 6ad52bb9e..3eb397e29 100644 --- a/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx @@ -31,7 +31,7 @@ import { useKeymapType } from "@/hooks/useKeymapType"; import { KeymapType } from "@/lib/types"; import { cn } from "@/lib/utils"; import { - ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowUpCircle, ChevronsUpDown, CodeIcon, + ArrowLeftToLineIcon, ArrowRightToLineIcon, ChevronsUpDown, CodeIcon, Laptop, LogIn, LogOut, Moon, SettingsIcon, Sun, UserIcon } from "lucide-react"; import { Session } from "next-auth"; @@ -44,7 +44,7 @@ import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Separator } from "@/components/ui/separator"; import { WhatsNewSidebarButton } from "./whatsNewSidebarButton"; -import { UpsellBadge } from "./upsellBadge"; +import { UpgradeButton } from "./upsell/upgradeButton"; interface SidebarBaseProps { session: Session | null; @@ -89,7 +89,7 @@ export function SidebarBase({ session, collapsible = "icon", headerContent, chil {children} - {!isValidLicenseActive && isOwner && } + {!isValidLicenseActive && isOwner && } {collapsible !== "none" && } {session ? ( diff --git a/packages/web/src/app/(app)/@sidebar/components/upsell/upgradeBadge.tsx b/packages/web/src/app/(app)/@sidebar/components/upsell/upgradeBadge.tsx new file mode 100644 index 000000000..7762797ba --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/upsell/upgradeBadge.tsx @@ -0,0 +1,11 @@ +import { Badge } from "@/components/ui/badge" + +export const UpgradeBadge = () => { + return ( + + Upgrade + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/(app)/@sidebar/components/upsellBadge.tsx b/packages/web/src/app/(app)/@sidebar/components/upsell/upgradeButton.tsx similarity index 73% rename from packages/web/src/app/(app)/@sidebar/components/upsellBadge.tsx rename to packages/web/src/app/(app)/@sidebar/components/upsell/upgradeButton.tsx index afc1cf681..61f9452ec 100644 --- a/packages/web/src/app/(app)/@sidebar/components/upsellBadge.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/upsell/upgradeButton.tsx @@ -6,7 +6,7 @@ import { UpsellDialog } from "./upsellDialog"; import { useOffers } from "@/ee/features/lighthouse/useOffers"; import { Skeleton } from "@/components/ui/skeleton"; -export const UpsellBadge = () => { +export const UpgradeButton = () => { const [isDialogOpen, setIsDialogOpen] = useState(false); const { data: offers, isPending, isError } = useOffers(); @@ -21,14 +21,14 @@ export const UpsellBadge = () => { return null; } - const label = offers.trial.eligible ? "Try Enterprise" : "Free plan"; + const label = offers.trial.eligible ? "Try Enterprise" : "Upgrade to Enterprise"; return ( <>
- - ) } diff --git a/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx b/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx index 574001e5f..cd1d16b2f 100644 --- a/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx +++ b/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx @@ -16,6 +16,7 @@ interface ChatThreadPanelProps { messages: SBChatMessage[]; isOwner: boolean; isAuthenticated: boolean; + isLoginWallEnabled: boolean; chatName?: string; } @@ -26,6 +27,7 @@ export const ChatThreadPanel = ({ messages, isOwner, isAuthenticated, + isLoginWallEnabled, chatName, }: ChatThreadPanelProps) => { // @note: we are guaranteed to have a chatId because this component will only be @@ -74,6 +76,7 @@ export const ChatThreadPanel = ({ onSelectedSearchScopesChange={setSelectedSearchScopes} isOwner={isOwner} isAuthenticated={isAuthenticated} + isLoginWallEnabled={isLoginWallEnabled} chatName={chatName} /> diff --git a/packages/web/src/app/(app)/chat/[id]/page.tsx b/packages/web/src/app/(app)/chat/[id]/page.tsx index 610d4eb47..e31e0acf4 100644 --- a/packages/web/src/app/(app)/chat/[id]/page.tsx +++ b/packages/web/src/app/(app)/chat/[id]/page.tsx @@ -160,6 +160,7 @@ export default async function Page(props: PageProps) { messages={messages} isOwner={isOwner} isAuthenticated={!!session} + isLoginWallEnabled={env.EXPERIMENT_ASK_GH_ENABLED === 'true'} chatName={name ?? undefined} /> diff --git a/packages/web/src/app/(app)/chat/chatLandingPage.tsx b/packages/web/src/app/(app)/chat/chatLandingPage.tsx index 18dc52473..5bd84a3d0 100644 --- a/packages/web/src/app/(app)/chat/chatLandingPage.tsx +++ b/packages/web/src/app/(app)/chat/chatLandingPage.tsx @@ -69,6 +69,7 @@ export async function ChatLandingPage() { repos={allRepos} searchContexts={searchContexts} isAuthenticated={!!session} + isLoginWallEnabled={env.EXPERIMENT_ASK_GH_ENABLED === 'true'} /> diff --git a/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx b/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx index 9d6b92381..d33d2e5b4 100644 --- a/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx +++ b/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx @@ -11,13 +11,13 @@ import { useLocalStorage } from "usehooks-ts"; import { SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY } from "@/features/chat/constants"; import { SearchModeSelector } from "../../components/searchModeSelector"; import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner"; -import { LoginModal } from "@/app/components/loginModal"; interface LandingPageChatBox { languageModels: LanguageModelInfo[]; repos: RepositoryQuery[]; searchContexts: SearchContextQuery[]; isAuthenticated: boolean; + isLoginWallEnabled: boolean; } export const LandingPageChatBox = ({ @@ -25,8 +25,9 @@ export const LandingPageChatBox = ({ repos, searchContexts, isAuthenticated, + isLoginWallEnabled, }: LandingPageChatBox) => { - const { createNewChatThread, isLoading, loginWall } = useCreateNewChatThread({ isAuthenticated }); + const { createNewChatThread, isLoading } = useCreateNewChatThread(); const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage(SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY, [], { initializeWithValue: false }); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const isChatBoxDisabled = languageModels.length === 0; @@ -36,7 +37,7 @@ export const LandingPageChatBox = ({
{ - createNewChatThread(children, selectedSearchScopes); + createNewChatThread(children); }} className="min-h-[50px]" isRedirecting={isLoading} @@ -44,6 +45,8 @@ export const LandingPageChatBox = ({ selectedSearchScopes={selectedSearchScopes} searchContexts={searchContexts} isDisabled={isChatBoxDisabled} + isAuthenticated={isAuthenticated} + isLoginWallEnabled={isLoginWallEnabled} />
@@ -68,13 +71,6 @@ export const LandingPageChatBox = ({ {isChatBoxDisabled && ( )} - -
) } diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index a03ef575a..96f84eeea 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -112,6 +112,7 @@ export const getSidebarNavGroups = async () => title: "Analytics", href: `/settings/analytics`, icon: "chart-area" as const, + requiredEntitlement: 'analytics' }, { title: "License", diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 24365f073..a16124428 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -11,6 +11,8 @@ import { SessionProvider } from "next-auth/react"; import { env, SOURCEBOT_VERSION } from "@sourcebot/shared"; import { PlanProvider } from "@/features/entitlements/planProvider"; import { getEntitlements } from "@/lib/entitlements"; +import { IdentityProvidersProvider } from "@/features/auth/identityProvidersProvider"; +import { getIdentityProviderMetadata } from "@/lib/identityProviders"; export const metadata: Metadata = { metadataBase: env.AUTH_URL ? new URL(env.AUTH_URL) : undefined, @@ -31,6 +33,7 @@ export default async function RootLayout({ children: React.ReactNode; }>) { const entitlements = await getEntitlements(); + const identityProviders = await getIdentityProviderMetadata(); return ( - - + - - - - {children} - - - - - + + + + + {children} + + + + + + diff --git a/packages/web/src/features/auth/identityProvidersProvider.tsx b/packages/web/src/features/auth/identityProvidersProvider.tsx new file mode 100644 index 000000000..e0b36e0c8 --- /dev/null +++ b/packages/web/src/features/auth/identityProvidersProvider.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { createContext } from "react"; +import type { IdentityProviderMetadata } from "@/lib/identityProviders"; + +export const IdentityProvidersContext = createContext<{ providers: IdentityProviderMetadata[] }>({ providers: [] }); + +interface IdentityProvidersProviderProps { + children: React.ReactNode; + providers: IdentityProviderMetadata[]; +} + +export const IdentityProvidersProvider = ({ children, providers }: IdentityProvidersProviderProps) => { + return ( + + {children} + + ) +}; diff --git a/packages/web/src/features/auth/useIdentityProviders.ts b/packages/web/src/features/auth/useIdentityProviders.ts new file mode 100644 index 000000000..c0a06382a --- /dev/null +++ b/packages/web/src/features/auth/useIdentityProviders.ts @@ -0,0 +1,9 @@ +'use client'; + +import { useContext } from "react"; +import { IdentityProvidersContext } from "./identityProvidersProvider"; + +export const useIdentityProviders = () => { + const { providers } = useContext(IdentityProvidersContext); + return providers; +} diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts index 6cc585353..2564ec751 100644 --- a/packages/web/src/features/chat/actions.ts +++ b/packages/web/src/features/chat/actions.ts @@ -527,15 +527,3 @@ export const submitFeedback = async ({ return { success: true }; }) ) - -// eslint-disable-next-line authz/require-auth-wrapper -- returns identity provider metadata for the login wall, consulted before auth -export const getAskGhLoginWallData = async () => sew(async () => { - const isEnabled = env.EXPERIMENT_ASK_GH_ENABLED === 'true'; - if (!isEnabled) { - return { isEnabled: false as const, providers: [] }; - } - - const providers = await getIdentityProviderMetadata(); - return { isEnabled: true as const, providers }; -}); - diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx index f25ed311d..ab3be678d 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBox.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx @@ -21,6 +21,10 @@ import { useSuggestionsData } from "./useSuggestionsData"; import { useToast } from "@/components/hooks/use-toast"; import { SearchContextQuery } from "@/lib/types"; import isEqual from "fast-deep-equal/react"; +import { LoginModal } from "./loginModal"; +import { usePathname } from "next/navigation"; +import { PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY } from "@/features/chat/constants"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; interface ChatBoxProps { onSubmit: (children: Descendant[], editor: CustomEditor) => void; @@ -33,6 +37,8 @@ interface ChatBoxProps { languageModels: LanguageModelInfo[]; selectedSearchScopes: SearchScope[]; searchContexts: SearchContextQuery[]; + isLoginWallEnabled: boolean; + isAuthenticated: boolean; } const ChatBoxComponent = ({ @@ -43,6 +49,8 @@ const ChatBoxComponent = ({ isRedirecting, isGenerating, isDisabled, + isLoginWallEnabled, + isAuthenticated, languageModels, selectedSearchScopes, searchContexts, @@ -50,6 +58,7 @@ const ChatBoxComponent = ({ const suggestionsBoxRef = useRef(null); const [index, setIndex] = useState(0); const editor = useSlate(); + const captureEvent = useCaptureEvent(); const { suggestionQuery, suggestionMode, range } = useSuggestionModeAndQuery(); const { suggestions, isLoading } = useSuggestionsData({ suggestionMode, @@ -74,6 +83,9 @@ const ChatBoxComponent = ({ }); const { toast } = useToast(); + const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); + const pathname = usePathname(); + // Reset the index when the suggestion mode changes. useEffect(() => { setIndex(0); @@ -158,8 +170,48 @@ const ChatBoxComponent = ({ return; } + if (isLoginWallEnabled && !isAuthenticated) { + sessionStorage.setItem( + PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY, + JSON.stringify({ pathname, children: editor.children }), + ); + captureEvent('wa_askgh_login_wall_prompted', {}); + setIsLoginModalOpen(true); + return; + } + _onSubmit(editor.children, editor); - }, [_onSubmit, editor, isSubmitDisabled, isSubmitDisabledReason, toast]); + }, [ + isSubmitDisabled, + isLoginWallEnabled, + isAuthenticated, + _onSubmit, + editor, + isSubmitDisabledReason, + toast, + pathname, + captureEvent + ]); + + useEffect(() => { + const stored = sessionStorage.getItem(PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY); + if (!stored) { + return; + } + + try { + const { pathname: storedPathname, children } = JSON.parse(stored) as { pathname: string; children: Descendant[] }; + if (storedPathname !== pathname) { + return; + } + + sessionStorage.removeItem(PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY); + _onSubmit(children, editor); + } catch (error) { + console.error('Failed to restore pending chat submission:', error); + sessionStorage.removeItem(PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY); + } + }, [isAuthenticated, pathname, editor, _onSubmit]); const onInsertSuggestion = useCallback((suggestion: Suggestion) => { switch (suggestion.type) { @@ -272,76 +324,87 @@ const ChatBoxComponent = ({ }, [editor, index, range, preferredSuggestionsBoxPlacement]); return ( -
- -
- {isRedirecting ? ( - - ) : - isGenerating ? ( + <> +
+ +
+ {isRedirecting ? ( - ) : ( - - -
{ - // @hack: When submission is disabled, we still want to issue - // a warning to the user as to why the submission is disabled. - // onSubmit on the Button will not be called because of the - // disabled prop, hence the call here. - if (isSubmitDisabled) { - onSubmit(); - } - }} - > - + ) : ( + + +
{ + // @hack: When submission is disabled, we still want to issue + // a warning to the user as to why the submission is disabled. + // onSubmit on the Button will not be called because of the + // disabled prop, hence the call here. + if (isSubmitDisabled) { + onSubmit(); + } + }} > - - -
-
-
- )} + +
+
+
+ )} +
+ {suggestionMode !== "none" && ( + + )}
- {suggestionMode !== "none" && ( - - )} -
+ { + setIsLoginModalOpen(open); + if (!open) { + sessionStorage.removeItem(PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY); + } + }} + /> + ) } diff --git a/packages/web/src/app/components/loginModal.tsx b/packages/web/src/features/chat/components/chatBox/loginModal.tsx similarity index 77% rename from packages/web/src/app/components/loginModal.tsx rename to packages/web/src/features/chat/components/chatBox/loginModal.tsx index f1fc67ed2..f01db988a 100644 --- a/packages/web/src/app/components/loginModal.tsx +++ b/packages/web/src/features/chat/components/chatBox/loginModal.tsx @@ -8,26 +8,26 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { AuthMethodSelector } from "@/app/components/authMethodSelector"; -import type { IdentityProviderMetadata } from "@/lib/identityProviders"; +import { useIdentityProviders } from "@/features/auth/useIdentityProviders"; +import { usePathname } from "next/navigation"; interface LoginModalProps { isOpen: boolean; onOpenChange: (open: boolean) => void; - providers: IdentityProviderMetadata[]; - callbackUrl: string; } export const LoginModal = ({ isOpen, onOpenChange, - providers, - callbackUrl, }: LoginModalProps) => { + const providers = useIdentityProviders(); + const pathname = usePathname(); + return ( - Sign up to continue + Sign in to continue Sign into your account to continue. @@ -35,8 +35,8 @@ export const LoginModal = ({
diff --git a/packages/web/src/features/chat/components/chatThread/chatThread.tsx b/packages/web/src/features/chat/components/chatThread/chatThread.tsx index f60d281b7..9394e62d0 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThread.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThread.tsx @@ -28,16 +28,11 @@ import { NotConfiguredErrorBanner } from '../notConfiguredErrorBanner'; import useCaptureEvent from '@/hooks/useCaptureEvent'; import { SignInPromptBanner } from './signInPromptBanner'; import { DuplicateChatDialog } from '@/app/(app)/chat/components/duplicateChatDialog'; -import { LoginModal } from '@/app/components/loginModal'; -import type { IdentityProviderMetadata } from '@/lib/identityProviders'; -import { getAskGhLoginWallData } from '../../actions'; type ChatHistoryState = { scrollOffset?: number; } -const PENDING_MESSAGE_STORAGE_KEY = "askgh_chat_pending_message"; - interface ChatThreadProps { id?: string | undefined; initialMessages?: SBChatMessage[]; @@ -48,7 +43,8 @@ interface ChatThreadProps { selectedSearchScopes: SearchScope[]; onSelectedSearchScopesChange: (items: SearchScope[]) => void; isOwner?: boolean; - isAuthenticated?: boolean; + isAuthenticated: boolean; + isLoginWallEnabled: boolean; chatName?: string; } @@ -62,7 +58,8 @@ export const ChatThread = ({ selectedSearchScopes, onSelectedSearchScopesChange, isOwner = true, - isAuthenticated = false, + isAuthenticated, + isLoginWallEnabled, chatName, }: ChatThreadProps) => { const [isErrorBannerVisible, setIsErrorBannerVisible] = useState(false); @@ -72,9 +69,6 @@ export const ChatThread = ({ const router = useRouter(); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false); - const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); - const [loginWallProviders, setLoginWallProviders] = useState([]); - const hasRestoredPendingMessage = useRef(false); const captureEvent = useCaptureEvent(); // Initial state is from attachments that exist in in the chat history. @@ -207,38 +201,6 @@ export const ChatThread = ({ hasSubmittedInputMessage.current = true; }, [inputMessage, scrollToBottom, sendMessage]); - // Restore pending message after OAuth redirect (askgh login wall) - useEffect(() => { - if (!isAuthenticated || !isOwner || hasRestoredPendingMessage.current) { - return; - } - - const stored = sessionStorage.getItem(PENDING_MESSAGE_STORAGE_KEY); - if (!stored) { - return; - } - - hasRestoredPendingMessage.current = true; - sessionStorage.removeItem(PENDING_MESSAGE_STORAGE_KEY); - - try { - const { chatId: storedChatId, children } = JSON.parse(stored) as { chatId: string; children: Descendant[] }; - - // Only restore if we're on the same chat that stored the pending message - if (storedChatId !== chatId) { - return; - } - - const text = slateContentToString(children); - const mentions = getAllMentionElements(children); - const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes); - sendMessage(message); - scrollToBottom(); - } catch (error) { - console.error('Failed to restore pending message:', error); - } - }, [isAuthenticated, isOwner, chatId, sendMessage, selectedSearchScopes, scrollToBottom]); - // Track scroll position for history state restoration. useEffect(() => { const scrollElement = scrollRef.current; @@ -305,17 +267,6 @@ export const ChatThread = ({ }, [error]); const onSubmit = useCallback(async (children: Descendant[], editor: CustomEditor) => { - if (!isAuthenticated) { - const result = await getAskGhLoginWallData(); - if (!isServiceError(result) && result.isEnabled) { - captureEvent('wa_askgh_login_wall_prompted', {}); - sessionStorage.setItem(PENDING_MESSAGE_STORAGE_KEY, JSON.stringify({ chatId, children })); - setLoginWallProviders(result.providers); - setIsLoginModalOpen(true); - return; - } - } - const text = slateContentToString(children); const mentions = getAllMentionElements(children); @@ -325,7 +276,7 @@ export const ChatThread = ({ scrollToBottom(); resetEditor(editor); - }, [sendMessage, selectedSearchScopes, isAuthenticated, captureEvent, chatId, scrollToBottom]); + }, [sendMessage, selectedSearchScopes, scrollToBottom]); const onDuplicate = useCallback(async (newName: string): Promise => { if (!defaultChatId) { @@ -437,6 +388,8 @@ export const ChatThread = ({ selectedSearchScopes={selectedSearchScopes} searchContexts={searchContexts} isDisabled={languageModels.length === 0} + isAuthenticated={isAuthenticated} + isLoginWallEnabled={isLoginWallEnabled} />
)}
- - ); } diff --git a/packages/web/src/features/chat/constants.ts b/packages/web/src/features/chat/constants.ts index b84e9d922..1038852c6 100644 --- a/packages/web/src/features/chat/constants.ts +++ b/packages/web/src/features/chat/constants.ts @@ -9,3 +9,4 @@ export const ANSWER_TAG = ''; export const SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY = 'selectedSearchScopes'; export const SET_CHAT_STATE_SESSION_STORAGE_KEY = 'setChatState'; +export const PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY = 'pendingChatSubmission'; diff --git a/packages/web/src/features/chat/useCreateNewChatThread.ts b/packages/web/src/features/chat/useCreateNewChatThread.ts index 63ead0249..18a5a58b9 100644 --- a/packages/web/src/features/chat/useCreateNewChatThread.ts +++ b/packages/web/src/features/chat/useCreateNewChatThread.ts @@ -1,39 +1,36 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import { Descendant } from "slate"; import { createUIMessage, getAllMentionElements } from "./utils"; import { slateContentToString } from "./utils"; import { useToast } from "@/components/hooks/use-toast"; import { useRouter } from "next/navigation"; -import { createChat, getAskGhLoginWallData } from "./actions"; +import { createChat } from "./actions"; import { isServiceError } from "@/lib/utils"; import { createPathWithQueryParams } from "@/lib/utils"; import { SearchScope, SetChatStatePayload } from "./types"; -import { SET_CHAT_STATE_SESSION_STORAGE_KEY } from "./constants"; +import { SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY, SET_CHAT_STATE_SESSION_STORAGE_KEY } from "./constants"; import { useSessionStorage } from "usehooks-ts"; -import type { IdentityProviderMetadata } from "@/lib/identityProviders"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; -const PENDING_NEW_CHAT_KEY = "askgh_pending_new_chat"; - -interface UseCreateNewChatThreadOptions { - isAuthenticated?: boolean; -} - -export const useCreateNewChatThread = ({ isAuthenticated = false }: UseCreateNewChatThreadOptions = {}) => { +export const useCreateNewChatThread = () => { const [isLoading, setIsLoading] = useState(false); const { toast } = useToast(); const router = useRouter(); const [, setChatState] = useSessionStorage(SET_CHAT_STATE_SESSION_STORAGE_KEY, null); - const [loginWallState, setLoginWallState] = useState<{ isOpen: boolean; providers: IdentityProviderMetadata[] }>({ isOpen: false, providers: [] }); - const hasRestoredPendingMessage = useRef(false); - const captureEvent = useCaptureEvent(); - const doCreateChat = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[]) => { + const createNewChatThread = useCallback(async (children: Descendant[], overrideSearchScopes?: SearchScope[]) => { const text = slateContentToString(children); const mentions = getAllMentionElements(children); + let storedScopes: SearchScope[] = []; + try { + const stored = window.localStorage.getItem(SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY); + if (stored) storedScopes = JSON.parse(stored) as SearchScope[]; + } catch { /* fall through to [] */ } + + const selectedSearchScopes = overrideSearchScopes ?? storedScopes; + const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedSearchScopes); setIsLoading(true); @@ -56,52 +53,8 @@ export const useCreateNewChatThread = ({ isAuthenticated = false }: UseCreateNew router.push(url); }, [router, toast, setChatState]); - const createNewChatThread = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[]) => { - if (!isAuthenticated) { - const result = await getAskGhLoginWallData(); - if (!isServiceError(result) && result.isEnabled) { - captureEvent('wa_askgh_login_wall_prompted', {}); - sessionStorage.setItem(PENDING_NEW_CHAT_KEY, JSON.stringify({ children, selectedSearchScopes })); - setLoginWallState({ isOpen: true, providers: result.providers }); - return; - } - } - - doCreateChat(children, selectedSearchScopes); - }, [isAuthenticated, captureEvent, doCreateChat]); - - // Restore pending message after OAuth redirect - useEffect(() => { - if (!isAuthenticated || hasRestoredPendingMessage.current) { - return; - } - - const stored = sessionStorage.getItem(PENDING_NEW_CHAT_KEY); - if (!stored) { - return; - } - - hasRestoredPendingMessage.current = true; - sessionStorage.removeItem(PENDING_NEW_CHAT_KEY); - - try { - const { children, selectedSearchScopes } = JSON.parse(stored) as { - children: Descendant[]; - selectedSearchScopes: SearchScope[]; - }; - doCreateChat(children, selectedSearchScopes); - } catch (error) { - console.error('Failed to restore pending message:', error); - } - }, [isAuthenticated, doCreateChat]); - return { createNewChatThread, isLoading, - loginWall: { - isOpen: loginWallState.isOpen, - providers: loginWallState.providers, - onOpenChange: (open: boolean) => setLoginWallState(prev => ({ ...prev, isOpen: open })), - }, }; } diff --git a/packages/web/src/features/entitlements/useEntitlements.ts b/packages/web/src/features/entitlements/useEntitlements.ts new file mode 100644 index 000000000..f30fc6ede --- /dev/null +++ b/packages/web/src/features/entitlements/useEntitlements.ts @@ -0,0 +1,9 @@ +'use client'; + +import { useContext } from "react"; +import { PlanContext } from "./planProvider"; + +export const useEntitlements = () => { + const { entitlements } = useContext(PlanContext); + return entitlements; +} \ No newline at end of file From d09c76064ed516b312b677bf950afe0d1b3951ee Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 22 May 2026 22:44:18 -0700 Subject: [PATCH 2/2] wip on adding upsell dialog --- .../chat/components/chatBox/chatBox.tsx | 45 ++++++++++++++++--- .../{loginModal.tsx => loginDialog.tsx} | 6 +-- 2 files changed, 41 insertions(+), 10 deletions(-) rename packages/web/src/features/chat/components/chatBox/{loginModal.tsx => loginDialog.tsx} (93%) diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx index ab3be678d..c07921e0f 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBox.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx @@ -21,10 +21,13 @@ import { useSuggestionsData } from "./useSuggestionsData"; import { useToast } from "@/components/hooks/use-toast"; import { SearchContextQuery } from "@/lib/types"; import isEqual from "fast-deep-equal/react"; -import { LoginModal } from "./loginModal"; +import { LoginDialog } from "./loginDialog"; import { usePathname } from "next/navigation"; import { PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY } from "@/features/chat/constants"; import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; +import { UpsellDialog } from "@/app/(app)/@sidebar/components/upsell/upsellDialog"; +import { useOffers } from "@/ee/features/lighthouse/useOffers"; interface ChatBoxProps { onSubmit: (children: Descendant[], editor: CustomEditor) => void; @@ -82,8 +85,9 @@ const ChatBoxComponent = ({ languageModels, }); const { toast } = useToast(); - - const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); + const isAskEnabled = useHasEntitlement('ask'); + const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false); + const [isUpsellDialogOpen, setIsUpsellDialogOpen] = useState(false); const pathname = usePathname(); // Reset the index when the suggestion mode changes. @@ -176,7 +180,12 @@ const ChatBoxComponent = ({ JSON.stringify({ pathname, children: editor.children }), ); captureEvent('wa_askgh_login_wall_prompted', {}); - setIsLoginModalOpen(true); + setIsLoginDialogOpen(true); + return; + } + + if (!isAskEnabled) { + setIsUpsellDialogOpen(true); return; } @@ -185,6 +194,7 @@ const ChatBoxComponent = ({ isSubmitDisabled, isLoginWallEnabled, isAuthenticated, + isAskEnabled, _onSubmit, editor, isSubmitDisabledReason, @@ -395,15 +405,36 @@ const ChatBoxComponent = ({ /> )}
- { - setIsLoginModalOpen(open); + setIsLoginDialogOpen(open); if (!open) { sessionStorage.removeItem(PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY); } }} /> + ) } diff --git a/packages/web/src/features/chat/components/chatBox/loginModal.tsx b/packages/web/src/features/chat/components/chatBox/loginDialog.tsx similarity index 93% rename from packages/web/src/features/chat/components/chatBox/loginModal.tsx rename to packages/web/src/features/chat/components/chatBox/loginDialog.tsx index f01db988a..25f6f8ef9 100644 --- a/packages/web/src/features/chat/components/chatBox/loginModal.tsx +++ b/packages/web/src/features/chat/components/chatBox/loginDialog.tsx @@ -11,15 +11,15 @@ import { AuthMethodSelector } from "@/app/components/authMethodSelector"; import { useIdentityProviders } from "@/features/auth/useIdentityProviders"; import { usePathname } from "next/navigation"; -interface LoginModalProps { +interface LoginDialogProps { isOpen: boolean; onOpenChange: (open: boolean) => void; } -export const LoginModal = ({ +export const LoginDialog = ({ isOpen, onOpenChange, -}: LoginModalProps) => { +}: LoginDialogProps) => { const providers = useIdentityProviders(); const pathname = usePathname();