diff --git a/package-lock.json b/package-lock.json index bfab5a03f1..34075ca7fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8548,6 +8548,12 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==" }, + "node_modules/@types/recurly__recurly-js": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@types/recurly__recurly-js/-/recurly__recurly-js-4.22.0.tgz", + "integrity": "sha512-2dJ1QnwcyCmxeIAzOaBx/r1JqMIqZ7rohxJMY0UynSQidEDfb9X2x3OHMthBXDtTzSFJ1usY934wakxgm7d+Wg==", + "dev": true + }, "node_modules/@types/redis": { "version": "2.8.31", "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.31.tgz", @@ -34591,6 +34597,7 @@ "@types/react": "^17.0.40", "@types/react-bootstrap": "^0.32.29", "@types/react-dom": "^17.0.13", + "@types/recurly__recurly-js": "^4.22.0", "@types/sinon-chai": "^3.2.8", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.12.1", @@ -43599,6 +43606,7 @@ "@types/react": "^17.0.40", "@types/react-bootstrap": "^0.32.29", "@types/react-dom": "^17.0.13", + "@types/recurly__recurly-js": "*", "@types/sinon-chai": "^3.2.8", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.12.1", @@ -46607,6 +46615,12 @@ "@types/react": "*" } }, + "@types/recurly__recurly-js": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@types/recurly__recurly-js/-/recurly__recurly-js-4.22.0.tgz", + "integrity": "sha512-2dJ1QnwcyCmxeIAzOaBx/r1JqMIqZ7rohxJMY0UynSQidEDfb9X2x3OHMthBXDtTzSFJ1usY934wakxgm7d+Wg==", + "dev": true + }, "@types/redis": { "version": "2.8.31", "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.31.tgz", diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index fe4d9a9258..6c37446658 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -175,38 +175,22 @@ async function _paymentReactPage(req, res) { currency = recommendedCurrency } - const refreshedPaymentPageAssignment = - await SplitTestHandler.promises.getAssignment( - req, - res, - 'payment-page-refresh' - ) - const useRefreshedPaymentPage = - refreshedPaymentPageAssignment && - refreshedPaymentPageAssignment.variant === 'refreshed-payment-page' - await SplitTestHandler.promises.getAssignment( req, res, 'student-check-modal' ) - // TODO - const template = useRefreshedPaymentPage - ? 'subscriptions/new-react' - : 'subscriptions/new-react' - - res.render(template, { + res.render('subscriptions/new-react', { title: 'subscribe', currency, countryCode, plan, - recurlyConfig: JSON.stringify({ - currency, - subdomain: Settings.apis.recurly.subdomain, - }), + couponCode: req.query.cc, showCouponField: !!req.query.scf, - showVatField: !!req.query.svf, + itm_campaign: req.query.itm_campaign, + itm_content: req.query.itm_content, + itm_referrer: req.query.itm_referrer, }) } } diff --git a/services/web/app/views/subscriptions/new-react.pug b/services/web/app/views/subscriptions/new-react.pug index fd611d4c6c..c4226b145e 100644 --- a/services/web/app/views/subscriptions/new-react.pug +++ b/services/web/app/views/subscriptions/new-react.pug @@ -5,10 +5,36 @@ include ./_new_mixins block entrypointVar - entrypoint = 'pages/user/subscription/new' +block vars + - var suppressNavbarRight = true + - var suppressFooter = true + block append meta meta(name="ol-countryCode" content=countryCode) meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey) - meta(name="ol-recommendedCurrency" content=String(currency).slice(0,3)) + 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-showCouponField" data-type="boolean" content=showCouponField) + meta(name="ol-couponCode" content=couponCode) + meta(name="ol-itm_campaign" content=itm_campaign) + meta(name="ol-itm_content" content=itm_content) + meta(name="ol-itm_referrer" content=itm_referrer) + +block head-scripts + script(type="text/javascript", nonce=scriptNonce, src="https://js.recurly.com/v4/recurly.js") block content main.content.content-alt#subscription-new-root + + script(type="text/javascript", nonce=scriptNonce). + ga('send', 'event', 'pageview', 'payment_form', "#{plan_code}") + + script( + type="text/ng-template" + id="cvv-tooltip-tpl.html" + ) + p !{translate("for_visa_mastercard_and_discover", {}, ['strong', 'strong', 'strong'])} + p !{translate("for_american_express", {}, ['strong', 'strong', 'strong'])} + + +studentCheckModal diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index b19eef35f5..1da5cfa833 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -15,7 +15,9 @@ "acct_linked_to_institution_acct_2": "", "actions": "", "add_affiliation": "", + "add_another_address_line": "", "add_another_email": "", + "add_company_details": "", "add_email_to_claim_features": "", "add_files": "", "add_new_email": "", @@ -23,8 +25,11 @@ "add_role_and_department": "", "add_to_folder": "", "additional_licenses": "", + "address_line_1": "", + "address_second_line_optional": "", "all_projects": "", "also": "", + "an_error_occurred_when_verifying_the_coupon_code": "", "anyone_with_link_can_edit": "", "anyone_with_link_can_view": "", "approaching_compile_timeout_limit_upgrade_for_more_compile_time": "", @@ -53,16 +58,22 @@ "blank_project": "", "blocked_filename": "", "browser": "", + "by_subscribing_you_agree_to_our_terms_of_service": "", "can_edit": "", "can_link_institution_email_acct_to_institution_acct": "", "can_link_your_institution_acct_2": "", "can_now_relink_dropbox": "", "cancel": "", + "cancel_anytime": "", "cancel_your_subscription": "", "cannot_invite_non_user": "", "cannot_invite_self": "", "cannot_verify_user_not_robot": "", "cant_see_what_youre_looking_for_question": "", + "card_details": "", + "card_details_are_not_valid": "", + "card_must_be_authenticated_by_3dsecure": "", + "card_payment": "", "category_arrows": "", "category_greek": "", "category_misc": "", @@ -97,6 +108,7 @@ "common": "", "commons_plan_tooltip": "", "compact": "", + "company_name": "", "compile_error_entry_description": "", "compile_error_handling": "", "compile_larger_projects": "", @@ -119,6 +131,8 @@ "copying": "", "country": "", "country_flag": "", + "coupon_code": "", + "coupon_code_is_not_valid_for_selected_plan": "", "create": "", "create_first_project": "", "create_new_folder": "", @@ -508,13 +522,17 @@ "please_select_an_output_file": "", "please_set_main_file": "", "plus_upgraded_accounts_receive": "", + "postal_code": "", "premium_feature": "", + "premium_makes_collab_easier_with_features": "", "premium_plan_label": "", "press_shortcut_to_open_advanced_reference_search": "", "priority_support": "", "privacy_policy": "", "private": "", "problem_with_subscription_contact_us": "", + "proceed_to_paypal": "", + "proceeding_to_paypal_takes_you_to_the_paypal_site_to_pay": "", "processing": "", "professional": "", "project": "", @@ -606,6 +624,7 @@ "search_search_for": "", "search_whole_word": "", "select_a_file": "", + "select_a_payment_method": "", "select_a_project": "", "select_all_projects": "", "select_an_output_file": "", @@ -648,6 +667,7 @@ "showing_x_results": "", "showing_x_results_of_total": "", "something_went_wrong_loading_pdf_viewer": "", + "something_went_wrong_processing_the_request": "", "something_went_wrong_rendering_pdf": "", "something_went_wrong_rendering_pdf_expected": "<0>", "something_went_wrong_server": "", @@ -665,6 +685,7 @@ "stop_on_first_error_enabled_title": "", "stop_on_validation_error": "", "store_your_work": "", + "student_disclaimer": "", "subject": "", "subject_to_additional_vat": "", "submit_title": "", @@ -694,6 +715,8 @@ "thanks_settings_updated": "", "the_following_files_already_exist_in_this_project": "", "this_action_cannot_be_undone": "", + "this_address_will_be_shown_on_the_invoice": "", + "this_field_is_required": "", "this_grants_access_to_features_2": "", "this_project_is_public": "", "this_project_is_public_read_only": "", @@ -751,6 +774,7 @@ "update_dropbox_settings": "", "update_your_billing_details": "", "upgrade": "", + "upgrade_cc_btn": "", "upgrade_for_longer_compiles": "", "upgrade_now": "", "upgrade_to_get_feature": "", @@ -764,6 +788,7 @@ "user_deletion_password_reset_tip": "", "user_sessions": "", "validation_issue_entry_description": "", + "vat_number": "", "view_all": "", "view_logs": "", "view_metrics": "", 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 new file mode 100644 index 0000000000..390bf234a6 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx @@ -0,0 +1,5 @@ +function CheckoutPanel() { + return

Checkout panel

+} + +export default CheckoutPanel 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 new file mode 100644 index 0000000000..871611fc7f --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel.tsx @@ -0,0 +1,5 @@ +function PaymentPreviewPanel() { + return

Preview panel

+} + +export default PaymentPreviewPanel diff --git a/services/web/frontend/js/features/subscription/components/new/root.tsx b/services/web/frontend/js/features/subscription/components/new/root.tsx index 15b05356dd..95aca17ce5 100644 --- a/services/web/frontend/js/features/subscription/components/new/root.tsx +++ b/services/web/frontend/js/features/subscription/components/new/root.tsx @@ -1,4 +1,9 @@ import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n' +import PaymentPreviewPanel from './payment-preview/payment-preview-panel' +import CheckoutPanel from './checkout/checkout-panel' +import { Col, Row } from 'react-bootstrap' +import { PaymentProvider } from '../../context/payment-context' +import getMeta from '../../../../utils/meta' function Root() { const { isReady } = useWaitForI18n() @@ -7,7 +12,26 @@ function Root() { return null } - return

React Subscription New

+ const publicKey = getMeta('ol-recurlyApiKey') + + return ( + +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ ) } export default Root diff --git a/services/web/frontend/js/features/subscription/context/payment-context.tsx b/services/web/frontend/js/features/subscription/context/payment-context.tsx new file mode 100644 index 0000000000..4f71388a63 --- /dev/null +++ b/services/web/frontend/js/features/subscription/context/payment-context.tsx @@ -0,0 +1,356 @@ +import { + useState, + useEffect, + useLayoutEffect, + useMemo, + useCallback, + useRef, + useContext, + createContext, +} from 'react' +import { useTranslation } from 'react-i18next' +import getMeta from '../../../utils/meta' +import * as eventTracking from '../../../infrastructure/event-tracking' +import { + PaymentContextValue, + PricingFormState, +} from './types/payment-context-value' +import { Plan } from '../../../../../types/subscription/plan' +import { + RecurlyOptions, + SubscriptionPricingStateTax, +} from 'recurly__recurly-js' +import { SubscriptionPricingInstanceCustom } from '../../../../../types/recurly/pricing/subscription' + +function usePayment({ publicKey }: RecurlyOptions) { + const { t } = useTranslation() + const plan: Plan = getMeta('ol-plan') + const initialCountry: PricingFormState['country'] = getMeta( + 'ol-countryCode', + '' + ) + const initialCouponCode: PricingFormState['coupon'] = getMeta( + 'ol-couponCode', + '' + ) + const currencySymbols: Record< + PaymentContextValue['currencyCode'], + PaymentContextValue['currencySymbol'] + > = getMeta('ol-currencySymbols') + const initiallySelectedCurrencyCode: string = getMeta( + 'ol-recommendedCurrency' + ) + + const [recurlyLoading, setRecurlyLoading] = useState(true) + const [recurlyLoadError, setRecurlyLoadError] = useState(false) + const [recurlyPrice, setRecurlyPrice] = useState<{ + subtotal: string + plan: string + addons: string + setup_fee: string + discount: string + tax: string + total: string + }>() + const [monthlyBilling, setMonthlyBilling] = useState() + const [taxes, setTaxes] = useState([]) + const [coupon, setCoupon] = useState<{ + discountMonths?: number + discountRate?: number + singleUse: boolean + normalPrice: number + name: string + normalPriceWithoutTax: number + }>() + const [couponError, setCouponError] = useState('') + const [trialLength, setTrialLength] = useState() + const [currencyCode, setCurrencyCode] = useState( + initiallySelectedCurrencyCode + ) + const [pricingFormState, setPricingFormState] = useState({ + first_name: '', + last_name: '', + postal_code: '', + address1: '', + address2: '', + state: '', + city: '', + company: '', + vat_number: '', + country: initialCountry, + coupon: initialCouponCode, + }) + const pricing = useRef() + + const limitedCurrencyCodes = Array.from( + new Set([initiallySelectedCurrencyCode, 'USD', 'EUR', 'GBP']) + ) + const limitedCurrencies = limitedCurrencyCodes.reduce((prev, cur) => { + return { ...prev, [cur]: currencySymbols[cur] } + }, {} as Record) + const currencySymbol = limitedCurrencies[currencyCode] + + useLayoutEffect(() => { + if (typeof recurly === 'undefined' || !recurly) { + setRecurlyLoadError(true) + return + } + + eventTracking.sendMB('payment-page-view', { plan: plan.planCode }) + eventTracking.send( + 'subscription-funnel', + 'subscription-form-viewed', + plan.planCode + ) + + recurly.configure({ publicKey }) + pricing.current = + recurly.Pricing.Subscription() as SubscriptionPricingInstanceCustom + + const setupPricing = () => { + setRecurlyLoading(true) + + pricing.current + ?.plan(plan.planCode, { quantity: 1 }) + .address({ + first_name: '', + last_name: '', + country: initialCountry, + }) + .tax({ tax_code: 'digital', vat_number: '' }) + .currency(initiallySelectedCurrencyCode) + .coupon(initialCouponCode) + .catch(function (err) { + if ( + initiallySelectedCurrencyCode !== 'USD' && + err.name === 'invalid-currency' + ) { + setCurrencyCode('USD') + setupPricing() + } else if (err.name === 'api-error' && err.code === 'not-found') { + // not-found here should refer to the coupon code, plan_code should be valid + setCouponError(t('coupon_code_is_not_valid_for_selected_plan')) + } else { + // Bail out on other errors, form state will not be correct + setRecurlyLoadError(true) + throw err + } + }) + .done(() => { + setRecurlyLoading(false) + }) + } + + setupPricing() + }, [ + initialCountry, + initialCouponCode, + initiallySelectedCurrencyCode, + plan.planCode, + publicKey, + t, + ]) + + useEffect(() => { + pricing.current?.on('change', function () { + if (!pricing.current) return + + const trialLength = pricing.current.items.plan?.trial?.length + setTrialLength(trialLength) + + const recurlyPrice = trialLength + ? pricing.current.price.next + : pricing.current.price.now + setRecurlyPrice(recurlyPrice) + + const monthlyBilling = pricing.current.items.plan?.period.length === 1 + setMonthlyBilling(monthlyBilling) + + 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 + const basePrice = parseInt(pricing.current.price.base.plan.unit, 10) + const discountData = + coupon.applies_for_months > 0 && coupon.discount.rate + ? { + discountMonths: coupon.applies_for_months, + discountRate: coupon.discount.rate * 100, + } + : {} + + const couponData = { + singleUse: coupon.single_use, + normalPrice: basePrice, + name: coupon.name, + normalPriceWithoutTax: basePrice, + ...discountData, + } + + if (pricing.current.price.taxes[0]?.rate) { + couponData.normalPrice += + basePrice * parseFloat(pricing.current.price.taxes[0].rate) + } + + return couponData + } + })() + setCoupon(couponData) + }) + }, []) + + const addCoupon = useCallback( + (coupon: PricingFormState['coupon']) => { + setRecurlyLoading(true) + setCouponError('') + + pricing.current + ?.coupon(coupon) + .catch(function (err) { + if (err.name === 'api-error' && err.code === 'not-found') { + setCouponError(t('coupon_code_is_not_valid_for_selected_plan')) + } else { + setCouponError( + t('an_error_occurred_when_verifying_the_coupon_code') + ) + throw err + } + }) + .done(() => { + setRecurlyLoading(false) + }) + }, + [t] + ) + + const updateCountry = useCallback( + (country: PricingFormState['country']) => { + setRecurlyLoading(true) + + pricing.current + ?.address({ + country, + first_name: pricingFormState.first_name, + last_name: pricingFormState.last_name, + }) + .done(() => { + setRecurlyLoading(false) + }) + }, + [pricingFormState.first_name, pricingFormState.last_name] + ) + + const applyVatNumber = useCallback( + (vatNumber: PricingFormState['vat_number']) => { + setRecurlyLoading(true) + + pricing.current + ?.tax({ tax_code: 'digital', vat_number: vatNumber }) + .done(() => { + setRecurlyLoading(false) + }) + }, + [] + ) + + // TODO - for preview panel + const changeCurrency = useCallback( + (newCurrency: string) => { + setRecurlyLoading(true) + setCurrencyCode(newCurrency) + + pricing.current + ?.currency(newCurrency) + .catch(function (err) { + if (currencyCode !== 'USD' && err.name === 'invalid-currency') { + setCurrencyCode('USD') + } else { + throw err + } + }) + .done(() => { + setRecurlyLoading(false) + }) + }, + [currencyCode] + ) + + const value = useMemo( + () => ({ + currencyCode, + setCurrencyCode, + currencySymbol, + limitedCurrencies, + pricingFormState, + setPricingFormState, + plan, + pricing, + recurlyLoading, + recurlyLoadError, + recurlyPrice, + monthlyBilling, + taxes, + coupon, + couponError, + trialLength, + addCoupon, + applyVatNumber, + changeCurrency, + updateCountry, + }), + [ + currencyCode, + setCurrencyCode, + currencySymbol, + limitedCurrencies, + pricingFormState, + setPricingFormState, + plan, + pricing, + recurlyLoading, + recurlyLoadError, + recurlyPrice, + monthlyBilling, + taxes, + coupon, + couponError, + trialLength, + addCoupon, + applyVatNumber, + changeCurrency, + updateCountry, + ] + ) + + return { value } +} + +const PaymentContext = createContext(undefined) + +type PaymentProviderProps = { + publicKey: string + children: React.ReactNode +} + +export function PaymentProvider({ publicKey, ...props }: PaymentProviderProps) { + const { value } = usePayment({ publicKey }) + + return +} + +export function usePaymentContext() { + const context = useContext(PaymentContext) + if (!context) { + throw new Error('PaymentContext is only available inside PaymentProvider') + } + return context +} 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 new file mode 100644 index 0000000000..ff21ef317b --- /dev/null +++ b/services/web/frontend/js/features/subscription/context/types/payment-context-value.tsx @@ -0,0 +1,67 @@ +import countries from '../../data/countries' +import { Plan } from '../../../../../../types/subscription/plan' +import { SubscriptionPricingStateTax } from 'recurly__recurly-js' +import { SubscriptionPricingInstanceCustom } from '../../../../../../types/recurly/pricing/subscription' + +export type PricingFormState = { + first_name: string + last_name: string + postal_code: string + address1: string + address2: string + state: string + city: string + company: string + vat_number: string + country: typeof countries[number]['code'] | '' + coupon: string +} + +export type PaymentContextValue = { + currencyCode: string + setCurrencyCode: React.Dispatch< + React.SetStateAction + > + currencySymbol: string + limitedCurrencies: Record< + PaymentContextValue['currencyCode'], + PaymentContextValue['currencySymbol'] + > + pricingFormState: PricingFormState + setPricingFormState: React.Dispatch< + React.SetStateAction + > + plan: Plan + pricing: React.MutableRefObject + recurlyLoading: boolean + recurlyLoadError: boolean + recurlyPrice: + | { + subtotal: string + plan: string + addons: string + setup_fee: string + discount: string + tax: string + total: string + } + | undefined + monthlyBilling: boolean | undefined + taxes: SubscriptionPricingStateTax[] + coupon: + | { + discountMonths?: number + discountRate?: number + singleUse: boolean + normalPrice: number + name: string + normalPriceWithoutTax: number + } + | undefined + couponError: string + trialLength: number | undefined + applyVatNumber: (vatNumber: PricingFormState['vat_number']) => void + addCoupon: (coupon: PricingFormState['coupon']) => void + changeCurrency: (newCurrency: string) => void + updateCountry: (country: PricingFormState['country']) => void +} diff --git a/services/web/frontend/js/features/subscription/data/countries.ts b/services/web/frontend/js/features/subscription/data/countries.ts new file mode 100644 index 0000000000..ef77c0c6d9 --- /dev/null +++ b/services/web/frontend/js/features/subscription/data/countries.ts @@ -0,0 +1,279 @@ +// list taken from Recurly (see https://docs.recurly.com/docs/countries-provinces-and-states). Country code must exist on Recurly, so update with care +const countries = [ + { code: 'AF', name: 'Afghanistan' }, + { code: 'AX', name: 'Åland Islands' }, + { code: 'AL', name: 'Albania' }, + { code: 'DZ', name: 'Algeria' }, + { code: 'AS', name: 'American Samoa' }, + { code: 'AD', name: 'Andorra' }, + { code: 'AO', name: 'Angola' }, + { code: 'AI', name: 'Anguilla' }, + { code: 'AQ', name: 'Antarctica' }, + { code: 'AG', name: 'Antigua and Barbuda' }, + { code: 'AR', name: 'Argentina' }, + { code: 'AM', name: 'Armenia' }, + { code: 'AW', name: 'Aruba' }, + { code: 'AC', name: 'Ascension Island' }, + { code: 'AU', name: 'Australia' }, + { code: 'AT', name: 'Austria' }, + { code: 'AZ', name: 'Azerbaijan' }, + { code: 'BS', name: 'Bahamas' }, + { code: 'BH', name: 'Bahrain' }, + { code: 'BD', name: 'Bangladesh' }, + { code: 'BB', name: 'Barbados' }, + { code: 'BY', name: 'Belarus' }, + { code: 'BE', name: 'Belgium' }, + { code: 'BZ', name: 'Belize' }, + { code: 'BJ', name: 'Benin' }, + { code: 'BM', name: 'Bermuda' }, + { code: 'BT', name: 'Bhutan' }, + { code: 'BO', name: 'Bolivia' }, + { code: 'BA', name: 'Bosnia and Herzegovina' }, + { code: 'BW', name: 'Botswana' }, + { code: 'BV', name: 'Bouvet Island' }, + { code: 'BR', name: 'Brazil' }, + { code: 'BQ', name: 'British Antarctic Territory' }, + { code: 'IO', name: 'British Indian Ocean Territory' }, + { code: 'VG', name: 'British Virgin Islands' }, + { code: 'BN', name: 'Brunei' }, + { code: 'BG', name: 'Bulgaria' }, + { code: 'BF', name: 'Burkina Faso' }, + { code: 'BI', name: 'Burundi' }, + { code: 'CV', name: 'Cabo Verde' }, + { code: 'KH', name: 'Cambodia' }, + { code: 'CM', name: 'Cameroon' }, + { code: 'CA', name: 'Canada' }, + { code: 'IC', name: 'Canary Islands' }, + { code: 'CT', name: 'Canton and Enderbury Islands' }, + { code: 'KY', name: 'Cayman Islands' }, + { code: 'CF', name: 'Central African Republic' }, + { code: 'EA', name: 'Ceuta and Melilla' }, + { code: 'TD', name: 'Chad' }, + { code: 'CL', name: 'Chile' }, + { code: 'CN', name: 'China' }, + { code: 'CX', name: 'Christmas Island' }, + { code: 'CP', name: 'Clipperton Island' }, + { code: 'CC', name: 'Cocos [Keeling] Islands' }, + { code: 'CO', name: 'Colombia' }, + { code: 'KM', name: 'Comoros' }, + { code: 'CG', name: 'Congo - Brazzaville' }, + { code: 'CD', name: 'Congo - Kinshasa' }, + { code: 'CD', name: 'Congo [DRC]' }, + { code: 'CG', name: 'Congo [Republic]' }, + { code: 'CK', name: 'Cook Islands' }, + { code: 'CR', name: 'Costa Rica' }, + { code: 'CI', name: 'Côte d’Ivoire' }, + { code: 'HR', name: 'Croatia' }, + { code: 'CU', name: 'Cuba' }, + { code: 'CY', name: 'Cyprus' }, + { code: 'CZ', name: 'Czech Republic' }, + { code: 'DK', name: 'Denmark' }, + { code: 'DG', name: 'Diego Garcia' }, + { code: 'DJ', name: 'Djibouti' }, + { code: 'DM', name: 'Dominica' }, + { code: 'DO', name: 'Dominican Republic' }, + { code: 'NQ', name: 'Dronning Maud Land' }, + { code: 'TL', name: 'East Timor' }, + { code: 'EC', name: 'Ecuador' }, + { code: 'EG', name: 'Egypt' }, + { code: 'SV', name: 'El Salvador' }, + { code: 'GQ', name: 'Equatorial Guinea' }, + { code: 'ER', name: 'Eritrea' }, + { code: 'EE', name: 'Estonia' }, + { code: 'ET', name: 'Ethiopia' }, + { code: 'FK', name: 'Falkland Islands [Islas Malvinas]' }, + { code: 'FK', name: 'Falkland Islands' }, + { code: 'FO', name: 'Faroe Islands' }, + { code: 'FJ', name: 'Fiji' }, + { code: 'FI', name: 'Finland' }, + { code: 'FR', name: 'France' }, + { code: 'GF', name: 'French Guiana' }, + { code: 'PF', name: 'French Polynesia' }, + { code: 'FQ', name: 'French Southern and Antarctic Territories' }, + { code: 'TF', name: 'French Southern Territories' }, + { code: 'GA', name: 'Gabon' }, + { code: 'GM', name: 'Gambia' }, + { code: 'GE', name: 'Georgia' }, + { code: 'DE', name: 'Germany' }, + { code: 'GH', name: 'Ghana' }, + { code: 'GI', name: 'Gibraltar' }, + { code: 'GR', name: 'Greece' }, + { code: 'GL', name: 'Greenland' }, + { code: 'GD', name: 'Grenada' }, + { code: 'GP', name: 'Guadeloupe' }, + { code: 'GU', name: 'Guam' }, + { code: 'GT', name: 'Guatemala' }, + { code: 'GG', name: 'Guernsey' }, + { code: 'GW', name: 'Guinea-Bissau' }, + { code: 'GN', name: 'Guinea' }, + { code: 'GY', name: 'Guyana' }, + { code: 'HT', name: 'Haiti' }, + { code: 'HM', name: 'Heard Island and McDonald Islands' }, + { code: 'HN', name: 'Honduras' }, + { code: 'HK', name: 'Hong Kong' }, + { code: 'HU', name: 'Hungary' }, + { code: 'IS', name: 'Iceland' }, + { code: 'IN', name: 'India' }, + { code: 'ID', name: 'Indonesia' }, + { code: 'IR', name: 'Iran' }, + { code: 'IQ', name: 'Iraq' }, + { code: 'IE', name: 'Ireland' }, + { code: 'IM', name: 'Isle of Man' }, + { code: 'IL', name: 'Israel' }, + { code: 'IT', name: 'Italy' }, + { code: 'CI', name: 'Ivory Coast' }, + { code: 'JM', name: 'Jamaica' }, + { code: 'JP', name: 'Japan' }, + { code: 'JE', name: 'Jersey' }, + { code: 'JT', name: 'Johnston Island' }, + { code: 'JO', name: 'Jordan' }, + { code: 'KZ', name: 'Kazakhstan' }, + { code: 'KE', name: 'Kenya' }, + { code: 'KI', name: 'Kiribati' }, + { code: 'KW', name: 'Kuwait' }, + { code: 'KG', name: 'Kyrgyzstan' }, + { code: 'LA', name: 'Laos' }, + { code: 'LV', name: 'Latvia' }, + { code: 'LB', name: 'Lebanon' }, + { code: 'LS', name: 'Lesotho' }, + { code: 'LR', name: 'Liberia' }, + { code: 'LY', name: 'Libya' }, + { code: 'LI', name: 'Liechtenstein' }, + { code: 'LT', name: 'Lithuania' }, + { code: 'LU', name: 'Luxembourg' }, + { code: 'MO', name: 'Macau SAR China' }, + { code: 'MO', name: 'Macau' }, + { code: 'MK', name: 'Macedonia [FYROM]' }, + { code: 'MK', name: 'Macedonia' }, + { code: 'MG', name: 'Madagascar' }, + { code: 'MW', name: 'Malawi' }, + { code: 'MY', name: 'Malaysia' }, + { code: 'MV', name: 'Maldives' }, + { code: 'ML', name: 'Mali' }, + { code: 'MT', name: 'Malta' }, + { code: 'MH', name: 'Marshall Islands' }, + { code: 'MQ', name: 'Martinique' }, + { code: 'MR', name: 'Mauritania' }, + { code: 'MU', name: 'Mauritius' }, + { code: 'YT', name: 'Mayotte' }, + { code: 'FX', name: 'Metropolitan France' }, + { code: 'MX', name: 'Mexico' }, + { code: 'FM', name: 'Micronesia' }, + { code: 'MI', name: 'Midway Islands' }, + { code: 'MD', name: 'Moldova' }, + { code: 'MC', name: 'Monaco' }, + { code: 'MN', name: 'Mongolia' }, + { code: 'ME', name: 'Montenegro' }, + { code: 'MS', name: 'Montserrat' }, + { code: 'MA', name: 'Morocco' }, + { code: 'MZ', name: 'Mozambique' }, + { code: 'MM', name: 'Myanmar [Burma]' }, + { code: 'NA', name: 'Namibia' }, + { code: 'NR', name: 'Nauru' }, + { code: 'NP', name: 'Nepal' }, + { code: 'AN', name: 'Netherlands Antilles' }, + { code: 'NL', name: 'Netherlands' }, + { code: 'NC', name: 'New Caledonia' }, + { code: 'NZ', name: 'New Zealand' }, + { code: 'NI', name: 'Nicaragua' }, + { code: 'NE', name: 'Niger' }, + { code: 'NG', name: 'Nigeria' }, + { code: 'NU', name: 'Niue' }, + { code: 'NF', name: 'Norfolk Island' }, + { code: 'KP', name: 'North Korea' }, + { code: 'VD', name: 'North Vietnam' }, + { code: 'MP', name: 'Northern Mariana Islands' }, + { code: 'NO', name: 'Norway' }, + { code: 'OM', name: 'Oman' }, + { code: 'QO', name: 'Outlying Oceania' }, + { code: 'PC', name: 'Pacific Islands Trust Territory' }, + { code: 'PK', name: 'Pakistan' }, + { code: 'PW', name: 'Palau' }, + { code: 'PS', name: 'Palestinian Territories' }, + { code: 'PZ', name: 'Panama Canal Zone' }, + { code: 'PA', name: 'Panama' }, + { code: 'PG', name: 'Papua New Guinea' }, + { code: 'PY', name: 'Paraguay' }, + { code: 'YD', name: "People's Democratic Republic of Yemen" }, + { code: 'PE', name: 'Peru' }, + { code: 'PH', name: 'Philippines' }, + { code: 'PN', name: 'Pitcairn Islands' }, + { code: 'PL', name: 'Poland' }, + { code: 'PT', name: 'Portugal' }, + { code: 'PR', name: 'Puerto Rico' }, + { code: 'QA', name: 'Qatar' }, + { code: 'RE', name: 'Réunion' }, + { code: 'RO', name: 'Romania' }, + { code: 'RU', name: 'Russia' }, + { code: 'RW', name: 'Rwanda' }, + { code: 'BL', name: 'Saint Barthélemy' }, + { code: 'SH', name: 'Saint Helena' }, + { code: 'KN', name: 'Saint Kitts and Nevis' }, + { code: 'LC', name: 'Saint Lucia' }, + { code: 'MF', name: 'Saint Martin' }, + { code: 'PM', name: 'Saint Pierre and Miquelon' }, + { code: 'VC', name: 'Saint Vincent and the Grenadines' }, + { code: 'WS', name: 'Samoa' }, + { code: 'SM', name: 'San Marino' }, + { code: 'ST', name: 'São Tomé and Príncipe' }, + { code: 'SA', name: 'Saudi Arabia' }, + { code: 'SN', name: 'Senegal' }, + { code: 'CS', name: 'Serbia and Montenegro' }, + { code: 'RS', name: 'Serbia' }, + { code: 'SC', name: 'Seychelles' }, + { code: 'SL', name: 'Sierra Leone' }, + { code: 'SG', name: 'Singapore' }, + { code: 'SK', name: 'Slovakia' }, + { code: 'SI', name: 'Slovenia' }, + { code: 'SB', name: 'Solomon Islands' }, + { code: 'SO', name: 'Somalia' }, + { code: 'ZA', name: 'South Africa' }, + { code: 'GS', name: 'South Georgia and the South Sandwich Islands' }, + { code: 'KR', name: 'South Korea' }, + { code: 'ES', name: 'Spain' }, + { code: 'LK', name: 'Sri Lanka' }, + { code: 'SD', name: 'Sudan' }, + { code: 'SR', name: 'Suriname' }, + { code: 'SJ', name: 'Svalbard and Jan Mayen' }, + { code: 'SZ', name: 'Swaziland' }, + { code: 'SE', name: 'Sweden' }, + { code: 'CH', name: 'Switzerland' }, + { code: 'SY', name: 'Syria' }, + { code: 'TW', name: 'Taiwan' }, + { code: 'TJ', name: 'Tajikistan' }, + { code: 'TZ', name: 'Tanzania' }, + { code: 'TH', name: 'Thailand' }, + { code: 'TL', name: 'Timor-Leste' }, + { code: 'TG', name: 'Togo' }, + { code: 'TK', name: 'Tokelau' }, + { code: 'TO', name: 'Tonga' }, + { code: 'TT', name: 'Trinidad and Tobago' }, + { code: 'TA', name: 'Tristan da Cunha' }, + { code: 'TN', name: 'Tunisia' }, + { code: 'TR', name: 'Turkey' }, + { code: 'TM', name: 'Turkmenistan' }, + { code: 'TC', name: 'Turks and Caicos Islands' }, + { code: 'TV', name: 'Tuvalu' }, + { code: 'UM', name: 'U.S. Minor Outlying Islands' }, + { code: 'PU', name: 'U.S. Miscellaneous Pacific Islands' }, + { code: 'VI', name: 'U.S. Virgin Islands' }, + { code: 'UG', name: 'Uganda' }, + { code: 'UA', name: 'Ukraine' }, + { code: 'AE', name: 'United Arab Emirates' }, + { code: 'GB', name: 'United Kingdom' }, + { code: 'US', name: 'United States' }, + { code: 'UY', name: 'Uruguay' }, + { code: 'UZ', name: 'Uzbekistan' }, + { code: 'VU', name: 'Vanuatu' }, + { code: 'VA', name: 'Vatican City' }, + { code: 'VE', name: 'Venezuela' }, + { code: 'VN', name: 'Vietnam' }, + { code: 'WK', name: 'Wake Island' }, + { code: 'WF', name: 'Wallis and Futuna' }, + { code: 'EH', name: 'Western Sahara' }, + { code: 'YE', name: 'Yemen' }, + { code: 'ZM', name: 'Zambia' }, + { code: 'ZW', name: 'Zimbabwe' }, +] + +export default countries diff --git a/services/web/frontend/stylesheets/app/subscription.less b/services/web/frontend/stylesheets/app/subscription.less index 0d408c4f41..b830c1186f 100644 --- a/services/web/frontend/stylesheets/app/subscription.less +++ b/services/web/frontend/stylesheets/app/subscription.less @@ -81,7 +81,8 @@ text-transform: capitalize; } -.three-d-secure-container { +.three-d-secure-container, +.three-d-secure-container--react { > .three-d-secure-recurly-container { height: 400px; diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 4d04aa4ac4..0d3632459c 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -42,6 +42,7 @@ "activation_token_expired": "Your activation token has expired, you will need to get another one sent to you.", "add": "Add", "add_affiliation": "Add Affiliation", + "add_another_address_line": "Add another address line", "add_another_email": "Add another email", "add_comma_separated_emails_help": "Separate multiple email addresses using the comma (,) character.", "add_comment": "Add comment", @@ -81,6 +82,7 @@ "also_available_as_on_premises": "Also available as On-Premises", "also_provides_free_plan": "__appName__ also provides a free plan -- simply <0>register here to get started.", "alternatively_create_new_institution_account": "Alternatively, you can create a new account with your institution email (__email__) by clicking __clickText__.", + "an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code", "and": "and", "annual": "Annual", "annual_billing_enabled": "Annual billing enabled", @@ -176,6 +178,7 @@ "cant_find_page": "Sorry, we can’t find the page you are looking for.", "cant_see_what_youre_looking_for_question": "Can’t see what you’re looking for?", "card_details": "Card details", + "card_details_are_not_valid": "Card details are not valid", "card_must_be_authenticated_by_3dsecure": "Your card must be authenticated with 3D Secure before continuing", "card_payment": "Card payment", "careers": "Careers", @@ -274,6 +277,7 @@ "country": "Country", "country_flag": "__country__ country flag", "coupon_code": "Coupon code", + "coupon_code_is_not_valid_for_selected_plan": "Coupon code is not valid for selected plan", "coupons_not_included": "This does not include your current discounts, which will be applied automatically before your next payment", "create": "Create", "create_a_new_password_for_your_account": "Create a new password for your account", @@ -1339,6 +1343,7 @@ "sl_included_history_of_changes_blurb": "__appName__ includes a history of all of your changes so you can see exactly who changed what, and when. This makes it extremely easy to keep up to date with any progress made by your collaborators and allows you to review recent work.", "something_went_wrong_canceling_your_subscription": "Something went wrong canceling your subscription. Please contact support.", "something_went_wrong_loading_pdf_viewer": "Something went wrong loading the PDF viewer. This might be caused by issues like <0>temporary network problems or an <0>outdated web browser. Please follow the <1>troubleshooting steps for access, loading and display problems. If the issue persists, please <2>let us know.", + "something_went_wrong_processing_the_request": "Something went wrong processing the request", "something_went_wrong_rendering_pdf": "Something went wrong while rendering this PDF.", "something_went_wrong_rendering_pdf_expected": "There was an issue displaying this PDF. <0>Please recompile", "something_went_wrong_server": "Something went wrong. Please try again.", diff --git a/services/web/package.json b/services/web/package.json index 88a713944d..885ab6782b 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -265,6 +265,7 @@ "@types/react": "^17.0.40", "@types/react-bootstrap": "^0.32.29", "@types/react-dom": "^17.0.13", + "@types/recurly__recurly-js": "^4.22.0", "@types/sinon-chai": "^3.2.8", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.12.1", diff --git a/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx index eab3074c2e..4eef227425 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx @@ -11,11 +11,13 @@ import { describe('', function () { beforeEach(function () { window.metaAttributesCache = new Map() + // @ts-ignore window.recurly = {} }) afterEach(function () { window.metaAttributesCache = new Map() + // @ts-ignore delete window.recurly }) @@ -116,6 +118,7 @@ describe('', function () { 'Sorry, there was an error talking to our payment provider. Please try again in a few moments. If you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them.' it('shows an alert and hides "Change plan" option when Recurly did not load', function () { + // @ts-ignore delete window.recurly render( diff --git a/services/web/test/frontend/features/subscription/fixtures/subscriptions.tsx b/services/web/test/frontend/features/subscription/fixtures/subscriptions.tsx index 38953bd27e..ff5e4d644c 100644 --- a/services/web/test/frontend/features/subscription/fixtures/subscriptions.tsx +++ b/services/web/test/frontend/features/subscription/fixtures/subscriptions.tsx @@ -28,7 +28,6 @@ export const annualActiveSubscription: Subscription = { name: 'Standard (Collaborator) Annual', price_in_cents: 21900, annual: true, - features: {}, featureDescription: [], }, recurly: { @@ -68,7 +67,6 @@ export const pastDueExpiredSubscription: Subscription = { name: 'Standard (Collaborator) Annual', price_in_cents: 21900, annual: true, - features: {}, featureDescription: [], }, recurly: { @@ -108,7 +106,6 @@ export const canceledSubscription: Subscription = { name: 'Standard (Collaborator) Annual', price_in_cents: 21900, annual: true, - features: {}, featureDescription: [], }, recurly: { @@ -148,7 +145,6 @@ export const pendingSubscriptionChange: Subscription = { name: 'Standard (Collaborator) Annual', price_in_cents: 21900, annual: true, - features: {}, featureDescription: [], }, recurly: { @@ -175,7 +171,6 @@ export const pendingSubscriptionChange: Subscription = { name: 'Professional Annual', price_in_cents: 42900, annual: true, - features: {}, featureDescription: [], }, } @@ -198,7 +193,6 @@ export const groupActiveSubscription: GroupSubscription = { hideFromUsers: true, price_in_cents: 129000, annual: true, - features: {}, groupPlan: true, membersLimit: 10, membersLimitAddOn: 'additional-license', @@ -243,7 +237,6 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription hideFromUsers: true, price_in_cents: 129000, annual: true, - features: {}, groupPlan: true, membersLimit: 10, membersLimitAddOn: 'additional-license', @@ -286,7 +279,6 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription hideFromUsers: true, price_in_cents: 129000, annual: true, - features: {}, groupPlan: true, membersLimit: 10, membersLimitAddOn: 'additional-license', @@ -308,7 +300,6 @@ export const trialSubscription: Subscription = { planCode: 'paid-personal_free_trial_7_days', name: 'Personal', price_in_cents: 1500, - features: {}, featureDescription: [], hideFromUsers: true, }, diff --git a/services/web/types/recurly/pricing/subscription.ts b/services/web/types/recurly/pricing/subscription.ts new file mode 100644 index 0000000000..e018daf085 --- /dev/null +++ b/services/web/types/recurly/pricing/subscription.ts @@ -0,0 +1,64 @@ +import { + SubscriptionPricingInstance, + SubscriptionPricingState, + Address, + Tax, +} from 'recurly__recurly-js' + +interface Plan { + code: string + name: string + period: { + interval: string + length: number + } + price: Record< + string, + { + unit_amount: number + symbol: string + setup_fee: number + } + > + quantity: number + tax_code: string + tax_exempt: boolean + trial?: { + interval: string + length: number + } +} + +interface Coupon { + code: string + name: string + discount: { + type: string + rate: number + } + single_use: boolean + applies_for_months: number + duration: string + temporal_unit: string + temporal_amount: number + plans: unknown[] + applies_to_non_plan_charges: boolean + applies_to_plans: boolean + applies_to_all_plans: boolean + redemption_resource: string +} + +// Extending the default interface as it lacks the `items` prop +export interface SubscriptionPricingInstanceCustom + extends SubscriptionPricingInstance, + SubscriptionPricingState { + id: string + items: { + addons: unknown[] + address?: Address + coupon?: Coupon + currency: string + plan?: Plan + tax?: Tax + } +} diff --git a/services/web/types/subscription/api.ts b/services/web/types/subscription/api.ts new file mode 100644 index 0000000000..7188c0d8ac --- /dev/null +++ b/services/web/types/subscription/api.ts @@ -0,0 +1,6 @@ +export interface CreateError { + data: { + message?: string + threeDSecureActionTokenId?: string + } +} diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts index 3add94fe34..faf932e45b 100644 --- a/services/web/types/subscription/dashboard/subscription.ts +++ b/services/web/types/subscription/dashboard/subscription.ts @@ -1,17 +1,5 @@ import { Nullable } from '../../utils' - -type Plan = { - planCode: string - name: string - price_in_cents: number - annual?: boolean - features: object - hideFromUsers?: boolean - featureDescription?: object[] - groupPlan?: boolean - membersLimit?: number - membersLimitAddOn?: string -} +import { Plan } from '../plan' type SubscriptionState = 'active' | 'canceled' | 'expired' diff --git a/services/web/types/subscription/plan.ts b/services/web/types/subscription/plan.ts new file mode 100644 index 0000000000..eecf9bed79 --- /dev/null +++ b/services/web/types/subscription/plan.ts @@ -0,0 +1,29 @@ +type Features = { + collaborators: number + compileGroup: string + compileTimeout: number + dropbox: boolean + gitBridge: boolean + github: boolean + mendeley: boolean + references: boolean + referencesSearch: boolean + symbolPalette: boolean + templates: boolean + trackChanges: boolean + versioning: boolean + zotero: boolean +} + +export type Plan = { + annual?: boolean + featureDescription?: Record[] + features?: Features + groupPlan?: boolean + hideFromUsers?: boolean + membersLimit?: number + membersLimitAddOn?: string + name: string + planCode: string + price_in_cents: number +} diff --git a/services/web/types/window.ts b/services/web/types/window.ts index f6f4dd255c..9f02651ea4 100644 --- a/services/web/types/window.ts +++ b/services/web/types/window.ts @@ -31,6 +31,5 @@ declare global { _reportAcePerf: () => void MathJax: Record overallThemes: OverallThemeMeta[] - recurly?: object } }