From 2e11f2c7b74713943e1115c6f3db169c80efdbcd Mon Sep 17 00:00:00 2001 From: roo hutton Date: Wed, 5 Nov 2025 12:03:59 +0000 Subject: [PATCH] Merge pull request #29394 from overleaf/rh-compile-timeout-modal Add compile timeout modal for compile-timeout-target-plans test GitOrigin-RevId: b352cb239742aa7ffbef7f3cd5c65ac719569ebf --- .../Features/Project/ProjectController.mjs | 99 ++++---- .../web/app/views/project/editor/_meta.pug | 7 + .../web/frontend/extracted-translations.json | 11 + .../compile-timeout-paywall-modal.tsx | 240 ++++++++++++++++++ .../components/timeout-upgrade-prompt-new.tsx | 32 ++- .../components/billing-period-toggle.tsx | 66 +++++ .../components/start-free-trial-button.tsx | 3 + services/web/frontend/js/utils/meta.ts | 5 + .../shared/billing-period-toggle.stories.tsx | 84 ++++++ .../components/billing-period-toggle.scss | 84 ++++++ .../compile-time-paywall-modal.scss | 144 +++++++++++ services/web/locales/en.json | 5 + .../src/Project/ProjectController.test.mjs | 11 + 13 files changed, 738 insertions(+), 53 deletions(-) create mode 100644 services/web/frontend/js/features/pdf-preview/components/compile-timeout-paywall-modal.tsx create mode 100644 services/web/frontend/js/shared/components/billing-period-toggle.tsx create mode 100644 services/web/frontend/stories/shared/billing-period-toggle.stories.tsx create mode 100644 services/web/frontend/stylesheets/components/billing-period-toggle.scss create mode 100644 services/web/frontend/stylesheets/components/compile-time-paywall-modal.scss diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 8bac8835b9..f2c607d580 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -395,7 +395,6 @@ const _ProjectController = { 'track-pdf-download', !anonymous && 'writefull-oauth-promotion', 'hotjar', - 'hotjar-editor-onboarding', 'editor-redesign', 'overleaf-assist-bundle', 'word-count-client', @@ -404,6 +403,7 @@ const _ProjectController = { 'writefull-frontend-migration', 'chat-edit-delete', 'compile-timeout-remove-info', + 'compile-timeout-target-plans', ].filter(Boolean) const getUserValues = async userId => @@ -507,38 +507,11 @@ const _ProjectController = { } const getSplitTestAssignment = async splitTest => { - if (splitTest === 'hotjar-editor-onboarding') { - const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - const userRegisteredMoreThan7DaysAgo = - user.signUpDate && user.signUpDate < sevenDaysAgo - - const isExcluded = - user.betaProgram || - inEnterpriseCommons || - userIsMemberOfGroupSubscription || - userRegisteredMoreThan7DaysAgo - - if (!isExcluded) { - return await SplitTestHandler.promises.getAssignment( - req, - res, - splitTest - ) - } else { - return { - variant: 'default', - analytics: { - segmentation: {}, - }, - } - } - } else { - return await SplitTestHandler.promises.getAssignment( - req, - res, - splitTest - ) - } + return await SplitTestHandler.promises.getAssignment( + req, + res, + splitTest + ) } const splitTestAssignments = {} await Promise.all( @@ -811,6 +784,19 @@ const _ProjectController = { isOverleafAssistBundleEnabled && (await ProjectController._getAddonPrices(req, res)) + let standardPlanPricing + let recommendedCurrency + if (Features.hasFeature('saas')) { + standardPlanPricing = await ProjectController._getPlanPricing( + req, + res, + 'collaborator' + ) + const { currency } = + await SubscriptionController.getRecommendedCurrency(req, res) + recommendedCurrency = currency + } + let planCode = subscription?.planCode if (!planCode && !userInNonIndividualSub) { planCode = 'personal' @@ -818,6 +804,12 @@ const _ProjectController = { const planDetails = Settings.plans.find(p => p.planCode === planCode) + const shouldLoadHotjar = + splitTestAssignments['compile-timeout-target-plans']?.variant === + 'enabled' && + !userHasPremiumSub && + !userInNonIndividualSub + res.render(template, { title: project.name, priority_title: true, @@ -897,15 +889,15 @@ const _ProjectController = { otMigrationStage: project.overleaf?.history?.otMigrationStage ?? 0, projectTags, isSaas: Features.hasFeature('saas'), - shouldLoadHotjar: - splitTestAssignments['hotjar-editor-onboarding']?.variant === - 'enabled', + shouldLoadHotjar, isOverleafAssistBundleEnabled, customerIoEnabled, addonPrices, compileSettings: { compileTimeout: ownerFeatures?.compileTimeout, }, + standardPlanPricing, + recommendedCurrency, }) timer.done() } catch (err) { @@ -914,30 +906,33 @@ const _ProjectController = { } }, - async _getPaywallPlansPrices( - req, - res, - paywallPlans = ['collaborator', 'student'] - ) { - const plansData = {} - + async _getPlanPricing(req, res, plan = 'collaborator') { const locale = req.i18n.language const { currency } = await SubscriptionController.getRecommendedCurrency( req, res ) - paywallPlans.forEach(plan => { - const planPrice = Settings.localizedPlanPricing[currency][plan].monthly - const formattedPlanPrice = formatCurrency( - planPrice, + const pricingForCurrency = Settings.localizedPlanPricing[currency] + if (!pricingForCurrency) { + return null + } + + const planPricing = pricingForCurrency[plan] + if (!planPricing) { + return null + } + + return { + monthly: formatCurrency(planPricing.monthly, currency, locale, true), + annual: formatCurrency(planPricing.annual, currency, locale, true), + monthlyTimesTwelve: formatCurrency( + planPricing.monthlyTimesTwelve, currency, locale, true - ) - plansData[plan] = formattedPlanPrice - }) - return plansData + ), + } }, async _getAddonPrices(req, res, addonPlans = ['assistant']) { @@ -1319,7 +1314,7 @@ const ProjectController = { _injectProjectUsers: _ProjectController._injectProjectUsers, _isInPercentageRollout: _ProjectController._isInPercentageRollout, _refreshFeatures: _ProjectController._refreshFeatures, - _getPaywallPlansPrices: _ProjectController._getPaywallPlansPrices, + _getPlanPricing: _ProjectController._getPlanPricing, _getAddonPrices: _ProjectController._getAddonPrices, _setWritefullTrialState: _ProjectController._setWritefullTrialState, } diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug index 5f44c487b2..d1d871679a 100644 --- a/services/web/app/views/project/editor/_meta.pug +++ b/services/web/app/views/project/editor/_meta.pug @@ -45,6 +45,13 @@ meta(name='ol-compileSettings' data-type="json" content=compileSettings) if(isOverleafAssistBundleEnabled) //- expose plans info to show prices in paywall-change-compile-timeout test meta(name="ol-addonPrices" data-type="json" content=addonPrices) +if (standardPlanPricing) + meta( + name='ol-recommendedCurrency' + data-type='string' + content=recommendedCurrency + ) + meta(name="ol-standardPlanPricing" data-type="json" content=standardPlanPricing) // translations for the loading page, before i18n has loaded in the client meta(name="ol-loadingText" data-type="string" content=translate("loading")) meta(name="ol-translationIoNotLoaded" data-type="string" content=translate("could_not_connect_to_websocket_server")) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index a22b755a5a..60433dcc38 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -137,6 +137,7 @@ "an_email_has_already_been_sent_to": "", "an_error_occured_while_restoring_project": "", "an_error_occurred_when_verifying_the_coupon_code": "", + "and_much_more": "", "annual_discount": "", "anonymous": "", "anyone_with_link_can_edit": "", @@ -189,6 +190,7 @@ "billed_monthly_at": "", "billed_yearly": "", "billing": "", + "billing_period_sentence_case": "", "binary_history_error": "", "blank_project": "", "blocked_filename": "", @@ -200,6 +202,7 @@ "bullet_list": "", "buy_licenses": "", "buy_more_licenses": "", + "buy_now_no_exclamation_mark": "", "by_subscribing_you_agree_to_our_terms_of_service": "", "can_link_institution_email_acct_to_institution_acct": "", "can_link_your_institution_acct_2": "", @@ -307,6 +310,7 @@ "compile_larger_projects": "", "compile_mode": "", "compile_terminated_by_user": "", + "compile_timeout_modal_intro": "", "compiler": "", "compiling": "", "compliance": "", @@ -666,6 +670,7 @@ "get_error_assist": "", "get_exclusive_access_to_labs": "", "get_in_touch": "", + "get_more_compile_time": "", "get_most_subscription_by_checking_ai_writefull": "", "get_most_subscription_by_checking_overleaf": "", "get_most_subscription_by_checking_overleaf_ai_writefull": "", @@ -694,6 +699,7 @@ "github_file_name_error": "", "github_file_sync_error": "", "github_git_folder_error": "", + "github_integration": "", "github_integration_lowercase": "", "github_is_no_longer_connected": "", "github_is_premium": "", @@ -1072,6 +1078,7 @@ "money_back_guarantee": "", "month": "", "month_plural": "", + "monthly": "", "more": "", "more_actions": "", "more_collabs_per_project": "", @@ -1265,6 +1272,7 @@ "per_license": "", "per_month": "", "per_month_x_annually": "", + "per_year": "", "percent_is_the_percentage_of_the_line_width": "", "permanently_disables_the_preview": "", "personal_library": "", @@ -1514,6 +1522,7 @@ "saml_missing_signature_error": "", "saml_response": "", "save": "", + "save_20_percent": "", "save_or_cancel-cancel": "", "save_or_cancel-or": "", "save_or_cancel-save": "", @@ -2093,6 +2102,7 @@ "verify_your_email_address": "", "view": "", "view_all": "", + "view_all_plans": "", "view_audit_logs_group_subtext": "", "view_billing_details": "", "view_code": "", @@ -2173,6 +2183,7 @@ "x_price_per_user": "", "x_price_per_year": "", "year": "", + "yearly": "", "yes_move_me_to_personal_plan": "", "you": "", "you_already_have_a_subscription": "", diff --git a/services/web/frontend/js/features/pdf-preview/components/compile-timeout-paywall-modal.tsx b/services/web/frontend/js/features/pdf-preview/components/compile-timeout-paywall-modal.tsx new file mode 100644 index 0000000000..b0b2ec549c --- /dev/null +++ b/services/web/frontend/js/features/pdf-preview/components/compile-timeout-paywall-modal.tsx @@ -0,0 +1,240 @@ +import '../../../../stylesheets/components/compile-time-paywall-modal.scss' + +import { + OLModal, + OLModalBody, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' +import MaterialIcon from '@/shared/components/material-icon' +import BillingPeriodToggle, { + type BillingPeriod, +} from '@/shared/components/billing-period-toggle' +import getMeta from '@/utils/meta' +import { sendMB } from '@/infrastructure/event-tracking' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const PLAN_CODE = { + monthly: 'collaborator', + annual: 'collaborator-annual', +} as const + +export type CompileTimeoutPaywallModalProps = { + show: boolean + onHide: () => void +} + +export function CompileTimeoutPaywallModal({ + show, + onHide, +}: CompileTimeoutPaywallModalProps) { + const { t } = useTranslation() + const standardPlanPricing = getMeta('ol-standardPlanPricing') + const currency = getMeta('ol-recommendedCurrency') + + const supportsAnnualPricing = Boolean(standardPlanPricing?.annual) + const [annual, setAnnual] = useState(false) + + useEffect(() => { + if (!show) { + setAnnual(false) + } + }, [show]) + + const billingPeriod: BillingPeriod = annual ? 'annual' : 'monthly' + const planCode = PLAN_CODE[billingPeriod] + + const hasShownRef = useRef(false) + useEffect(() => { + if (show && !hasShownRef.current) { + const countryCode = getMeta('ol-countryCode') + + sendMB('paywall-plans-page-view', { + currency, + countryCode, + version: 'compile-timeout', + }) + + hasShownRef.current = true + } else if (!show) { + hasShownRef.current = false + } + }, [show, currency]) + + const handleToggleBilling = useCallback(() => { + const nextAnnual = !annual + setAnnual(nextAnnual) + sendMB('paywall-plans-page-toggle', { + 'billing-period': nextAnnual ? 'annual' : 'monthly', + checked: nextAnnual ? 'checked' : 'unchecked', + version: 'compile-timeout', + }) + }, [annual]) + + const openCheckout = useCallback(() => { + const params = new URLSearchParams({ + itm_campaign: 'compile-timeout', + }) + + window.open( + `/user/subscription/new?planCode=${planCode}&${params.toString()}`, + '_blank', + 'noopener,noreferrer' + ) + }, [planCode]) + + const handleUpgrade = useCallback(() => { + sendMB('paywall-plans-page-click', { + plan: 'collaborator', + 'billing-period': billingPeriod, + currency, + version: 'compile-timeout', + button: 'buy', + }) + openCheckout() + }, [billingPeriod, currency, openCheckout]) + + const viewAllPlansHref = useMemo(() => { + const params = new URLSearchParams({ + itm_campaign: 'compile-timeout', + }) + return `/user/subscription/choose-your-plan?${params.toString()}` + }, []) + + const handleViewAllPlans = useCallback(() => { + sendMB('paywall-plans-page-click', { + button: 'plans', + 'billing-period': billingPeriod, + currency, + version: 'compile-timeout', + }) + }, [billingPeriod, currency]) + + const price = annual + ? standardPlanPricing?.annual + : standardPlanPricing?.monthly + + const priceSubtext = annual ? t('per_year') : t('per_month') + + const strikethroughPrice = annual + ? standardPlanPricing?.monthlyTimesTwelve + : undefined + + return ( + + +
+ {t('get_more_compile_time')} +
+
+ + +
+
+
+

+ {t('standard')} {t('plan')} +

+
+ {strikethroughPrice ? ( + + {strikethroughPrice} + + ) : ( + + )} + {price} + {priceSubtext && ( + + {priceSubtext} + + )} +
+
+ + {supportsAnnualPricing && ( +
+ { + if ((period === 'annual') !== annual) { + handleToggleBilling() + } + }} + variant="premium" + /> +
+ )} +
+ +
+
+ +
+
+

+ {t('compile_timeout_modal_intro')} +

+
+ + {t('collabs_per_proj', { collabcount: 10 })} + + {t('track_changes')} + {t('github_integration')} + {t('and_much_more')} +
+
+
+ +
+ + {t('buy_now_no_exclamation_mark')} + + + + {t('view_all_plans')} + +
+
+
+
+ ) +} + +function ListItem({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +export default CompileTimeoutPaywallModal diff --git a/services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt-new.tsx b/services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt-new.tsx index b2a49c21c8..b279f1ca84 100644 --- a/services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt-new.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt-new.tsx @@ -1,7 +1,7 @@ import { Trans, useTranslation } from 'react-i18next' import { useDetachCompileContext } from '../../../shared/context/detach-compile-context' import StartFreeTrialButton from '../../../shared/components/start-free-trial-button' -import { memo, useCallback, useMemo } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import PdfLogEntry from './pdf-log-entry' import { useStopOnFirstError } from '../../../shared/hooks/use-stop-on-first-error' import OLButton from '@/shared/components/ol/ol-button' @@ -13,6 +13,7 @@ import { useIsNewEditorEnabled, } from '@/features/ide-redesign/utils/new-editor-utils' import { getSplitTestVariant, isSplitTestEnabled } from '@/utils/splitTestUtils' +import CompileTimeoutPaywallModal from '@/features/pdf-preview/components/compile-timeout-paywall-modal' function TimeoutUpgradePromptNew() { const { @@ -26,6 +27,13 @@ function TimeoutUpgradePromptNew() { 'compile-timeout-remove-info' ) + const isCompileTimeoutTargetPlansEnabled = isSplitTestEnabled( + 'compile-timeout-target-plans' + ) + + const [showCompileTimeoutPaywall, setShowCompileTimeoutPaywall] = + useState(false) + const { enableStopOnFirstError } = useStopOnFirstError({ eventSource: 'timeout-new', }) @@ -56,6 +64,8 @@ function TimeoutUpgradePromptNew() { setShowCompileTimeoutPaywall(true)} + isCompileTimeoutTargetPlansEnabled={isCompileTimeoutTargetPlansEnabled} /> {getMeta('ol-ExposedSettings').enableSubscriptions && !shouldHideCompileTimeoutInfo && ( @@ -66,6 +76,10 @@ function TimeoutUpgradePromptNew() { lastCompileOptions={lastCompileOptions} /> )} + setShowCompileTimeoutPaywall(false)} + /> ) } @@ -73,11 +87,15 @@ function TimeoutUpgradePromptNew() { type CompileTimeoutProps = { isProjectOwner: boolean segmentation: eventTracking.Segmentation + onShowPaywallModal: () => void + isCompileTimeoutTargetPlansEnabled: boolean } const CompileTimeout = memo(function CompileTimeout({ isProjectOwner, segmentation, + onShowPaywallModal, + isCompileTimeoutTargetPlansEnabled, }: CompileTimeoutProps) { const { t } = useTranslation() const extraSearchParams = useMemo(() => { @@ -96,6 +114,17 @@ const CompileTimeout = memo(function CompileTimeout({ } }, []) + const handleFreeTrialClick = useCallback( + (event: React.MouseEvent) => { + if (isCompileTimeoutTargetPlansEnabled) { + event.preventDefault() + event.stopPropagation() + onShowPaywallModal() + } + }, + [isCompileTimeoutTargetPlansEnabled, onShowPaywallModal] + ) + return ( {t('start_a_free_trial')} diff --git a/services/web/frontend/js/shared/components/billing-period-toggle.tsx b/services/web/frontend/js/shared/components/billing-period-toggle.tsx new file mode 100644 index 0000000000..0cc468f349 --- /dev/null +++ b/services/web/frontend/js/shared/components/billing-period-toggle.tsx @@ -0,0 +1,66 @@ +import { useTranslation } from 'react-i18next' +import '../../../stylesheets/components/billing-period-toggle.scss' + +export type BillingPeriod = 'monthly' | 'annual' + +export type BillingPeriodToggleProps = { + value: BillingPeriod + onChange: (period: BillingPeriod) => void + id?: string + showDiscount?: boolean + variant?: 'default' | 'premium' +} + +export function BillingPeriodToggle({ + value, + onChange, + id = 'billing-period', + showDiscount = true, + variant = 'default', +}: BillingPeriodToggleProps) { + const { t } = useTranslation() + + return ( +
+ + {t('billing_period_sentence_case')} + + { + if (value !== 'annual') { + onChange('annual') + } + }} + /> + + + { + if (value !== 'monthly') { + onChange('monthly') + } + }} + /> + +
+ ) +} + +export default BillingPeriodToggle diff --git a/services/web/frontend/js/shared/components/start-free-trial-button.tsx b/services/web/frontend/js/shared/components/start-free-trial-button.tsx index 0226ced4d8..d035bd43f1 100644 --- a/services/web/frontend/js/shared/components/start-free-trial-button.tsx +++ b/services/web/frontend/js/shared/components/start-free-trial-button.tsx @@ -44,6 +44,9 @@ export default function StartFreeTrialButton({ if (handleClick) { handleClick(event) + if (event.isPropagationStopped()) { + return + } } startFreeTrial(source, variant, segmentation, extraSearchParams) diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 704dfe5347..fd67855a8b 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -270,6 +270,11 @@ export interface Meta { 'ol-ssoDisabled': boolean 'ol-ssoErrorMessage': string 'ol-ssoInitPath': string + 'ol-standardPlanPricing': { + monthly?: string + annual?: string + monthlyTimesTwelve?: string + } 'ol-stripeAccountId': string 'ol-stripePublicKeyUK': string 'ol-stripePublicKeyUS': string diff --git a/services/web/frontend/stories/shared/billing-period-toggle.stories.tsx b/services/web/frontend/stories/shared/billing-period-toggle.stories.tsx new file mode 100644 index 0000000000..ea773e2554 --- /dev/null +++ b/services/web/frontend/stories/shared/billing-period-toggle.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' +import BillingPeriodToggle, { + type BillingPeriod, +} from '../../js/shared/components/billing-period-toggle' + +type Args = React.ComponentProps + +const meta: Meta = { + title: 'Subscription / Billing Period Toggle', + component: BillingPeriodToggle, + parameters: { + controls: { + include: ['value', 'showDiscount', 'variant'], + }, + }, + argTypes: { + value: { + control: 'radio', + options: ['monthly', 'annual'], + }, + showDiscount: { + control: 'boolean', + }, + variant: { + control: 'radio', + options: ['default', 'premium'], + }, + onChange: { action: 'onChange' }, + }, + args: { + value: 'monthly', + showDiscount: true, + variant: 'default', + }, +} + +export default meta + +type Story = StoryObj + +const InteractiveToggle = (args: Args) => { + const [value, setValue] = useState(args.value) + return ( + { + setValue(period) + args.onChange?.(period) + }} + /> + ) +} + +export const Default: Story = { + render: InteractiveToggle, +} + +export const WithoutDiscount: Story = { + render: args => , +} + +export const AnnualSelected: Story = { + args: { + value: 'annual', + }, + render: InteractiveToggle, +} + +export const PremiumVariant: Story = { + args: { + variant: 'premium', + }, + render: InteractiveToggle, +} + +export const PremiumVariantAnnualSelected: Story = { + args: { + variant: 'premium', + value: 'annual', + }, + render: InteractiveToggle, +} diff --git a/services/web/frontend/stylesheets/components/billing-period-toggle.scss b/services/web/frontend/stylesheets/components/billing-period-toggle.scss new file mode 100644 index 0000000000..b2809b139c --- /dev/null +++ b/services/web/frontend/stylesheets/components/billing-period-toggle.scss @@ -0,0 +1,84 @@ +@import '../foundations/all'; +@import '../abstracts/mixins'; + +.billing-period-toggle { + position: relative; + display: inline-flex; + background-color: var(--bg-light-secondary); + border-radius: var(--border-radius-full); + padding: var(--spacing-03); + gap: var(--spacing-04); + border: none; + + label { + display: inline-flex; + align-items: center; + margin: 0; + font-size: var(--font-size-05); + font-weight: 600; + line-height: var(--line-height-04); + text-align: center; + padding: var(--spacing-01) var(--spacing-04); + border-radius: var(--border-radius-full); + + &:hover { + background-color: var(--neutral-20); + cursor: pointer; + } + } + + input[type='radio'] { + position: absolute; + left: -9999px; + + &:focus, + &:focus-visible { + outline: 0; + } + } + + input[type='radio']:focus-visible + label, + input[type='radio']:checked:focus-visible + label { + box-shadow: 0 0 0 2px var(--blue-30); + outline: 2px solid transparent; + } + + input[type='radio']:checked + label { + background-color: var(--green-50); + color: white; + /* stylelint-disable-next-line length-zero-no-unit, color-function-notation, alpha-value-notation */ + box-shadow: 0px 2px 4px 0px rgba(30, 37, 48, 0.16); + + .billing-period-toggle-discount-badge { + background-color: var(--green-10); + color: var(--green-60); + } + } + + &.billing-period-toggle-premium { + input[type='radio']:checked + label { + background: var(--premium-gradient); + color: white; + + .billing-period-toggle-discount-badge { + background-color: var(--green-10); + color: var(--blue-70); + } + } + } +} + +.billing-period-toggle-discount-badge { + font-size: var(--font-size-01); + font-family: 'DM Mono', monospace; + padding: 2px 8px; + height: 20px; + border-radius: 10px; + background-color: var(--neutral-70); + color: white; + display: flex; + align-items: center; + font-weight: 500; + line-height: var(--line-height-01); + margin-left: var(--spacing-03); +} diff --git a/services/web/frontend/stylesheets/components/compile-time-paywall-modal.scss b/services/web/frontend/stylesheets/components/compile-time-paywall-modal.scss new file mode 100644 index 0000000000..60e2d1648a --- /dev/null +++ b/services/web/frontend/stylesheets/components/compile-time-paywall-modal.scss @@ -0,0 +1,144 @@ +@import '../foundations/all'; +@import '../abstracts/mixins'; + +.compile-time-paywall-modal { + .modal-dialog { + max-width: 600px; + } + + .modal-header .modal-title { + margin: var(--spacing-09) 0 var(--spacing-06); + font-weight: 600; + font-size: var(--font-size-08); + text-align: center; + line-height: var(--line-height-07); + } + + .compile-time-paywall-card { + display: flex; + flex-direction: column; + gap: var(--spacing-07); + padding: var(--spacing-08); + border: 2px solid transparent; + border-radius: var(--border-radius-medium); + background: + linear-gradient(var(--bg-light-primary), var(--bg-light-primary)) + padding-box, + var(--premium-gradient) border-box; + } + + .compile-time-paywall-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--spacing-09); + } + + .compile-time-paywall-period-toggle { + margin-top: -8px; + } + + .compile-time-paywall-plan-meta, + .compile-time-paywall-price-container, + .compile-time-paywall-feature-list, + .compile-time-paywall-actions { + display: flex; + flex-direction: column; + } + + .compile-time-paywall-plan-meta { + flex: 1; + } + + .compile-time-paywall-plan-label { + font-size: var(--font-size-05); + font-weight: 600; + color: var(--content-secondary); + margin: 0 0 var(--spacing-05); + } + + .compile-time-paywall-price-container { + align-items: flex-start; + } + + .compile-time-paywall-price { + font-weight: 600; + font-size: var(--font-size-08); + line-height: var(--line-height-07); + } + + .compile-time-paywall-price-strikethrough { + font-weight: 600; + font-size: var(--font-size-06); + line-height: var(--line-height-05); + color: var(--neutral-60); + text-decoration: line-through; + } + + .compile-time-paywall-price-subtext { + font-size: var(--font-size-02); + color: var(--content-secondary); + line-height: var(--line-height-02); + } + + .compile-time-paywall-body { + display: flex; + gap: var(--spacing-09); + align-items: flex-start; + justify-content: space-between; + } + + .compile-time-paywall-illustration { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + flex-shrink: 0; + + @include premium-text; + } + + .compile-time-paywall-illustration .material-symbols { + font-size: 150px; + } + + .compile-time-paywall-content { + flex: 1.2; + } + + .compile-time-paywall-intro { + font-size: var(--font-size-03); + line-height: var(--line-height-03); + margin-bottom: var(--spacing-04); + } + + .compile-time-paywall-feature-list { + gap: var(--spacing-04); + } + + .compile-time-paywall-list-item { + display: flex; + align-items: flex-start; + gap: var(--spacing-05); + font-size: var(--font-size-02); + line-height: var(--line-height-02); + } + + .compile-time-paywall-list-item .material-symbols { + font-size: var(--font-size-04); + flex-shrink: 0; + + @include premium-text; + } + + .compile-time-paywall-actions { + align-items: stretch; + gap: var(--spacing-03); + } + + .compile-time-paywall-view-plans { + font-size: var(--font-size-02); + text-decoration: none; + font-weight: 600; + } +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index ef9cd23888..0df1e6fc7a 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -170,6 +170,7 @@ "an_error_occured_while_restoring_project": "An error occured while restoring the project", "an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code", "and": "and", + "and_much_more": "and much more", "annual": "Annual", "annual_discount": "Annual discount", "anonymous": "Anonymous", @@ -394,6 +395,7 @@ "compile_servers": "Compile servers", "compile_servers_info_new": "The servers used to compile your project. Compiles for users on paid plans always run on the fastest available servers.", "compile_terminated_by_user": "The compile was cancelled using the ‘Stop compilation’ button. You can download the raw logs to see where the compile stopped.", + "compile_timeout_modal_intro": "24x compile time on the fastest servers, plus...", "compile_timeout_short": "Compile timeout", "compile_timeout_short_info_new": "This is how much time you get to compile your project on Overleaf. You may need additional time for longer or more complex projects.", "compiler": "Compiler", @@ -863,6 +865,7 @@ "get_in_touch": "Get in touch", "get_in_touch_having_problems": "Get in touch with support if you’re having problems", "get_involved": "Get involved", + "get_more_compile_time": "Get more compile time", "get_most_subscription_by_checking_ai_writefull": "Get the most out of your subscription by checking out <0>Overleaf’s AI features and <1>Writefull’s features.", "get_most_subscription_by_checking_overleaf": "Get the most out of your subscription by checking out <0>Overleaf’s features.", "get_most_subscription_by_checking_overleaf_ai_writefull": "Get the most out of your subscription by checking out <0>Overleaf’s features, <1>Overleaf’s AI features and <2>Writefull’s features.", @@ -895,6 +898,7 @@ "github_file_sync_error": "We are unable to sync the following files:", "github_git_and_dropbox_integrations": "<0>Github, <0>Git and <0>Dropbox integrations", "github_git_folder_error": "This project contains a .git folder at the top level, indicating that it is already a git repository. The Overleaf GitHub sync service cannot sync git histories. Please remove the .git folder and try again.", + "github_integration": "GitHub integration", "github_integration_lowercase": "Git and GitHub integration", "github_is_no_longer_connected": "GitHub is no longer connected to this project.", "github_is_premium": "GitHub Sync is a premium feature", @@ -2632,6 +2636,7 @@ "verify_your_email_address": "Verify your email address", "view": "View", "view_all": "View all", + "view_all_plans": "View all plans", "view_audit_logs_group_subtext": "View and download audit logs for your group subscription", "view_billing_details": "View billing details", "view_code": "View code", diff --git a/services/web/test/unit/src/Project/ProjectController.test.mjs b/services/web/test/unit/src/Project/ProjectController.test.mjs index 4bd364fd69..553b45bb58 100644 --- a/services/web/test/unit/src/Project/ProjectController.test.mjs +++ b/services/web/test/unit/src/Project/ProjectController.test.mjs @@ -31,6 +31,16 @@ describe('ProjectController', function () { algolia: {}, plans: [], features: {}, + localizedPlanPricing: { + USD: { + collaborator: { + monthly: 15, + annual: 180, + annualDividedByTwelve: 15, + monthlyTimesTwelve: 180, + }, + }, + }, } ctx.brandVariationDetails = { id: '12', @@ -64,6 +74,7 @@ describe('ProjectController', function () { }, } ctx.SubscriptionController = { + getRecommendedCurrency: sinon.stub().resolves({ currency: 'USD' }), promises: { getRecommendedCurrency: sinon.stub().resolves({ currency: 'USD' }), },