[web] INR Geo pricing test (#12976)

* Implement INR geopricing test

* Show again the INR banner after 30 days

* Update INRBanner to direct users to the plans page and add tracking

* Remove local testing hack in GeoIpLookup

* Update formatter for subscription dashboard

* Flip assignment to assign all users and add event segmentations

* Fix linting

* Review suggestions - factorised recommended currency helper function

GitOrigin-RevId: b1616520f8c7ead689a840720057297e67d3f574
This commit is contained in:
Alexandre Bourdin 2023-05-10 11:24:33 +03:00 committed by Copybot
parent acfeafd276
commit caeceba28c
10 changed files with 177 additions and 37 deletions

View file

@ -21,6 +21,7 @@ const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandl
const UserController = require('../User/UserController') const UserController = require('../User/UserController')
const LimitationsManager = require('../Subscription/LimitationsManager') const LimitationsManager = require('../Subscription/LimitationsManager')
const NotificationsBuilder = require('../Notifications/NotificationsBuilder') const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
const GeoIpLookup = require('../../infrastructure/GeoIpLookup')
const SplitTestHandler = require('../SplitTests/SplitTestHandler') const SplitTestHandler = require('../SplitTests/SplitTestHandler')
/** @typedef {import("./types").GetProjectsRequest} GetProjectsRequest */ /** @typedef {import("./types").GetProjectsRequest} GetProjectsRequest */
@ -337,6 +338,27 @@ async function projectListPage(req, res, next) {
} }
} }
let showINRBanner = false
if (usersBestSubscription?.type === 'free') {
try {
const inrGeoPricingAssignment =
await SplitTestHandler.promises.getAssignment(
req,
res,
'geo-pricing-inr'
)
const geoDetails = await GeoIpLookup.promises.getDetails(req.ip)
showINRBanner =
inrGeoPricingAssignment.variant === 'inr' &&
geoDetails?.country_code === 'IN'
} catch (error) {
logger.error(
{ err: error },
'Failed to get INR geo pricing lookup or assignment'
)
}
}
res.render('project/list-react', { res.render('project/list-react', {
title: 'your_projects', title: 'your_projects',
usersBestSubscription, usersBestSubscription,
@ -354,6 +376,7 @@ async function projectListPage(req, res, next) {
showGroupsAndEnterpriseBanner, showGroupsAndEnterpriseBanner,
groupsAndEnterpriseBannerVariant, groupsAndEnterpriseBannerVariant,
showWritefullPromoBanner, showWritefullPromoBanner,
showINRBanner,
projectDashboardReact: true, // used in navbar projectDashboardReact: true, // used in navbar
}) })
} }

View file

@ -34,18 +34,15 @@ const validGroupPlanModalOptions = {
async function plansPage(req, res) { async function plansPage(req, res) {
const plans = SubscriptionViewModelBuilder.buildPlansList() const plans = SubscriptionViewModelBuilder.buildPlansList()
let recommendedCurrency let currency = null
if (req.query.currency) { const queryCurrency = req.query.currency?.toUpperCase()
const queryCurrency = req.query.currency.toUpperCase() if (GeoIpLookup.isValidCurrencyParam(queryCurrency)) {
if (GeoIpLookup.isValidCurrencyParam(queryCurrency)) { currency = queryCurrency
recommendedCurrency = queryCurrency
}
} }
if (!recommendedCurrency) { const { recommendedCurrency, countryCode, geoPricingTestVariant } =
const currencyLookup = await GeoIpLookup.promises.getCurrencyCode( await _getRecommendedCurrency(req, res)
(req.query ? req.query.ip : undefined) || req.ip if (recommendedCurrency && currency == null) {
) currency = recommendedCurrency
recommendedCurrency = currencyLookup.currencyCode
} }
function getDefault(param, category, defaultValue) { function getDefault(param, category, defaultValue) {
@ -59,8 +56,8 @@ async function plansPage(req, res) {
const currentView = 'annual' const currentView = 'annual'
let defaultGroupPlanModalCurrency = 'USD' let defaultGroupPlanModalCurrency = 'USD'
if (validGroupPlanModalOptions.currency.includes(recommendedCurrency)) { if (validGroupPlanModalOptions.currency.includes(currency)) {
defaultGroupPlanModalCurrency = recommendedCurrency defaultGroupPlanModalCurrency = currency
} }
const groupPlanModalDefaults = { const groupPlanModalDefaults = {
plan_code: getDefault('plan', 'plan_code', 'collaborator'), plan_code: getDefault('plan', 'plan_code', 'collaborator'),
@ -70,7 +67,10 @@ async function plansPage(req, res) {
} }
AnalyticsManager.recordEventForSession(req.session, 'plans-page-view', { AnalyticsManager.recordEventForSession(req.session, 'plans-page-view', {
currency: recommendedCurrency, currency,
countryCode,
'geo-pricing-inr-group': geoPricingTestVariant,
'geo-pricing-inr-page': currency === 'INR' ? 'inr' : 'default',
}) })
res.render('subscriptions/plans-marketing-v2', { res.render('subscriptions/plans-marketing-v2', {
@ -80,16 +80,14 @@ async function plansPage(req, res) {
itm_content: req.query?.itm_content, itm_content: req.query?.itm_content,
itm_referrer: req.query?.itm_referrer, itm_referrer: req.query?.itm_referrer,
itm_campaign: 'plans', itm_campaign: 'plans',
recommendedCurrency, recommendedCurrency: currency,
planFeatures, planFeatures,
plansV2Config, plansV2Config,
groupPlans: GroupPlansData, groupPlans: GroupPlansData,
groupPlanModalOptions, groupPlanModalOptions,
groupPlanModalDefaults, groupPlanModalDefaults,
initialLocalizedGroupPrice: initialLocalizedGroupPrice:
SubscriptionHelper.generateInitialLocalizedGroupPrice( SubscriptionHelper.generateInitialLocalizedGroupPrice(currency),
recommendedCurrency
),
}) })
} }
@ -147,10 +145,8 @@ async function _paymentReactPage(req, res) {
currency = queryCurrency currency = queryCurrency
} }
} }
const { currencyCode: recommendedCurrency, countryCode } = const { recommendedCurrency, countryCode } =
await GeoIpLookup.promises.getCurrencyCode( await _getRecommendedCurrency(req, res)
(req.query ? req.query.ip : undefined) || req.ip
)
if (recommendedCurrency && currency == null) { if (recommendedCurrency && currency == null) {
currency = recommendedCurrency currency = recommendedCurrency
} }
@ -205,9 +201,7 @@ async function _paymentAngularPage(req, res) {
} }
} }
const { currencyCode: recommendedCurrency, countryCode } = const { currencyCode: recommendedCurrency, countryCode } =
await GeoIpLookup.promises.getCurrencyCode( await GeoIpLookup.promises.getCurrencyCode(req.query?.ip || req.ip)
(req.query ? req.query.ip : undefined) || req.ip
)
if (recommendedCurrency && currency == null) { if (recommendedCurrency && currency == null) {
currency = recommendedCurrency currency = recommendedCurrency
} }
@ -378,10 +372,8 @@ async function _userSubscriptionAngularPage(req, res) {
async function interstitialPaymentPage(req, res) { async function interstitialPaymentPage(req, res) {
const user = SessionManager.getSessionUser(req.session) const user = SessionManager.getSessionUser(req.session)
const { currencyCode: recommendedCurrency } = const { recommendedCurrency, countryCode, geoPricingTestVariant } =
await GeoIpLookup.promises.getCurrencyCode( await _getRecommendedCurrency(req, res)
(req.query ? req.query.ip : undefined) || req.ip
)
const hasSubscription = const hasSubscription =
await LimitationsManager.promises.userHasV1OrV2Subscription(user) await LimitationsManager.promises.userHasV1OrV2Subscription(user)
@ -391,9 +383,21 @@ async function interstitialPaymentPage(req, res) {
if (hasSubscription) { if (hasSubscription) {
res.redirect('/user/subscription?hasSubscription=true') res.redirect('/user/subscription?hasSubscription=true')
} else { } else {
AnalyticsManager.recordEventForSession(
req.session,
'paywall-plans-page-view',
{
currency: recommendedCurrency,
countryCode,
'geo-pricing-inr-group': geoPricingTestVariant,
'geo-pricing-inr-page':
recommendedCurrency === 'INR' ? 'inr' : 'default',
}
)
res.render('subscriptions/interstitial-payment', { res.render('subscriptions/interstitial-payment', {
title: 'subscribe', title: 'subscribe',
itm_content: req.query && req.query.itm_content, itm_content: req.query?.itm_content,
itm_campaign: req.query?.itm_campaign, itm_campaign: req.query?.itm_campaign,
itm_referrer: req.query?.itm_referrer, itm_referrer: req.query?.itm_referrer,
recommendedCurrency, recommendedCurrency,
@ -808,6 +812,38 @@ async function redirectToHostedPage(req, res) {
res.redirect(url) res.redirect(url)
} }
async function _getRecommendedCurrency(req, res) {
const currencyLookup = await GeoIpLookup.promises.getCurrencyCode(
req.query?.ip || req.ip
)
const countryCode = currencyLookup.countryCode
let recommendedCurrency = currencyLookup.currencyCode
let assignment
// for #12703
try {
assignment = await SplitTestHandler.promises.getAssignment(
req,
res,
'geo-pricing-inr'
)
} catch (error) {
logger.error(
{ err: error },
'Failed to get assignment for geo-pricing-inr 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') {
recommendedCurrency = GeoIpLookup.DEFAULT_CURRENCY_CODE
}
return {
recommendedCurrency,
countryCode,
geoPricingTestVariant: assignment.variant,
}
}
module.exports = { module.exports = {
plansPage: expressify(plansPage), plansPage: expressify(plansPage),
paymentPage: expressify(paymentPage), paymentPage: expressify(paymentPage),

View file

@ -1,6 +1,6 @@
const dateformat = require('dateformat') const dateformat = require('dateformat')
const currenySymbols = { const currencySymbols = {
EUR: '€', EUR: '€',
USD: '$', USD: '$',
GBP: '£', GBP: '£',
@ -12,6 +12,7 @@ const currenySymbols = {
NZD: '$', NZD: '$',
CHF: 'Fr', CHF: 'Fr',
SGD: '$', SGD: '$',
INR: '₹',
} }
module.exports = { module.exports = {
@ -31,7 +32,7 @@ module.exports = {
} }
const cents = string.slice(-2) const cents = string.slice(-2)
const dollars = string.slice(0, -2) const dollars = string.slice(0, -2)
const symbol = currenySymbols[currency] const symbol = currencySymbols[currency]
return `${symbol}${dollars}.${cents}` return `${symbol}${dollars}.${cents}`
}, },

View file

@ -5,6 +5,8 @@ const logger = require('@overleaf/logger')
const { URL } = require('url') const { URL } = require('url')
const { promisify, promisifyMultiResult } = require('../util/promises') const { promisify, promisifyMultiResult } = require('../util/promises')
const DEFAULT_CURRENCY_CODE = 'USD'
const currencyMappings = { const currencyMappings = {
GB: 'GBP', GB: 'GBP',
US: 'USD', US: 'USD',
@ -16,9 +18,13 @@ const currencyMappings = {
CA: 'CAD', CA: 'CAD',
SE: 'SEK', SE: 'SEK',
SG: 'SGD', SG: 'SGD',
IN: 'INR',
} }
const validCurrencyParams = Object.values(currencyMappings).concat(['EUR']) const validCurrencyParams = Object.values(currencyMappings).concat([
'EUR',
'INR',
])
// Countries which would likely prefer Euro's // Countries which would likely prefer Euro's
const EuroCountries = [ const EuroCountries = [
@ -82,15 +88,15 @@ function getCurrencyCode(ip, callback) {
if (err || !ipDetails) { if (err || !ipDetails) {
logger.err( logger.err(
{ err, ip }, { err, ip },
'problem getting currencyCode for ip, defaulting to USD' `problem getting currencyCode for ip, defaulting to ${DEFAULT_CURRENCY_CODE}`
) )
return callback(null, 'USD') return callback(null, DEFAULT_CURRENCY_CODE)
} }
const countryCode = const countryCode =
ipDetails && ipDetails.country_code ipDetails && ipDetails.country_code
? ipDetails.country_code.toUpperCase() ? ipDetails.country_code.toUpperCase()
: undefined : undefined
const currencyCode = currencyMappings[countryCode] || 'USD' const currencyCode = currencyMappings[countryCode] || DEFAULT_CURRENCY_CODE
logger.debug({ ip, currencyCode, ipDetails }, 'got currencyCode for ip') logger.debug({ ip, currencyCode, ipDetails }, 'got currencyCode for ip')
callback(err, currencyCode, countryCode) callback(err, currencyCode, countryCode)
}) })
@ -107,4 +113,5 @@ module.exports = {
'countryCode', 'countryCode',
]), ]),
}, },
DEFAULT_CURRENCY_CODE,
} }

View file

@ -28,6 +28,7 @@ block append meta
meta(name="ol-showGroupsAndEnterpriseBanner" data-type="boolean" content=showGroupsAndEnterpriseBanner) meta(name="ol-showGroupsAndEnterpriseBanner" data-type="boolean" content=showGroupsAndEnterpriseBanner)
meta(name="ol-showWritefullPromoBanner" data-type="boolean" content=showWritefullPromoBanner) meta(name="ol-showWritefullPromoBanner" data-type="boolean" content=showWritefullPromoBanner)
meta(name="ol-groupsAndEnterpriseBannerVariant" data-type="string" content=groupsAndEnterpriseBannerVariant) meta(name="ol-groupsAndEnterpriseBannerVariant" data-type="string" content=groupsAndEnterpriseBannerVariant)
meta(name="ol-showINRBanner" data-type="boolean" content=showINRBanner)
block content block content
main.content.content-alt.project-list-react#project-list-root main.content.content-alt.project-list-react#project-list-root

View file

@ -313,6 +313,7 @@
"generic_linked_file_compile_error": "", "generic_linked_file_compile_error": "",
"generic_something_went_wrong": "", "generic_something_went_wrong": "",
"get_collaborative_benefits": "", "get_collaborative_benefits": "",
"get_discounted_plan": "",
"get_most_subscription_by_checking_features": "", "get_most_subscription_by_checking_features": "",
"get_most_subscription_by_checking_premium_features": "", "get_most_subscription_by_checking_premium_features": "",
"git": "", "git": "",
@ -414,6 +415,7 @@
"in_order_to_match_institutional_metadata_2": "", "in_order_to_match_institutional_metadata_2": "",
"in_order_to_match_institutional_metadata_associated": "", "in_order_to_match_institutional_metadata_associated": "",
"increased_compile_timeout": "", "increased_compile_timeout": "",
"inr_discount_offer": "",
"institution": "", "institution": "",
"institution_account": "", "institution_account": "",
"institution_acct_successfully_linked_2": "", "institution_acct_successfully_linked_2": "",

View file

@ -0,0 +1,63 @@
import { useCallback, useEffect } 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'
export default function INRBanner() {
const { t } = useTranslation()
const [dismissedAt, setDismissedAt] = usePersistedState<Date | undefined>(
`has_dismissed_inr_banner`
)
useEffect(() => {
eventTracking.sendMB('paywall-prompt', {
'paywall-type': 'inr-banner',
})
}, [])
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])
const handleClick = useCallback(() => {
eventTracking.sendMB('paywall-click', { 'paywall-type': 'inr-banner' })
window.open('/user/subscription/plans')
}, [])
if (dismissedAt) {
return null
}
return (
<Notification bsStyle="info" onDismiss={() => setDismissedAt(new Date())}>
<Notification.Body>
<Trans
i18nKey="inr_discount_offer"
components={[<b />]} // eslint-disable-line react/jsx-key
/>
</Notification.Body>
<Notification.Action>
<Button
bsStyle="info"
bsSize="sm"
className="pull-right"
onClick={handleClick}
>
{t('get_discounted_plan')}
</Button>
</Notification.Action>
</Notification>
)
}

View file

@ -4,8 +4,12 @@ import ConfirmEmail from './groups/confirm-email'
import ReconfirmationInfo from './groups/affiliation/reconfirmation-info' import ReconfirmationInfo from './groups/affiliation/reconfirmation-info'
import GroupsAndEnterpriseBanner from './groups-and-enterprise-banner' import GroupsAndEnterpriseBanner from './groups-and-enterprise-banner'
import WritefullPromoBanner from './writefull-promo-banner' import WritefullPromoBanner from './writefull-promo-banner'
import INRBanner from './ads/inr-banner'
import getMeta from '../../../../utils/meta'
function UserNotifications() { function UserNotifications() {
const showIRNBanner = getMeta('ol-showINRBanner')
return ( return (
<div className="user-notifications"> <div className="user-notifications">
<ul className="list-unstyled"> <ul className="list-unstyled">
@ -13,7 +17,7 @@ function UserNotifications() {
<Institution /> <Institution />
<ConfirmEmail /> <ConfirmEmail />
<ReconfirmationInfo /> <ReconfirmationInfo />
<GroupsAndEnterpriseBanner /> {showIRNBanner ? <INRBanner /> : <GroupsAndEnterpriseBanner />}
<WritefullPromoBanner /> <WritefullPromoBanner />
</ul> </ul>
</div> </div>

View file

@ -10,6 +10,7 @@ export const currencies = <const>{
NZD: '$', NZD: '$',
CHF: 'Fr', CHF: 'Fr',
SGD: '$', SGD: '$',
INR: '₹',
} }
type Currency = typeof currencies type Currency = typeof currencies

View file

@ -566,6 +566,7 @@
"generic_linked_file_compile_error": "This projects output files are not available because it failed to compile. Please open the project to see the compilation error details.", "generic_linked_file_compile_error": "This projects output files are not available because it failed to compile. Please open the project to see the compilation error details.",
"generic_something_went_wrong": "Sorry, something went wrong", "generic_something_went_wrong": "Sorry, something went wrong",
"get_collaborative_benefits": "Get the collaborative benefits from __appName__, even if you prefer to work offline", "get_collaborative_benefits": "Get the collaborative benefits from __appName__, even if you prefer to work offline",
"get_discounted_plan": "Get discounted plan",
"get_in_touch_having_problems": "<a href=\"__link__\">Get in touch with support</a> if youre having problems", "get_in_touch_having_problems": "<a href=\"__link__\">Get in touch with support</a> if youre having problems",
"get_involved": "Get involved", "get_involved": "Get involved",
"get_most_subscription_by_checking_features": "Get the most out of your __appName__ subscription by checking out <0>__appName__s features</0>.", "get_most_subscription_by_checking_features": "Get the most out of your __appName__ subscription by checking out <0>__appName__s features</0>.",
@ -710,6 +711,7 @@
"increased_compile_timeout": "Increased compile timeout", "increased_compile_timeout": "Increased compile timeout",
"indvidual_plans": "Individual Plans", "indvidual_plans": "Individual Plans",
"info": "Info", "info": "Info",
"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.",
"institution": "Institution", "institution": "Institution",
"institution_account": "Institution Account", "institution_account": "Institution Account",
"institution_account_tried_to_add_affiliated_with_another_institution": "This email is <b>already associated</b> with your account but affiliated with another institution.", "institution_account_tried_to_add_affiliated_with_another_institution": "This email is <b>already associated</b> with your account but affiliated with another institution.",