From c1ec3044d7b88139cf8e7e87ae1cf938901ac018 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 11 Jul 2023 10:40:33 +0200 Subject: [PATCH] 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 --- .../Features/Project/ProjectListController.js | 32 +++++- .../Subscription/SubscriptionController.js | 60 ++++++++-- .../Subscription/SubscriptionFormatters.js | 12 ++ .../web/app/src/infrastructure/GeoIpLookup.js | 10 ++ services/web/app/views/project/list-react.pug | 2 + .../web/frontend/extracted-translations.json | 1 + .../notifications/ads/inr-banner.tsx | 18 +-- .../notifications/ads/latam-banner.tsx | 106 ++++++++++++++++++ .../notifications/user-notifications.tsx | 12 +- .../new/checkout/checkout-panel.tsx | 5 +- .../js/features/subscription/data/currency.ts | 5 + services/web/locales/en.json | 1 + 12 files changed, 239 insertions(+), 25 deletions(-) create mode 100644 services/web/frontend/js/features/project-list/components/notifications/ads/latam-banner.tsx diff --git a/services/web/app/src/Features/Project/ProjectListController.js b/services/web/app/src/Features/Project/ProjectListController.js index 0981ed58ef..ec85e4ac09 100644 --- a/services/web/app/src/Features/Project/ProjectListController.js +++ b/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: diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 3c6122c180..592a57bfe8 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/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, } } diff --git a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js b/services/web/app/src/Features/Subscription/SubscriptionFormatters.js index 0490d8045a..249b265487 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js +++ b/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) { diff --git a/services/web/app/src/infrastructure/GeoIpLookup.js b/services/web/app/src/infrastructure/GeoIpLookup.js index fdab06a7e0..f8471cc349 100644 --- a/services/web/app/src/infrastructure/GeoIpLookup.js +++ b/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 diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug index f3d8565cd3..1d3d2d5b7d 100644 --- a/services/web/app/views/project/list-react.pug +++ b/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) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 88ca9f218f..8b3f8ef1f1 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/project-list/components/notifications/ads/inr-banner.tsx b/services/web/frontend/js/features/project-list/components/notifications/ads/inr-banner.tsx index 1c2031b1a8..7198ba5b31 100644 --- a/services/web/frontend/js/features/project-list/components/notifications/ads/inr-banner.tsx +++ b/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( `has_dismissed_inr_banner` ) - - useEffect(() => { - eventTracking.sendMB('paywall-prompt', { - 'paywall-type': 'inr-banner', - }) - }, []) + const viewEventSent = useRef(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' }) diff --git a/services/web/frontend/js/features/project-list/components/notifications/ads/latam-banner.tsx b/services/web/frontend/js/features/project-list/components/notifications/ads/latam-banner.tsx new file mode 100644 index 0000000000..e1e7b9e956 --- /dev/null +++ b/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( + `has_dismissed_latam_banner` + ) + const viewEventSent = useRef(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 ( + handleDismiss()}> + + ]} // eslint-disable-line react/jsx-key + values={{ flag, currencyName, discountPercent }} + /> + + + + + + ) +} diff --git a/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx b/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx index 3bd9da76d4..64d23ec8fa 100644 --- a/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx +++ b/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 (
@@ -44,7 +46,13 @@ function UserNotifications() { - {showIRNBanner ? : } + {showLATAMBanner ? ( + + ) : showIRNBanner ? ( + + ) : ( + + )}
diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx index 073831192f..c0bf30c2ac 100644 --- a/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx +++ b/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() { {couponError} )} - {currencyCode === 'INR' ? null : ( + {hidePaypal ? null : ( { CHF: 'Fr', SGD: '$', INR: '₹', + BRL: 'R$', + MXN: '$', + COP: '$', + CLP: '$', + PEN: 'S/', } type Currency = typeof currencies diff --git a/services/web/locales/en.json b/services/web/locales/en.json index e735bdbc6a..00cabf90bc 100644 --- a/services/web/locales/en.json +++ b/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 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",