mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
8efac32c8a
commit
c1ec3044d7
12 changed files with 239 additions and 25 deletions
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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' })
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -11,6 +11,11 @@ export const currencies = <const>{
|
|||
CHF: 'Fr',
|
||||
SGD: '$',
|
||||
INR: '₹',
|
||||
BRL: 'R$',
|
||||
MXN: '$',
|
||||
COP: '$',
|
||||
CLP: '$',
|
||||
PEN: 'S/',
|
||||
}
|
||||
|
||||
type Currency = typeof currencies
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue