Add geo-pricing split test for enabling LATAM currencies (#13663)

* Implement LATAM geo-pricing split test

* Hide Paypal if currency is one of INR, COP, CLP, PEN

* Only send the LATAM/INR banner events when banner is rendered

* Workaround in Subscription dashboard for CLP not having minor units

GitOrigin-RevId: a677086a7762900563558126d2f81a4c57bbe9d7
This commit is contained in:
Thomas 2023-07-11 10:40:33 +02:00 committed by Copybot
parent 8efac32c8a
commit c1ec3044d7
12 changed files with 239 additions and 25 deletions

View file

@ -364,7 +364,11 @@ async function projectListPage(req, res, next) {
}
let showINRBanner = false
let showLATAMBanner = false
let recommendedCurrency
if (usersBestSubscription?.type === 'free') {
const { currencyCode, countryCode } =
await GeoIpLookup.promises.getCurrencyCode(req.ip)
try {
const inrGeoPricingAssignment =
await SplitTestHandler.promises.getAssignment(
@ -372,14 +376,32 @@ async function projectListPage(req, res, next) {
res,
'geo-pricing-inr'
)
const geoDetails = await GeoIpLookup.promises.getDetails(req.ip)
showINRBanner =
inrGeoPricingAssignment.variant === 'inr' &&
geoDetails?.country_code === 'IN'
inrGeoPricingAssignment.variant === 'inr' && countryCode === 'IN'
} catch (error) {
logger.error(
{ err: error },
'Failed to get INR geo pricing lookup or assignment'
'Failed to get geo-pricing-inr split test assignment'
)
}
try {
const latamGeoPricingAssignment =
await SplitTestHandler.promises.getAssignment(
req,
res,
'geo-pricing-latam'
)
showLATAMBanner =
latamGeoPricingAssignment.variant === 'latam' &&
['BR', 'MX', 'CO', 'CL', 'PE'].includes(countryCode)
// LATAM Banner needs to know which currency to display
if (showLATAMBanner) {
recommendedCurrency = currencyCode
}
} catch (error) {
logger.error(
{ err: error },
'Failed to get geo-pricing-latam split test assignment'
)
}
}
@ -402,6 +424,8 @@ async function projectListPage(req, res, next) {
groupsAndEnterpriseBannerVariant,
showWritefullPromoBanner,
showINRBanner,
showLATAMBanner,
recommendedCurrency,
projectDashboardReact: true, // used in navbar
welcomePageRedesignVariant: welcomePageRedesignAssignment.variant,
groupSubscriptionsPendingEnrollment:

View file

@ -58,8 +58,12 @@ async function plansPage(req, res) {
if (GeoIpLookup.isValidCurrencyParam(queryCurrency)) {
currency = queryCurrency
}
const { recommendedCurrency, countryCode, geoPricingTestVariant } =
await _getRecommendedCurrency(req, res)
const {
recommendedCurrency,
countryCode,
geoPricingINRTestVariant,
geoPricingLATAMTestVariant,
} = await _getRecommendedCurrency(req, res)
if (recommendedCurrency && currency == null) {
currency = recommendedCurrency
}
@ -108,8 +112,14 @@ async function plansPage(req, res) {
currency: recommendedCurrency,
'remove-personal-plan-page': removePersonalPlanAssingment?.variant,
countryCode,
'geo-pricing-inr-group': geoPricingTestVariant,
'geo-pricing-inr-group': geoPricingINRTestVariant,
'geo-pricing-inr-page': currency === 'INR' ? 'inr' : 'default',
'geo-pricing-latam-group': geoPricingLATAMTestVariant,
'geo-pricing-latam-page': ['BRL', 'MXN', 'COP', 'CLP', 'PEN'].includes(
currency
)
? 'latam'
: 'default',
})
res.render(`subscriptions/plans-marketing/${directory}/plans-marketing-v2`, {
@ -264,8 +274,12 @@ async function userSubscriptionPage(req, res) {
async function interstitialPaymentPage(req, res) {
const user = SessionManager.getSessionUser(req.session)
const { recommendedCurrency, countryCode, geoPricingTestVariant } =
await _getRecommendedCurrency(req, res)
const {
recommendedCurrency,
countryCode,
geoPricingINRTestVariant,
geoPricingLATAMTestVariant,
} = await _getRecommendedCurrency(req, res)
const hasSubscription =
await LimitationsManager.promises.userHasV1OrV2Subscription(user)
@ -300,9 +314,15 @@ async function interstitialPaymentPage(req, res) {
{
currency: recommendedCurrency,
countryCode,
'geo-pricing-inr-group': geoPricingTestVariant,
'geo-pricing-inr-group': geoPricingINRTestVariant,
'geo-pricing-inr-page':
recommendedCurrency === 'INR' ? 'inr' : 'default',
'geo-pricing-latam-group': geoPricingLATAMTestVariant,
'geo-pricing-latam-page': ['BRL', 'MXN', 'COP', 'CLP', 'PEN'].includes(
recommendedCurrency
)
? 'latam'
: 'default',
'remove-personal-plan-page': removePersonalPlanAssingment?.variant,
}
)
@ -665,10 +685,10 @@ async function _getRecommendedCurrency(req, res) {
)
const countryCode = currencyLookup.countryCode
let recommendedCurrency = currencyLookup.currencyCode
let assignment
let assignmentINR, assignmentLATAM
// for #12703
try {
assignment = await SplitTestHandler.promises.getAssignment(
assignmentINR = await SplitTestHandler.promises.getAssignment(
req,
res,
'geo-pricing-inr'
@ -679,15 +699,35 @@ async function _getRecommendedCurrency(req, res) {
'Failed to get assignment for geo-pricing-inr test'
)
}
// for #13559
try {
assignmentLATAM = await SplitTestHandler.promises.getAssignment(
req,
res,
'geo-pricing-latam'
)
} catch (error) {
logger.error(
{ err: error },
'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' && assignment?.variant !== 'inr') {
if (recommendedCurrency === 'INR' && assignmentINR?.variant !== 'inr') {
recommendedCurrency = GeoIpLookup.DEFAULT_CURRENCY_CODE
}
if (
['BRL', 'MXN', 'COP', 'CLP', 'PEN'].includes(recommendedCurrency) &&
assignmentLATAM?.variant !== 'latam'
) {
recommendedCurrency = GeoIpLookup.DEFAULT_CURRENCY_CODE
}
return {
recommendedCurrency,
countryCode,
geoPricingTestVariant: assignment?.variant,
geoPricingINRTestVariant: assignmentINR?.variant,
geoPricingLATAMTestVariant: assignmentLATAM?.variant,
}
}

View file

@ -13,12 +13,24 @@ const currencySymbols = {
CHF: 'Fr',
SGD: '$',
INR: '₹',
BRL: 'R$',
MXN: '$',
COP: '$',
CLP: '$',
PEN: 'S/',
}
module.exports = {
formatPrice(priceInCents, currency) {
if (!currency) {
currency = 'USD'
} else if (currency === 'CLP') {
// CLP doesn't have minor units, recurly stores the whole major unit without cents
return priceInCents.toLocaleString('es-CL', {
style: 'currency',
currency,
minimumFractionDigits: 0,
})
}
let string = String(Math.round(priceInCents))
if (string.length === 2) {

View file

@ -19,11 +19,21 @@ const currencyMappings = {
SE: 'SEK',
SG: 'SGD',
IN: 'INR',
BR: 'BRL',
MX: 'MXN',
CO: 'COP',
CL: 'CLP',
PE: 'PEN',
}
const validCurrencyParams = Object.values(currencyMappings).concat([
'EUR',
'INR',
'BRL',
'MXN',
'COP',
'CLP',
'PEN',
])
// Countries which would likely prefer Euro's

View file

@ -29,6 +29,8 @@ block append meta
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-showLATAMBanner" data-type="boolean" content=showLATAMBanner)
meta(name="ol-recommendedCurrency" data-type="string" content=recommendedCurrency)
meta(name="ol-welcomePageRedesignVariant" data-type="string" content=welcomePageRedesignVariant)
meta(name="ol-groupSubscriptionsPendingEnrollment" data-type="json" content=groupSubscriptionsPendingEnrollment)

View file

@ -528,6 +528,7 @@
"last_resort_trouble_shooting_guide": "",
"last_updated_date_by_x": "",
"last_used": "",
"latam_discount_offer": "",
"latex_help_guide": "",
"latex_places_figures_according_to_a_special_algorithm": "",
"layout": "",

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import usePersistedState from '../../../../../shared/hooks/use-persisted-state'
import Notification from '../notification'
@ -10,12 +10,7 @@ export default function INRBanner() {
const [dismissedAt, setDismissedAt] = usePersistedState<Date | undefined>(
`has_dismissed_inr_banner`
)
useEffect(() => {
eventTracking.sendMB('paywall-prompt', {
'paywall-type': 'inr-banner',
})
}, [])
const viewEventSent = useRef<boolean>(false)
useEffect(() => {
if (!dismissedAt) {
@ -30,6 +25,15 @@ export default function INRBanner() {
}
}, [dismissedAt, setDismissedAt])
useEffect(() => {
if (!dismissedAt && !viewEventSent.current) {
eventTracking.sendMB('paywall-prompt', {
'paywall-type': 'inr-banner',
})
viewEventSent.current = true
}
}, [dismissedAt])
const handleClick = useCallback(() => {
eventTracking.sendMB('paywall-click', { 'paywall-type': 'inr-banner' })

View file

@ -0,0 +1,106 @@
import { useCallback, useEffect, useRef } 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 getMeta from '../../../../../utils/meta'
const LATAM_CURRENCIES = {
BRL: { name: 'Reais', discount: '50', flag: '🇧🇷' },
MXN: { name: 'Pesos', discount: '40', flag: '🇲🇽' },
COP: { name: 'Pesos', discount: '70', flag: '🇨🇴' },
CLP: { name: 'Pesos', discount: '45', flag: '🇨🇱' },
PEN: { name: 'Soles', discount: '50', flag: '🇵🇪' },
}
export default function LATAMBanner() {
const { t } = useTranslation()
const [dismissedAt, setDismissedAt] = usePersistedState<Date | undefined>(
`has_dismissed_latam_banner`
)
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])
useEffect(() => {
if (!dismissedAt && !viewEventSent.current) {
eventTracking.sendMB('promo-prompt', {
location: 'dashboard-banner',
name: 'geo-pricing-latam',
content: 'blue',
})
viewEventSent.current = true
}
}, [dismissedAt])
const handleClick = useCallback(() => {
eventTracking.sendMB('promo-click', {
location: 'dashboard-banner',
name: 'geo-pricing-latam',
content: 'blue',
type: 'click',
})
window.open('/user/subscription/plans')
}, [])
const handleDismiss = useCallback(() => {
eventTracking.sendMB('promo-dismiss', {
location: 'dashboard-banner',
name: 'geo-pricing-latam',
content: 'blue',
})
setDismissedAt(new Date())
}, [setDismissedAt])
if (dismissedAt) {
return null
}
// Safety, but should always be a valid LATAM currency if ol-showLATAMBanner is true
const currency = getMeta('ol-recommendedCurrency')
if (!(currency in LATAM_CURRENCIES)) {
return null
}
const {
flag,
name: currencyName,
discount: discountPercent,
} = LATAM_CURRENCIES[currency as keyof typeof LATAM_CURRENCIES]
return (
<Notification bsStyle="info" onDismiss={() => handleDismiss()}>
<Notification.Body>
<Trans
i18nKey="latam_discount_offer"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{ flag, currencyName, discountPercent }}
/>
</Notification.Body>
<Notification.Action>
<Button
bsStyle="info"
bsSize="sm"
className="pull-right"
onClick={handleClick}
>
{t('get_discounted_plan')}
</Button>
</Notification.Action>
</Notification>
)
}

View file

@ -6,6 +6,7 @@ import ReconfirmationInfo from './groups/affiliation/reconfirmation-info'
import GroupsAndEnterpriseBanner from './groups-and-enterprise-banner'
import WritefullPromoBanner from './writefull-promo-banner'
import INRBanner from './ads/inr-banner'
import LATAMBanner from './ads/latam-banner'
import getMeta from '../../../../utils/meta'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
@ -23,11 +24,12 @@ const EnrollmentNotification: JSXElementConstructor<{
}> = enrollmentNotificationModule?.import.default
function UserNotifications() {
const showIRNBanner = getMeta('ol-showINRBanner', false)
const groupSubscriptionsPendingEnrollment: Subscription[] = getMeta(
'ol-groupSubscriptionsPendingEnrollment',
[]
)
const showIRNBanner = getMeta('ol-showINRBanner', false)
const showLATAMBanner = getMeta('ol-showLATAMBanner', false)
return (
<div className="user-notifications">
@ -44,7 +46,13 @@ function UserNotifications() {
<Institution />
<ConfirmEmail />
<ReconfirmationInfo />
{showIRNBanner ? <INRBanner /> : <GroupsAndEnterpriseBanner />}
{showLATAMBanner ? (
<LATAMBanner />
) : showIRNBanner ? (
<INRBanner />
) : (
<GroupsAndEnterpriseBanner />
)}
<WritefullPromoBanner />
</ul>
</div>

View file

@ -215,7 +215,8 @@ function CheckoutPanel() {
setCardIsValid(state.valid)
}, [])
if (currencyCode === 'INR' && paymentMethod !== 'credit_card') {
const hidePaypal = ['INR', 'COP', 'CLP', 'PEN'].includes(currencyCode)
if (hidePaypal && paymentMethod !== 'credit_card') {
setPaymentMethod('credit_card')
}
@ -326,7 +327,7 @@ function CheckoutPanel() {
<strong>{couponError}</strong>
</Alert>
)}
{currencyCode === 'INR' ? null : (
{hidePaypal ? null : (
<PaymentMethodToggle
onChange={handlePaymentMethod}
paymentMethod={paymentMethod}

View file

@ -11,6 +11,11 @@ export const currencies = <const>{
CHF: 'Fr',
SGD: '$',
INR: '₹',
BRL: 'R$',
MXN: '$',
COP: '$',
CLP: '$',
PEN: 'S/',
}
type Currency = typeof currencies

View file

@ -847,6 +847,7 @@
"last_updated": "Last Updated",
"last_updated_date_by_x": "__lastUpdatedDate__ by __person__",
"last_used": "last used",
"latam_discount_offer": "__flag__ Good news! You can now use __currencyName__ to pay for an Overleaf subscription, giving you a <0>__discountPercent__% discount</0> on our premium features.",
"latex_editor_info": "Everything you need in a modern LaTeX editor --- spell check, intelligent autocomplete, syntax highlighting, dozens of color themes, vim and emacs bindings, help with LaTeX warnings and error messages, and much more.",
"latex_guides": "LaTeX guides",
"latex_help_guide": "LaTeX help guide",