Merge pull request #29394 from overleaf/rh-compile-timeout-modal
Add compile timeout modal for compile-timeout-target-plans test GitOrigin-RevId: b352cb239742aa7ffbef7f3cd5c65ac719569ebf
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
+240
@@ -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 (
|
||||
<OLModal
|
||||
className="compile-time-paywall-modal"
|
||||
size="sm"
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
centered
|
||||
>
|
||||
<OLModalHeader className="border-0 pb-0" closeButton>
|
||||
<div className="w-100 text-center">
|
||||
<OLModalTitle>{t('get_more_compile_time')}</OLModalTitle>
|
||||
</div>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<div className="compile-time-paywall-card">
|
||||
<div className="compile-time-paywall-header">
|
||||
<div className="compile-time-paywall-plan-meta">
|
||||
<p className="compile-time-paywall-plan-label">
|
||||
{t('standard')} {t('plan')}
|
||||
</p>
|
||||
<div className="compile-time-paywall-price-container">
|
||||
{strikethroughPrice ? (
|
||||
<span className="compile-time-paywall-price-strikethrough">
|
||||
{strikethroughPrice}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="compile-time-paywall-price-strikethrough"
|
||||
style={{ visibility: 'hidden' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
</span>
|
||||
)}
|
||||
<span className="compile-time-paywall-price">{price}</span>
|
||||
{priceSubtext && (
|
||||
<span className="compile-time-paywall-price-subtext">
|
||||
{priceSubtext}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{supportsAnnualPricing && (
|
||||
<div className="compile-time-paywall-period-toggle">
|
||||
<BillingPeriodToggle
|
||||
id="compile-timeout-period"
|
||||
value={annual ? 'annual' : 'monthly'}
|
||||
onChange={(period: BillingPeriod) => {
|
||||
if ((period === 'annual') !== annual) {
|
||||
handleToggleBilling()
|
||||
}
|
||||
}}
|
||||
variant="premium"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="compile-time-paywall-body">
|
||||
<div className="compile-time-paywall-illustration">
|
||||
<MaterialIcon type="rocket_launch" />
|
||||
</div>
|
||||
<div className="compile-time-paywall-content">
|
||||
<p className="compile-time-paywall-intro">
|
||||
<strong>{t('compile_timeout_modal_intro')}</strong>
|
||||
</p>
|
||||
<div className="compile-time-paywall-feature-list">
|
||||
<ListItem>
|
||||
{t('collabs_per_proj', { collabcount: 10 })}
|
||||
</ListItem>
|
||||
<ListItem>{t('track_changes')}</ListItem>
|
||||
<ListItem>{t('github_integration')}</ListItem>
|
||||
<ListItem>{t('and_much_more')}</ListItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="compile-time-paywall-actions">
|
||||
<OLButton
|
||||
variant="premium"
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={handleUpgrade}
|
||||
>
|
||||
{t('buy_now_no_exclamation_mark')}
|
||||
</OLButton>
|
||||
|
||||
<OLButton
|
||||
className="compile-time-paywall-view-plans"
|
||||
variant="ghost"
|
||||
href={viewAllPlansHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleViewAllPlans}
|
||||
>
|
||||
{t('view_all_plans')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</div>
|
||||
</OLModalBody>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
function ListItem({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="compile-time-paywall-list-item">
|
||||
<MaterialIcon type="check_circle" />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CompileTimeoutPaywallModal
|
||||
+31
-1
@@ -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() {
|
||||
<CompileTimeout
|
||||
isProjectOwner={isProjectOwner}
|
||||
segmentation={sharedSegmentation}
|
||||
onShowPaywallModal={() => setShowCompileTimeoutPaywall(true)}
|
||||
isCompileTimeoutTargetPlansEnabled={isCompileTimeoutTargetPlansEnabled}
|
||||
/>
|
||||
{getMeta('ol-ExposedSettings').enableSubscriptions &&
|
||||
!shouldHideCompileTimeoutInfo && (
|
||||
@@ -66,6 +76,10 @@ function TimeoutUpgradePromptNew() {
|
||||
lastCompileOptions={lastCompileOptions}
|
||||
/>
|
||||
)}
|
||||
<CompileTimeoutPaywallModal
|
||||
show={showCompileTimeoutPaywall}
|
||||
onHide={() => 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<HTMLButtonElement>) => {
|
||||
if (isCompileTimeoutTargetPlansEnabled) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onShowPaywallModal()
|
||||
}
|
||||
},
|
||||
[isCompileTimeoutTargetPlansEnabled, onShowPaywallModal]
|
||||
)
|
||||
|
||||
return (
|
||||
<PdfLogEntry
|
||||
autoExpand
|
||||
@@ -132,6 +161,7 @@ const CompileTimeout = memo(function CompileTimeout({
|
||||
buttonProps={{ variant: 'primary', className: 'w-100' }}
|
||||
segmentation={segmentation}
|
||||
extraSearchParams={extraSearchParams}
|
||||
handleClick={handleFreeTrialClick}
|
||||
>
|
||||
{t('start_a_free_trial')}
|
||||
</StartFreeTrialButton>
|
||||
|
||||
@@ -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 (
|
||||
<fieldset
|
||||
className={`billing-period-toggle ${variant === 'premium' ? 'billing-period-toggle-premium' : ''}`}
|
||||
>
|
||||
<legend className="visually-hidden">
|
||||
{t('billing_period_sentence_case')}
|
||||
</legend>
|
||||
<input
|
||||
type="radio"
|
||||
id={`${id}-annual`}
|
||||
name={id}
|
||||
checked={value === 'annual'}
|
||||
onChange={() => {
|
||||
if (value !== 'annual') {
|
||||
onChange('annual')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={`${id}-annual`}>
|
||||
{t('yearly')}
|
||||
{showDiscount && (
|
||||
<span className="billing-period-toggle-discount-badge">
|
||||
{t('save_20_percent')}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="radio"
|
||||
id={`${id}-monthly`}
|
||||
name={id}
|
||||
checked={value === 'monthly'}
|
||||
onChange={() => {
|
||||
if (value !== 'monthly') {
|
||||
onChange('monthly')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={`${id}-monthly`}>{t('monthly')}</label>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
export default BillingPeriodToggle
|
||||
@@ -44,6 +44,9 @@ export default function StartFreeTrialButton({
|
||||
|
||||
if (handleClick) {
|
||||
handleClick(event)
|
||||
if (event.isPropagationStopped()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
startFreeTrial(source, variant, segmentation, extraSearchParams)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof BillingPeriodToggle>
|
||||
|
||||
const meta: Meta<Args> = {
|
||||
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<Args>
|
||||
|
||||
const InteractiveToggle = (args: Args) => {
|
||||
const [value, setValue] = useState<BillingPeriod>(args.value)
|
||||
return (
|
||||
<BillingPeriodToggle
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={period => {
|
||||
setValue(period)
|
||||
args.onChange?.(period)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: InteractiveToggle,
|
||||
}
|
||||
|
||||
export const WithoutDiscount: Story = {
|
||||
render: args => <InteractiveToggle {...args} showDiscount={false} />,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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": "<a href=\"__link__\">Get in touch with support</a> 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</0> and <1>Writefull’s features</1>.",
|
||||
"get_most_subscription_by_checking_overleaf": "Get the most out of your subscription by checking out <0>Overleaf’s features</0>.",
|
||||
"get_most_subscription_by_checking_overleaf_ai_writefull": "Get the most out of your subscription by checking out <0>Overleaf’s features</0>, <1>Overleaf’s AI features</1> and <2>Writefull’s features</2>.",
|
||||
@@ -895,6 +898,7 @@
|
||||
"github_file_sync_error": "We are unable to sync the following files:",
|
||||
"github_git_and_dropbox_integrations": "<0>Github</0>, <0>Git</0> and <0>Dropbox</0> 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",
|
||||
|
||||
@@ -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' }),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user