Răsfoiți Sursa

[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
Alexandre Bourdin 3 ani în urmă
părinte
comite
caeceba28c

+ 23 - 0
services/web/app/src/Features/Project/ProjectListController.js

@@ -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
   })
 }

+ 66 - 30
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -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),

+ 3 - 2
services/web/app/src/Features/Subscription/SubscriptionFormatters.js

@@ -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}`
   },
 

+ 11 - 4
services/web/app/src/infrastructure/GeoIpLookup.js

@@ -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,
 }

+ 1 - 0
services/web/app/views/project/list-react.pug

@@ -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

+ 2 - 0
services/web/frontend/extracted-translations.json

@@ -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": "",

+ 63 - 0
services/web/frontend/js/features/project-list/components/notifications/ads/inr-banner.tsx

@@ -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>
+  )
+}

+ 5 - 1
services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx

@@ -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>

+ 1 - 0
services/web/frontend/js/features/subscription/data/currency.ts

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

+ 2 - 0
services/web/locales/en.json

@@ -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.",