diff --git a/services/web/app/src/Features/Project/ProjectListController.js b/services/web/app/src/Features/Project/ProjectListController.js index ec85e4ac09..c7701ba17d 100644 --- a/services/web/app/src/Features/Project/ProjectListController.js +++ b/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: diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 71e76d81f8..352162747a 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/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' diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug index 1d3d2d5b7d..c82ed6c124 100644 --- a/services/web/app/views/project/list-react.pug +++ b/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) diff --git a/services/web/app/views/subscriptions/plans-marketing/st-personal-off-default/interstitial-payment.pug b/services/web/app/views/subscriptions/plans-marketing/st-personal-off-default/interstitial-payment.pug index 4f34c0340c..d51dc39f49 100644 --- a/services/web/app/views/subscriptions/plans-marketing/st-personal-off-default/interstitial-payment.pug +++ b/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 diff --git a/services/web/app/views/subscriptions/plans-marketing/st-personal-off-default/plans-marketing-v2.pug b/services/web/app/views/subscriptions/plans-marketing/st-personal-off-default/plans-marketing-v2.pug index 4cc687de3a..2206ed4ab2 100644 --- a/services/web/app/views/subscriptions/plans-marketing/st-personal-off-default/plans-marketing-v2.pug +++ b/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 diff --git a/services/web/app/views/subscriptions/plans-marketing/st-personal-off-variant/interstitial-payment.pug b/services/web/app/views/subscriptions/plans-marketing/st-personal-off-variant/interstitial-payment.pug index 4f34c0340c..d51dc39f49 100644 --- a/services/web/app/views/subscriptions/plans-marketing/st-personal-off-variant/interstitial-payment.pug +++ b/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 diff --git a/services/web/app/views/subscriptions/plans-marketing/st-personal-off-variant/plans-marketing-v2.pug b/services/web/app/views/subscriptions/plans-marketing/st-personal-off-variant/plans-marketing-v2.pug index 4cc687de3a..2206ed4ab2 100644 --- a/services/web/app/views/subscriptions/plans-marketing/st-personal-off-variant/plans-marketing-v2.pug +++ b/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 diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index cd86fb00cb..645144f71f 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", 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 7198ba5b31..ac930cf7d1 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,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( - `has_dismissed_inr_banner` - ) + const [dismissedUntil, setDismissedUntil] = usePersistedState< + Date | undefined + >(`has_dismissed_inr_banner_until`) 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]) + // Only used by 'modal' variant + const [showModal, setShowModal] = useState(true) useEffect(() => { - if (!dismissedAt && !viewEventSent.current) { - eventTracking.sendMB('paywall-prompt', { - 'paywall-type': 'inr-banner', + if (dismissedUntil && new Date(dismissedUntil) > new Date()) { + return + } + 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]) - if (dismissedAt) { + 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]) + + 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 ( - setDismissedAt(new Date())}> - - ]} // eslint-disable-line react/jsx-key - /> - - - - - - ) + if (variant === 'default') { + return ( + + + ]} // eslint-disable-line react/jsx-key + /> + + + + + + ) + } else if (variant === 'green-banner') { + return ( + + + ,
]} // eslint-disable-line react/jsx-key + values={{ flag: '🇮🇳' }} + /> +
+ + + +
+ ) + } else if (variant === 'modal') { + return ( + + + {t('inr_discount_modal_title')} + + +

+ {t('inr_discount_modal_title')} +

+

+ +

+
+ + + + +
+ ) + } else { + return null + } } 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 64d23ec8fa..41b21ed98d 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 @@ -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() { {showLATAMBanner ? ( - ) : showIRNBanner ? ( - + ) : showInrGeoBanner ? ( + ) : ( )} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index f86a88f258..ac20f4ecb0 100644 --- a/services/web/locales/en.json +++ b/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 on our premium features.", + "inr_discount_offer_green_banner": "__flag__ <0>Big news! You qualify for a <0>70% discount on our premium plans because you’re in India.
Get additional collaborators, document history, track changes, and more.", + "inr_discount_offer_plans_page_banner": "__flag__ Great news! Because you’re in India, you can get any Overleaf premium plan at a 70% discount.", "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", diff --git a/services/web/public/img/subscriptions/inr-discount-modal.png b/services/web/public/img/subscriptions/inr-discount-modal.png new file mode 100644 index 0000000000..ead8016058 Binary files /dev/null and b/services/web/public/img/subscriptions/inr-discount-modal.png differ