Răsfoiți Sursa

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
Thomas 3 ani în urmă
părinte
comite
c1ec3044d7

+ 28 - 4
services/web/app/src/Features/Project/ProjectListController.js

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

+ 50 - 10
services/web/app/src/Features/Subscription/SubscriptionController.js

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

+ 12 - 0
services/web/app/src/Features/Subscription/SubscriptionFormatters.js

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

+ 10 - 0
services/web/app/src/infrastructure/GeoIpLookup.js

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

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

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

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

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

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

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

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

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

+ 10 - 2
services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx

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

+ 3 - 2
services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx

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

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

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

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

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