Merge pull request #11646 from overleaf/ii-payment-page-payment-preview-panel

[web] Payment page payment preview panel

GitOrigin-RevId: 35f2e11a9a80e8b240dc8485b8062cf33d5b40b4
This commit is contained in:
ilkin-overleaf 2023-02-09 11:48:40 +02:00 committed by Copybot
parent a010822246
commit b0841592c7
16 changed files with 406 additions and 40 deletions

View file

@ -186,6 +186,7 @@ async function _paymentReactPage(req, res) {
currency,
countryCode,
plan,
planCode: req.query.planCode,
couponCode: req.query.cc,
showCouponField: !!req.query.scf,
itm_campaign: req.query.itm_campaign,

View file

@ -14,7 +14,7 @@ block append meta
meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey)
meta(name="ol-recommendedCurrency" content=String(currency).slice(0, 3))
meta(name="ol-plan" data-type="json" content=plan)
meta(name="ol-currencySymbols" data-type="json" content=settings.groupPlanModalOptions.currencySymbols)
meta(name="ol-planCode" data-type="string" content=planCode)
meta(name="ol-showCouponField" data-type="boolean" content=showCouponField)
meta(name="ol-couponCode" content=couponCode)
meta(name="ol-itm_campaign" content=itm_campaign)
@ -28,7 +28,7 @@ block content
main.content.content-alt#subscription-new-root
script(type="text/javascript", nonce=scriptNonce).
ga('send', 'event', 'pageview', 'payment_form', "#{plan_code}")
ga('send', 'event', 'pageview', 'payment_form', "#{planCode}")
script(
type="text/ng-template"

View file

@ -32,6 +32,7 @@
"additional_licenses": "",
"address_line_1": "",
"address_second_line_optional": "",
"all_premium_features_including": "",
"all_projects": "",
"also": "",
"an_error_occurred_when_verifying_the_coupon_code": "",
@ -69,6 +70,7 @@
"can_link_your_institution_acct_2": "",
"can_now_relink_dropbox": "",
"cancel": "",
"cancel_anytime": "",
"cancel_your_subscription": "",
"cannot_invite_non_user": "",
"cannot_invite_self": "",
@ -84,6 +86,7 @@
"category_operators": "",
"category_relations": "",
"change": "",
"change_currency": "",
"change_or_cancel-cancel": "",
"change_or_cancel-change": "",
"change_or_cancel-or": "",
@ -108,6 +111,7 @@
"code_check_failed_explanation": "",
"collaborate_online_and_offline": "",
"collabs_per_proj": "",
"collabs_per_proj_single": "",
"collapse": "",
"commit": "",
"common": "",
@ -169,6 +173,7 @@
"did_you_know_institution_providing_professional": "",
"did_you_know_that_overleaf_offers": "",
"disable_stop_on_first_error": "",
"discount_of": "",
"dismiss": "",
"dismiss_error_popup": "",
"do_you_want_to_overwrite_them": "",
@ -239,6 +244,7 @@
"find_out_more_nt": "",
"find_the_symbols_you_need_with_premium": "",
"first_name": "",
"first_x_days_free_after_that_y_per_month": "",
"fold_line": "",
"following_paths_conflict": "",
"font_family": "",
@ -360,6 +366,7 @@
"importing_and_merging_changes_in_github": "",
"in_order_to_match_institutional_metadata_2": "",
"in_order_to_match_institutional_metadata_associated": "",
"increased_compile_timeout": "",
"institution": "",
"institution_account": "",
"institution_acct_successfully_linked_2": "",
@ -488,6 +495,8 @@
"no_search_results": "",
"no_symbols_found": "",
"normal": "",
"normally_x_price_per_month": "",
"normally_x_price_per_year": "",
"notification_project_invite_accepted_message": "",
"notification_project_invite_message": "",
"oauth_orcid_description": "",
@ -515,6 +524,7 @@
"password_managed_externally": "",
"password_was_detected_on_a_public_list_of_known_compromised_passwords": "",
"payment_provider_unreachable_error": "",
"payment_summary": "",
"pdf_compile_in_progress_error": "",
"pdf_compile_rate_limit_hit": "",
"pdf_compile_try_again": "",
@ -722,7 +732,9 @@
"sure_you_want_to_delete": "",
"switch_to_editor": "",
"switch_to_pdf": "",
"symbol_palette": "",
"sync": "",
"sync_dropbox_github": "",
"sync_project_to_github_explanation": "",
"sync_to_dropbox": "",
"sync_to_github": "",
@ -741,6 +753,8 @@
"thank_you_exclamation": "",
"thanks_settings_updated": "",
"the_following_files_already_exist_in_this_project": "",
"then_x_price_per_month": "",
"then_x_price_per_year": "",
"this_action_cannot_be_undone": "",
"this_address_will_be_shown_on_the_invoice": "",
"this_field_is_required": "",
@ -762,7 +776,10 @@
"too_many_requests": "",
"too_many_search_results": "",
"too_recently_compiled": "",
"total_per_month": "",
"total_per_year": "",
"total_words": "",
"track_changes": "",
"trash": "",
"trash_projects": "",
"trashed": "",
@ -784,6 +801,7 @@
"unconfirmed": "",
"unfold_line": "",
"university": "",
"unlimited_collabs": "",
"unlimited_projects": "",
"unlink": "",
"unlink_dropbox_folder": "",
@ -815,6 +833,7 @@
"user_deletion_password_reset_tip": "",
"user_sessions": "",
"validation_issue_entry_description": "",
"vat": "",
"vat_number": "",
"view_all": "",
"view_logs": "",
@ -831,6 +850,9 @@
"word_count": "",
"work_offline": "",
"work_with_non_overleaf_users": "",
"x_price_for_first_month": "",
"x_price_for_first_year": "",
"x_price_for_y_months": "",
"year": "",
"you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
"you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",

View file

@ -34,7 +34,8 @@ function CheckoutPanel() {
const { t } = useTranslation()
const {
couponError,
plan,
planCode,
planName,
pricingFormState,
pricing,
recurlyLoadError,
@ -150,7 +151,7 @@ function CheckoutPanel() {
eventTracking.send(
'subscription-funnel',
'subscription-submission-success',
plan.planCode
planCode
)
window.location.assign('/user/subscription/thank-you')
} catch (error) {
@ -173,7 +174,7 @@ function CheckoutPanel() {
ITMReferrer,
isAddCompanyDetailsChecked,
isPayPalPaymentMethod,
plan.planCode,
planCode,
pricing,
pricingFormState,
t,
@ -184,7 +185,7 @@ function CheckoutPanel() {
useEffect(() => {
payPal.current = recurly.PayPal({
display: { displayName: plan.name },
display: { displayName: planName },
})
payPal.current.on('token', token => {
@ -202,7 +203,7 @@ function CheckoutPanel() {
return () => {
payPalCopy.destroy()
}
}, [completeSubscription, plan.name])
}, [completeSubscription, planName])
const handleCardChange = useCallback((state: CardElementChangeState) => {
setCardIsValid(state.valid)
@ -291,7 +292,7 @@ function CheckoutPanel() {
)}
<div className={classnames({ hidden: threeDSecureActionTokenId })}>
<PriceSwitchHeader
planCode={plan.planCode}
planCode={planCode}
planCodes={[
'student-annual',
'student-monthly',

View file

@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next'
import { Plan } from '../../../../../../../types/subscription/plan'
function CollaboratorsWrapper({ children }: { children: React.ReactNode }) {
return <div className="text-small number-of-collaborators">{children}</div>
}
type CollaboratorsProps = {
count: NonNullable<Plan['features']>['collaborators']
}
function Collaborators({ count }: CollaboratorsProps) {
const { t } = useTranslation()
if (count === 1) {
return (
<CollaboratorsWrapper>
{t('collabs_per_proj_single', { collabcount: 1 })}
</CollaboratorsWrapper>
)
}
if (count > 1) {
return (
<CollaboratorsWrapper>
{t('collabs_per_proj', { collabcount: count })}
</CollaboratorsWrapper>
)
}
if (count === -1) {
return <CollaboratorsWrapper>{t('unlimited_collabs')}</CollaboratorsWrapper>
}
return null
}
export default Collaborators

View file

@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next'
import { Dropdown, DropdownProps, MenuItem } from 'react-bootstrap'
import Icon from '../../../../../shared/components/icon'
import { usePaymentContext } from '../../../context/payment-context'
function CurrencyDropdown(props: DropdownProps) {
const { t } = useTranslation()
const { currencyCode, limitedCurrencies, changeCurrency } =
usePaymentContext()
return (
<Dropdown {...props}>
<Dropdown.Toggle
className="change-currency-toggle"
bsStyle="link"
noCaret
>
{t('change_currency')}
</Dropdown.Toggle>
<Dropdown.Menu>
{Object.entries(limitedCurrencies).map(([currency, symbol]) => (
<MenuItem
eventKey={currency}
key={currency}
onSelect={eventKey => changeCurrency(eventKey)}
>
{currency === currencyCode && (
<span className="change-currency-dropdown-selected-icon">
<Icon type="check" />
</span>
)}
{currency} ({symbol})
</MenuItem>
))}
</Dropdown.Menu>
</Dropdown>
)
}
export default CurrencyDropdown

View file

@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next'
import { Plan } from '../../../../../../../types/subscription/plan'
type FeaturesListProps = {
features: NonNullable<Plan['features']>
}
function FeaturesList({ features }: FeaturesListProps) {
const { t } = useTranslation()
return (
<>
<div className="text-small">{t('all_premium_features_including')}</div>
<ul className="small">
{features.compileTimeout > 1 && (
<li>{t('increased_compile_timeout')}</li>
)}
{features.dropbox && features.github && (
<li>{t('sync_dropbox_github')}</li>
)}
{features.versioning && <li>{t('full_doc_history')}</li>}
{features.trackChanges && <li>{t('track_changes')}</li>}
{features.references && <li>{t('reference_search')}</li>}
{(features.mendeley || features.zotero) && (
<li>{t('reference_sync')}</li>
)}
{features.symbolPalette && <li>{t('symbol_palette')}</li>}
</ul>
</>
)
}
export default FeaturesList

View file

@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next'
import { usePaymentContext } from '../../../context/payment-context'
function NoDiscountPrice() {
const { t } = useTranslation()
const { currencySymbol, monthlyBilling, coupon } = usePaymentContext()
if (coupon?.normalPrice === undefined) {
return null
}
const price = `${currencySymbol}${coupon.normalPrice.toFixed(2)}`
return (
<div>
{!coupon.singleUse &&
coupon.discountMonths &&
coupon.discountMonths > 0 &&
monthlyBilling &&
t('then_x_price_per_month', { price })}
{!coupon.singleUse &&
!coupon.discountMonths &&
monthlyBilling &&
t('normally_x_price_per_month', { price })}
{!coupon.singleUse &&
!monthlyBilling &&
t('normally_x_price_per_year', { price })}
{coupon.singleUse &&
monthlyBilling &&
t('then_x_price_per_month', { price })}
{coupon.singleUse &&
!monthlyBilling &&
t('then_x_price_per_year', { price })}
</div>
)
}
export default NoDiscountPrice

View file

@ -1,5 +1,43 @@
import { useTranslation } from 'react-i18next'
import Collaborators from './collaborators'
import FeaturesList from './features-list'
import PriceSummary from './price-summary'
import TrialPrice from './trial-price'
import NoDiscountPrice from './no-discount-price'
import PriceForFirstXPeriod from './price-for-first-x-period'
import { usePaymentContext } from '../../../context/payment-context'
function PaymentPreviewPanel() {
return <h3>Preview panel</h3>
const { t } = useTranslation()
const { plan, planName } = usePaymentContext()
const trialPrice = <TrialPrice />
const priceForFirstXPeriod = <PriceForFirstXPeriod />
const noDiscountPrice = <NoDiscountPrice />
return (
<div className="price-feature-description">
<h4>{planName}</h4>
{plan.features && (
<>
<Collaborators count={plan.features.collaborators} />
<FeaturesList features={plan.features} />
</>
)}
<PriceSummary />
{(trialPrice || priceForFirstXPeriod || noDiscountPrice) && (
<>
<hr className="thin" />
<div className="trial-coupon-summary">
{trialPrice}
{priceForFirstXPeriod}
{noDiscountPrice}
</div>
</>
)}
<hr className="thin" />
<p className="price-cancel-anytime text-center">{t('cancel_anytime')}</p>
</div>
)
}
export default PaymentPreviewPanel

View file

@ -0,0 +1,49 @@
import { Trans } from 'react-i18next'
import { usePaymentContext } from '../../../context/payment-context'
function PriceForFirstXPeriod() {
const { currencySymbol, monthlyBilling, coupon, recurlyPrice } =
usePaymentContext()
if (!recurlyPrice || !coupon) {
return null
}
const price = `${currencySymbol}${recurlyPrice.total}`
return (
<div>
{coupon.discountMonths &&
coupon.discountMonths > 0 &&
!coupon.singleUse &&
monthlyBilling && (
<Trans
i18nKey="x_price_for_y_months"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{
discountMonths: coupon.discountMonths,
price,
}}
/>
)}
{coupon.singleUse && monthlyBilling && (
<Trans
i18nKey="x_price_for_first_month"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{ price }}
/>
)}
{coupon.singleUse && !monthlyBilling && (
<div>
<Trans
i18nKey="x_price_for_first_year"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{ price }}
/>
</div>
)}
</div>
)
}
export default PriceForFirstXPeriod

View file

@ -0,0 +1,78 @@
import { useTranslation } from 'react-i18next'
import { usePaymentContext } from '../../../context/payment-context'
import CurrencyDropdown from './currency-dropdown'
function PriceSummary() {
const { t } = useTranslation()
const {
coupon,
currencySymbol,
recurlyPrice,
planName,
taxes,
monthlyBilling,
} = usePaymentContext()
if (!recurlyPrice) {
return null
}
const rate = parseFloat(taxes?.[0]?.rate)
const subtotal =
coupon?.normalPriceWithoutTax.toFixed(2) ?? recurlyPrice.subtotal
return (
<>
<hr />
<div className="price-summary">
<h4>{t('payment_summary')}</h4>
<div className="small">
<div className="price-summary-line">
<span>{planName}</span>
<span>
{currencySymbol}
{subtotal}
</span>
</div>
{coupon && (
<div className="price-summary-line">
<span>{coupon.name}</span>
<span aria-hidden>
&ndash;{currencySymbol}
{recurlyPrice.discount}
</span>
<span className="sr-only">
{t('discount_of', {
amount: `${currencySymbol}${recurlyPrice.discount}`,
})}
</span>
</div>
)}
{rate > 0 && (
<div className="price-summary-line">
<span>
{t('vat')} {rate * 100}%
</span>
<span>
{currencySymbol}
{recurlyPrice.tax}
</span>
</div>
)}
<div className="price-summary-line price-summary-total-line">
<b>{monthlyBilling ? t('total_per_month') : t('total_per_year')}</b>
<b>
{currencySymbol}
{recurlyPrice.total}
</b>
</div>
</div>
<div className="change-currency">
<CurrencyDropdown id="change-currency-dropdown" />
</div>
</div>
</>
)
}
export default PriceSummary

View file

@ -0,0 +1,25 @@
import { Trans } from 'react-i18next'
import { usePaymentContext } from '../../../context/payment-context'
function TrialPrice() {
const { currencySymbol, trialLength, recurlyPrice } = usePaymentContext()
if (!trialLength || !recurlyPrice) {
return null
}
return (
<div>
<Trans
i18nKey="first_x_days_free_after_that_y_per_month"
components={[<strong />, <strong />]} // eslint-disable-line react/jsx-key
values={{
trialLen: trialLength,
price: `${currencySymbol}${recurlyPrice.total}`,
}}
/>
</div>
)
}
export default TrialPrice

View file

@ -8,6 +8,7 @@ import {
useContext,
createContext,
} from 'react'
import { currencies, CurrencyCode, CurrencySymbol } from '../data/currency'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../utils/meta'
import * as eventTracking from '../../../infrastructure/event-tracking'
@ -33,14 +34,12 @@ function usePayment({ publicKey }: RecurlyOptions) {
'ol-couponCode',
''
)
const currencySymbols: Record<
PaymentContextValue['currencyCode'],
PaymentContextValue['currencySymbol']
> = getMeta('ol-currencySymbols')
const initiallySelectedCurrencyCode: string = getMeta(
const initiallySelectedCurrencyCode: CurrencyCode = getMeta(
'ol-recommendedCurrency'
)
const planCode: string = getMeta('ol-planCode')
const [planName, setPlanName] = useState(plan.name)
const [recurlyLoading, setRecurlyLoading] = useState(true)
const [recurlyLoadError, setRecurlyLoadError] = useState(false)
const [recurlyPrice, setRecurlyPrice] = useState<{
@ -83,12 +82,12 @@ function usePayment({ publicKey }: RecurlyOptions) {
const pricing = useRef<SubscriptionPricingInstanceCustom>()
const limitedCurrencyCodes = Array.from(
new Set([initiallySelectedCurrencyCode, 'USD', 'EUR', 'GBP'])
new Set<CurrencyCode>([initiallySelectedCurrencyCode, 'USD', 'EUR', 'GBP'])
)
const limitedCurrencies = limitedCurrencyCodes.reduce((prev, cur) => {
return { ...prev, [cur]: currencySymbols[cur] }
}, {} as Record<string, string>)
const currencySymbol = limitedCurrencies[currencyCode]
return { ...prev, [cur]: currencies[cur] }
}, {} as Partial<typeof currencies>)
const currencySymbol = limitedCurrencies[currencyCode] as CurrencySymbol
useLayoutEffect(() => {
if (typeof recurly === 'undefined' || !recurly) {
@ -96,11 +95,11 @@ function usePayment({ publicKey }: RecurlyOptions) {
return
}
eventTracking.sendMB('payment-page-view', { plan: plan.planCode })
eventTracking.sendMB('payment-page-view', { plan: planCode })
eventTracking.send(
'subscription-funnel',
'subscription-form-viewed',
plan.planCode
planCode
)
recurly.configure({ publicKey })
@ -111,7 +110,7 @@ function usePayment({ publicKey }: RecurlyOptions) {
setRecurlyLoading(true)
pricing.current
?.plan(plan.planCode, { quantity: 1 })
?.plan(planCode, { quantity: 1 })
.address({
first_name: '',
last_name: '',
@ -146,7 +145,7 @@ function usePayment({ publicKey }: RecurlyOptions) {
initialCountry,
initialCouponCode,
initiallySelectedCurrencyCode,
plan.planCode,
planCode,
publicKey,
t,
])
@ -155,6 +154,11 @@ function usePayment({ publicKey }: RecurlyOptions) {
pricing.current?.on('change', function () {
if (!pricing.current) return
const planName = pricing.current.items.plan?.name
if (planName) {
setPlanName(planName)
}
const trialLength = pricing.current.items.plan?.trial?.length
setTrialLength(trialLength)
@ -168,14 +172,6 @@ function usePayment({ publicKey }: RecurlyOptions) {
setTaxes(pricing.current.price.taxes)
// TODO availableCurrencies for preview section (limitedCurrencies is implemented)
// for (const currencyCode in pricing.items.plan.price) {
// if (MultiCurrencyPricing.plans[currencyCode]) {
// $scope.availableCurrencies[currencyCode] =
// MultiCurrencyPricing.plans[currencyCode]
// }
// }
const couponData = (() => {
if (pricing.current.items.coupon?.discount.type === 'percent') {
const coupon = pricing.current.items.coupon
@ -262,9 +258,8 @@ function usePayment({ publicKey }: RecurlyOptions) {
[]
)
// TODO - for preview panel
const changeCurrency = useCallback(
(newCurrency: string) => {
(newCurrency: CurrencyCode) => {
setRecurlyLoading(true)
setCurrencyCode(newCurrency)
@ -293,6 +288,8 @@ function usePayment({ publicKey }: RecurlyOptions) {
pricingFormState,
setPricingFormState,
plan,
planCode,
planName,
pricing,
recurlyLoading,
recurlyLoadError,
@ -315,6 +312,8 @@ function usePayment({ publicKey }: RecurlyOptions) {
pricingFormState,
setPricingFormState,
plan,
planCode,
planName,
pricing,
recurlyLoading,
recurlyLoadError,

View file

@ -2,6 +2,7 @@ import countries from '../../data/countries'
import { Plan } from '../../../../../../types/subscription/plan'
import { SubscriptionPricingStateTax } from 'recurly__recurly-js'
import { SubscriptionPricingInstanceCustom } from '../../../../../../types/recurly/pricing/subscription'
import { currencies, CurrencyCode, CurrencySymbol } from '../../data/currency'
export type PricingFormState = {
first_name: string
@ -18,20 +19,19 @@ export type PricingFormState = {
}
export type PaymentContextValue = {
currencyCode: string
currencyCode: CurrencyCode
setCurrencyCode: React.Dispatch<
React.SetStateAction<PaymentContextValue['currencyCode']>
>
currencySymbol: string
limitedCurrencies: Record<
PaymentContextValue['currencyCode'],
PaymentContextValue['currencySymbol']
>
currencySymbol: CurrencySymbol
limitedCurrencies: Partial<typeof currencies>
pricingFormState: PricingFormState
setPricingFormState: React.Dispatch<
React.SetStateAction<PaymentContextValue['pricingFormState']>
>
plan: Plan
planCode: string
planName: string
pricing: React.MutableRefObject<SubscriptionPricingInstanceCustom | undefined>
recurlyLoading: boolean
recurlyLoadError: boolean
@ -62,6 +62,6 @@ export type PaymentContextValue = {
trialLength: number | undefined
applyVatNumber: (vatNumber: PricingFormState['vat_number']) => void
addCoupon: (coupon: PricingFormState['coupon']) => void
changeCurrency: (newCurrency: string) => void
changeCurrency: (newCurrency: CurrencyCode) => void
updateCountry: (country: PricingFormState['country']) => void
}

View file

@ -12,4 +12,6 @@ export const currencies = <const>{
SGD: '$',
}
export type CurrencyCode = keyof typeof currencies
type Currency = typeof currencies
export type CurrencyCode = keyof Currency
export type CurrencySymbol = Currency[CurrencyCode]

View file

@ -188,6 +188,7 @@
"category_operators": "Operators",
"category_relations": "Relations",
"change": "Change",
"change_currency": "Change currency",
"change_or_cancel-cancel": "cancel",
"change_or_cancel-change": "Change",
"change_or_cancel-or": "or",
@ -334,6 +335,7 @@
"direct_link": "Direct Link",
"disable_stop_on_first_error": "Disable “Stop on first error”",
"disconnected": "Disconnected",
"discount_of": "Discount of __amount__",
"discounted_group_accounts": "discounted group accounts",
"dismiss": "Dismiss",
"dismiss_error_popup": "Dismiss first error alert",