Merge pull request #13804 from overleaf/tm-geopricing-inr-banners

Implement split test for new INR geo-pricing banners/modal

GitOrigin-RevId: 06fbcf70b7ee90b9b365ac96c1fa0373cbe60847
This commit is contained in:
Thomas 2023-07-13 16:22:33 +02:00 committed by Copybot
parent 780e5fdca5
commit e704afdcca
12 changed files with 299 additions and 75 deletions

View file

@ -363,21 +363,24 @@ async function projectListPage(req, res, next) {
}
}
let showINRBanner = false
let showInrGeoBanner, inrGeoBannerSplitTestName
let inrGeoBannerVariant = 'default'
let showLATAMBanner = false
let recommendedCurrency
if (usersBestSubscription?.type === 'free') {
const { currencyCode, countryCode } =
await GeoIpLookup.promises.getCurrencyCode(req.ip)
let inrGeoPricingVariant = 'default'
try {
// Split test is kept active, but all users geolocated in India can
// now use the INR currency (See #13507)
const inrGeoPricingAssignment =
await SplitTestHandler.promises.getAssignment(
req,
res,
'geo-pricing-inr'
)
showINRBanner =
inrGeoPricingAssignment.variant === 'inr' && countryCode === 'IN'
inrGeoPricingVariant = inrGeoPricingAssignment.variant
} catch (error) {
logger.error(
{ err: error },
@ -404,6 +407,27 @@ async function projectListPage(req, res, next) {
'Failed to get geo-pricing-latam split test assignment'
)
}
if (countryCode === 'IN') {
inrGeoBannerSplitTestName =
inrGeoPricingVariant === 'inr'
? 'geo-banners-inr-2'
: 'geo-banners-inr-1'
try {
const geoBannerAssignment =
await SplitTestHandler.promises.getAssignment(
req,
res,
inrGeoBannerSplitTestName
)
showInrGeoBanner = true
inrGeoBannerVariant = geoBannerAssignment.variant
} catch (error) {
logger.error(
{ err: error },
`Failed to get INR geo banner lookup or assignment (${inrGeoBannerSplitTestName})`
)
}
}
}
res.render('project/list-react', {
@ -423,9 +447,11 @@ async function projectListPage(req, res, next) {
showGroupsAndEnterpriseBanner,
groupsAndEnterpriseBannerVariant,
showWritefullPromoBanner,
showINRBanner,
showLATAMBanner,
recommendedCurrency,
showInrGeoBanner,
inrGeoBannerVariant,
inrGeoBannerSplitTestName,
projectDashboardReact: true, // used in navbar
welcomePageRedesignVariant: welcomePageRedesignAssignment.variant,
groupSubscriptionsPendingEnrollment:

View file

@ -108,7 +108,32 @@ async function plansPage(req, res) {
removePersonalPlanAssingment
)
AnalyticsManager.recordEventForSession(req.session, 'plans-page-view', {
let showInrGeoBanner, inrGeoBannerSplitTestName
let inrGeoBannerVariant = 'default'
if (countryCode === 'IN') {
inrGeoBannerSplitTestName =
geoPricingINRTestVariant === 'inr'
? 'geo-banners-inr-2'
: 'geo-banners-inr-1'
try {
const geoBannerAssignment = await SplitTestHandler.promises.getAssignment(
req,
res,
inrGeoBannerSplitTestName
)
inrGeoBannerVariant = geoBannerAssignment.variant
if (inrGeoBannerVariant !== 'default') {
showInrGeoBanner = true
}
} catch (error) {
logger.error(
{ err: error },
`Failed to get INR geo banner lookup or assignment (${inrGeoBannerSplitTestName})`
)
}
}
const plansPageViewSegmentation = {
currency: recommendedCurrency,
'remove-personal-plan-page': removePersonalPlanAssingment?.variant,
countryCode,
@ -120,7 +145,16 @@ async function plansPage(req, res) {
)
? 'latam'
: 'default',
})
}
if (inrGeoBannerSplitTestName) {
plansPageViewSegmentation[inrGeoBannerSplitTestName] = inrGeoBannerVariant
}
AnalyticsManager.recordEventForSession(
req.session,
'plans-page-view',
plansPageViewSegmentation
)
res.render(`subscriptions/plans-marketing/${directory}/plans-marketing-v2`, {
title: 'plans_and_pricing',
@ -137,6 +171,7 @@ async function plansPage(req, res) {
groupPlanModalDefaults,
initialLocalizedGroupPrice:
SubscriptionHelper.generateInitialLocalizedGroupPrice(currency),
showInrGeoBanner,
})
}
@ -302,15 +337,36 @@ async function interstitialPaymentPage(req, res) {
if (hasSubscription) {
res.redirect('/user/subscription?hasSubscription=true')
} else {
AnalyticsManager.recordEventForSession(
req.session,
'paywall-plans-page-view',
{
let showInrGeoBanner, inrGeoBannerSplitTestName
let inrGeoBannerVariant = 'default'
if (countryCode === 'IN') {
inrGeoBannerSplitTestName =
geoPricingINRTestVariant === 'inr'
? 'geo-banners-inr-2'
: 'geo-banners-inr-1'
try {
const geoBannerAssignment =
await SplitTestHandler.promises.getAssignment(
req,
res,
inrGeoBannerSplitTestName
)
inrGeoBannerVariant = geoBannerAssignment.variant
if (inrGeoBannerVariant !== 'default') {
showInrGeoBanner = true
}
} catch (error) {
logger.error(
{ err: error },
`Failed to get INR geo banner lookup or assignment (${inrGeoBannerSplitTestName})`
)
}
}
const paywallPlansPageViewSegmentation = {
currency: recommendedCurrency,
countryCode,
'geo-pricing-inr-group': geoPricingINRTestVariant,
'geo-pricing-inr-page':
recommendedCurrency === 'INR' ? 'inr' : 'default',
'geo-pricing-inr-page': recommendedCurrency === 'INR' ? 'inr' : 'default',
'geo-pricing-latam-group': geoPricingLATAMTestVariant,
'geo-pricing-latam-page': ['BRL', 'MXN', 'COP', 'CLP', 'PEN'].includes(
recommendedCurrency
@ -319,6 +375,14 @@ async function interstitialPaymentPage(req, res) {
: 'default',
'remove-personal-plan-page': removePersonalPlanAssingment?.variant,
}
if (inrGeoBannerSplitTestName) {
paywallPlansPageViewSegmentation[inrGeoBannerSplitTestName] =
inrGeoBannerVariant
}
AnalyticsManager.recordEventForSession(
req.session,
'paywall-plans-page-view',
paywallPlansPageViewSegmentation
)
res.render(
@ -331,6 +395,7 @@ async function interstitialPaymentPage(req, res) {
recommendedCurrency,
interstitialPaymentConfig,
showSkipLink,
showInrGeoBanner,
}
)
}
@ -678,10 +743,12 @@ async function _getRecommendedCurrency(req, res) {
req.query?.ip || req.ip
)
const countryCode = currencyLookup.countryCode
let recommendedCurrency = currencyLookup.currencyCode
let assignmentINR, assignmentLATAM
let recommendedCurrency = currencyLookup.currencyCode
// for #12703
try {
// Split test is kept active, but all users geolocated in India can
// now use the INR currency (See #13507)
assignmentINR = await SplitTestHandler.promises.getAssignment(
req,
res,
@ -706,11 +773,6 @@ async function _getRecommendedCurrency(req, res) {
'Failed to get assignment for geo-pricing-latam test'
)
}
// if the user has been detected as located in India (thus recommended INR as currency)
// but is not part of the geo pricing test, we fall back to the default currency instead
if (recommendedCurrency === 'INR' && assignmentINR?.variant !== 'inr') {
recommendedCurrency = GeoIpLookup.DEFAULT_CURRENCY_CODE
}
if (
['BRL', 'MXN', 'COP', 'CLP', 'PEN'].includes(recommendedCurrency) &&
assignmentLATAM?.variant !== 'latam'

View file

@ -28,7 +28,9 @@ block append meta
meta(name="ol-showGroupsAndEnterpriseBanner" data-type="boolean" content=showGroupsAndEnterpriseBanner)
meta(name="ol-showWritefullPromoBanner" data-type="boolean" content=showWritefullPromoBanner)
meta(name="ol-groupsAndEnterpriseBannerVariant" data-type="string" content=groupsAndEnterpriseBannerVariant)
meta(name="ol-showINRBanner" data-type="boolean" content=showINRBanner)
meta(name="ol-showInrGeoBanner" data-type="boolean" content=showInrGeoBanner)
meta(name="ol-inrGeoBannerVariant" data-type="string" content=inrGeoBannerVariant)
meta(name="ol-inrGeoBannerSplitTestName" data-type="string" content=inrGeoBannerSplitTestName)
meta(name="ol-showLATAMBanner" data-type="boolean" content=showLATAMBanner)
meta(name="ol-recommendedCurrency" data-type="string" content=recommendedCurrency)
meta(name="ol-welcomePageRedesignVariant" data-type="string" content=welcomePageRedesignVariant)

View file

@ -16,6 +16,9 @@ block content
.content-page
.plans
.container
if showInrGeoBanner
div.alert.alert-success.text-centered !{translate("inr_discount_offer_plans_page_banner", {flag: '🇮🇳'})}
.row
.col-md-12
.page-header.centered.plans-header.text-centered.top-page-header

View file

@ -15,6 +15,9 @@ block content
.content-page
.plans
.container(ng-cloak)
if showInrGeoBanner
div.alert.alert-success.text-centered !{translate("inr_discount_offer_plans_page_banner", {flag: '🇮🇳'})}
.row
.col-md-12
.page-header.centered.plans-header.text-centered.top-page-header

View file

@ -16,6 +16,9 @@ block content
.content-page
.plans
.container
if showInrGeoBanner
div.alert.alert-success.text-centered !{translate("inr_discount_offer_plans_page_banner", {flag: '🇮🇳'})}
.row
.col-md-12
.page-header.centered.plans-header.text-centered.top-page-header

View file

@ -15,6 +15,9 @@ block content
.content-page
.plans
.container(ng-cloak)
if showInrGeoBanner
div.alert.alert-success.text-centered !{translate("inr_discount_offer_plans_page_banner", {flag: '🇮🇳'})}
.row
.col-md-12
.page-header.centered.plans-header.text-centered.top-page-header

View file

@ -494,7 +494,10 @@
"include_caption": "",
"include_label": "",
"increased_compile_timeout": "",
"inr_discount_modal_info": "",
"inr_discount_modal_title": "",
"inr_discount_offer": "",
"inr_discount_offer_green_banner": "",
"insert_figure": "",
"insert_from_another_project": "",
"insert_from_project_files": "",
@ -613,6 +616,7 @@
"math_display": "",
"math_inline": "",
"maximum_files_uploaded_together": "",
"maybe_later": "",
"members_management": "",
"mendeley_groups_loading_error": "",
"mendeley_groups_relink": "",

View file

@ -1,51 +1,102 @@
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import usePersistedState from '../../../../../shared/hooks/use-persisted-state'
import Notification from '../notification'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import { Button } from 'react-bootstrap'
import { Modal, Button } from 'react-bootstrap'
import AccessibleModal from '../../../../../shared/components/accessible-modal'
export default function INRBanner() {
interface VariantContents {
default: string
'green-banner': string
modal: string
}
const contentLookup: VariantContents = {
default: 'blue',
'green-banner': 'green',
modal: 'modal',
}
type INRBannerProps = {
variant: keyof VariantContents
splitTestName: string
}
export default function INRBanner({ variant, splitTestName }: INRBannerProps) {
const { t } = useTranslation()
const [dismissedAt, setDismissedAt] = usePersistedState<Date | undefined>(
`has_dismissed_inr_banner`
)
const [dismissedUntil, setDismissedUntil] = usePersistedState<
Date | undefined
>(`has_dismissed_inr_banner_until`)
const viewEventSent = useRef<boolean>(false)
useEffect(() => {
if (!dismissedAt) {
return
}
const dismissedAtDate = new Date(dismissedAt)
const recentlyDismissedCutoff = new Date()
recentlyDismissedCutoff.setDate(recentlyDismissedCutoff.getDate() - 30) // 30 days
// once dismissedAt passes the cut-off mark, banner will be shown again
if (dismissedAtDate <= recentlyDismissedCutoff) {
setDismissedAt(undefined)
}
}, [dismissedAt, setDismissedAt])
// Only used by 'modal' variant
const [showModal, setShowModal] = useState(true)
useEffect(() => {
if (!dismissedAt && !viewEventSent.current) {
eventTracking.sendMB('paywall-prompt', {
'paywall-type': 'inr-banner',
if (dismissedUntil && new Date(dismissedUntil) > new Date()) {
return
}
if (!viewEventSent.current) {
eventTracking.sendMB('promo-prompt', {
location: variant === 'modal' ? 'dashboard-modal' : 'dashboard-banner',
name: splitTestName,
content: contentLookup[variant],
})
viewEventSent.current = true
}
}, [dismissedAt])
}, [dismissedUntil, splitTestName, variant])
const handleClick = useCallback(() => {
eventTracking.sendMB('paywall-click', { 'paywall-type': 'inr-banner' })
eventTracking.sendMB('promo-click', {
location: variant === 'modal' ? 'dashboard-modal' : 'dashboard-banner',
name: splitTestName,
content: contentLookup[variant],
type: 'click',
})
setShowModal(false)
window.open('/user/subscription/plans')
}, [])
}, [splitTestName, variant])
if (dismissedAt) {
const bannerDismissed = useCallback(() => {
eventTracking.sendMB('promo-dismiss', {
location: variant === 'modal' ? 'dashboard-modal' : 'dashboard-banner',
name: splitTestName,
content: contentLookup[variant],
type: 'click',
})
const until = new Date()
until.setDate(until.getDate() + 30) // 30 days
setDismissedUntil(until)
}, [setDismissedUntil, splitTestName, variant])
const handleHide = useCallback(() => {
setShowModal(false)
bannerDismissed()
}, [bannerDismissed])
const handleMaybeLater = useCallback(() => {
eventTracking.sendMB('promo-click', {
location: variant === 'modal' ? 'dashboard-modal' : 'dashboard-banner',
name: splitTestName,
content: contentLookup[variant],
type: 'pause',
})
setShowModal(false)
const until = new Date()
until.setDate(until.getDate() + 1) // 1 day
setDismissedUntil(until)
}, [setDismissedUntil, splitTestName, variant])
if (dismissedUntil && new Date(dismissedUntil) > new Date()) {
return null
}
if (variant === 'default') {
return (
<Notification bsStyle="info" onDismiss={() => setDismissedAt(new Date())}>
<Notification bsStyle="info" onDismiss={bannerDismissed}>
<Notification.Body>
<Trans
i18nKey="inr_discount_offer"
@ -64,4 +115,58 @@ export default function INRBanner() {
</Notification.Action>
</Notification>
)
} else if (variant === 'green-banner') {
return (
<Notification bsStyle="success" onDismiss={bannerDismissed}>
<Notification.Body>
<Trans
i18nKey="inr_discount_offer_green_banner"
components={[<b />, <br />]} // eslint-disable-line react/jsx-key
values={{ flag: '🇮🇳' }}
/>
</Notification.Body>
<Notification.Action>
<Button
bsStyle="success"
className="pull-right"
onClick={handleClick}
>
{t('get_discounted_plan')}
</Button>
</Notification.Action>
</Notification>
)
} else if (variant === 'modal') {
return (
<AccessibleModal show={showModal} onHide={handleHide}>
<Modal.Header closeButton>
<Modal.Title>{t('inr_discount_modal_title')}</Modal.Title>
</Modal.Header>
<Modal.Body className="modal-body-share">
<p>
<img
alt={t('inr_discount_modal_title')}
src="/img/subscriptions/inr-discount-modal.png"
style={{
width: '100%',
}}
/>
</p>
<p>
<Trans i18nKey="inr_discount_modal_info" />
</p>
</Modal.Body>
<Modal.Footer>
<Button bsStyle="default" onClick={handleMaybeLater}>
{t('maybe_later')}
</Button>
<Button type="button" bsStyle="primary" onClick={handleClick}>
{t('get_discounted_plan')}
</Button>
</Modal.Footer>
</AccessibleModal>
)
} else {
return null
}
}

View file

@ -28,7 +28,12 @@ function UserNotifications() {
'ol-groupSubscriptionsPendingEnrollment',
[]
)
const showIRNBanner = getMeta('ol-showINRBanner', false)
const showInrGeoBanner = getMeta('ol-showInrGeoBanner', false)
const inrGeoBannerVariant = getMeta('ol-inrGeoBannerVariant', 'default')
const inrGeoBannerSplitTestName = getMeta(
'ol-inrGeoBannerSplitTestName',
'unassigned'
)
const showLATAMBanner = getMeta('ol-showLATAMBanner', false)
return (
@ -48,8 +53,11 @@ function UserNotifications() {
<ReconfirmationInfo />
{showLATAMBanner ? (
<LATAMBanner />
) : showIRNBanner ? (
<INRBanner />
) : showInrGeoBanner ? (
<INRBanner
variant={inrGeoBannerVariant}
splitTestName={inrGeoBannerSplitTestName}
/>
) : (
<GroupsAndEnterpriseBanner />
)}

View file

@ -765,7 +765,11 @@
"increased_compile_timeout": "Increased compile timeout",
"indvidual_plans": "Individual Plans",
"info": "Info",
"inr_discount_modal_info": "Get document history, track changes, additional collaborators, and more at Purchasing Power Parity prices.",
"inr_discount_modal_title": "70% off all Overleaf premium plans for users in India",
"inr_discount_offer": "Good news! You can now use Rupees ₹ to pay for an Overleaf subscription, giving you a <0>70% discount</0> on our premium features.",
"inr_discount_offer_green_banner": "__flag__ <0>Big news!</0> You qualify for a <0>70% discount</0> on our premium plans because youre in India. <br />Get additional collaborators, document history, track changes, and more.",
"inr_discount_offer_plans_page_banner": "__flag__ <b>Great news!</b> Because youre in India, you can get any Overleaf premium plan at a <b>70% discount</b>.",
"insert_figure": "Insert figure",
"insert_from_another_project": "Insert from another project",
"insert_from_project_files": "Insert from project files",
@ -976,6 +980,7 @@
"max_collab_per_project_info": "The number of people you can invite to work on each project. They just need to have an Overleaf account. They can be different people in each project.",
"maximum_files_uploaded_together": "Maximum __max__ files uploaded together",
"may": "May",
"maybe_later": "Maybe later",
"members_management": "Members management",
"mendeley": "Mendeley",
"mendeley_groups_loading_error": "There was an error loading groups from Mendeley",

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB