diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 69f0a633a5..8b6daf60da 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -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, diff --git a/services/web/app/views/subscriptions/new-react.pug b/services/web/app/views/subscriptions/new-react.pug index e34754b738..2c8c12e99d 100644 --- a/services/web/app/views/subscriptions/new-react.pug +++ b/services/web/app/views/subscriptions/new-react.pug @@ -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" diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index b17b9abb6a..d6e3b8f29f 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx index 6b20653094..1c3431b7f4 100644 --- a/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx +++ b/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx @@ -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() { )}
{children}
+} + +type CollaboratorsProps = { + count: NonNullable['collaborators'] +} + +function Collaborators({ count }: CollaboratorsProps) { + const { t } = useTranslation() + + if (count === 1) { + return ( + + {t('collabs_per_proj_single', { collabcount: 1 })} + + ) + } + + if (count > 1) { + return ( + + {t('collabs_per_proj', { collabcount: count })} + + ) + } + + if (count === -1) { + return {t('unlimited_collabs')} + } + + return null +} + +export default Collaborators diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/currency-dropdown.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/currency-dropdown.tsx new file mode 100644 index 0000000000..99b0e1cb9e --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/payment-preview/currency-dropdown.tsx @@ -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 ( + + + {t('change_currency')} + + + {Object.entries(limitedCurrencies).map(([currency, symbol]) => ( + changeCurrency(eventKey)} + > + {currency === currencyCode && ( + + + + )} + {currency} ({symbol}) + + ))} + + + ) +} + +export default CurrencyDropdown diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/features-list.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/features-list.tsx new file mode 100644 index 0000000000..3524c9bbda --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/payment-preview/features-list.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next' +import { Plan } from '../../../../../../../types/subscription/plan' + +type FeaturesListProps = { + features: NonNullable +} + +function FeaturesList({ features }: FeaturesListProps) { + const { t } = useTranslation() + + return ( + <> +
{t('all_premium_features_including')}
+ + + ) +} + +export default FeaturesList diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/no-discount-price.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/no-discount-price.tsx new file mode 100644 index 0000000000..964d5b8165 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/payment-preview/no-discount-price.tsx @@ -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 ( +
+ {!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 })} +
+ ) +} + +export default NoDiscountPrice diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel.tsx index 871611fc7f..e6fe4fe770 100644 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel.tsx +++ b/services/web/frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel.tsx @@ -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

Preview panel

+ const { t } = useTranslation() + const { plan, planName } = usePaymentContext() + const trialPrice = + const priceForFirstXPeriod = + const noDiscountPrice = + + return ( +
+

{planName}

+ {plan.features && ( + <> + + + + )} + + {(trialPrice || priceForFirstXPeriod || noDiscountPrice) && ( + <> +
+
+ {trialPrice} + {priceForFirstXPeriod} + {noDiscountPrice} +
+ + )} +
+

{t('cancel_anytime')}

+
+ ) } export default PaymentPreviewPanel diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/price-for-first-x-period.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/price-for-first-x-period.tsx new file mode 100644 index 0000000000..c32d830f39 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/payment-preview/price-for-first-x-period.tsx @@ -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 ( +
+ {coupon.discountMonths && + coupon.discountMonths > 0 && + !coupon.singleUse && + monthlyBilling && ( + ]} // eslint-disable-line react/jsx-key + values={{ + discountMonths: coupon.discountMonths, + price, + }} + /> + )} + {coupon.singleUse && monthlyBilling && ( + ]} // eslint-disable-line react/jsx-key + values={{ price }} + /> + )} + {coupon.singleUse && !monthlyBilling && ( +
+ ]} // eslint-disable-line react/jsx-key + values={{ price }} + /> +
+ )} +
+ ) +} + +export default PriceForFirstXPeriod diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/price-summary.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/price-summary.tsx new file mode 100644 index 0000000000..7962df736c --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/payment-preview/price-summary.tsx @@ -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 ( + <> +
+
+

{t('payment_summary')}

+
+
+ {planName} + + {currencySymbol} + {subtotal} + +
+ {coupon && ( +
+ {coupon.name} + + –{currencySymbol} + {recurlyPrice.discount} + + + {t('discount_of', { + amount: `${currencySymbol}${recurlyPrice.discount}`, + })} + +
+ )} + {rate > 0 && ( +
+ + {t('vat')} {rate * 100}% + + + {currencySymbol} + {recurlyPrice.tax} + +
+ )} +
+ {monthlyBilling ? t('total_per_month') : t('total_per_year')} + + {currencySymbol} + {recurlyPrice.total} + +
+
+
+ +
+
+ + ) +} + +export default PriceSummary diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/trial-price.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/trial-price.tsx new file mode 100644 index 0000000000..7bd62ad893 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/payment-preview/trial-price.tsx @@ -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 ( +
+ , ]} // eslint-disable-line react/jsx-key + values={{ + trialLen: trialLength, + price: `${currencySymbol}${recurlyPrice.total}`, + }} + /> +
+ ) +} + +export default TrialPrice diff --git a/services/web/frontend/js/features/subscription/context/payment-context.tsx b/services/web/frontend/js/features/subscription/context/payment-context.tsx index 4f71388a63..65059dfbe1 100644 --- a/services/web/frontend/js/features/subscription/context/payment-context.tsx +++ b/services/web/frontend/js/features/subscription/context/payment-context.tsx @@ -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() const limitedCurrencyCodes = Array.from( - new Set([initiallySelectedCurrencyCode, 'USD', 'EUR', 'GBP']) + new Set([initiallySelectedCurrencyCode, 'USD', 'EUR', 'GBP']) ) const limitedCurrencies = limitedCurrencyCodes.reduce((prev, cur) => { - return { ...prev, [cur]: currencySymbols[cur] } - }, {} as Record) - const currencySymbol = limitedCurrencies[currencyCode] + return { ...prev, [cur]: currencies[cur] } + }, {} as Partial) + 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, diff --git a/services/web/frontend/js/features/subscription/context/types/payment-context-value.tsx b/services/web/frontend/js/features/subscription/context/types/payment-context-value.tsx index ff21ef317b..28e94a4c57 100644 --- a/services/web/frontend/js/features/subscription/context/types/payment-context-value.tsx +++ b/services/web/frontend/js/features/subscription/context/types/payment-context-value.tsx @@ -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 > - currencySymbol: string - limitedCurrencies: Record< - PaymentContextValue['currencyCode'], - PaymentContextValue['currencySymbol'] - > + currencySymbol: CurrencySymbol + limitedCurrencies: Partial pricingFormState: PricingFormState setPricingFormState: React.Dispatch< React.SetStateAction > plan: Plan + planCode: string + planName: string pricing: React.MutableRefObject 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 } diff --git a/services/web/frontend/js/features/subscription/data/currency.ts b/services/web/frontend/js/features/subscription/data/currency.ts index ad82a701ce..db1b8542cc 100644 --- a/services/web/frontend/js/features/subscription/data/currency.ts +++ b/services/web/frontend/js/features/subscription/data/currency.ts @@ -12,4 +12,6 @@ export const currencies = { SGD: '$', } -export type CurrencyCode = keyof typeof currencies +type Currency = typeof currencies +export type CurrencyCode = keyof Currency +export type CurrencySymbol = Currency[CurrencyCode] diff --git a/services/web/locales/en.json b/services/web/locales/en.json index fdcb24d852..37755f8d2b 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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",