diff --git a/services/web/app/src/Features/Project/ProjectListController.js b/services/web/app/src/Features/Project/ProjectListController.js index b891f2cbfc..52abb030a0 100644 --- a/services/web/app/src/Features/Project/ProjectListController.js +++ b/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 }) } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 8d4c128bcb..326bdcc24d 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/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), diff --git a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js b/services/web/app/src/Features/Subscription/SubscriptionFormatters.js index 39d60f2eb0..740b2ba703 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js +++ b/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}` }, diff --git a/services/web/app/src/infrastructure/GeoIpLookup.js b/services/web/app/src/infrastructure/GeoIpLookup.js index 274bb1b239..fdab06a7e0 100644 --- a/services/web/app/src/infrastructure/GeoIpLookup.js +++ b/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, } diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug index f223e9acf3..2ba0581079 100644 --- a/services/web/app/views/project/list-react.pug +++ b/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 diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index b31c9e1fc2..2b3a783fe1 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", 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 new file mode 100644 index 0000000000..1c2031b1a8 --- /dev/null +++ b/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( + `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 ( + setDismissedAt(new Date())}> + + ]} // eslint-disable-line react/jsx-key + /> + + + + + + ) +} 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 72f8cba2d2..5749ad02e0 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 @@ -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 (
diff --git a/services/web/frontend/js/features/subscription/data/currency.ts b/services/web/frontend/js/features/subscription/data/currency.ts index db1b8542cc..e333cd37c3 100644 --- a/services/web/frontend/js/features/subscription/data/currency.ts +++ b/services/web/frontend/js/features/subscription/data/currency.ts @@ -10,6 +10,7 @@ export const currencies = { NZD: '$', CHF: 'Fr', SGD: '$', + INR: '₹', } type Currency = typeof currencies diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 93b7ea0c20..d89135f1cd 100644 --- a/services/web/locales/en.json +++ b/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": "Get in touch with support 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.", @@ -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 on our premium features.", "institution": "Institution", "institution_account": "Institution Account", "institution_account_tried_to_add_affiliated_with_another_institution": "This email is already associated with your account but affiliated with another institution.",