Add promo notifications for AI assist (#26514)

* Add promo notifications for AI assist

* format pug

GitOrigin-RevId: 8895145e1e5dcd8e28f29bf2601a4bd21456a407
This commit is contained in:
Domagoj Kriskovic
2025-06-24 11:29:53 +02:00
committed by Copybot
parent f53a13ae1e
commit fda96b2fdf
13 changed files with 248 additions and 7 deletions

View File

@@ -27,6 +27,8 @@ import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js'
import TutorialHandler from '../Tutorial/TutorialHandler.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"
@@ -117,7 +119,7 @@ async function projectListPage(req, res, next) {
const user = await User.findById(
userId,
`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'
)
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 =
await SplitTestHandler.promises.hasUserBeenAssignedToVariant(
req,
@@ -450,6 +471,7 @@ async function projectListPage(req, res, next) {
hasIndividualPaidSubscription,
userRestrictions: Array.from(req.userRestrictions || []),
customerIoEnabled,
showAiAssistNotification: canUseAiAssist && !hasAiAssist,
})
}

View File

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

View File

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

View File

@@ -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)

View File

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

View File

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

View File

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

View File

@@ -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>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

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

View File

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

View File

@@ -549,6 +549,8 @@
"discount": "Discount",
"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_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_why_over_people_worldwide_trust_overleaf": "Discover why over __count__ million people worldwide trust Overleaf with their work.",
"display": "Display",
@@ -2655,9 +2657,13 @@
"work_offline": "Work offline",
"work_offline_pull_to_overleaf": "Work offline, then pull to __appName__",
"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_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_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_loading_error_body": "Try refreshing the page. If this doesnt work, try disabling any active browser extensions to check they arent blocking Writefull from loading.",
"writefull_loading_error_title": "Writefull didnt load correctly",

View File

@@ -875,7 +875,7 @@ describe('<ProjectListRoot />', function () {
const modals = await screen.findAllByRole('dialog')
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(
'project-list-page-interaction',
@@ -1014,7 +1014,7 @@ describe('<ProjectListRoot />', function () {
)
).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(
'project-list-page-interaction',
@@ -1179,7 +1179,7 @@ describe('<ProjectListRoot />', function () {
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(
'project-list-page-interaction',

View File

@@ -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', () => ({
default: { ObjectId },
}))
@@ -250,6 +262,19 @@ describe('ProjectListController', function () {
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.req = {