mirror of
https://github.com/overleaf/overleaf.git
synced 2025-02-02 23:31:35 +00:00
[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:
parent
acfeafd276
commit
caeceba28c
10 changed files with 177 additions and 37 deletions
|
@ -21,6 +21,7 @@ const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandl
|
|||
const UserController = require('../User/UserController')
|
||||
const LimitationsManager = require('../Subscription/LimitationsManager')
|
||||
const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
|
||||
const GeoIpLookup = require('../../infrastructure/GeoIpLookup')
|
||||
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
|
||||
|
||||
/** @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', {
|
||||
title: 'your_projects',
|
||||
usersBestSubscription,
|
||||
|
@ -354,6 +376,7 @@ async function projectListPage(req, res, next) {
|
|||
showGroupsAndEnterpriseBanner,
|
||||
groupsAndEnterpriseBannerVariant,
|
||||
showWritefullPromoBanner,
|
||||
showINRBanner,
|
||||
projectDashboardReact: true, // used in navbar
|
||||
})
|
||||
}
|
||||
|
|
|
@ -34,18 +34,15 @@ const validGroupPlanModalOptions = {
|
|||
async function plansPage(req, res) {
|
||||
const plans = SubscriptionViewModelBuilder.buildPlansList()
|
||||
|
||||
let recommendedCurrency
|
||||
if (req.query.currency) {
|
||||
const queryCurrency = req.query.currency.toUpperCase()
|
||||
if (GeoIpLookup.isValidCurrencyParam(queryCurrency)) {
|
||||
recommendedCurrency = queryCurrency
|
||||
}
|
||||
let currency = null
|
||||
const queryCurrency = req.query.currency?.toUpperCase()
|
||||
if (GeoIpLookup.isValidCurrencyParam(queryCurrency)) {
|
||||
currency = queryCurrency
|
||||
}
|
||||
if (!recommendedCurrency) {
|
||||
const currencyLookup = await GeoIpLookup.promises.getCurrencyCode(
|
||||
(req.query ? req.query.ip : undefined) || req.ip
|
||||
)
|
||||
recommendedCurrency = currencyLookup.currencyCode
|
||||
const { recommendedCurrency, countryCode, geoPricingTestVariant } =
|
||||
await _getRecommendedCurrency(req, res)
|
||||
if (recommendedCurrency && currency == null) {
|
||||
currency = recommendedCurrency
|
||||
}
|
||||
|
||||
function getDefault(param, category, defaultValue) {
|
||||
|
@ -59,8 +56,8 @@ async function plansPage(req, res) {
|
|||
const currentView = 'annual'
|
||||
|
||||
let defaultGroupPlanModalCurrency = 'USD'
|
||||
if (validGroupPlanModalOptions.currency.includes(recommendedCurrency)) {
|
||||
defaultGroupPlanModalCurrency = recommendedCurrency
|
||||
if (validGroupPlanModalOptions.currency.includes(currency)) {
|
||||
defaultGroupPlanModalCurrency = currency
|
||||
}
|
||||
const groupPlanModalDefaults = {
|
||||
plan_code: getDefault('plan', 'plan_code', 'collaborator'),
|
||||
|
@ -70,7 +67,10 @@ async function plansPage(req, res) {
|
|||
}
|
||||
|
||||
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', {
|
||||
|
@ -80,16 +80,14 @@ async function plansPage(req, res) {
|
|||
itm_content: req.query?.itm_content,
|
||||
itm_referrer: req.query?.itm_referrer,
|
||||
itm_campaign: 'plans',
|
||||
recommendedCurrency,
|
||||
recommendedCurrency: currency,
|
||||
planFeatures,
|
||||
plansV2Config,
|
||||
groupPlans: GroupPlansData,
|
||||
groupPlanModalOptions,
|
||||
groupPlanModalDefaults,
|
||||
initialLocalizedGroupPrice:
|
||||
SubscriptionHelper.generateInitialLocalizedGroupPrice(
|
||||
recommendedCurrency
|
||||
),
|
||||
SubscriptionHelper.generateInitialLocalizedGroupPrice(currency),
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -147,10 +145,8 @@ async function _paymentReactPage(req, res) {
|
|||
currency = queryCurrency
|
||||
}
|
||||
}
|
||||
const { currencyCode: recommendedCurrency, countryCode } =
|
||||
await GeoIpLookup.promises.getCurrencyCode(
|
||||
(req.query ? req.query.ip : undefined) || req.ip
|
||||
)
|
||||
const { recommendedCurrency, countryCode } =
|
||||
await _getRecommendedCurrency(req, res)
|
||||
if (recommendedCurrency && currency == null) {
|
||||
currency = recommendedCurrency
|
||||
}
|
||||
|
@ -205,9 +201,7 @@ async function _paymentAngularPage(req, res) {
|
|||
}
|
||||
}
|
||||
const { currencyCode: recommendedCurrency, countryCode } =
|
||||
await GeoIpLookup.promises.getCurrencyCode(
|
||||
(req.query ? req.query.ip : undefined) || req.ip
|
||||
)
|
||||
await GeoIpLookup.promises.getCurrencyCode(req.query?.ip || req.ip)
|
||||
if (recommendedCurrency && currency == null) {
|
||||
currency = recommendedCurrency
|
||||
}
|
||||
|
@ -378,10 +372,8 @@ async function _userSubscriptionAngularPage(req, res) {
|
|||
|
||||
async function interstitialPaymentPage(req, res) {
|
||||
const user = SessionManager.getSessionUser(req.session)
|
||||
const { currencyCode: recommendedCurrency } =
|
||||
await GeoIpLookup.promises.getCurrencyCode(
|
||||
(req.query ? req.query.ip : undefined) || req.ip
|
||||
)
|
||||
const { recommendedCurrency, countryCode, geoPricingTestVariant } =
|
||||
await _getRecommendedCurrency(req, res)
|
||||
|
||||
const hasSubscription =
|
||||
await LimitationsManager.promises.userHasV1OrV2Subscription(user)
|
||||
|
@ -391,9 +383,21 @@ async function interstitialPaymentPage(req, res) {
|
|||
if (hasSubscription) {
|
||||
res.redirect('/user/subscription?hasSubscription=true')
|
||||
} 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', {
|
||||
title: 'subscribe',
|
||||
itm_content: req.query && req.query.itm_content,
|
||||
itm_content: req.query?.itm_content,
|
||||
itm_campaign: req.query?.itm_campaign,
|
||||
itm_referrer: req.query?.itm_referrer,
|
||||
recommendedCurrency,
|
||||
|
@ -808,6 +812,38 @@ async function redirectToHostedPage(req, res) {
|
|||
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 = {
|
||||
plansPage: expressify(plansPage),
|
||||
paymentPage: expressify(paymentPage),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const dateformat = require('dateformat')
|
||||
|
||||
const currenySymbols = {
|
||||
const currencySymbols = {
|
||||
EUR: '€',
|
||||
USD: '$',
|
||||
GBP: '£',
|
||||
|
@ -12,6 +12,7 @@ const currenySymbols = {
|
|||
NZD: '$',
|
||||
CHF: 'Fr',
|
||||
SGD: '$',
|
||||
INR: '₹',
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
@ -31,7 +32,7 @@ module.exports = {
|
|||
}
|
||||
const cents = string.slice(-2)
|
||||
const dollars = string.slice(0, -2)
|
||||
const symbol = currenySymbols[currency]
|
||||
const symbol = currencySymbols[currency]
|
||||
return `${symbol}${dollars}.${cents}`
|
||||
},
|
||||
|
||||
|
|
|
@ -5,6 +5,8 @@ const logger = require('@overleaf/logger')
|
|||
const { URL } = require('url')
|
||||
const { promisify, promisifyMultiResult } = require('../util/promises')
|
||||
|
||||
const DEFAULT_CURRENCY_CODE = 'USD'
|
||||
|
||||
const currencyMappings = {
|
||||
GB: 'GBP',
|
||||
US: 'USD',
|
||||
|
@ -16,9 +18,13 @@ const currencyMappings = {
|
|||
CA: 'CAD',
|
||||
SE: 'SEK',
|
||||
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
|
||||
const EuroCountries = [
|
||||
|
@ -82,15 +88,15 @@ function getCurrencyCode(ip, callback) {
|
|||
if (err || !ipDetails) {
|
||||
logger.err(
|
||||
{ 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 =
|
||||
ipDetails && ipDetails.country_code
|
||||
? ipDetails.country_code.toUpperCase()
|
||||
: undefined
|
||||
const currencyCode = currencyMappings[countryCode] || 'USD'
|
||||
const currencyCode = currencyMappings[countryCode] || DEFAULT_CURRENCY_CODE
|
||||
logger.debug({ ip, currencyCode, ipDetails }, 'got currencyCode for ip')
|
||||
callback(err, currencyCode, countryCode)
|
||||
})
|
||||
|
@ -107,4 +113,5 @@ module.exports = {
|
|||
'countryCode',
|
||||
]),
|
||||
},
|
||||
DEFAULT_CURRENCY_CODE,
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ 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)
|
||||
|
||||
block content
|
||||
main.content.content-alt.project-list-react#project-list-root
|
||||
|
|
|
@ -313,6 +313,7 @@
|
|||
"generic_linked_file_compile_error": "",
|
||||
"generic_something_went_wrong": "",
|
||||
"get_collaborative_benefits": "",
|
||||
"get_discounted_plan": "",
|
||||
"get_most_subscription_by_checking_features": "",
|
||||
"get_most_subscription_by_checking_premium_features": "",
|
||||
"git": "",
|
||||
|
@ -414,6 +415,7 @@
|
|||
"in_order_to_match_institutional_metadata_2": "",
|
||||
"in_order_to_match_institutional_metadata_associated": "",
|
||||
"increased_compile_timeout": "",
|
||||
"inr_discount_offer": "",
|
||||
"institution": "",
|
||||
"institution_account": "",
|
||||
"institution_acct_successfully_linked_2": "",
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -4,8 +4,12 @@ import ConfirmEmail from './groups/confirm-email'
|
|||
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 getMeta from '../../../../utils/meta'
|
||||
|
||||
function UserNotifications() {
|
||||
const showIRNBanner = getMeta('ol-showINRBanner')
|
||||
|
||||
return (
|
||||
<div className="user-notifications">
|
||||
<ul className="list-unstyled">
|
||||
|
@ -13,7 +17,7 @@ function UserNotifications() {
|
|||
<Institution />
|
||||
<ConfirmEmail />
|
||||
<ReconfirmationInfo />
|
||||
<GroupsAndEnterpriseBanner />
|
||||
{showIRNBanner ? <INRBanner /> : <GroupsAndEnterpriseBanner />}
|
||||
<WritefullPromoBanner />
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -10,6 +10,7 @@ export const currencies = <const>{
|
|||
NZD: '$',
|
||||
CHF: 'Fr',
|
||||
SGD: '$',
|
||||
INR: '₹',
|
||||
}
|
||||
|
||||
type Currency = typeof currencies
|
||||
|
|
|
@ -566,6 +566,7 @@
|
|||
"generic_linked_file_compile_error": "This project’s 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",
|
||||
"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 you’re having problems",
|
||||
"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>.",
|
||||
|
@ -710,6 +711,7 @@
|
|||
"increased_compile_timeout": "Increased compile timeout",
|
||||
"indvidual_plans": "Individual Plans",
|
||||
"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_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.",
|
||||
|
|
Loading…
Reference in a new issue