Răsfoiți Sursa

Merge pull request #13804 from overleaf/tm-geopricing-inr-banners

Implement split test for new INR geo-pricing banners/modal

GitOrigin-RevId: 06fbcf70b7ee90b9b365ac96c1fa0373cbe60847
Thomas 3 ani în urmă
părinte
comite
e704afdcca

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

@@ -363,21 +363,24 @@ async function projectListPage(req, res, next) {
     }
   }
 
-  let showINRBanner = false
+  let showInrGeoBanner, inrGeoBannerSplitTestName
+  let inrGeoBannerVariant = 'default'
   let showLATAMBanner = false
   let recommendedCurrency
   if (usersBestSubscription?.type === 'free') {
     const { currencyCode, countryCode } =
       await GeoIpLookup.promises.getCurrencyCode(req.ip)
+    let inrGeoPricingVariant = 'default'
     try {
+      // Split test is kept active, but all users geolocated in India can
+      // now use the INR currency (See #13507)
       const inrGeoPricingAssignment =
         await SplitTestHandler.promises.getAssignment(
           req,
           res,
           'geo-pricing-inr'
         )
-      showINRBanner =
-        inrGeoPricingAssignment.variant === 'inr' && countryCode === 'IN'
+      inrGeoPricingVariant = inrGeoPricingAssignment.variant
     } catch (error) {
       logger.error(
         { err: error },
@@ -404,6 +407,27 @@ async function projectListPage(req, res, next) {
         'Failed to get geo-pricing-latam split test assignment'
       )
     }
+    if (countryCode === 'IN') {
+      inrGeoBannerSplitTestName =
+        inrGeoPricingVariant === 'inr'
+          ? 'geo-banners-inr-2'
+          : 'geo-banners-inr-1'
+      try {
+        const geoBannerAssignment =
+          await SplitTestHandler.promises.getAssignment(
+            req,
+            res,
+            inrGeoBannerSplitTestName
+          )
+        showInrGeoBanner = true
+        inrGeoBannerVariant = geoBannerAssignment.variant
+      } catch (error) {
+        logger.error(
+          { err: error },
+          `Failed to get INR geo banner lookup or assignment (${inrGeoBannerSplitTestName})`
+        )
+      }
+    }
   }
 
   res.render('project/list-react', {
@@ -423,9 +447,11 @@ async function projectListPage(req, res, next) {
     showGroupsAndEnterpriseBanner,
     groupsAndEnterpriseBannerVariant,
     showWritefullPromoBanner,
-    showINRBanner,
     showLATAMBanner,
     recommendedCurrency,
+    showInrGeoBanner,
+    inrGeoBannerVariant,
+    inrGeoBannerSplitTestName,
     projectDashboardReact: true, // used in navbar
     welcomePageRedesignVariant: welcomePageRedesignAssignment.variant,
     groupSubscriptionsPendingEnrollment:

+ 84 - 22
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -108,7 +108,32 @@ async function plansPage(req, res) {
     removePersonalPlanAssingment
   )
 
-  AnalyticsManager.recordEventForSession(req.session, 'plans-page-view', {
+  let showInrGeoBanner, inrGeoBannerSplitTestName
+  let inrGeoBannerVariant = 'default'
+  if (countryCode === 'IN') {
+    inrGeoBannerSplitTestName =
+      geoPricingINRTestVariant === 'inr'
+        ? 'geo-banners-inr-2'
+        : 'geo-banners-inr-1'
+    try {
+      const geoBannerAssignment = await SplitTestHandler.promises.getAssignment(
+        req,
+        res,
+        inrGeoBannerSplitTestName
+      )
+      inrGeoBannerVariant = geoBannerAssignment.variant
+      if (inrGeoBannerVariant !== 'default') {
+        showInrGeoBanner = true
+      }
+    } catch (error) {
+      logger.error(
+        { err: error },
+        `Failed to get INR geo banner lookup or assignment (${inrGeoBannerSplitTestName})`
+      )
+    }
+  }
+
+  const plansPageViewSegmentation = {
     currency: recommendedCurrency,
     'remove-personal-plan-page': removePersonalPlanAssingment?.variant,
     countryCode,
@@ -120,7 +145,16 @@ async function plansPage(req, res) {
     )
       ? 'latam'
       : 'default',
-  })
+  }
+  if (inrGeoBannerSplitTestName) {
+    plansPageViewSegmentation[inrGeoBannerSplitTestName] = inrGeoBannerVariant
+  }
+
+  AnalyticsManager.recordEventForSession(
+    req.session,
+    'plans-page-view',
+    plansPageViewSegmentation
+  )
 
   res.render(`subscriptions/plans-marketing/${directory}/plans-marketing-v2`, {
     title: 'plans_and_pricing',
@@ -137,6 +171,7 @@ async function plansPage(req, res) {
     groupPlanModalDefaults,
     initialLocalizedGroupPrice:
       SubscriptionHelper.generateInitialLocalizedGroupPrice(currency),
+    showInrGeoBanner,
   })
 }
 
@@ -302,23 +337,52 @@ async function interstitialPaymentPage(req, res) {
   if (hasSubscription) {
     res.redirect('/user/subscription?hasSubscription=true')
   } else {
+    let showInrGeoBanner, inrGeoBannerSplitTestName
+    let inrGeoBannerVariant = 'default'
+    if (countryCode === 'IN') {
+      inrGeoBannerSplitTestName =
+        geoPricingINRTestVariant === 'inr'
+          ? 'geo-banners-inr-2'
+          : 'geo-banners-inr-1'
+      try {
+        const geoBannerAssignment =
+          await SplitTestHandler.promises.getAssignment(
+            req,
+            res,
+            inrGeoBannerSplitTestName
+          )
+        inrGeoBannerVariant = geoBannerAssignment.variant
+        if (inrGeoBannerVariant !== 'default') {
+          showInrGeoBanner = true
+        }
+      } catch (error) {
+        logger.error(
+          { err: error },
+          `Failed to get INR geo banner lookup or assignment (${inrGeoBannerSplitTestName})`
+        )
+      }
+    }
+    const paywallPlansPageViewSegmentation = {
+      currency: recommendedCurrency,
+      countryCode,
+      '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,
+    }
+    if (inrGeoBannerSplitTestName) {
+      paywallPlansPageViewSegmentation[inrGeoBannerSplitTestName] =
+        inrGeoBannerVariant
+    }
     AnalyticsManager.recordEventForSession(
       req.session,
       'paywall-plans-page-view',
-      {
-        currency: recommendedCurrency,
-        countryCode,
-        '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,
-      }
+      paywallPlansPageViewSegmentation
     )
 
     res.render(
@@ -331,6 +395,7 @@ async function interstitialPaymentPage(req, res) {
         recommendedCurrency,
         interstitialPaymentConfig,
         showSkipLink,
+        showInrGeoBanner,
       }
     )
   }
@@ -678,10 +743,12 @@ async function _getRecommendedCurrency(req, res) {
     req.query?.ip || req.ip
   )
   const countryCode = currencyLookup.countryCode
-  let recommendedCurrency = currencyLookup.currencyCode
   let assignmentINR, assignmentLATAM
+  let recommendedCurrency = currencyLookup.currencyCode
   // for #12703
   try {
+    // Split test is kept active, but all users geolocated in India can
+    // now use the INR currency (See #13507)
     assignmentINR = await SplitTestHandler.promises.getAssignment(
       req,
       res,
@@ -706,11 +773,6 @@ async function _getRecommendedCurrency(req, res) {
       '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' && assignmentINR?.variant !== 'inr') {
-    recommendedCurrency = GeoIpLookup.DEFAULT_CURRENCY_CODE
-  }
   if (
     ['BRL', 'MXN', 'COP', 'CLP', 'PEN'].includes(recommendedCurrency) &&
     assignmentLATAM?.variant !== 'latam'

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

@@ -28,7 +28,9 @@ 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)
+	meta(name="ol-showInrGeoBanner" data-type="boolean" content=showInrGeoBanner)
+	meta(name="ol-inrGeoBannerVariant" data-type="string" content=inrGeoBannerVariant)
+	meta(name="ol-inrGeoBannerSplitTestName" data-type="string" content=inrGeoBannerSplitTestName)
 	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)

+ 3 - 0
services/web/app/views/subscriptions/plans-marketing/st-personal-off-default/interstitial-payment.pug

@@ -16,6 +16,9 @@ block content
 		.content-page
 			.plans
 				.container
+					if showInrGeoBanner
+						div.alert.alert-success.text-centered !{translate("inr_discount_offer_plans_page_banner", {flag: '🇮🇳'})}
+
 					.row
 						.col-md-12
 							.page-header.centered.plans-header.text-centered.top-page-header

+ 3 - 0
services/web/app/views/subscriptions/plans-marketing/st-personal-off-default/plans-marketing-v2.pug

@@ -15,6 +15,9 @@ block content
 		.content-page
 			.plans
 				.container(ng-cloak)
+					if showInrGeoBanner
+						div.alert.alert-success.text-centered !{translate("inr_discount_offer_plans_page_banner", {flag: '🇮🇳'})}
+
 					.row
 						.col-md-12
 							.page-header.centered.plans-header.text-centered.top-page-header

+ 3 - 0
services/web/app/views/subscriptions/plans-marketing/st-personal-off-variant/interstitial-payment.pug

@@ -16,6 +16,9 @@ block content
 		.content-page
 			.plans
 				.container
+					if showInrGeoBanner
+						div.alert.alert-success.text-centered !{translate("inr_discount_offer_plans_page_banner", {flag: '🇮🇳'})}
+
 					.row
 						.col-md-12
 							.page-header.centered.plans-header.text-centered.top-page-header

+ 3 - 0
services/web/app/views/subscriptions/plans-marketing/st-personal-off-variant/plans-marketing-v2.pug

@@ -15,6 +15,9 @@ block content
 		.content-page
 			.plans
 				.container(ng-cloak)
+					if showInrGeoBanner
+						div.alert.alert-success.text-centered !{translate("inr_discount_offer_plans_page_banner", {flag: '🇮🇳'})}
+
 					.row
 						.col-md-12
 							.page-header.centered.plans-header.text-centered.top-page-header

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

@@ -494,7 +494,10 @@
   "include_caption": "",
   "include_label": "",
   "increased_compile_timeout": "",
+  "inr_discount_modal_info": "",
+  "inr_discount_modal_title": "",
   "inr_discount_offer": "",
+  "inr_discount_offer_green_banner": "",
   "insert_figure": "",
   "insert_from_another_project": "",
   "insert_from_project_files": "",
@@ -613,6 +616,7 @@
   "math_display": "",
   "math_inline": "",
   "maximum_files_uploaded_together": "",
+  "maybe_later": "",
   "members_management": "",
   "mendeley_groups_loading_error": "",
   "mendeley_groups_relink": "",

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

@@ -1,67 +1,172 @@
-import { useCallback, useEffect, useRef } from 'react'
+import { useCallback, useEffect, useRef, useState } 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 { Modal, Button } from 'react-bootstrap'
+import AccessibleModal from '../../../../../shared/components/accessible-modal'
 
-export default function INRBanner() {
+interface VariantContents {
+  default: string
+  'green-banner': string
+  modal: string
+}
+
+const contentLookup: VariantContents = {
+  default: 'blue',
+  'green-banner': 'green',
+  modal: 'modal',
+}
+
+type INRBannerProps = {
+  variant: keyof VariantContents
+  splitTestName: string
+}
+
+export default function INRBanner({ variant, splitTestName }: INRBannerProps) {
   const { t } = useTranslation()
-  const [dismissedAt, setDismissedAt] = usePersistedState<Date | undefined>(
-    `has_dismissed_inr_banner`
-  )
+  const [dismissedUntil, setDismissedUntil] = usePersistedState<
+    Date | undefined
+  >(`has_dismissed_inr_banner_until`)
   const viewEventSent = useRef<boolean>(false)
 
+  // Only used by 'modal' variant
+  const [showModal, setShowModal] = useState(true)
+
   useEffect(() => {
-    if (!dismissedAt) {
+    if (dismissedUntil && new Date(dismissedUntil) > new Date()) {
       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('paywall-prompt', {
-        'paywall-type': 'inr-banner',
+    if (!viewEventSent.current) {
+      eventTracking.sendMB('promo-prompt', {
+        location: variant === 'modal' ? 'dashboard-modal' : 'dashboard-banner',
+        name: splitTestName,
+        content: contentLookup[variant],
       })
       viewEventSent.current = true
     }
-  }, [dismissedAt])
+  }, [dismissedUntil, splitTestName, variant])
 
   const handleClick = useCallback(() => {
-    eventTracking.sendMB('paywall-click', { 'paywall-type': 'inr-banner' })
+    eventTracking.sendMB('promo-click', {
+      location: variant === 'modal' ? 'dashboard-modal' : 'dashboard-banner',
+      name: splitTestName,
+      content: contentLookup[variant],
+      type: 'click',
+    })
+
+    setShowModal(false)
 
     window.open('/user/subscription/plans')
-  }, [])
+  }, [splitTestName, variant])
+
+  const bannerDismissed = useCallback(() => {
+    eventTracking.sendMB('promo-dismiss', {
+      location: variant === 'modal' ? 'dashboard-modal' : 'dashboard-banner',
+      name: splitTestName,
+      content: contentLookup[variant],
+      type: 'click',
+    })
+    const until = new Date()
+    until.setDate(until.getDate() + 30) // 30 days
+    setDismissedUntil(until)
+  }, [setDismissedUntil, splitTestName, variant])
 
-  if (dismissedAt) {
+  const handleHide = useCallback(() => {
+    setShowModal(false)
+    bannerDismissed()
+  }, [bannerDismissed])
+
+  const handleMaybeLater = useCallback(() => {
+    eventTracking.sendMB('promo-click', {
+      location: variant === 'modal' ? 'dashboard-modal' : 'dashboard-banner',
+      name: splitTestName,
+      content: contentLookup[variant],
+      type: 'pause',
+    })
+    setShowModal(false)
+    const until = new Date()
+    until.setDate(until.getDate() + 1) // 1 day
+    setDismissedUntil(until)
+  }, [setDismissedUntil, splitTestName, variant])
+
+  if (dismissedUntil && new Date(dismissedUntil) > new Date()) {
     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>
-  )
+  if (variant === 'default') {
+    return (
+      <Notification bsStyle="info" onDismiss={bannerDismissed}>
+        <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>
+    )
+  } else if (variant === 'green-banner') {
+    return (
+      <Notification bsStyle="success" onDismiss={bannerDismissed}>
+        <Notification.Body>
+          <Trans
+            i18nKey="inr_discount_offer_green_banner"
+            components={[<b />, <br />]} // eslint-disable-line react/jsx-key
+            values={{ flag: '🇮🇳' }}
+          />
+        </Notification.Body>
+        <Notification.Action>
+          <Button
+            bsStyle="success"
+            className="pull-right"
+            onClick={handleClick}
+          >
+            {t('get_discounted_plan')} ₹
+          </Button>
+        </Notification.Action>
+      </Notification>
+    )
+  } else if (variant === 'modal') {
+    return (
+      <AccessibleModal show={showModal} onHide={handleHide}>
+        <Modal.Header closeButton>
+          <Modal.Title>{t('inr_discount_modal_title')}</Modal.Title>
+        </Modal.Header>
+        <Modal.Body className="modal-body-share">
+          <p>
+            <img
+              alt={t('inr_discount_modal_title')}
+              src="/img/subscriptions/inr-discount-modal.png"
+              style={{
+                width: '100%',
+              }}
+            />
+          </p>
+          <p>
+            <Trans i18nKey="inr_discount_modal_info" />
+          </p>
+        </Modal.Body>
+        <Modal.Footer>
+          <Button bsStyle="default" onClick={handleMaybeLater}>
+            {t('maybe_later')}
+          </Button>
+          <Button type="button" bsStyle="primary" onClick={handleClick}>
+            {t('get_discounted_plan')}
+          </Button>
+        </Modal.Footer>
+      </AccessibleModal>
+    )
+  } else {
+    return null
+  }
 }

+ 11 - 3
services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx

@@ -28,7 +28,12 @@ function UserNotifications() {
     'ol-groupSubscriptionsPendingEnrollment',
     []
   )
-  const showIRNBanner = getMeta('ol-showINRBanner', false)
+  const showInrGeoBanner = getMeta('ol-showInrGeoBanner', false)
+  const inrGeoBannerVariant = getMeta('ol-inrGeoBannerVariant', 'default')
+  const inrGeoBannerSplitTestName = getMeta(
+    'ol-inrGeoBannerSplitTestName',
+    'unassigned'
+  )
   const showLATAMBanner = getMeta('ol-showLATAMBanner', false)
 
   return (
@@ -48,8 +53,11 @@ function UserNotifications() {
         <ReconfirmationInfo />
         {showLATAMBanner ? (
           <LATAMBanner />
-        ) : showIRNBanner ? (
-          <INRBanner />
+        ) : showInrGeoBanner ? (
+          <INRBanner
+            variant={inrGeoBannerVariant}
+            splitTestName={inrGeoBannerSplitTestName}
+          />
         ) : (
           <GroupsAndEnterpriseBanner />
         )}

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

@@ -765,7 +765,11 @@
   "increased_compile_timeout": "Increased compile timeout",
   "indvidual_plans": "Individual Plans",
   "info": "Info",
+  "inr_discount_modal_info": "Get document history, track changes, additional collaborators, and more at Purchasing Power Parity prices.",
+  "inr_discount_modal_title": "70% off all Overleaf premium plans for users in India",
   "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.",
+  "inr_discount_offer_green_banner": "__flag__ <0>Big news!</0> You qualify for a <0>70% discount</0> on our premium plans because you’re in India. <br />Get additional collaborators, document history, track changes, and more.",
+  "inr_discount_offer_plans_page_banner": "__flag__ <b>Great news!</b> Because you’re in India, you can get any Overleaf premium plan at a <b>70% discount</b>.",
   "insert_figure": "Insert figure",
   "insert_from_another_project": "Insert from another project",
   "insert_from_project_files": "Insert from project files",
@@ -976,6 +980,7 @@
   "max_collab_per_project_info": "The number of people you can invite to work on each project. They just need to have an Overleaf account. They can be different people in each project.",
   "maximum_files_uploaded_together": "Maximum __max__ files uploaded together",
   "may": "May",
+  "maybe_later": "Maybe later",
   "members_management": "Members management",
   "mendeley": "Mendeley",
   "mendeley_groups_loading_error": "There was an error loading groups from Mendeley",

BIN
services/web/public/img/subscriptions/inr-discount-modal.png