Răsfoiți Sursa

Add promo notifications for AI assist (#26514)

* Add promo notifications for AI assist

* format pug

GitOrigin-RevId: 8895145e1e5dcd8e28f29bf2601a4bd21456a407
Domagoj Kriskovic 1 an în urmă
părinte
comite
fda96b2fdf

+ 23 - 1
services/web/app/src/Features/Project/ProjectListController.mjs

@@ -27,6 +27,8 @@ import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
 import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js'
 import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js'
 import TutorialHandler from '../Tutorial/TutorialHandler.js'
 import TutorialHandler from '../Tutorial/TutorialHandler.js'
 import SubscriptionHelper from '../Subscription/SubscriptionHelper.js'
 import SubscriptionHelper from '../Subscription/SubscriptionHelper.js'
+import PermissionsManager from '../Authorization/PermissionsManager.js'
+import SubscriptionLocator from '../Subscription/SubscriptionLocator.js'
 
 
 /**
 /**
  * @import { GetProjectsRequest, GetProjectsResponse, AllUsersProjects, MongoProject } from "./types"
  * @import { GetProjectsRequest, GetProjectsResponse, AllUsersProjects, MongoProject } from "./types"
@@ -117,7 +119,7 @@ async function projectListPage(req, res, next) {
   const user = await User.findById(
   const user = await User.findById(
     userId,
     userId,
     `email emails features alphaProgram betaProgram lastPrimaryEmailCheck signUpDate refProviders${
     `email emails features alphaProgram betaProgram lastPrimaryEmailCheck signUpDate refProviders${
-      isSaas ? ' enrollment writefull completedTutorials' : ''
+      isSaas ? ' enrollment writefull completedTutorials aiErrorAssistant' : ''
     }`
     }`
   )
   )
 
 
@@ -409,6 +411,25 @@ async function projectListPage(req, res, next) {
     'papers-notification-banner'
     'papers-notification-banner'
   )
   )
 
 
+  await SplitTestHandler.promises.getAssignment(
+    req,
+    res,
+    'ai-assist-notification'
+  )
+
+  const canUseAi = await PermissionsManager.promises.checkUserPermissions(
+    user,
+    ['use-ai']
+  )
+  const assistantDisabled = user.aiErrorAssistant?.enabled === false // the assistant has been manually disabled by the user
+  const subscription =
+    await SubscriptionLocator.promises.getUsersSubscription(userId)
+  const hasManuallyCollectedSubscription =
+    subscription?.collectionMethod === 'manual'
+  const canUseAiAssist =
+    canUseAi && !hasManuallyCollectedSubscription && !assistantDisabled
+  const hasAiAssist = user.features?.aiErrorAssistant
+
   const customerIoEnabled =
   const customerIoEnabled =
     await SplitTestHandler.promises.hasUserBeenAssignedToVariant(
     await SplitTestHandler.promises.hasUserBeenAssignedToVariant(
       req,
       req,
@@ -450,6 +471,7 @@ async function projectListPage(req, res, next) {
     hasIndividualPaidSubscription,
     hasIndividualPaidSubscription,
     userRestrictions: Array.from(req.userRestrictions || []),
     userRestrictions: Array.from(req.userRestrictions || []),
     customerIoEnabled,
     customerIoEnabled,
+    showAiAssistNotification: canUseAiAssist && !hasAiAssist,
   })
   })
 }
 }
 
 

+ 5 - 0
services/web/app/views/project/list-react.pug

@@ -86,6 +86,11 @@ block append meta
 		data-type='string'
 		data-type='string'
 		content=usGovBannerVariant
 		content=usGovBannerVariant
 	)
 	)
+	meta(
+		name='ol-showAiAssistNotification'
+		data-type='boolean'
+		content=showAiAssistNotification
+	)
 
 
 block content
 block content
 	#project-list-root
 	#project-list-root

+ 6 - 0
services/web/frontend/extracted-translations.json

@@ -420,6 +420,8 @@
   "disconnected": "",
   "disconnected": "",
   "discount": "",
   "discount": "",
   "discount_of": "",
   "discount_of": "",
+  "discover_research_writing_toolkit": "",
+  "discover_research_writing_toolkit_description": "",
   "discover_the_fastest_way_to_search_and_cite": "",
   "discover_the_fastest_way_to_search_and_cite": "",
   "display": "",
   "display": "",
   "display_deleted_user": "",
   "display_deleted_user": "",
@@ -2098,9 +2100,13 @@
   "work_in_vim_or_emacs_emulation_mode": "",
   "work_in_vim_or_emacs_emulation_mode": "",
   "work_offline": "",
   "work_offline": "",
   "work_offline_pull_to_overleaf": "",
   "work_offline_pull_to_overleaf": "",
+  "work_smarter_with_ai_assist": "",
+  "work_smarter_with_ai_assist_description": "",
   "work_with_non_overleaf_users": "",
   "work_with_non_overleaf_users": "",
   "work_with_other_github_users": "",
   "work_with_other_github_users": "",
   "write_faster_smarter_with_overleaf_and_writefull_ai_tools": "",
   "write_faster_smarter_with_overleaf_and_writefull_ai_tools": "",
+  "write_smarter_right_now": "",
+  "write_smarter_right_now_description": "",
   "writefull": "",
   "writefull": "",
   "writefull_loading_error_body": "",
   "writefull_loading_error_body": "",
   "writefull_loading_error_title": "",
   "writefull_loading_error_title": "",

+ 128 - 0
services/web/frontend/js/features/project-list/components/notifications/ai-assist-banner.tsx

@@ -0,0 +1,128 @@
+import { memo, useCallback, useEffect } from 'react'
+import Notification from './notification'
+import { useTranslation } from 'react-i18next'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import { sendMB } from '@/infrastructure/event-tracking'
+import sparkle from '@/shared/svgs/sparkle-2-stars.svg'
+import { useSplitTest } from '@/shared/context/split-test-context'
+import { useProjectListContext } from '../../context/project-list-context'
+import getMeta from '@/utils/meta'
+import usePersistedState from '@/shared/hooks/use-persisted-state'
+
+function AiAssistBanner() {
+  const { title, description, cta } = useTitleDescription()
+  const { totalProjectsCount } = useProjectListContext()
+  const [dismissed, setDismissed] = usePersistedState(
+    'ai-assist-notification-banner-dismissed',
+    false
+  )
+  const { variant } = useSplitTest('ai-assist-notification')
+  const { t } = useTranslation()
+
+  useEffect(() => {
+    if (!dismissed) {
+      sendMB('promo-prompt', {
+        location: 'dashboard-banner',
+        name: 'ai-assist',
+        variant,
+      })
+    }
+  }, [dismissed, variant])
+
+  const handleClose = useCallback(() => {
+    sendMB('promo-dismiss', {
+      location: 'dashboard-banner',
+      name: 'ai-assist',
+      variant,
+    })
+    setDismissed(true)
+  }, [setDismissed, variant])
+
+  const handleUpgradeClick = useCallback(() => {
+    sendMB('promo-click', {
+      location: 'dashboard-banner',
+      name: 'ai-assist',
+      variant,
+      type: 'click-upgrade',
+    })
+  }, [variant])
+
+  const handleLearnMoreClick = useCallback(() => {
+    sendMB('promo-click', {
+      location: 'dashboard-banner',
+      name: 'ai-assist',
+      type: 'click-learn-more',
+      variant,
+    })
+  }, [variant])
+
+  if (
+    !title ||
+    dismissed ||
+    totalProjectsCount === 0 ||
+    !getMeta('ol-showAiAssistNotification')
+  ) {
+    return null
+  }
+
+  return (
+    <Notification
+      type="offer"
+      customIcon={<img aria-hidden="true" alt="" src={sparkle} width={32} />}
+      iconPlacement="center"
+      title={title}
+      onDismiss={handleClose}
+      content={
+        <p>
+          {description}{' '}
+          <a onClick={handleLearnMoreClick} href="/about/ai-features">
+            {t('learn_more')}
+          </a>
+          .
+        </p>
+      }
+      action={
+        <OLButton
+          variant="secondary"
+          href="/user/subscription/plans#ai-assist"
+          onClick={handleUpgradeClick}
+        >
+          {cta}
+        </OLButton>
+      }
+    />
+  )
+}
+
+function useTitleDescription() {
+  const { variant } = useSplitTest('ai-assist-notification')
+  const { t } = useTranslation()
+
+  switch (variant) {
+    case 'work-smarter':
+      return {
+        title: t('work_smarter_with_ai_assist'),
+        description: t('work_smarter_with_ai_assist_description'),
+        cta: t('add_ai_assist'),
+      }
+    case 'discover-toolkit':
+      return {
+        title: t('discover_research_writing_toolkit'),
+        description: t('discover_research_writing_toolkit_description'),
+        cta: t('add_ai_assist'),
+      }
+    case 'write-smarter':
+      return {
+        title: t('write_smarter_right_now'),
+        description: t('write_smarter_right_now_description'),
+        cta: t('add_ai_assist'),
+      }
+    default:
+      return {
+        title: null,
+        description: null,
+        cta: null,
+      }
+  }
+}
+export default memo(AiAssistBanner)

+ 8 - 1
services/web/frontend/js/features/project-list/components/notifications/notification.tsx

@@ -3,7 +3,14 @@ import NewNotification from '@/shared/components/notification'
 
 
 type NotificationProps = Pick<
 type NotificationProps = Pick<
   React.ComponentProps<typeof NewNotification>,
   React.ComponentProps<typeof NewNotification>,
-  'type' | 'action' | 'content' | 'onDismiss' | 'className' | 'title'
+  | 'type'
+  | 'action'
+  | 'content'
+  | 'onDismiss'
+  | 'className'
+  | 'title'
+  | 'customIcon'
+  | 'iconPlacement'
 >
 >
 
 
 function Notification({ className, ...props }: NotificationProps) {
 function Notification({ className, ...props }: NotificationProps) {

+ 2 - 0
services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx

@@ -14,6 +14,7 @@ import {
   isDeprecatedBrowser,
   isDeprecatedBrowser,
 } from '@/shared/components/deprecated-browser'
 } from '@/shared/components/deprecated-browser'
 import PapersNotificationBanner from './papers-notification-banner'
 import PapersNotificationBanner from './papers-notification-banner'
+import AiAssistBanner from './ai-assist-banner'
 import { usePapersNotification } from './hooks/use-papers-notification'
 import { usePapersNotification } from './hooks/use-papers-notification'
 
 
 const [enrollmentNotificationModule] = importOverleafModules(
 const [enrollmentNotificationModule] = importOverleafModules(
@@ -57,6 +58,7 @@ function UserNotifications() {
         {USGovBanner && <USGovBanner />}
         {USGovBanner && <USGovBanner />}
 
 
         {showPapersNotificationBanner && <PapersNotificationBanner />}
         {showPapersNotificationBanner && <PapersNotificationBanner />}
+        <AiAssistBanner />
         <AccessibilitySurveyBanner />
         <AccessibilitySurveyBanner />
 
 
         {isDeprecatedBrowser() && <DeprecatedBrowser />}
         {isDeprecatedBrowser() && <DeprecatedBrowser />}

+ 19 - 2
services/web/frontend/js/shared/components/notification.tsx

@@ -18,6 +18,7 @@ export type NotificationProps = {
   className?: string
   className?: string
   content: React.ReactNode
   content: React.ReactNode
   customIcon?: React.ReactElement | null
   customIcon?: React.ReactElement | null
+  iconPlacement?: 'top' | 'center'
   disclaimer?: React.ReactElement | string
   disclaimer?: React.ReactElement | string
   isDismissible?: boolean
   isDismissible?: boolean
   isActionBelowContent?: boolean
   isActionBelowContent?: boolean
@@ -30,9 +31,11 @@ export type NotificationProps = {
 export function NotificationIcon({
 export function NotificationIcon({
   notificationType,
   notificationType,
   customIcon,
   customIcon,
+  iconPlacement,
 }: {
 }: {
   notificationType: NotificationType
   notificationType: NotificationType
   customIcon?: ReactElement
   customIcon?: ReactElement
+  iconPlacement?: 'top' | 'center'
 }) {
 }) {
   let icon = <MaterialIcon type="info" />
   let icon = <MaterialIcon type="info" />
 
 
@@ -47,7 +50,16 @@ export function NotificationIcon({
   } else if (notificationType === 'offer') {
   } else if (notificationType === 'offer') {
     icon = <MaterialIcon type="campaign" />
     icon = <MaterialIcon type="campaign" />
   }
   }
-  return <div className="notification-icon">{icon}</div>
+  return (
+    <div
+      className={classNames(
+        'notification-icon',
+        iconPlacement ? `notification-icon-${iconPlacement}` : ''
+      )}
+    >
+      {icon}
+    </div>
+  )
 }
 }
 
 
 function Notification({
 function Notification({
@@ -56,6 +68,7 @@ function Notification({
   className = '',
   className = '',
   content,
   content,
   customIcon,
   customIcon,
+  iconPlacement = 'top',
   disclaimer,
   disclaimer,
   isActionBelowContent,
   isActionBelowContent,
   isDismissible,
   isDismissible,
@@ -94,7 +107,11 @@ function Notification({
       id={id}
       id={id}
     >
     >
       {customIcon !== null && (
       {customIcon !== null && (
-        <NotificationIcon notificationType={type} customIcon={customIcon} />
+        <NotificationIcon
+          notificationType={type}
+          customIcon={customIcon}
+          iconPlacement={iconPlacement}
+        />
       )}
       )}
 
 
       <div className="notification-content-and-cta">
       <div className="notification-content-and-cta">

+ 16 - 0
services/web/frontend/js/shared/svgs/sparkle-2-stars.svg

@@ -0,0 +1,16 @@
+<svg width="41" height="40" viewBox="0 0 41 40" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M30.2344 23.5171C20.275 21.2682 18.7323 19.7255 16.4837 9.76641C16.3807 9.31122 15.9756 8.987 15.5074 8.987C15.0392 8.987 14.6341 9.3112 14.531 9.76641C12.281 19.7259 10.7394 21.2686 0.780336 23.5171C0.324219 23.6212 0 24.0253 0 24.4935C0 24.9616 0.324203 25.3659 0.780336 25.4698C10.7398 27.7199 12.2813 29.2624 14.531 39.2205C14.6341 39.6757 15.0392 39.9999 15.5074 39.9999C15.9756 39.9999 16.3807 39.6757 16.4837 39.2205C18.7338 29.2622 20.2754 27.7196 30.2344 25.4698C30.6906 25.3657 31.0139 24.9616 31.0139 24.4935C31.0139 24.0253 30.6897 23.6211 30.2344 23.5171Z" fill="url(#paint0_linear_4820_1102)"/>
+<path d="M39.2371 8.01179C33.9428 6.81627 33.1993 6.07299 32.004 0.779476C31.8998 0.32336 31.4958 6.10352e-05 31.0276 6.10352e-05C30.5595 6.10352e-05 30.1552 0.323168 30.0513 0.779476C28.8558 6.07255 28.1125 6.81607 22.819 8.01179C22.3628 8.11589 22.0396 8.51996 22.0396 8.98812C22.0396 9.45629 22.3627 9.86054 22.819 9.96447C28.112 11.16 28.8556 11.9033 30.0513 17.1976C30.1554 17.6528 30.5595 17.977 31.0276 17.977C31.4958 17.977 31.9 17.6528 32.004 17.1976C33.1995 11.9033 33.9427 11.1598 39.2371 9.96447C39.6923 9.86036 40.0165 9.45629 40.0165 8.98812C40.0165 8.51996 39.6923 8.11571 39.2371 8.01179Z" fill="url(#paint1_linear_4820_1102)"/>
+<defs>
+<linearGradient id="paint0_linear_4820_1102" x1="31.0139" y1="8.987" x2="-5.13702" y2="25.3634" gradientUnits="userSpaceOnUse">
+<stop stop-color="#214475"/>
+<stop offset="0.295154" stop-color="#254C84"/>
+<stop offset="1" stop-color="#6597E0"/>
+</linearGradient>
+<linearGradient id="paint1_linear_4820_1102" x1="40.0165" y1="6.2448e-05" x2="19.0617" y2="9.49231" gradientUnits="userSpaceOnUse">
+<stop stop-color="#214475"/>
+<stop offset="0.295154" stop-color="#254C84"/>
+<stop offset="1" stop-color="#6597E0"/>
+</linearGradient>
+</defs>
+</svg>

+ 1 - 0
services/web/frontend/js/utils/meta.ts

@@ -228,6 +228,7 @@ export interface Meta {
   'ol-settingsPlans': Plan[]
   'ol-settingsPlans': Plan[]
   'ol-shouldAllowEditingDetails': boolean
   'ol-shouldAllowEditingDetails': boolean
   'ol-shouldLoadHotjar': boolean
   'ol-shouldLoadHotjar': boolean
+  'ol-showAiAssistNotification': boolean
   'ol-showAiErrorAssistant': boolean
   'ol-showAiErrorAssistant': boolean
   'ol-showBrlGeoBanner': boolean
   'ol-showBrlGeoBanner': boolean
   'ol-showCouponField': boolean
   'ol-showCouponField': boolean

+ 6 - 0
services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss

@@ -70,6 +70,12 @@
     padding: 18px $spacing-06 0 0;
     padding: 18px $spacing-06 0 0;
   }
   }
 
 
+  .notification-icon.notification-icon-center {
+    padding-top: 0;
+    display: flex;
+    align-items: center;
+  }
+
   .notification-content-and-cta {
   .notification-content-and-cta {
     // shared container to align cta with text on smaller screens
     // shared container to align cta with text on smaller screens
     display: flex;
     display: flex;

+ 6 - 0
services/web/locales/en.json

@@ -549,6 +549,8 @@
   "discount": "Discount",
   "discount": "Discount",
   "discount_of": "Discount of __amount__",
   "discount_of": "Discount of __amount__",
   "discover_latex_templates_and_examples": "Discover LaTeX templates and examples to help with everything from writing a journal article to using a specific LaTeX package.",
   "discover_latex_templates_and_examples": "Discover LaTeX templates and examples to help with everything from writing a journal article to using a specific LaTeX package.",
+  "discover_research_writing_toolkit": "NEW: Discover the ultimate research writing toolkit",
+  "discover_research_writing_toolkit_description": "Add AI Assist to get unlimited access to LaTeX-tailored AI tools from Overleaf and Writefull.",
   "discover_the_fastest_way_to_search_and_cite": "Discover the fastest way to search and cite",
   "discover_the_fastest_way_to_search_and_cite": "Discover the fastest way to search and cite",
   "discover_why_over_people_worldwide_trust_overleaf": "Discover why over __count__ million people worldwide trust Overleaf with their work.",
   "discover_why_over_people_worldwide_trust_overleaf": "Discover why over __count__ million people worldwide trust Overleaf with their work.",
   "display": "Display",
   "display": "Display",
@@ -2655,9 +2657,13 @@
   "work_offline": "Work offline",
   "work_offline": "Work offline",
   "work_offline_pull_to_overleaf": "Work offline, then pull to __appName__",
   "work_offline_pull_to_overleaf": "Work offline, then pull to __appName__",
   "work_or_university_sso": "Work/university single sign-on",
   "work_or_university_sso": "Work/university single sign-on",
+  "work_smarter_with_ai_assist": "Work smarter with AI Assist: AI tools built by TeXperts, for LaTeX",
+  "work_smarter_with_ai_assist_description": "Get help with everything from language suggestions and writing support to generating LaTeX tables and fixing errors.",
   "work_with_non_overleaf_users": "Work with non Overleaf users",
   "work_with_non_overleaf_users": "Work with non Overleaf users",
   "work_with_other_github_users": "Work with other GitHub users",
   "work_with_other_github_users": "Work with other GitHub users",
   "write_faster_smarter_with_overleaf_and_writefull_ai_tools": "Write faster, smarter, and with confidence with Overleaf and Writefull AI tools",
   "write_faster_smarter_with_overleaf_and_writefull_ai_tools": "Write faster, smarter, and with confidence with Overleaf and Writefull AI tools",
+  "write_smarter_right_now": "Write smarter, right now",
+  "write_smarter_right_now_description": "Write faster and with confidence with AI Assist—LaTeX-tailored AI tools from Overleaf and Writefull.",
   "writefull": "Writefull",
   "writefull": "Writefull",
   "writefull_loading_error_body": "Try refreshing the page. If this doesn’t work, try disabling any active browser extensions to check they aren’t blocking Writefull from loading.",
   "writefull_loading_error_body": "Try refreshing the page. If this doesn’t work, try disabling any active browser extensions to check they aren’t blocking Writefull from loading.",
   "writefull_loading_error_title": "Writefull didn’t load correctly",
   "writefull_loading_error_title": "Writefull didn’t load correctly",

+ 3 - 3
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -875,7 +875,7 @@ describe('<ProjectListRoot />', function () {
             const modals = await screen.findAllByRole('dialog')
             const modals = await screen.findAllByRole('dialog')
             const modal = modals[0]
             const modal = modals[0]
 
 
-            expect(sendMBSpy).to.have.been.calledTwice
+            expect(sendMBSpy).to.have.been.calledThrice
             expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
             expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
             expect(sendMBSpy).to.have.been.calledWith(
             expect(sendMBSpy).to.have.been.calledWith(
               'project-list-page-interaction',
               'project-list-page-interaction',
@@ -1014,7 +1014,7 @@ describe('<ProjectListRoot />', function () {
               )
               )
             ).to.be.true
             ).to.be.true
 
 
-            expect(sendMBSpy).to.have.been.calledTwice
+            expect(sendMBSpy).to.have.been.calledThrice
             expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
             expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
             expect(sendMBSpy).to.have.been.calledWith(
             expect(sendMBSpy).to.have.been.calledWith(
               'project-list-page-interaction',
               'project-list-page-interaction',
@@ -1179,7 +1179,7 @@ describe('<ProjectListRoot />', function () {
 
 
         await fetchMock.callHistory.flush(true)
         await fetchMock.callHistory.flush(true)
 
 
-        expect(sendMBSpy).to.have.been.calledTwice
+        expect(sendMBSpy).to.have.been.calledThrice
         expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
         expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
         expect(sendMBSpy).to.have.been.calledWith(
         expect(sendMBSpy).to.have.been.calledWith(
           'project-list-page-interaction',
           'project-list-page-interaction',

+ 25 - 0
services/web/test/unit/src/Project/ProjectListController.test.mjs

@@ -145,6 +145,18 @@ describe('ProjectListController', function () {
       },
       },
     }
     }
 
 
+    ctx.PermissionsManager = {
+      promises: {
+        checkUserPermissions: sinon.stub().resolves(true),
+      },
+    }
+
+    ctx.SubscriptionLocator = {
+      promises: {
+        getUsersSubscription: sinon.stub().resolves({}),
+      },
+    }
+
     vi.doMock('mongodb-legacy', () => ({
     vi.doMock('mongodb-legacy', () => ({
       default: { ObjectId },
       default: { ObjectId },
     }))
     }))
@@ -250,6 +262,19 @@ describe('ProjectListController', function () {
       default: ctx.TutorialHandler,
       default: ctx.TutorialHandler,
     }))
     }))
 
 
+    vi.doMock(
+      '../../../../app/src/Features/Authorization/PermissionsManager',
+      () => ({
+        default: ctx.PermissionsManager,
+      })
+    )
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionLocator',
+      () => ({
+        default: ctx.SubscriptionLocator,
+      })
+    )
+
     ctx.ProjectListController = (await import(MODULE_PATH)).default
     ctx.ProjectListController = (await import(MODULE_PATH)).default
 
 
     ctx.req = {
     ctx.req = {