Move checkout to subscriptions module (#15022)

* Move checkout to subscriptions module

GitOrigin-RevId: 0ad6587ddd7042aed7f2e18d9d0668e02942eb1e
This commit is contained in:
Thomas
2023-10-02 16:23:07 +02:00
committed by Copybot
parent 5bd5bb27a0
commit bccb91343e
58 changed files with 3 additions and 4594 deletions

View File

@@ -1,6 +1,5 @@
const SessionManager = require('../Authentication/SessionManager')
const SubscriptionHandler = require('./SubscriptionHandler')
const PlansLocator = require('./PlansLocator')
const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
const LimitationsManager = require('./LimitationsManager')
const RecurlyWrapper = require('./RecurlyWrapper')
@@ -13,9 +12,6 @@ const plansConfig = require('./plansConfig')
const interstitialPaymentConfig = require('./interstitialPaymentConfig')
const GroupPlansData = require('./GroupPlansData')
const V1SubscriptionManager = require('./V1SubscriptionManager')
const Errors = require('../Errors/Errors')
const HttpErrorHandler = require('../Errors/HttpErrorHandler')
const SubscriptionErrors = require('./Errors')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
const RecurlyEventHandler = require('./RecurlyEventHandler')
const { expressify } = require('../../util/promises')
@@ -23,8 +19,6 @@ const OError = require('@overleaf/o-error')
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const SubscriptionHelper = require('./SubscriptionHelper')
const Features = require('../../infrastructure/Features')
const UserGetter = require('../User/UserGetter')
const Modules = require('../../infrastructure/Modules')
const AuthorizationManager = require('../Authorization/AuthorizationManager')
const groupPlanModalOptions = Settings.groupPlanModalOptions
@@ -188,83 +182,6 @@ async function plansPage(req, res) {
})
}
/**
* @param {import('express').Request} req
* @param {import('express').Response} res
* @returns {Promise<void>}
*/
async function paymentPage(req, res) {
const user = SessionManager.getSessionUser(req.session)
const plan = PlansLocator.findLocalPlanInSettings(req.query.planCode)
if (!plan) {
return HttpErrorHandler.unprocessableEntity(req, res, 'Plan not found')
}
const hasSubscription =
await LimitationsManager.promises.userHasV1OrV2Subscription(user)
if (hasSubscription) {
res.redirect('/user/subscription?hasSubscription=true')
} else {
// LimitationsManager.userHasV2Subscription only checks Mongo. Double check with
// Recurly as well at this point (we don't do this most places for speed).
const valid =
await SubscriptionHandler.promises.validateNoSubscriptionInRecurly(
user._id
)
if (!valid) {
res.redirect('/user/subscription?hasSubscription=true')
} else {
let currency = null
if (req.query.currency) {
const queryCurrency = req.query.currency.toUpperCase()
if (GeoIpLookup.isValidCurrencyParam(queryCurrency)) {
currency = queryCurrency
}
}
const { recommendedCurrency, countryCode } =
await _getRecommendedCurrency(req, res)
if (recommendedCurrency && currency == null) {
currency = recommendedCurrency
}
// Block web sales to restricted countries
if (Settings.restrictedCountries.includes(countryCode)) {
return res.render('subscriptions/restricted-country', {
title: 'restricted',
})
}
res.render('subscriptions/new-react', {
title: 'subscribe',
currency,
countryCode,
plan,
planCode: req.query.planCode,
couponCode: req.query.cc,
showCouponField: !!req.query.scf,
itm_campaign: req.query.itm_campaign,
itm_content: req.query.itm_content,
itm_referrer: req.query.itm_referrer,
})
}
}
}
async function requireConfirmedPrimaryEmailAddress(req, res, next) {
const userData = await UserGetter.promises.getUser(req.user._id, {
email: 1,
emails: 1,
})
const userPrimaryEmail = userData.emails.find(
emailEntry => emailEntry.email === userData.email
)
if (userPrimaryEmail?.confirmedAt != null) return next()
res.status(422).render('subscriptions/unconfirmed-primary-email', {
title: 'confirm_email',
email: userData.email,
})
}
function formatGroupPlansDataForDash() {
return {
plans: [...groupPlanModalOptions.plan_codes],
@@ -493,78 +410,6 @@ async function interstitialPaymentPage(req, res) {
}
}
async function createSubscription(req, res) {
const user = SessionManager.getSessionUser(req.session)
const recurlyTokenIds = {
billing: req.body.recurly_token_id,
threeDSecureActionResult:
req.body.recurly_three_d_secure_action_result_token_id,
}
const { subscriptionDetails } = req.body
const hasSubscription =
await LimitationsManager.promises.userHasV1OrV2Subscription(user)
if (hasSubscription) {
logger.warn({ userId: user._id }, 'user already has subscription')
return res.sendStatus(409) // conflict
}
const { countryCode } = await _getRecommendedCurrency(req, res)
// Block web sales to restricted countries
if (Settings.restrictedCountries.includes(countryCode)) {
return HttpErrorHandler.unprocessableEntity(
req,
res,
req.i18n.translate('sorry_detected_sales_restricted_region', {
link: '/contact',
})
)
}
const result = {}
await Modules.promises.hooks.fire(
'createSubscription',
req,
res,
user,
result
)
if (result.error) {
return HttpErrorHandler.unprocessableEntity(req, res)
}
try {
await SubscriptionHandler.promises.createSubscription(
user,
subscriptionDetails,
recurlyTokenIds
)
res.sendStatus(201)
} catch (err) {
if (
err instanceof SubscriptionErrors.RecurlyTransactionError ||
err instanceof Errors.InvalidError
) {
logger.error({ err }, 'recurly transaction error, potential 422')
HttpErrorHandler.unprocessableEntity(
req,
res,
err.message,
OError.getFullInfo(err).public
)
} else {
logger.warn(
{ err, userId: user._id },
'something went wrong creating subscription'
)
throw err
}
}
}
/**
* @param {import('express').Request} req
* @param {import('express').Response} res
@@ -922,10 +767,8 @@ async function _getRecommendedCurrency(req, res) {
module.exports = {
plansPage: expressify(plansPage),
paymentPage: expressify(paymentPage),
userSubscriptionPage: expressify(userSubscriptionPage),
interstitialPaymentPage: expressify(interstitialPaymentPage),
createSubscription: expressify(createSubscription),
successfulSubscription: expressify(successfulSubscription),
cancelSubscription,
canceledSubscription,
@@ -941,7 +784,7 @@ module.exports = {
recurlyNotificationParser,
refreshUserFeatures: expressify(refreshUserFeatures),
redirectToHostedPage: expressify(redirectToHostedPage),
requireConfirmedPrimaryEmailAddress: expressify(
requireConfirmedPrimaryEmailAddress
),
promises: {
getRecommendedCurrency: _getRecommendedCurrency,
},
}

View File

@@ -27,13 +27,6 @@ module.exports = {
SubscriptionController.userSubscriptionPage
)
webRouter.get(
'/user/subscription/new',
AuthenticationController.requireLogin(),
SubscriptionController.requireConfirmedPrimaryEmailAddress,
SubscriptionController.paymentPage
)
webRouter.get(
'/user/subscription/choose-your-plan',
AuthenticationController.requireLogin(),
@@ -90,13 +83,6 @@ module.exports = {
)
// user changes their account state
webRouter.post(
'/user/subscription/create',
AuthenticationController.requireLogin(),
PermissionsController.requirePermission('start-subscription'),
SubscriptionController.requireConfirmedPrimaryEmailAddress,
SubscriptionController.createSubscription
)
webRouter.post(
'/user/subscription/update',
AuthenticationController.requireLogin(),

View File

@@ -1,37 +0,0 @@
extends ../layout-marketing
block entrypointVar
- entrypoint = 'pages/user/subscription/new'
block vars
- var suppressNavbarRight = true
- var suppressFooter = true
- var suppressCookieBanner = 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-plan" data-type="json" content=plan)
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)
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', "#{planCode}")
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'])}

View File

@@ -1,11 +0,0 @@
extends ../layout-marketing
block content
main.content.content-alt#main-content
.container
.error-container
.error-details
p.error-status #{translate("restricted")}
p.error-description !{translate("sorry_detected_sales_restricted_region", {link: "/contact"})}
p.error-actions
a.error-btn(href="/") #{translate("home")}

View File

@@ -1,11 +0,0 @@
extends ../layout-marketing
block content
main.content.content-alt#main-content
.container
.error-container
.error-details
p.error-status #{translate("confirm_email")}
p.error-description !{translate("please_confirm_email", {emailAddress: email})}
p.error-actions
a.error-btn(href="/user/settings") #{translate("account_settings")}

View File

@@ -1,64 +0,0 @@
import { useTranslation } from 'react-i18next'
import { FormGroup, ControlLabel } from 'react-bootstrap'
import Tooltip from '../../../../../shared/components/tooltip'
import Icon from '../../../../../shared/components/icon'
import useValidateField from '../../../hooks/use-validate-field'
import classnames from 'classnames'
import { callFnsInSequence } from '../../../../../utils/functions'
type AddressFirstLineProps = {
errorFields: Record<string, boolean> | undefined
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
function AddressFirstLine({
errorFields,
value,
onChange,
}: AddressFirstLineProps) {
const { t } = useTranslation()
const { validate, isValid } = useValidateField()
return (
<FormGroup
controlId="address-line-1"
className={classnames({
'has-error': !isValid || errorFields?.address1,
})}
>
<ControlLabel>
{t('address_line_1')}{' '}
<Tooltip
id="tooltip-address"
description={t('this_address_will_be_shown_on_the_invoice')}
overlayProps={{ placement: 'right' }}
>
<Icon
type="question-circle"
aria-label={t('this_address_will_be_shown_on_the_invoice')}
/>
</Tooltip>
</ControlLabel>
<input
id="address-line-1"
className="form-control"
name="address1"
data-recurly="address1"
type="text"
required
maxLength={255}
onBlur={validate}
onChange={callFnsInSequence(validate, onChange)}
value={value}
/>
{!isValid && (
<span className="input-feedback-message">
{t('this_field_is_required')}
</span>
)}
</FormGroup>
)
}
export default AddressFirstLine

View File

@@ -1,53 +0,0 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FormGroup, ControlLabel, Button } from 'react-bootstrap'
import classnames from 'classnames'
type AddressSecondLineProps = {
errorFields: Record<string, boolean> | undefined
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
function AddressSecondLine({
errorFields,
value,
onChange,
}: AddressSecondLineProps) {
const { t } = useTranslation()
const [showAddressSecondLine, setShowAddressSecondLine] = useState(false)
if (showAddressSecondLine) {
return (
<FormGroup
controlId="address-line-2"
className={classnames({ 'has-error': errorFields?.address2 })}
>
<ControlLabel>{t('address_second_line_optional')}</ControlLabel>
<input
id="address-line-2"
className="form-control"
name="address2"
data-recurly="address2"
type="text"
required
maxLength={255}
onChange={onChange}
value={value}
/>
</FormGroup>
)
}
return (
<Button
bsStyle="link"
onClick={() => setShowAddressSecondLine(true)}
className="mb-2 p-0"
>
+ {t('add_another_address_line')}
</Button>
)
}
export default AddressSecondLine

View File

@@ -1,78 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { FormGroup, ControlLabel } from 'react-bootstrap'
import { CardElementChangeState } from '../../../../../../../types/recurly/elements'
import { ElementsInstance } from 'recurly__recurly-js'
import classnames from 'classnames'
import getMeta from '../../../../../utils/meta'
type CardElementProps = {
className?: string
elements: ElementsInstance
onChange: (state: CardElementChangeState) => void
}
function CardElement({ className, elements, onChange }: CardElementProps) {
const { t } = useTranslation()
const [showCardElementInvalid, setShowCardElementInvalid] =
useState<boolean>()
const cardRef = useRef<HTMLDivElement | null>(null)
// Card initialization
useEffect(() => {
if (!cardRef.current) return
const showNewDesign =
getMeta('ol-splitTestVariants')?.['design-system-updates'] === 'enabled'
const style = showNewDesign
? {
fontColor: '#1b222c',
placeholder: {
color: '#677283',
},
invalid: {
fontColor: '#b83a33',
},
}
: {
fontColor: '#5d6879',
placeholder: {},
invalid: {
fontColor: '#a93529',
},
}
const card = elements.CardElement({
displayIcon: true,
inputType: 'mobileSelect',
style,
})
card.attach(cardRef.current)
card.on('change', state => {
setShowCardElementInvalid(!state.focus && !state.empty && !state.valid)
onChange(state)
})
return () => {
cardRef.current = null
}
}, [elements, onChange])
return (
<FormGroup
className={classnames(className, { 'has-error': showCardElementInvalid })}
>
<ControlLabel>{t('card_details')}</ControlLabel>
<div ref={cardRef} />
{showCardElementInvalid && (
<span className="input-feedback-message">
{t('card_details_are_not_valid')}
</span>
)}
</FormGroup>
)
}
export default CardElement

View File

@@ -1,421 +0,0 @@
import { useRef, useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { usePaymentContext } from '../../../context/payment-context'
import { Row, Col, Alert } from 'react-bootstrap'
import PriceSwitchHeader from './price-switch-header'
import PaymentMethodToggle from './payment-method-toggle'
import CardElement from './card-element'
import FirstName from './first-name'
import LastName from './last-name'
import AddressFirstLine from './address-first-line'
import AddressSecondLine from './address-second-line'
import PostalCode from './postal-code'
import CountrySelect from './country-select'
import CompanyDetails from './company-details'
import CouponCode from './coupon-code'
import TosAgreementNotice from './tos-agreement-notice'
import SubmitButton from './submit-button'
import ThreeDSecure from './three-d-secure'
import getMeta from '../../../../../utils/meta'
import { getSplitTestVariant } from '../../../../../../../frontend/js/utils/splitTestUtils'
import { postJSON } from '../../../../../infrastructure/fetch-json'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import classnames from 'classnames'
import {
TokenPayload,
RecurlyError,
ElementsInstance,
PayPalInstance,
} from 'recurly__recurly-js'
import { PricingFormState } from '../../../context/types/payment-context-value'
import { CreateError } from '../../../../../../../types/subscription/api'
import { CardElementChangeState } from '../../../../../../../types/recurly/elements'
import { useLocation } from '../../../../../shared/hooks/use-location'
function CheckoutPanel() {
const { t } = useTranslation()
const {
couponError,
currencyCode,
planCode,
planName,
pricingFormState,
pricing,
recurlyLoadError,
setPricingFormState,
trialLength,
taxes,
} = usePaymentContext()
const showCouponField: boolean = getMeta('ol-showCouponField')
const ITMCampaign: string = getMeta('ol-itm_campaign', '')
const ITMContent: string = getMeta('ol-itm_content', '')
const ITMReferrer: string = getMeta('ol-itm_referrer', '')
const formRef = useRef<HTMLFormElement>(null)
const cachedRecurlyBillingToken = useRef<TokenPayload>()
const elements = useRef<ElementsInstance | undefined>(recurly?.Elements())
const [isProcessing, setIsProcessing] = useState(false)
const [errorFields, setErrorFields] = useState<Record<string, boolean>>()
const [genericError, setGenericError] = useState('')
const [paymentMethod, setPaymentMethod] = useState('credit_card')
const [cardIsValid, setCardIsValid] = useState<boolean>()
const [formIsValid, setFormIsValid] = useState<boolean>()
const [threeDSecureActionTokenId, setThreeDSecureActionTokenId] =
useState<string>()
const location = useLocation()
const isCreditCardPaymentMethod = paymentMethod === 'credit_card'
const isPayPalPaymentMethod = paymentMethod === 'paypal'
const isAddCompanyDetailsChecked = Boolean(
formRef.current?.querySelector<HTMLInputElement>(
'#add-company-details-checkbox'
)?.checked
)
const designSystemUpdatesVariant = getSplitTestVariant(
'design-system-updates',
'default'
)
const completeSubscription = useCallback(
async (
err?: RecurlyError | null,
recurlyBillingToken?: TokenPayload,
threeDResultToken?: TokenPayload
) => {
if (recurlyBillingToken) {
// temporary store the billing token as it might be needed when
// re-sending the request after SCA authentication
cachedRecurlyBillingToken.current = recurlyBillingToken
}
setErrorFields(undefined)
if (err) {
eventTracking.sendMB('payment-page-form-error', err)
eventTracking.send('subscription-funnel', 'subscription-error')
setIsProcessing(false)
setGenericError(err.message)
const errFields = err.fields?.reduce<typeof errorFields>(
(prev, cur) => {
return { ...prev, [cur]: true }
},
{}
)
setErrorFields(errFields)
} else {
const billingFields = ['company', 'vat_number'] as const
const billingInfo = billingFields.reduce((prev, cur) => {
if (isPayPalPaymentMethod && isAddCompanyDetailsChecked) {
prev[cur] = pricingFormState[cur]
}
return prev
}, {} as Partial<Pick<PricingFormState, typeof billingFields[number]>>)
const postData = {
_csrf: getMeta('ol-csrfToken'),
recurly_token_id: cachedRecurlyBillingToken.current?.id,
recurly_three_d_secure_action_result_token_id: threeDResultToken?.id,
subscriptionDetails: {
currencyCode: pricing.current?.items.currency,
plan_code: pricing.current?.items.plan?.code,
coupon_code: pricing.current?.items.coupon?.code ?? '',
first_name: pricingFormState.first_name,
last_name: pricingFormState.last_name,
isPaypal: isPayPalPaymentMethod,
address: {
address1: pricingFormState.address1,
address2: pricingFormState.address2,
country: pricingFormState.country,
state: pricingFormState.state,
zip: pricingFormState.postal_code,
},
ITMCampaign,
ITMContent,
ITMReferrer,
...(Object.keys(billingInfo).length && {
billing_info: billingInfo,
}),
},
}
eventTracking.sendMB('payment-page-form-submit', {
currencyCode: postData.subscriptionDetails.currencyCode,
plan_code: postData.subscriptionDetails.plan_code,
coupon_code: postData.subscriptionDetails.coupon_code,
isPaypal: postData.subscriptionDetails.isPaypal,
'split-test-design-system-updates': designSystemUpdatesVariant,
})
eventTracking.send(
'subscription-funnel',
'subscription-form-submitted',
postData.subscriptionDetails.plan_code
)
try {
await postJSON(`/user/subscription/create`, { body: postData })
eventTracking.sendMB('payment-page-form-success')
eventTracking.send(
'subscription-funnel',
'subscription-submission-success',
planCode
)
location.assign('/user/subscription/thank-you')
} catch (error) {
setIsProcessing(false)
const { data } = error as CreateError
const errorMessage: string =
data.message || t('something_went_wrong_processing_the_request')
setGenericError(errorMessage)
if (data.threeDSecureActionTokenId) {
setThreeDSecureActionTokenId(data.threeDSecureActionTokenId)
}
}
}
},
[
designSystemUpdatesVariant,
ITMCampaign,
ITMContent,
ITMReferrer,
isAddCompanyDetailsChecked,
isPayPalPaymentMethod,
location,
planCode,
pricing,
pricingFormState,
t,
]
)
const payPal = useRef<PayPalInstance>()
useEffect(() => {
if (!recurly) return
payPal.current = recurly.PayPal({
display: { displayName: planName },
})
payPal.current.on('token', token => {
completeSubscription(null, token)
})
payPal.current.on('error', err => {
completeSubscription(err)
})
payPal.current.on('cancel', () => {
setIsProcessing(false)
})
const payPalCopy = payPal.current
return () => {
payPalCopy.destroy()
}
}, [completeSubscription, planName])
const handleCardChange = useCallback((state: CardElementChangeState) => {
setCardIsValid(state.valid)
}, [])
const hidePaypal = ['INR', 'COP', 'CLP', 'PEN'].includes(currencyCode)
if (hidePaypal && paymentMethod !== 'credit_card') {
setPaymentMethod('credit_card')
}
if (recurlyLoadError) {
return (
<Alert bsStyle="danger">
<strong>{t('payment_provider_unreachable_error')}</strong>
</Alert>
)
}
const handleThreeDToken = (token: TokenPayload) => {
// on SCA verification success: show payment UI in processing mode and
// resubmit the payment with the new token final success or error will be
// handled by `completeSubscription`
completeSubscription(null, undefined, token)
setGenericError('')
setThreeDSecureActionTokenId(undefined)
setIsProcessing(true)
}
const handleThreeDError = (error: RecurlyError) => {
// on SCA verification error: show payment UI with the error message
setGenericError(`Error: ${error.message}`)
setThreeDSecureActionTokenId(undefined)
}
const handlePaymentMethod = (e: React.ChangeEvent<HTMLInputElement>) => {
setPaymentMethod(e.target.value)
}
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
name: keyof PricingFormState
) => {
setPricingFormState(s => ({ ...s, [name]: e.target.value }))
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!recurly || !elements.current) return
setIsProcessing(true)
if (isPayPalPaymentMethod) {
payPal.current?.start()
return
}
const {
company: _1,
vat_number: _2,
...tokenDataWithoutCompanyDetails
} = pricingFormState
const tokenData = isAddCompanyDetailsChecked
? { ...pricingFormState }
: tokenDataWithoutCompanyDetails
recurly.token(elements.current, tokenData, completeSubscription)
}
const handleFormValidation = () => {
setFormIsValid(Boolean(formRef.current?.checkValidity()))
}
const isFormValid = (): boolean => {
if (isPayPalPaymentMethod) {
return pricingFormState.country !== ''
} else {
return Boolean(formIsValid && cardIsValid)
}
}
return (
<>
{threeDSecureActionTokenId && (
<ThreeDSecure
actionTokenId={threeDSecureActionTokenId}
onToken={handleThreeDToken}
onError={handleThreeDError}
/>
)}
<div className={classnames({ hidden: threeDSecureActionTokenId })}>
<PriceSwitchHeader
planCode={planCode}
planCodes={[
'student-annual',
'student-monthly',
'student_free_trial_7_days',
]}
/>
<form
noValidate
onSubmit={handleSubmit}
onChange={handleFormValidation}
ref={formRef}
data-testid="checkout-form"
>
{genericError && (
<Alert bsStyle="warning" className="small">
<strong>{genericError}</strong>
</Alert>
)}
{couponError && (
<Alert bsStyle="warning" className="small">
<strong>{couponError}</strong>
</Alert>
)}
{hidePaypal ? null : (
<PaymentMethodToggle
onChange={handlePaymentMethod}
paymentMethod={paymentMethod}
/>
)}
{elements.current && (
<CardElement
className={classnames({ hidden: !isCreditCardPaymentMethod })}
elements={elements.current}
onChange={handleCardChange}
/>
)}
{isCreditCardPaymentMethod && (
<Row>
<Col xs={6}>
<FirstName
errorFields={errorFields}
value={pricingFormState.first_name}
onChange={e => handleChange(e, 'first_name')}
/>
</Col>
<Col xs={6}>
<LastName
errorFields={errorFields}
value={pricingFormState.last_name}
onChange={e => handleChange(e, 'last_name')}
/>
</Col>
</Row>
)}
<AddressFirstLine
errorFields={errorFields}
value={pricingFormState.address1}
onChange={e => handleChange(e, 'address1')}
/>
<AddressSecondLine
errorFields={errorFields}
value={pricingFormState.address2}
onChange={e => handleChange(e, 'address2')}
/>
<Row>
<Col xs={4}>
<PostalCode
errorFields={errorFields}
value={pricingFormState.postal_code}
onChange={e => handleChange(e, 'postal_code')}
/>
</Col>
<Col xs={8}>
<CountrySelect
errorFields={errorFields}
value={pricingFormState.country}
onChange={e => handleChange(e, 'country')}
/>
</Col>
</Row>
<CompanyDetails taxesCount={taxes.length} />
{showCouponField && (
<CouponCode
value={pricingFormState.coupon}
onChange={e => handleChange(e, 'coupon')}
/>
)}
{isPayPalPaymentMethod &&
t('proceeding_to_paypal_takes_you_to_the_paypal_site_to_pay')}
<hr className="thin" />
<div className="payment-submit">
<SubmitButton
isProcessing={isProcessing}
isFormValid={isFormValid()}
>
{isCreditCardPaymentMethod &&
(trialLength ? t('upgrade_cc_btn') : t('upgrade_now'))}
{isPayPalPaymentMethod && t('proceed_to_paypal')}
</SubmitButton>
</div>
<TosAgreementNotice />
</form>
</div>
</>
)
}
export default CheckoutPanel

View File

@@ -1,82 +0,0 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FormGroup, ControlLabel } from 'react-bootstrap'
import { usePaymentContext } from '../../../context/payment-context'
import { PricingFormState } from '../../../context/types/payment-context-value'
type CompanyDetailsProps = {
taxesCount: number
}
function CompanyDetails(props: CompanyDetailsProps) {
const { t } = useTranslation()
const [addCompanyDetailsChecked, setAddCompanyDetailsChecked] =
useState(false)
const { pricingFormState, setPricingFormState, applyVatNumber } =
usePaymentContext()
const handleAddCompanyDetails = (e: React.ChangeEvent<HTMLInputElement>) => {
setAddCompanyDetailsChecked(e.target.checked)
}
const handleChange = (
e: React.ChangeEvent<HTMLInputElement>,
name: keyof PricingFormState
) => {
setPricingFormState(s => ({ ...s, [name]: e.target.value }))
}
const handleApplyVatNumber = (e: React.FocusEvent<HTMLInputElement>) => {
applyVatNumber(e.target.value)
}
return (
<>
<FormGroup>
<div className="checkbox">
<ControlLabel>
<input
type="checkbox"
id="add-company-details-checkbox"
onChange={handleAddCompanyDetails}
/>
{t('add_company_details')}
</ControlLabel>
</div>
</FormGroup>
{addCompanyDetailsChecked && (
<>
<FormGroup controlId="company-name">
<ControlLabel>{t('company_name')}</ControlLabel>
<input
id="company-name"
className="form-control"
name="companyName"
data-recurly="company"
type="text"
onChange={e => handleChange(e, 'company')}
value={pricingFormState.company}
/>
</FormGroup>
{props.taxesCount > 0 && (
<FormGroup controlId="vat-number">
<ControlLabel>{t('vat_number')}</ControlLabel>
<input
id="vat-number"
className="form-control"
name="vatNumber"
data-recurly="vat_number"
type="text"
onChange={e => handleChange(e, 'vat_number')}
onBlur={handleApplyVatNumber}
value={pricingFormState.vat_number}
/>
</FormGroup>
)}
</>
)}
</>
)
}
export default CompanyDetails

View File

@@ -1,68 +0,0 @@
import { useTranslation } from 'react-i18next'
import { FormGroup, ControlLabel } from 'react-bootstrap'
import classnames from 'classnames'
import useValidateField from '../../../hooks/use-validate-field'
import countries from '../../../data/countries'
import { callFnsInSequence } from '../../../../../utils/functions'
import { PricingFormState } from '../../../context/types/payment-context-value'
import { usePaymentContext } from '../../../context/payment-context'
type CountrySelectProps = {
errorFields: Record<string, boolean> | undefined
value: PricingFormState['country']
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void
}
function CountrySelect(props: CountrySelectProps) {
const { t } = useTranslation()
const { validate, isValid } = useValidateField()
const { updateCountry } = usePaymentContext()
const handleUpdateCountry = (e: React.ChangeEvent<HTMLSelectElement>) => {
updateCountry(e.target.value as PricingFormState['country'])
}
return (
<FormGroup
controlId="country"
className={classnames({
'has-error': !isValid || props.errorFields?.country,
})}
>
<ControlLabel>{t('country')}</ControlLabel>
<select
id="country"
className="form-control"
name="country"
data-recurly="country"
required
onBlur={validate}
onChange={callFnsInSequence(
validate,
props.onChange,
handleUpdateCountry
)}
value={props.value}
>
<option disabled value="">
{t('country')}
</option>
<option disabled value="-">
--------------
</option>
{countries.map(country => (
<option value={country.code} key={country.name}>
{country.name}
</option>
))}
</select>
{!isValid && (
<span className="input-feedback-message">
{t('this_field_is_required')}
</span>
)}
</FormGroup>
)
}
export default CountrySelect

View File

@@ -1,34 +0,0 @@
import { useTranslation } from 'react-i18next'
import { FormGroup, ControlLabel } from 'react-bootstrap'
import { usePaymentContext } from '../../../context/payment-context'
type CouponCodeProps = {
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
function CouponCode(props: CouponCodeProps) {
const { t } = useTranslation()
const { addCoupon } = usePaymentContext()
const handleApplyCoupon = (e: React.FocusEvent<HTMLInputElement>) => {
addCoupon(e.target.value)
}
return (
<FormGroup controlId="coupon-code">
<ControlLabel>{t('coupon_code')}</ControlLabel>
<input
id="coupon-code"
className="form-control"
data-recurly="coupon"
type="text"
onBlur={handleApplyCoupon}
onChange={props.onChange}
value={props.value}
/>
</FormGroup>
)
}
export default CouponCode

View File

@@ -1,46 +0,0 @@
import { useTranslation } from 'react-i18next'
import { FormGroup, ControlLabel } from 'react-bootstrap'
import useValidateField from '../../../hooks/use-validate-field'
import classnames from 'classnames'
import { callFnsInSequence } from '../../../../../utils/functions'
type FirstNameProps = {
errorFields: Record<string, boolean> | undefined
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
function FirstName(props: FirstNameProps) {
const { t } = useTranslation()
const { validate, isValid } = useValidateField()
return (
<FormGroup
controlId="first-name"
className={classnames({
'has-error': !isValid || props.errorFields?.first_name,
})}
>
<ControlLabel>{t('first_name')}</ControlLabel>
<input
id="first-name"
className="form-control"
name="firstName"
data-recurly="first_name"
type="text"
required
maxLength={255}
onBlur={validate}
onChange={callFnsInSequence(validate, props.onChange)}
value={props.value}
/>
{!isValid && (
<span className="input-feedback-message">
{t('this_field_is_required')}
</span>
)}
</FormGroup>
)
}
export default FirstName

View File

@@ -1,46 +0,0 @@
import { useTranslation } from 'react-i18next'
import { FormGroup, ControlLabel } from 'react-bootstrap'
import useValidateField from '../../../hooks/use-validate-field'
import classnames from 'classnames'
import { callFnsInSequence } from '../../../../../utils/functions'
type LastNameProps = {
errorFields: Record<string, boolean> | undefined
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
function LastName(props: LastNameProps) {
const { t } = useTranslation()
const { validate, isValid } = useValidateField()
return (
<FormGroup
controlId="last-name"
className={classnames({
'has-error': !isValid || props.errorFields?.last_name,
})}
>
<ControlLabel>{t('last_name')}</ControlLabel>
<input
id="last-name"
className="form-control"
name="lastName"
data-recurly="last_name"
type="text"
required
maxLength={255}
onBlur={validate}
onChange={callFnsInSequence(validate, props.onChange)}
value={props.value}
/>
{!isValid && (
<span className="input-feedback-message">
{t('this_field_is_required')}
</span>
)}
</FormGroup>
)
}
export default LastName

View File

@@ -1,60 +0,0 @@
import { useTranslation } from 'react-i18next'
import { FormGroup, ControlLabel, Col } from 'react-bootstrap'
import Icon from '../../../../../shared/components/icon'
type PaymentMethodToggleProps = {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
paymentMethod: string
}
function PaymentMethodToggle(props: PaymentMethodToggleProps) {
const { t } = useTranslation()
return (
<FormGroup
className="payment-method-toggle"
data-testid="payment-method-toggle"
>
<hr className="thin" />
<div className="radio">
<Col xs={8}>
<ControlLabel>
<input
type="radio"
name="payment_method"
value="credit_card"
onChange={props.onChange}
checked={props.paymentMethod === 'credit_card'}
/>
<strong>
{t('card_payment')}&nbsp;
<span className="hidden-xs">
<Icon type="cc-visa" /> <Icon type="cc-mastercard" />{' '}
<Icon type="cc-amex" />
</span>
</strong>
</ControlLabel>
</Col>
<Col xs={4}>
<ControlLabel>
<input
type="radio"
name="payment_method"
value="paypal"
onChange={props.onChange}
checked={props.paymentMethod === 'paypal'}
/>
<strong>
PayPal&nbsp;
<span className="hidden-xs">
<Icon type="cc-paypal" />
</span>
</strong>
</ControlLabel>
</Col>
</div>
</FormGroup>
)
}
export default PaymentMethodToggle

View File

@@ -1,46 +0,0 @@
import { useTranslation } from 'react-i18next'
import { FormGroup, ControlLabel } from 'react-bootstrap'
import useValidateField from '../../../hooks/use-validate-field'
import classnames from 'classnames'
import { callFnsInSequence } from '../../../../../utils/functions'
type PostalCodeProps = {
errorFields: Record<string, boolean> | undefined
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
function PostalCode(props: PostalCodeProps) {
const { t } = useTranslation()
const { validate, isValid } = useValidateField()
return (
<FormGroup
controlId="postal-code"
className={classnames({
'has-error': !isValid || props.errorFields?.postal_code,
})}
>
<ControlLabel>{t('postal_code')}</ControlLabel>
<input
id="postal-code"
className="form-control"
name="postalCode"
data-recurly="postal_code"
type="text"
required
maxLength={255}
onBlur={validate}
onChange={callFnsInSequence(validate, props.onChange)}
value={props.value}
/>
{!isValid && (
<span className="input-feedback-message">
{t('this_field_is_required')}
</span>
)}
</FormGroup>
)
}
export default PostalCode

View File

@@ -1,32 +0,0 @@
import { useTranslation } from 'react-i18next'
import { Row, Col } from 'react-bootstrap'
import { Plan } from '../../../../../../../types/subscription/plan'
type PriceSwitchHeaderProps = {
planCode: Plan['planCode']
planCodes: Array<Plan['planCode']>
}
function PriceSwitchHeader({ planCode, planCodes }: PriceSwitchHeaderProps) {
const { t } = useTranslation()
const showStudentDisclaimer = planCodes.includes(planCode)
return (
<div className="price-switch-header">
<Row>
<Col xs={9}>
<h2>{t('select_a_payment_method')}</h2>
</Col>
</Row>
{showStudentDisclaimer && (
<Row>
<Col xs={12}>
<p className="student-disclaimer">{t('student_disclaimer')}</p>
</Col>
</Row>
)}
</div>
)
}
export default PriceSwitchHeader

View File

@@ -1,32 +0,0 @@
import { useTranslation } from 'react-i18next'
import { Button } from 'react-bootstrap'
import Icon from '../../../../../shared/components/icon'
type SubmitButtonProps = {
isProcessing: boolean
isFormValid: boolean
children: React.ReactNode
}
function SubmitButton(props: SubmitButtonProps) {
const { t } = useTranslation()
return (
<Button
type="submit"
bsStyle="primary"
className="btn-block"
disabled={props.isProcessing || !props.isFormValid}
>
{props.isProcessing && (
<>
<Icon type="spinner" spin />
<span className="sr-only">{t('processing')}</span>
</>
)}{' '}
{props.children}
</Button>
)
}
export default SubmitButton

View File

@@ -1,58 +0,0 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert } from 'react-bootstrap'
import { RecurlyError, TokenPayload } from 'recurly__recurly-js'
type ThreeDSecureProps = {
actionTokenId: string
onToken: (token: TokenPayload) => void
onError: (error: RecurlyError) => void
}
function ThreeDSecure({ actionTokenId, onToken, onError }: ThreeDSecureProps) {
const { t } = useTranslation()
const container = useRef<HTMLDivElement>(null)
const recurlyContainer = useRef<HTMLDivElement | null>(null)
useEffect(() => {
// scroll the UI into view (timeout needed to make sure the element is
// visible)
const timeout = setTimeout(() => {
container.current?.scrollIntoView()
}, 0)
return () => {
clearTimeout(timeout)
}
}, [])
useEffect(() => {
if (!recurly || !recurlyContainer.current) return
// instanciate and configure Recurly 3DSecure flow
const risk = recurly.Risk()
const threeDSecure = risk.ThreeDSecure({ actionTokenId })
threeDSecure.on('token', onToken)
threeDSecure.on('error', onError)
threeDSecure.attach(recurlyContainer.current)
return () => {
recurlyContainer.current = null
}
}, [actionTokenId, onToken, onError])
return (
<div className="three-d-secure-container--react" ref={container}>
<Alert bsStyle="info" className="small" aria-live="assertive">
<strong>{t('card_must_be_authenticated_by_3dsecure')}</strong>
</Alert>
<div
className="three-d-secure-recurly-container"
ref={recurlyContainer}
/>
</div>
)
}
export default ThreeDSecure

View File

@@ -1,17 +0,0 @@
import { Trans } from 'react-i18next'
function TosAgreementNotice() {
return (
<p className="tos-agreement-notice">
<Trans
i18nKey="by_subscribing_you_agree_to_our_terms_of_service"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="/legal#Terms" target="_blank" rel="noopener noreferrer" />,
]}
/>
</p>
)
}
export default TosAgreementNotice

View File

@@ -1,38 +0,0 @@
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

@@ -1,40 +0,0 @@
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" accessibilityLabel={t('selected')} />
</span>
)}
{currency} ({symbol})
</MenuItem>
))}
</Dropdown.Menu>
</Dropdown>
)
}
export default CurrencyDropdown

View File

@@ -1,33 +0,0 @@
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" data-testid="features-list">
{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

@@ -1,38 +0,0 @@
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,29 +0,0 @@
import { useTranslation } from 'react-i18next'
import Collaborators from './collaborators'
import FeaturesList from './features-list'
import PriceSummary from './price-summary'
import TrialCouponSummary from './trial-coupon-summary'
import { usePaymentContext } from '../../../context/payment-context'
function PaymentPreviewPanel() {
const { t } = useTranslation()
const { plan, planName } = usePaymentContext()
return (
<div className="price-feature-description">
<h4>{planName}</h4>
{plan.features && (
<>
<Collaborators count={plan.features.collaborators} />
<FeaturesList features={plan.features} />
</>
)}
<PriceSummary />
<TrialCouponSummary />
<hr className="thin" />
<p className="price-cancel-anytime text-center">{t('cancel_anytime')}</p>
</div>
)
}
export default PaymentPreviewPanel

View File

@@ -1,49 +0,0 @@
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

@@ -1,84 +0,0 @@
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" data-testid="price-summary">
<h4>{t('payment_summary')}</h4>
<div className="small">
<div className="price-summary-line" data-testid="price-summary-plan">
<span>{planName}</span>
<span>
{currencySymbol}
{subtotal}
</span>
</div>
{coupon && (
<div
className="price-summary-line"
data-testid="price-summary-coupon"
>
<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" data-testid="price-summary-vat">
<span>
{t('vat')} {rate * 100}%
</span>
<span>
{currencySymbol}
{recurlyPrice.tax}
</span>
</div>
)}
<div
className="price-summary-line price-summary-total-line"
data-testid="price-summary-total"
>
<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

@@ -1,24 +0,0 @@
import TrialPrice from './trial-price'
import NoDiscountPrice from './no-discount-price'
import PriceForFirstXPeriod from './price-for-first-x-period'
function TrialCouponSummary() {
const children = [TrialPrice, NoDiscountPrice, PriceForFirstXPeriod].map(
(Component, index) => <Component key={index} />
)
const showChildren = children.some(child => child.type() != null)
if (!showChildren) return null
return (
<>
<hr className="thin" />
<div className="trial-coupon-summary" data-testid="trial-coupon-summary">
{children}
</div>
</>
)
}
export default TrialCouponSummary

View File

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

@@ -1,37 +0,0 @@
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()
if (!isReady) {
return null
}
const publicKey = getMeta('ol-recurlyApiKey')
return (
<PaymentProvider publicKey={publicKey}>
<div className="container">
<Row className="card-group">
<Col md={3} mdPush={1}>
<div className="card card-highlighted">
<PaymentPreviewPanel />
</div>
</Col>
<Col md={5} mdPush={1}>
<div className="card card-highlighted card-border">
<CheckoutPanel />
</div>
</Col>
</Row>
</div>
</PaymentProvider>
)
}
export default Root

View File

@@ -1,365 +0,0 @@
import {
useState,
useEffect,
useLayoutEffect,
useMemo,
useCallback,
useRef,
useContext,
createContext,
} from 'react'
import { currencies, CurrencyCode, CurrencySymbol } from '../data/currency'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../utils/meta'
import { getSplitTestVariant } from '../../../../../frontend/js/utils/splitTestUtils'
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 initiallySelectedCurrencyCode: CurrencyCode = getMeta(
'ol-recommendedCurrency'
)
const planCode: string = getMeta('ol-planCode')
const designSystemUpdatesVariant = getSplitTestVariant(
'design-system-updates',
'default'
)
const [planName, setPlanName] = useState(plan.name)
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<boolean>()
const [taxes, setTaxes] = useState<SubscriptionPricingStateTax[]>([])
const [coupon, setCoupon] = useState<{
discountMonths?: number
discountRate?: number
singleUse: boolean
normalPrice: number
name: string
normalPriceWithoutTax: number
}>()
const [couponError, setCouponError] = useState('')
const [trialLength, setTrialLength] = useState<number>()
const [currencyCode, setCurrencyCode] = useState(
initiallySelectedCurrencyCode
)
const [pricingFormState, setPricingFormState] = useState<PricingFormState>({
first_name: '',
last_name: '',
postal_code: '',
address1: '',
address2: '',
state: '',
city: '',
company: '',
vat_number: '',
country: initialCountry,
coupon: initialCouponCode,
})
const pricing = useRef<SubscriptionPricingInstanceCustom>()
const limitedCurrencyCodes = Array.from(
new Set<CurrencyCode>([initiallySelectedCurrencyCode, 'USD', 'EUR', 'GBP'])
)
const limitedCurrencies = limitedCurrencyCodes.reduce((prev, cur) => {
return { ...prev, [cur]: currencies[cur] }
}, {} as Partial<typeof currencies>)
const currencySymbol = limitedCurrencies[currencyCode] as CurrencySymbol
useLayoutEffect(() => {
if (typeof recurly === 'undefined' || !recurly) {
setRecurlyLoadError(true)
return
}
eventTracking.sendMB('payment-page-view', {
plan: planCode,
currency: currencyCode,
'split-test-design-system-updates': designSystemUpdatesVariant,
})
eventTracking.send(
'subscription-funnel',
'subscription-form-viewed',
planCode
)
recurly.configure({ publicKey })
pricing.current =
recurly.Pricing.Subscription() as SubscriptionPricingInstanceCustom
const setupPricing = () => {
setRecurlyLoading(true)
pricing.current
?.plan(planCode, { quantity: 1 })
.address({
first_name: '',
last_name: '',
country: initialCountry,
})
.tax({ tax_code: 'digital', vat_number: '' })
.currency(currencyCode)
.coupon(initialCouponCode)
.catch(function (err) {
if (currencyCode !== '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()
}, [
designSystemUpdatesVariant,
initialCountry,
initialCouponCode,
initiallySelectedCurrencyCode,
planCode,
publicKey,
currencyCode,
t,
])
useEffect(() => {
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)
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)
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)
})
}, [currencyCode])
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)
})
},
[]
)
const changeCurrency = useCallback(
(newCurrency: CurrencyCode) => {
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<PaymentContextValue>(
() => ({
currencyCode,
setCurrencyCode,
currencySymbol,
limitedCurrencies,
pricingFormState,
setPricingFormState,
plan,
planCode,
planName,
pricing,
recurlyLoading,
recurlyLoadError,
recurlyPrice,
monthlyBilling,
taxes,
coupon,
couponError,
trialLength,
addCoupon,
applyVatNumber,
changeCurrency,
updateCountry,
}),
[
currencyCode,
setCurrencyCode,
currencySymbol,
limitedCurrencies,
pricingFormState,
setPricingFormState,
plan,
planCode,
planName,
pricing,
recurlyLoading,
recurlyLoadError,
recurlyPrice,
monthlyBilling,
taxes,
coupon,
couponError,
trialLength,
addCoupon,
applyVatNumber,
changeCurrency,
updateCountry,
]
)
return { value }
}
export const PaymentContext = createContext<PaymentContextValue | undefined>(
undefined
)
type PaymentProviderProps = {
publicKey: string
children?: React.ReactNode
}
export function PaymentProvider({ publicKey, ...props }: PaymentProviderProps) {
const { value } = usePayment({ publicKey })
return <PaymentContext.Provider value={value} {...props} />
}
export function usePaymentContext() {
const context = useContext(PaymentContext)
if (!context) {
throw new Error('PaymentContext is only available inside PaymentProvider')
}
return context
}

View File

@@ -1,67 +0,0 @@
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
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: CurrencyCode
setCurrencyCode: React.Dispatch<
React.SetStateAction<PaymentContextValue['currencyCode']>
>
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
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: CurrencyCode) => void
updateCountry: (country: PricingFormState['country']) => void
}

View File

@@ -1,64 +0,0 @@
import { useState } from 'react'
import AddressFirstLineComponent from '../../../../js/features/subscription/components/new/checkout/address-first-line'
type Args = Pick<
React.ComponentProps<typeof AddressFirstLineComponent>,
'errorFields'
>
const Template = ({ errorFields }: Args) => {
const [value, setValue] = useState('')
return (
<AddressFirstLineComponent
errorFields={errorFields}
value={value}
onChange={e => setValue(e.target.value)}
/>
)
}
export const AddressFirstLineDefault = Template.bind({}) as typeof Template & {
args: Args
}
AddressFirstLineDefault.args = {
errorFields: {
address1: false,
},
}
export const AddressFirstLineError = Template.bind({}) as typeof Template & {
args: Args
}
AddressFirstLineError.args = {
errorFields: {
address1: true,
},
}
export default {
title: 'Subscription / New / Checkout / Form Fields',
component: AddressFirstLineComponent,
argTypes: {
value: {
table: {
disable: true,
},
},
onChange: {
table: {
disable: true,
},
},
},
decorators: [
(Story: React.ComponentType) => (
<div
className="card card-highlighted card-border"
style={{ maxWidth: '500px' }}
>
<Story />
</div>
),
],
}

View File

@@ -1,64 +0,0 @@
import { useState } from 'react'
import AddressSecondLineComponent from '../../../../js/features/subscription/components/new/checkout/address-second-line'
type Args = Pick<
React.ComponentProps<typeof AddressSecondLineComponent>,
'errorFields'
>
const Template = ({ errorFields }: Args) => {
const [value, setValue] = useState('')
return (
<AddressSecondLineComponent
errorFields={errorFields}
value={value}
onChange={e => setValue(e.target.value)}
/>
)
}
export const AddressSecondLineDefault = Template.bind({}) as typeof Template & {
args: Args
}
AddressSecondLineDefault.args = {
errorFields: {
address2: false,
},
}
export const AddressSecondLineError = Template.bind({}) as typeof Template & {
args: Args
}
AddressSecondLineError.args = {
errorFields: {
address2: true,
},
}
export default {
title: 'Subscription / New / Checkout / Form Fields',
component: AddressSecondLineComponent,
argTypes: {
value: {
table: {
disable: true,
},
},
onChange: {
table: {
disable: true,
},
},
},
decorators: [
(Story: React.ComponentType) => (
<div
className="card card-highlighted card-border"
style={{ maxWidth: '500px' }}
>
<Story />
</div>
),
],
}

View File

@@ -1,40 +0,0 @@
import { useRef } from 'react'
import CardElementComponent from '../../../../js/features/subscription/components/new/checkout/card-element'
import ExternalScriptLoader from '../../../../js/shared/utils/external-script-loader'
export const CardElement = () => {
const elements = useRef(recurly.Elements())
return (
<CardElementComponent elements={elements.current} onChange={() => {}} />
)
}
export default {
title: 'Subscription / New / Checkout / Form Fields',
component: CardElementComponent,
argTypes: {
elements: {
table: {
disable: true,
},
},
onChange: {
table: {
disable: true,
},
},
},
decorators: [
(Story: React.ComponentType) => (
<div
className="card card-highlighted card-border"
style={{ maxWidth: '500px' }}
>
<ExternalScriptLoader src="https://js.recurly.com/v4/recurly.js">
<Story />
</ExternalScriptLoader>
</div>
),
],
}

View File

@@ -1,65 +0,0 @@
import { useState } from 'react'
import CompanyDetailsComponent from '../../../../js/features/subscription/components/new/checkout/company-details'
import { PaymentProvider } from '../helpers/context-provider'
import {
PaymentContextValue,
PricingFormState,
} from '../../../../js/features/subscription/context/types/payment-context-value'
type Args = Pick<
React.ComponentProps<typeof CompanyDetailsComponent>,
'taxesCount'
>
export const CompanyDetails = (args: Args) => {
const [pricingFormState, setPricingFormState] = useState<PricingFormState>({
first_name: '',
last_name: '',
postal_code: '',
address1: '',
address2: '',
state: '',
city: '',
company: '',
vat_number: '',
country: 'GB',
coupon: '',
})
const providerValue = {
applyVatNumber: () => {},
pricingFormState,
setPricingFormState,
} as unknown as PaymentContextValue
return (
<PaymentProvider value={providerValue}>
<CompanyDetailsComponent {...args} />
</PaymentProvider>
)
}
export default {
title: 'Subscription / New / Checkout / Form Fields',
component: CompanyDetailsComponent,
argTypes: {
taxesCount: {
control: {
type: 'number',
},
},
},
args: {
taxesCount: 1,
},
decorators: [
(Story: React.ComponentType) => (
<div
className="card card-highlighted card-border"
style={{ maxWidth: '500px' }}
>
<Story />
</div>
),
],
}

View File

@@ -1,74 +0,0 @@
import { useState } from 'react'
import countries from '../../../../js/features/subscription/data/countries'
import CountrySelectComponent from '../../../../js/features/subscription/components/new/checkout/country-select'
import { PaymentProvider } from '../helpers/context-provider'
import { PaymentContextValue } from '../../../../js/features/subscription/context/types/payment-context-value'
type Args = Pick<
React.ComponentProps<typeof CountrySelectComponent>,
'errorFields'
>
const Template = ({ errorFields }: Args) => {
const [value, setValue] = useState<typeof countries[number]['code']>('GB')
const providerValue = {
updateCountry: () => {},
} as unknown as PaymentContextValue
return (
<PaymentProvider value={providerValue}>
<CountrySelectComponent
errorFields={errorFields}
value={value}
onChange={e =>
setValue(e.target.value as typeof countries[number]['code'])
}
/>
</PaymentProvider>
)
}
export const CountrySelectDefault = Template.bind({}) as typeof Template & {
args: Args
}
CountrySelectDefault.args = {
errorFields: {
country: false,
},
}
export const CountrySelectError = Template.bind({}) as typeof Template & {
args: Args
}
CountrySelectError.args = {
errorFields: {
country: true,
},
}
export default {
title: 'Subscription / New / Checkout / Form Fields',
component: CountrySelectComponent,
argTypes: {
value: {
table: {
disable: true,
},
},
onChange: {
table: {
disable: true,
},
},
},
decorators: [
(Story: React.ComponentType) => (
<div
className="card card-highlighted card-border"
style={{ maxWidth: '500px' }}
>
<Story />
</div>
),
],
}

View File

@@ -1,47 +0,0 @@
import { useState } from 'react'
import CouponCodeComponent from '../../../../js/features/subscription/components/new/checkout/coupon-code'
import { PaymentProvider } from '../helpers/context-provider'
import { PaymentContextValue } from '../../../../js/features/subscription/context/types/payment-context-value'
export const CouponCode = () => {
const [value, setValue] = useState('')
const providerValue = {
addCoupon: () => {},
} as unknown as PaymentContextValue
return (
<PaymentProvider value={providerValue}>
<CouponCodeComponent
value={value}
onChange={e => setValue(e.target.value)}
/>
</PaymentProvider>
)
}
export default {
title: 'Subscription / New / Checkout / Form Fields',
component: CouponCodeComponent,
argTypes: {
value: {
table: {
disable: true,
},
},
onChange: {
table: {
disable: true,
},
},
},
decorators: [
(Story: React.ComponentType) => (
<div
className="card card-highlighted card-border"
style={{ maxWidth: '500px' }}
>
<Story />
</div>
),
],
}

View File

@@ -1,61 +0,0 @@
import { useState } from 'react'
import FirstNameComponent from '../../../../js/features/subscription/components/new/checkout/first-name'
type Args = Pick<React.ComponentProps<typeof FirstNameComponent>, 'errorFields'>
const Template = ({ errorFields }: Args) => {
const [value, setValue] = useState('')
return (
<FirstNameComponent
errorFields={errorFields}
value={value}
onChange={e => setValue(e.target.value)}
/>
)
}
export const FirstNameDefault = Template.bind({}) as typeof Template & {
args: Args
}
FirstNameDefault.args = {
errorFields: {
first_name: false,
},
}
export const FirstNameError = Template.bind({}) as typeof Template & {
args: Args
}
FirstNameError.args = {
errorFields: {
first_name: true,
},
}
export default {
title: 'Subscription / New / Checkout / Form Fields',
component: FirstNameComponent,
argTypes: {
value: {
table: {
disable: true,
},
},
onChange: {
table: {
disable: true,
},
},
},
decorators: [
(Story: React.ComponentType) => (
<div
className="card card-highlighted card-border"
style={{ maxWidth: '500px' }}
>
<Story />
</div>
),
],
}

View File

@@ -1,61 +0,0 @@
import { useState } from 'react'
import LastNameComponent from '../../../../js/features/subscription/components/new/checkout/last-name'
type Args = Pick<React.ComponentProps<typeof LastNameComponent>, 'errorFields'>
const Template = ({ errorFields }: Args) => {
const [value, setValue] = useState('')
return (
<LastNameComponent
errorFields={errorFields}
value={value}
onChange={e => setValue(e.target.value)}
/>
)
}
export const LastNameDefault = Template.bind({}) as typeof Template & {
args: Args
}
LastNameDefault.args = {
errorFields: {
last_name: false,
},
}
export const LastNameError = Template.bind({}) as typeof Template & {
args: Args
}
LastNameError.args = {
errorFields: {
last_name: true,
},
}
export default {
title: 'Subscription / New / Checkout / Form Fields',
component: LastNameComponent,
argTypes: {
value: {
table: {
disable: true,
},
},
onChange: {
table: {
disable: true,
},
},
},
decorators: [
(Story: React.ComponentType) => (
<div
className="card card-highlighted card-border"
style={{ maxWidth: '500px' }}
>
<Story />
</div>
),
],
}

View File

@@ -1,28 +0,0 @@
import { useState } from 'react'
import PaymentMethodToggleComponent from '../../../../js/features/subscription/components/new/checkout/payment-method-toggle'
export const PaymentMethodToggle = () => {
const [paymentMethod, setPaymentMethod] = useState('credit_card')
return (
<PaymentMethodToggleComponent
paymentMethod={paymentMethod}
onChange={e => setPaymentMethod(e.target.value)}
/>
)
}
export default {
title: 'Subscription / New / Checkout',
component: PaymentMethodToggleComponent,
decorators: [
(Story: React.ComponentType) => (
<div
className="card card-highlighted card-border"
style={{ maxWidth: '500px' }}
>
<Story />
</div>
),
],
}

View File

@@ -1,64 +0,0 @@
import { useState } from 'react'
import PostalCodeComponent from '../../../../js/features/subscription/components/new/checkout/postal-code'
type Args = Pick<
React.ComponentProps<typeof PostalCodeComponent>,
'errorFields'
>
const Template = ({ errorFields }: Args) => {
const [value, setValue] = useState('')
return (
<PostalCodeComponent
errorFields={errorFields}
value={value}
onChange={e => setValue(e.target.value)}
/>
)
}
export const PostalCodeDefault = Template.bind({}) as typeof Template & {
args: Args
}
PostalCodeDefault.args = {
errorFields: {
postal_code: false,
},
}
export const PostalCodeError = Template.bind({}) as typeof Template & {
args: Args
}
PostalCodeError.args = {
errorFields: {
postal_code: true,
},
}
export default {
title: 'Subscription / New / Checkout / Form Fields',
component: PostalCodeComponent,
argTypes: {
value: {
table: {
disable: true,
},
},
onChange: {
table: {
disable: true,
},
},
},
decorators: [
(Story: React.ComponentType) => (
<div
className="card card-highlighted card-border"
style={{ maxWidth: '500px' }}
>
<Story />
</div>
),
],
}

View File

@@ -1,43 +0,0 @@
import PriceSwitchHeaderComponent from '../../../../js/features/subscription/components/new/checkout/price-switch-header'
type Args = React.ComponentProps<typeof PriceSwitchHeaderComponent>
export const PriceSwitchHeader = (args: Args) => (
<PriceSwitchHeaderComponent {...args} />
)
const options = {
current: 'fake_plan',
other: 'fake_plan_new',
}
export default {
title: 'Subscription / New / Checkout',
component: PriceSwitchHeaderComponent,
argTypes: {
planCode: {
options: Object.values(options),
control: {
type: 'select',
labels: Object.entries(options).reduce(
(prev, [key, value]) => ({ ...prev, [String(value)]: key }),
{}
),
},
},
},
args: {
planCode: 'fake_plan',
planCodes: ['fake_plan'],
},
decorators: [
(Story: React.ComponentType) => (
<div
className="card card-highlighted card-border"
style={{ maxWidth: '500px' }}
>
<Story />
</div>
),
],
}

View File

@@ -1,47 +0,0 @@
import SubmitButtonComponent from '../../../../js/features/subscription/components/new/checkout/submit-button'
type Args = React.ComponentProps<typeof SubmitButtonComponent>
const Template = (args: Args) => <SubmitButtonComponent {...args} />
export const SubmitButton = Template.bind({}) as typeof Template & {
args: Args
}
SubmitButton.args = {
isProcessing: false,
isFormValid: true,
children: 'Submit',
}
export const SubmitButtonDisabled = Template.bind({}) as typeof Template & {
args: Args
}
SubmitButtonDisabled.args = {
isProcessing: false,
isFormValid: false,
children: 'Submit',
}
export const SubmitButtonProcessing = Template.bind({}) as typeof Template & {
args: Args
}
SubmitButtonProcessing.args = {
isProcessing: true,
isFormValid: true,
children: 'Submit',
}
export default {
title: 'Subscription / New / Checkout / Submit Button',
component: SubmitButtonComponent,
decorators: [
(Story: React.ComponentType) => (
<div
className="card card-highlighted card-border"
style={{ maxWidth: '500px' }}
>
<Story />
</div>
),
],
}

View File

@@ -1,17 +0,0 @@
import TosAgreementNoticeComponent from '../../../../js/features/subscription/components/new/checkout/tos-agreement-notice'
export const TosAgreementNotice = () => <TosAgreementNoticeComponent />
export default {
title: 'Subscription / New / Checkout',
decorators: [
(Story: React.ComponentType) => (
<div
className="card card-highlighted card-border"
style={{ maxWidth: '500px' }}
>
<Story />
</div>
),
],
}

View File

@@ -1,13 +0,0 @@
import { PaymentContext } from '../../../../js/features/subscription/context/payment-context'
import { PaymentContextValue } from '../../../../js/features/subscription/context/types/payment-context-value'
type PaymentProviderProps = {
children: React.ReactNode
value: PaymentContextValue
}
export function PaymentProvider({ value, children }: PaymentProviderProps) {
return (
<PaymentContext.Provider value={value}>{children}</PaymentContext.Provider>
)
}

View File

@@ -1,40 +0,0 @@
import CollaboratorsComponent from '../../../../js/features/subscription/components/new/payment-preview/collaborators'
type Args = React.ComponentProps<typeof CollaboratorsComponent>
export const Collaborators = (args: Args) => (
<CollaboratorsComponent {...args} />
)
const options = {
unlimited: -1,
single: 1,
multiple: 2,
}
export default {
title: 'Subscription / New / Payment Preview',
component: CollaboratorsComponent,
argTypes: {
count: {
options: Object.values(options),
control: {
type: 'select',
labels: Object.entries(options).reduce(
(prev, [key, value]) => ({ ...prev, [String(value)]: key }),
{}
),
},
},
},
args: {
count: options.single, // default
},
decorators: [
(Story: React.ComponentType) => (
<div style={{ maxWidth: '300px' }}>
<Story />
</div>
),
],
}

View File

@@ -1,33 +0,0 @@
import FeaturesListComponent from '../../../../js/features/subscription/components/new/payment-preview/features-list'
import { Plan } from '../../../../../types/subscription/plan'
type Args = React.ComponentProps<typeof FeaturesListComponent>
export const FeaturesList = (args: Args) => <FeaturesListComponent {...args} />
const features = {
compileTimeout: 2,
dropbox: true,
github: true,
versioning: true,
trackChanges: true,
references: true,
mendeley: true,
zotero: true,
symbolPalette: true,
} as unknown as Plan['features']
export default {
title: 'Subscription / New / Payment Preview',
component: FeaturesListComponent,
args: {
features,
},
decorators: [
(Story: React.ComponentType) => (
<div style={{ maxWidth: '300px' }}>
<Story />
</div>
),
],
}

View File

@@ -1,93 +0,0 @@
import NoDiscountPriceComponent from '../../../../js/features/subscription/components/new/payment-preview/no-discount-price'
import { PaymentProvider } from '../helpers/context-provider'
import { PaymentContextValue } from '../../../../js/features/subscription/context/types/payment-context-value'
type Args = Pick<React.ComponentProps<typeof PaymentProvider>, 'value'>
const Template = (args: Args) => {
return (
<PaymentProvider value={args.value}>
<NoDiscountPriceComponent />
</PaymentProvider>
)
}
const commonValues = {
currencySymbol: '$',
coupon: {
normalPrice: 2,
},
}
export const ThenXPricePerMonth = Template.bind({}) as typeof Template & {
args: Args
}
const thenXPricePerMonthValue = {
...commonValues,
monthlyBilling: true,
coupon: {
...commonValues.coupon,
discountMonths: 2,
singleUse: false,
},
} as PaymentContextValue
ThenXPricePerMonth.args = {
value: thenXPricePerMonthValue,
}
export const ThenXPricePerYear = Template.bind({}) as typeof Template & {
args: Args
}
const thenXPricePerYearValue = {
...commonValues,
monthlyBilling: false,
coupon: {
...commonValues.coupon,
singleUse: true,
},
} as PaymentContextValue
ThenXPricePerYear.args = {
value: thenXPricePerYearValue,
}
export const NormallyXPricePerMonth = Template.bind({}) as typeof Template & {
args: Args
}
const normallyXPricePerMonthValue = {
...commonValues,
monthlyBilling: true,
coupon: {
...commonValues.coupon,
singleUse: false,
},
} as PaymentContextValue
NormallyXPricePerMonth.args = {
value: normallyXPricePerMonthValue,
}
export const NormallyXPricePerYear = Template.bind({}) as typeof Template & {
args: Args
}
const normallyXPricePerYearValue = {
...commonValues,
monthlyBilling: false,
coupon: {
...commonValues.coupon,
singleUse: false,
},
} as PaymentContextValue
NormallyXPricePerYear.args = {
value: normallyXPricePerYearValue,
}
export default {
title: 'Subscription / New / Payment Preview / No Discount Price',
component: NoDiscountPriceComponent,
decorators: [
(Story: React.ComponentType) => (
<div style={{ maxWidth: '300px' }}>
<Story />
</div>
),
],
}

View File

@@ -1,75 +0,0 @@
import PriceForFirstXPeriod from '../../../../js/features/subscription/components/new/payment-preview/price-for-first-x-period'
import { PaymentProvider } from '../helpers/context-provider'
import { PaymentContextValue } from '../../../../js/features/subscription/context/types/payment-context-value'
type Args = Pick<React.ComponentProps<typeof PaymentProvider>, 'value'>
const Template = (args: Args) => {
return (
<PaymentProvider value={args.value}>
<PriceForFirstXPeriod />
</PaymentProvider>
)
}
const commonValues = {
currencySymbol: '$',
recurlyPrice: {
total: '10.00',
},
}
export const XPriceForYMonths = Template.bind({}) as typeof Template & {
args: Args
}
const xPriceForYMonthsValue = {
...commonValues,
monthlyBilling: true,
coupon: {
discountMonths: 2,
singleUse: false,
},
} as PaymentContextValue
XPriceForYMonths.args = {
value: xPriceForYMonthsValue,
}
export const XPriceForFirstMonth = Template.bind({}) as typeof Template & {
args: Args
}
const xPriceForFirstMonthValue = {
...commonValues,
monthlyBilling: true,
coupon: {
singleUse: true,
},
} as PaymentContextValue
XPriceForFirstMonth.args = {
value: xPriceForFirstMonthValue,
}
export const XPriceForFirstYear = Template.bind({}) as typeof Template & {
args: Args
}
const xPriceForFirstYearValue = {
...commonValues,
monthlyBilling: false,
coupon: {
singleUse: true,
},
} as PaymentContextValue
XPriceForFirstYear.args = {
value: xPriceForFirstYearValue,
}
export default {
title: 'Subscription / New / Payment Preview / Price For First X Period',
component: PriceForFirstXPeriod,
decorators: [
(Story: React.ComponentType) => (
<div style={{ maxWidth: '300px' }}>
<Story />
</div>
),
],
}

View File

@@ -1,51 +0,0 @@
import PriceSummaryComponent from '../../../../js/features/subscription/components/new/payment-preview/price-summary'
import { PaymentProvider } from '../helpers/context-provider'
type Args = Pick<React.ComponentProps<typeof PaymentProvider>, 'value'>
export const PriceSummary = (args: Args) => {
return (
<PaymentProvider value={args.value}>
<PriceSummaryComponent />
</PaymentProvider>
)
}
export default {
title: 'Subscription / New / Payment Preview',
component: PriceSummaryComponent,
args: {
value: {
currencyCode: 'USD',
currencySymbol: '$',
coupon: {
name: 'react',
normalPriceWithoutTax: 15,
},
changeCurrency: (_eventKey: string) => {},
limitedCurrencies: {
USD: '$',
EUR: '€',
},
monthlyBilling: true,
planName: 'Test plan',
recurlyPrice: {
discount: '3',
tax: '5.00',
total: '10.00',
},
taxes: [
{
rate: '1',
},
],
},
},
decorators: [
(Story: React.ComponentType) => (
<div style={{ maxWidth: '300px' }}>
<Story />
</div>
),
],
}

View File

@@ -1,33 +0,0 @@
import TrialPriceComponent from '../../../../js/features/subscription/components/new/payment-preview/trial-price'
import { PaymentProvider } from '../helpers/context-provider'
type Args = Pick<React.ComponentProps<typeof PaymentProvider>, 'value'>
export const TrialPrice = (args: Args) => {
return (
<PaymentProvider value={args.value}>
<TrialPriceComponent />
</PaymentProvider>
)
}
export default {
title: 'Subscription / New / Payment Preview',
component: TrialPriceComponent,
args: {
value: {
currencySymbol: '$',
recurlyPrice: {
total: '10.00',
},
trialLength: 7,
},
},
decorators: [
(Story: React.ComponentType) => (
<div style={{ maxWidth: '300px' }}>
<Story />
</div>
),
],
}

View File

@@ -1,452 +0,0 @@
import CheckoutPanel from '../../../../../../frontend/js/features/subscription/components/new/checkout/checkout-panel'
import { PaymentProvider } from '../../../../../../frontend/js/features/subscription/context/payment-context'
import { plans } from '../../fixtures/plans'
import {
createFakeRecurly,
defaultSubscription,
ElementsBase,
} from '../../fixtures/recurly-mock'
import { fillForm } from '../../helpers/payment'
import { cloneDeep } from 'lodash'
import { TokenHandler, RecurlyError } from 'recurly__recurly-js'
function CheckoutPanelWithPaymentProvider() {
return (
<PaymentProvider publicKey="0000">
<CheckoutPanel />
</PaymentProvider>
)
}
describe('checkout panel', function () {
const itmCampaign = 'fake_itm_campaign'
const itmContent = 'fake_itm_content'
const itmReferrer = 'fake_itm_referrer'
beforeEach(function () {
const plan = plans.find(({ planCode }) => planCode === 'student-annual')
if (!plan) {
throw new Error('No plan was found while running the test!')
}
cy.window().then(win => {
win.metaAttributesCache = new Map()
win.metaAttributesCache.set('ol-countryCode', '')
win.metaAttributesCache.set('ol-recurlyApiKey', '0000')
win.metaAttributesCache.set('ol-recommendedCurrency', 'USD')
win.metaAttributesCache.set('ol-plan', plan)
win.metaAttributesCache.set('ol-planCode', plan.planCode)
win.metaAttributesCache.set('ol-showCouponField', true)
win.metaAttributesCache.set('ol-itm_campaign', itmCampaign)
win.metaAttributesCache.set('ol-itm_content', itmContent)
win.metaAttributesCache.set('ol-itm_referrer', itmReferrer)
cy.wrap(plan).as('plan')
// init default recurly
win.recurly = createFakeRecurly(defaultSubscription)
cy.interceptEvents()
})
cy.mount(<CheckoutPanelWithPaymentProvider />)
cy.findByTestId('checkout-form').as('form')
})
it('renders heading', function () {
cy.contains(/select a payment method/i)
})
it('renders student disclaimer', function () {
cy.contains(
'The educational discount applies to all students at secondary and postsecondary institutions ' +
'(schools and universities). We may contact you to confirm that youre eligible for the discount.'
)
})
it('renders payment method toggle', function () {
cy.findByTestId('payment-method-toggle').within(() => {
cy.findByLabelText(/card payment/i)
cy.findByLabelText(/paypal/i)
})
})
it('renders address first line input', function () {
cy.get('@form').within(() => {
cy.findByLabelText('Address')
cy.findByLabelText(/this address will be shown on the invoice/i)
})
})
it('renders address second line input', function () {
cy.get('@form').within(() => {
cy.findByLabelText(/address second line/i).should('not.exist')
cy.findByRole('button', { name: /add another address line/i }).click()
cy.findByLabelText(/address second line/i)
})
})
it('renders postal code input', function () {
cy.get('@form').within(() => {
cy.findByLabelText(/postal code/i)
})
})
it('renders country dropdown', function () {
cy.get('@form').within(() => {
cy.findByLabelText(/country/i)
})
})
it('renders company details', function () {
cy.get('@form').within(() => {
cy.findByLabelText(/add company details/i).as('checkbox')
cy.get('@checkbox').should('not.be.checked')
cy.findByLabelText(/company name/i).should('not.exist')
cy.findByLabelText(/vat number/i).should('not.exist')
cy.get('@checkbox').click()
cy.findByLabelText(/company name/i)
cy.findByLabelText(/vat number/i)
})
})
it('renders coupon field', function () {
cy.get('@form').within(() => {
cy.findByLabelText(/coupon code/i)
})
})
it('renders tos agreement notice', function () {
cy.contains(/by subscribing, you agree to our terms of service/i)
})
it('renders recurly error', function () {
cy.window().then(win => {
win.recurly = undefined!
})
cy.mount(<CheckoutPanelWithPaymentProvider />)
cy.contains(
/sorry, there was an error talking to our payment provider. Please try again in a few moments/i
)
cy.contains(
/if you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them/i
)
})
it('calls recurly.token on submit', function () {
cy.window().then(win => {
cy.stub(win.recurly, 'token')
})
cy.mount(<CheckoutPanelWithPaymentProvider />)
cy.get('@form').within(() => fillForm())
cy.findByRole('button', { name: /upgrade now/i }).click()
cy.window().then(win => {
expect(win.recurly.token).to.be.calledOnceWith(
Cypress.sinon.match.instanceOf(ElementsBase),
{
first_name: '1',
last_name: '1',
postal_code: '1',
address1: '1',
address2: '',
state: '',
city: '',
country: 'BG',
coupon: '',
},
Cypress.sinon.match.func
)
})
})
it('renders generic error', function () {
const errorMessage = 'generic error'
cy.window().then(win => {
win.recurly = createFakeRecurly(defaultSubscription, {
token: (_1: unknown, _2: unknown, handler: TokenHandler) => {
const err = new Error(errorMessage) as RecurlyError
setTimeout(() => handler(err, { id: '1', type: 'abc' }), 100)
},
})
})
cy.mount(<CheckoutPanelWithPaymentProvider />)
cy.get('@form').within(() => fillForm())
cy.findByRole('button', { name: /upgrade now/i }).as('button')
cy.get('@button').click()
cy.get('@button').within(() => {
cy.contains(/processing/i)
})
cy.findByRole('alert').should('have.text', errorMessage)
cy.get('@button').within(() => {
cy.findByText(/processing/i).should('not.exist')
})
})
it('renders prefilled coupon input', function () {
const couponCode = 'promo_code'
cy.window().then(win => {
win.metaAttributesCache.set('ol-couponCode', couponCode)
})
cy.mount(<CheckoutPanelWithPaymentProvider />)
cy.findByLabelText(/coupon code/i).should('have.value', couponCode)
})
it('calls coupon method when entering coupon code', function () {
const couponCode = 'promo_code'
cy.window().then(win => {
const couponStub = cy.stub().as('coupon')
couponStub.returnsThis()
win.recurly = createFakeRecurly({
...defaultSubscription,
coupon: couponStub,
})
})
cy.mount(<CheckoutPanelWithPaymentProvider />)
cy.get('@coupon').should('have.been.calledOnce')
cy.findByTestId('checkout-form').within(() => {
cy.findByLabelText(/coupon code/i).type(couponCode, { delay: 0 })
cy.findByLabelText(/coupon code/i).blur()
})
cy.get('@coupon')
.should('have.been.calledTwice')
.and('have.been.calledWith', couponCode)
})
it('enters invalid coupon code', function () {
cy.window().then(win => {
const catchStub = cy.stub().as('catch')
catchStub.onFirstCall().returnsThis()
catchStub
.onSecondCall()
.callsFake(function (this: unknown, cb: (err: RecurlyError) => void) {
const err = {
name: 'api-error',
code: 'not-found',
} as RecurlyError
cb(err)
return this
})
win.recurly = createFakeRecurly({
...defaultSubscription,
catch: catchStub,
})
})
cy.mount(<CheckoutPanelWithPaymentProvider />)
cy.findByTestId('checkout-form').within(() => {
cy.findByLabelText(/coupon code/i).type('promo_code', { delay: 0 })
cy.findByLabelText(/coupon code/i).blur()
})
cy.findByRole('alert').within(() => {
cy.contains(/coupon code is not valid for selected plan/i)
})
})
it('fails coupon verification', function () {
cy.window().then(win => {
const catchStub = cy.stub().as('catch')
// call original method on change event
catchStub.onFirstCall().returnsThis()
catchStub
.onSecondCall()
.callsFake(function (this: unknown, cb: (err: RecurlyError) => void) {
const err = {} as RecurlyError
try {
cb(err)
} catch (e) {}
return this
})
win.recurly = createFakeRecurly({
...defaultSubscription,
catch: catchStub,
})
})
cy.mount(<CheckoutPanelWithPaymentProvider />)
cy.get('@catch').should('have.been.calledOnce')
cy.findByTestId('checkout-form').within(() => {
cy.findByLabelText(/coupon code/i).type('promo_code', { delay: 0 })
cy.findByLabelText(/coupon code/i).blur()
})
cy.get('@catch').should('have.been.calledTwice')
cy.findByRole('alert').within(() => {
cy.contains(/an error occurred when verifying the coupon code/i)
})
})
/* The test is disabled due to https://github.com/overleaf/internal/issues/12004
it.skip('creates a new subscription', function () {
cy.stub(locationModule, 'assign').as('assign')
cy.intercept('POST', 'user/subscription/create', {
statusCode: 201,
}).as('create')
cy.mount(<CheckoutPanelWithPaymentProvider />)
cy.findByTestId('checkout-form').within(() => fillForm())
cy.findByRole('button', { name: /upgrade now/i }).click()
// verify itm params are also passed
cy.get('@create')
.its('request.body.subscriptionDetails')
.should('contain', {
ITMCampaign: itmCampaign,
ITMContent: itmContent,
ITMReferrer: itmReferrer,
})
cy.get('@assign')
.should('have.been.calledOnce')
.and('have.been.calledWith', '/user/subscription/thank-you')
})
*/
it('fails to create a new subscription', function () {
cy.intercept('POST', 'user/subscription/create', {
statusCode: 404,
})
cy.mount(<CheckoutPanelWithPaymentProvider />)
cy.findByTestId('checkout-form').within(() => fillForm())
cy.findByRole('button', { name: /upgrade now/i }).click()
cy.findByRole('alert').within(() => {
cy.contains(/something went wrong processing the request/i)
})
})
describe('3DS challenge', function () {
it('shows three d secure challenge', function () {
cy.intercept('POST', 'user/subscription/create', {
statusCode: 404,
body: {
threeDSecureActionTokenId: '123',
},
})
cy.mount(<CheckoutPanelWithPaymentProvider />)
cy.findByTestId('checkout-form').within(() => fillForm())
cy.findByRole('button', { name: /upgrade now/i }).click()
cy.findByRole('alert').within(() => {
cy.contains(
/your card must be authenticated with 3D Secure before continuing/i
)
})
cy.contains('3D challenge content')
})
})
describe('card payments', function () {
beforeEach(function () {
cy.findByLabelText(/card payment/i).click()
})
it('renders card element', function () {
cy.get('@form').within(() => {
cy.findByText(/card details/i, { selector: 'label' })
cy.findByTestId('test-card-element')
})
})
it('verifies the card element does not disappear when switching between payment methods', function () {
cy.get('@form').within(() => {
cy.findByText(/card details/i, { selector: 'label' })
cy.findByTestId('test-card-element')
cy.findByLabelText(/paypal/i).click()
cy.findByLabelText(/card payment/i).click()
cy.findByText(/card details/i, { selector: 'label' })
cy.findByTestId('test-card-element')
})
})
it('renders first name input', function () {
cy.get('@form').within(() => {
cy.findByLabelText(/first name/i)
})
})
it('renders last name input', function () {
cy.get('@form').within(() => {
cy.findByLabelText(/last name/i)
})
})
describe('submit button', function () {
it('renders trial button', function () {
cy.get('@form').within(() => {
cy.findByRole('button', { name: /upgrade now, pay after \d+ days/i })
})
})
it('renders non-trial button', function () {
cy.window().then(win => {
const clone = cloneDeep(defaultSubscription)
clone.items.plan!.trial = undefined
win.recurly = createFakeRecurly(clone)
})
cy.mount(<CheckoutPanelWithPaymentProvider />)
cy.findByTestId('checkout-form').within(() => {
cy.findByRole('button', { name: 'Upgrade Now' })
})
})
it('handles the disabled state of submit button', function () {
cy.get('@form').within(() => {
cy.findByRole('button', { name: /upgrade now/i }).should(
'be.disabled'
)
fillForm()
cy.findByRole('button', { name: /upgrade now/i }).should(
'not.be.disabled'
)
})
})
})
})
describe('paypal payments', function () {
beforeEach(function () {
cy.findByLabelText(/paypal/i).click()
})
it('should not render card element', function () {
cy.get('@form').within(() => {
cy.findByLabelText(/card details/i).should('not.exist')
})
})
it('should not render first name input', function () {
cy.get('@form').within(() => {
cy.findByLabelText(/first name/i).should('not.exist')
})
})
it('should not render last name input', function () {
cy.get('@form').within(() => {
cy.findByLabelText(/last name/i).should('not.exist')
})
})
it('renders proceeding to PayPal notice', function () {
cy.get('@form').within(() => {
cy.contains(
/proceeding to PayPal will take you to the PayPal site to pay for your subscription/i
)
})
})
it('handles the disabled state of submit button', function () {
cy.get('@form').within(() => {
cy.findByRole('button', { name: /proceed to paypal/i }).should(
'be.disabled'
)
cy.findByLabelText(/country/i).select('Bulgaria')
cy.findByRole('button', { name: /proceed to paypal/i }).should(
'not.be.disabled'
)
})
})
})
})

View File

@@ -1,68 +0,0 @@
import {
createFakeRecurly,
defaultSubscription,
} from '../../fixtures/recurly-mock'
import { PaymentProvider } from '../../../../../../frontend/js/features/subscription/context/payment-context'
import { plans } from '../../fixtures/plans'
import PaymentPreviewPanel from '../../../../../../frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel'
import CheckoutPanel from '../../../../../../frontend/js/features/subscription/components/new/checkout/checkout-panel'
import { fillForm } from '../../helpers/payment'
describe('common recurly validations', function () {
beforeEach(function () {
const plan = plans.find(({ planCode }) => planCode === 'collaborator')
if (!plan) {
throw new Error('No plan was found while running the test!')
}
cy.window().then(win => {
win.metaAttributesCache = new Map()
win.metaAttributesCache.set('ol-countryCode', '')
win.metaAttributesCache.set('ol-recurlyApiKey', '1234')
win.metaAttributesCache.set('ol-recommendedCurrency', 'USD')
win.metaAttributesCache.set('ol-plan', plan)
win.metaAttributesCache.set('ol-planCode', plan.planCode)
win.metaAttributesCache.set('ol-showCouponField', true)
win.recurly = createFakeRecurly(defaultSubscription)
cy.interceptEvents()
})
})
it('initializes recurly', function () {
cy.window().then(win => {
cy.spy(win.recurly, 'configure')
cy.spy(win.recurly.Pricing, 'Subscription')
})
cy.mount(<PaymentProvider publicKey="0000" />)
cy.window().then(win => {
expect(win.recurly.configure).to.be.calledOnce
expect(win.recurly.Pricing.Subscription).to.be.calledOnce
})
})
it('shows three d secure challenge content only once when changing currency', function () {
cy.intercept('POST', 'user/subscription/create', {
statusCode: 404,
body: {
threeDSecureActionTokenId: '123',
},
})
cy.mount(
<PaymentProvider publicKey="0000">
<PaymentPreviewPanel />
<CheckoutPanel />
</PaymentProvider>
)
cy.findByTestId('checkout-form').within(() => fillForm())
cy.findByRole('button', { name: /upgrade now/i }).click()
cy.findByRole('button', { name: /change currency/i }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /gbp/i }).click()
})
cy.findAllByText('3D challenge content').should('have.length', 1)
})
})

View File

@@ -1,310 +0,0 @@
import PaymentPreviewPanel from '../../../../../../frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel'
import { PaymentProvider } from '../../../../../../frontend/js/features/subscription/context/payment-context'
import { plans } from '../../fixtures/plans'
import {
createFakeRecurly,
defaultSubscription,
} from '../../fixtures/recurly-mock'
import { cloneDeep } from 'lodash'
import { Plan } from '../../../../../../types/subscription/plan'
function PaymentPreviewPanelWithPaymentProvider() {
return (
<PaymentProvider publicKey="0000">
<PaymentPreviewPanel />
</PaymentProvider>
)
}
describe('payment preview panel', function () {
beforeEach(function () {
const plan = plans.find(({ planCode }) => planCode === 'collaborator')
if (!plan) {
throw new Error('No plan was found while running the test!')
}
cy.window().then(win => {
win.metaAttributesCache = new Map()
win.metaAttributesCache.set('ol-countryCode', '')
win.metaAttributesCache.set('ol-recurlyApiKey', '0000')
win.metaAttributesCache.set('ol-recommendedCurrency', 'USD')
win.metaAttributesCache.set('ol-plan', plan)
win.metaAttributesCache.set('ol-planCode', plan.planCode)
cy.wrap(plan).as('plan')
// init default recurly
win.recurly = createFakeRecurly(defaultSubscription)
cy.interceptEvents()
})
})
it('renders plan name', function () {
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.contains(defaultSubscription.items.plan!.name)
})
it('renders collaborators per project', function () {
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.get<Plan>('@plan').then(plan => {
cy.contains(`${plan.features?.collaborators} collaborators per project`)
})
})
it('renders features list', function () {
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.contains(/all premium features/i)
cy.findByTestId('features-list').within(() => {
cy.get(':nth-child(1)').contains(/increased compile timeout/i)
cy.get(':nth-child(2)').contains(/sync with dropbox and github/i)
cy.get(':nth-child(3)').contains(/full document history/i)
cy.get(':nth-child(4)').contains(/track changes/i)
cy.get(':nth-child(5)').contains(/advanced reference search/i)
cy.get(':nth-child(6)').contains(/reference manager sync/i)
cy.get(':nth-child(7)').contains(/symbol palette/i)
})
})
it('renders no features list', function () {
cy.window().then(win => {
cy.get<Plan>('@plan').then(plan => {
const { features: _, ...noFeaturesPlan } = plan
win.metaAttributesCache.set('ol-plan', noFeaturesPlan)
})
})
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.findByTestId('features-list').should('not.exist')
})
describe('price summary', function () {
beforeEach(function () {
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.findByTestId('price-summary').as('priceSummary')
cy.findByTestId('price-summary-plan').as('priceSummaryPlan')
cy.findByTestId('price-summary-coupon').as('priceSummaryCoupon')
cy.findByTestId('price-summary-vat').as('priceSummaryVat')
cy.findByTestId('price-summary-total').as('priceSummaryTotal')
})
it('renders title', function () {
cy.get('@priceSummary').contains(/payment summary/i)
})
it('renders plan info', function () {
cy.get('@priceSummaryPlan').contains(defaultSubscription.items.plan!.name)
cy.get('@priceSummaryPlan').contains(
`$${defaultSubscription.price.base.plan.unit}`
)
})
it('renders coupon info', function () {
cy.get('@priceSummaryCoupon').contains(
defaultSubscription.items.coupon!.name
)
cy.get('@priceSummaryCoupon').contains(
`Discount of $${defaultSubscription.price.now.discount}`
)
})
it('does not render coupon info when there is no coupon', function () {
cy.window().then(win => {
const { coupon: _, ...items } = defaultSubscription.items
win.recurly = createFakeRecurly({ ...defaultSubscription, items })
})
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.findByTestId('price-summary-coupon').should('not.exist')
})
it('renders VAT', function () {
cy.get('@priceSummaryVat').contains(
`VAT ${parseFloat(defaultSubscription.price.taxes[0].rate) * 100}%`
)
cy.get('@priceSummaryVat').contains(
`$${defaultSubscription.price.now.tax}`
)
})
describe('total amount', function () {
it('renders "total per month" text', function () {
cy.window().then(win => {
const clone = cloneDeep(defaultSubscription)
clone.items.plan!.period.length = 1
win.recurly = createFakeRecurly(clone)
})
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.findByTestId('price-summary-total').contains(/total per month/i)
})
it('renders "total per year" text', function () {
cy.window().then(win => {
const clone = cloneDeep(defaultSubscription)
clone.items.plan!.period.length = 2
win.recurly = createFakeRecurly(clone)
})
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.findByTestId('price-summary-total').contains(/total per year/i)
})
it('renders total amount', function () {
cy.get('@priceSummaryTotal').contains(
`$${defaultSubscription.price.now.total}`
)
})
})
it('renders "change currency" dropdown and changes currency', function () {
cy.get('@priceSummary').within(() => {
cy.get('@priceSummary')
.findByRole('button', { name: /change currency/i })
.as('button')
cy.findByRole('menu').should('not.exist')
cy.get('@button').click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /usd \(\$\)/i }).contains(
/selected/i
)
cy.findByRole('menuitem', { name: /eur \(€\)/i })
cy.findByRole('menuitem', { name: /gbp \(£\)/i }).click()
cy.get('@priceSummaryPlan').contains(
`£${defaultSubscription.price.base.plan.unit}`
)
cy.get('@priceSummaryCoupon').contains(
`Discount of £${defaultSubscription.price.now.discount}`
)
cy.get('@priceSummaryVat').contains(
`£${defaultSubscription.price.now.tax}`
)
cy.get('@priceSummaryTotal').contains(
`£${defaultSubscription.price.now.total}`
)
})
})
cy.findByTestId('trial-coupon-summary')
.should('not.contain.text', '$')
.should('contain.text', '£')
})
})
describe('trial coupon summary', function () {
it('renders trial price', function () {
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.findByTestId('trial-coupon-summary').contains(
`First ${defaultSubscription.items.plan!.trial!.length} days free, ` +
`after that $${defaultSubscription.price.now.total} per month`
)
})
it('renders "X price for Y months"', function () {
cy.window()
.then(win => {
const clone = cloneDeep(defaultSubscription)
clone.items.coupon!.applies_for_months = 6
clone.items.coupon!.single_use = false
clone.items.plan!.period.length = 1
win.recurly = createFakeRecurly(clone)
return clone
})
.then((clone: typeof defaultSubscription) => {
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.findByTestId('trial-coupon-summary').contains(
`$${clone.price.now.total} for your first ${
clone.items.coupon!.applies_for_months
} months`
)
})
})
it('renders "X price for first month"', function () {
cy.window()
.then(win => {
const clone = cloneDeep(defaultSubscription)
clone.items.plan!.period.length = 1
win.recurly = createFakeRecurly(clone)
})
.then(() => {
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.findByTestId('trial-coupon-summary').contains(
`$${defaultSubscription.price.now.total} for your first month`
)
})
})
it('renders "X price for first year"', function () {
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.findByTestId('trial-coupon-summary').contains(
`$${defaultSubscription.price.now.total} for your first year`
)
})
it('renders "then X price per month"', function () {
cy.window()
.then(win => {
const clone = cloneDeep(defaultSubscription)
clone.items.coupon!.applies_for_months = 6
clone.items.coupon!.single_use = false
clone.items.plan!.period.length = 1
win.recurly = createFakeRecurly(clone)
})
.then(() => {
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.findByTestId('trial-coupon-summary').contains(
`Then $26.00 per month`
)
})
})
it('renders "then X price per year"', function () {
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.findByTestId('trial-coupon-summary').contains(`Then $26.00 per year`)
})
it('renders "normally X price per month"', function () {
cy.window()
.then(win => {
const clone = cloneDeep(defaultSubscription)
clone.items.coupon!.applies_for_months = 0
clone.items.coupon!.single_use = false
clone.items.plan!.period.length = 1
win.recurly = createFakeRecurly(clone)
})
.then(() => {
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.findByTestId('trial-coupon-summary').contains(
`Normally $26.00 per month`
)
})
})
it('renders "normally X price per year"', function () {
cy.window()
.then(win => {
const clone = cloneDeep(defaultSubscription)
clone.items.coupon!.single_use = false
clone.items.plan!.period.length = 2
win.recurly = createFakeRecurly(clone)
})
.then(() => {
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.findByTestId('trial-coupon-summary').contains(
`Normally $26.00 per year`
)
})
})
})
it('renders "cancel anytime" content', function () {
cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
cy.contains(
/were confident that youll love Overleaf, but if not you can cancel anytime/i
).contains(
/well give you your money back, no questions asked, if you let us know within 30 days/i
)
})
})

View File

@@ -1,232 +0,0 @@
import {
Address,
CardElementOptions,
ElementsInstance,
PayPalConfig,
PlanOptions,
Recurly,
RecurlyError,
RecurlyOptions,
RiskOptions,
Tax,
TokenPayload,
} from 'recurly__recurly-js'
import { SubscriptionPricingInstanceCustom } from '../../../../../types/recurly/pricing/subscription'
export const defaultSubscription = {
id: '123',
price: {
base: {
plan: {
unit: '20.00',
setup_fee: '0.00',
},
},
next: {
addons: '0.00',
discount: '1.00',
plan: '10.00',
setup_fee: '0.00',
subtotal: '9.00',
tax: '1.20',
total: '12.00',
},
now: {
addons: '0.00',
discount: '1.00',
plan: '10.00',
setup_fee: '0.00',
subtotal: '9.00',
tax: '1.20',
total: '12.00',
},
taxes: [
{
tax_type: 'tax_type_1',
region: 'EU',
rate: '0.3',
},
],
},
items: {
coupon: {
applies_for_months: 2,
code: 'react',
discount: {
type: 'percent',
rate: 0.2,
},
name: 'fake coupon',
single_use: true,
},
currency: 'USD',
plan: {
code: 'asd',
name: 'Standard (Collaborator)',
period: {
interval: '2',
length: 5,
},
price: {
'15': {
unit_amount: 5,
symbol: '$',
setup_fee: 2,
},
},
quantity: 1,
tax_code: 'digital',
tax_exempt: false,
trial: {
interval: 'weekly',
length: 7,
},
},
},
} as unknown as SubscriptionPricingInstanceCustom
class PayPalBase {
protected constructor(config?: PayPalConfig) {
Object.assign(this, config)
}
static PayPal = () => new this()
destroy() {}
on(_eventName: string, _callback: () => void) {}
}
class Card {
protected fakeCardEl
constructor() {
this.fakeCardEl = document.createElement('div')
}
attach(el: HTMLElement) {
this.fakeCardEl.dataset.testid = 'test-card-element'
const input = document.createElement('input')
input.style.border = '1px solid black'
input.style.width = '50px'
const cardNumberInput = input.cloneNode(true) as HTMLInputElement
cardNumberInput.style.width = '200px'
cardNumberInput.placeholder = 'XXXX-XXXX-XXXX-XXXX'
this.fakeCardEl.appendChild(cardNumberInput)
this.fakeCardEl.appendChild(input.cloneNode(true))
this.fakeCardEl.appendChild(input.cloneNode(true))
this.fakeCardEl.appendChild(input.cloneNode(true))
el.appendChild(this.fakeCardEl)
}
on(eventName = 'change', callback: (state: Record<string, unknown>) => void) {
this.fakeCardEl.querySelectorAll('input').forEach(node => {
node.addEventListener(eventName, () => {
const state = {
valid: true,
}
callback(state)
})
})
}
}
export class ElementsBase {
protected constructor(config?: unknown) {
Object.assign(this, config)
}
static Elements = () => new this()
CardElement(_cardElementOptions?: CardElementOptions) {
return new Card()
}
}
export class ThreeDSecureBase {
protected constructor(riskOptions?: RiskOptions) {
Object.assign(this, riskOptions)
}
static ThreeDSecure = (_riskOptions: RiskOptions) => new this()
on(_eventName = 'change', _callback: () => void) {}
attach(el: HTMLElement) {
const div = document.createElement('div')
div.appendChild(document.createTextNode('3D challenge content'))
el.appendChild(div)
}
}
abstract class PricingBase {
plan(_planCode: string, _options: PlanOptions) {
return this
}
address(_address: Address) {
return this
}
tax(_tax: Tax) {
return this
}
currency(_currency: string) {
return this
}
coupon(_coupon: string) {
return this
}
catch(_callback?: (reason?: RecurlyError) => void) {
return this
}
done(callback?: () => unknown) {
callback?.()
return this
}
on(_eventName: string, callback: () => void) {
callback()
}
}
const createSubscriptionClass = (classProps: unknown) => {
return class extends PricingBase {
protected constructor() {
super()
Object.assign(this, classProps)
}
static Subscription = () => new this()
}
}
// Using `overrides` as currently can't stub/spy external files with cypress
export const createFakeRecurly = (classProps: unknown, overrides = {}) => {
return {
configure: (_options: RecurlyOptions) => {},
token: (
_elements: ElementsInstance,
_second: unknown,
handler: (
err?: RecurlyError | null,
recurlyBillingToken?: TokenPayload,
threeDResultToken?: TokenPayload
) => void
) => {
handler(undefined, undefined, { id: '123', type: '456' })
},
Elements: ElementsBase.Elements,
PayPal: PayPalBase.PayPal,
Pricing: createSubscriptionClass(classProps),
Risk: () => ({
ThreeDSecure: ThreeDSecureBase.ThreeDSecure,
}),
...overrides,
} as unknown as Recurly
}

View File

@@ -1,12 +0,0 @@
export function fillForm() {
cy.findByTestId('test-card-element').within(() => {
cy.get('input').each(el => {
cy.wrap(el).type('1', { delay: 0 })
})
})
cy.findByLabelText(/first name/i).type('1', { delay: 0 })
cy.findByLabelText(/last name/i).type('1', { delay: 0 })
cy.findByLabelText('Address').type('1', { delay: 0 })
cy.findByLabelText(/postal code/i).type('1', { delay: 0 })
cy.findByLabelText(/country/i).select('Bulgaria')
}

View File

@@ -5,7 +5,6 @@ const MockRequest = require('../helpers/MockRequest')
const MockResponse = require('../helpers/MockResponse')
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionController'
const Errors = require('../../../../app/src/Features/Errors/Errors')
const SubscriptionErrors = require('../../../../app/src/Features/Subscription/Errors')
const mockSubscriptions = {
@@ -85,7 +84,6 @@ describe('SubscriptionController', function () {
.returns({ plans: [], planCodesChangingAtTermEnd: [] }),
}
this.settings = {
restrictedCountries: ['KP'],
coupon_codes: {
upgradeToAnnualPromo: {
student: 'STUDENTCODEHERE',
@@ -305,154 +303,6 @@ describe('SubscriptionController', function () {
})
})
describe('paymentPage', function () {
beforeEach(function () {
this.req.headers = {}
this.SubscriptionHandler.promises.validateNoSubscriptionInRecurly = sinon
.stub()
.resolves(true)
this.GeoIpLookup.promises.getCurrencyCode.resolves({
currencyCode: this.stubbedCurrencyCode,
})
})
describe('with a user without a subscription', function () {
beforeEach(function () {
this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(
false
)
this.PlansLocator.findLocalPlanInSettings.returns({})
})
describe('with a valid plan code', function () {
it('should render the new subscription page', function (done) {
this.res.render = (page, opts) => {
page.should.equal('subscriptions/new-react')
done()
}
this.SubscriptionController.paymentPage(this.req, this.res, done)
})
})
})
describe('with a user with subscription', function () {
it('should redirect to the subscription dashboard', function (done) {
this.PlansLocator.findLocalPlanInSettings.returns({})
this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(
true
)
this.res.redirect = url => {
url.should.equal('/user/subscription?hasSubscription=true')
done()
}
this.SubscriptionController.paymentPage(this.req, this.res)
})
})
describe('with an invalid plan code', function () {
it('should return 422 error - Unprocessable Entity', function (done) {
this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(
false
)
this.PlansLocator.findLocalPlanInSettings.returns(null)
this.HttpErrorHandler.unprocessableEntity = sinon.spy(
(req, res, message) => {
expect(req).to.exist
expect(res).to.exist
expect(message).to.deep.equal('Plan not found')
done()
}
)
this.SubscriptionController.paymentPage(this.req, this.res)
})
})
describe('which currency to use', function () {
beforeEach(function () {
this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(
false
)
this.PlansLocator.findLocalPlanInSettings.returns({})
})
it('should use the set currency from the query string', function (done) {
this.req.query.currency = 'EUR'
this.res.render = (page, opts) => {
opts.currency.should.equal('EUR')
opts.currency.should.not.equal(this.stubbedCurrencyCode)
done()
}
this.SubscriptionController.paymentPage(this.req, this.res)
})
it('should upercase the currency code', function (done) {
this.req.query.currency = 'eur'
this.res.render = (page, opts) => {
opts.currency.should.equal('EUR')
done()
}
this.SubscriptionController.paymentPage(this.req, this.res)
})
it('should use the geo ip currency if non is provided', function (done) {
this.req.query.currency = null
this.res.render = (page, opts) => {
opts.currency.should.equal(this.stubbedCurrencyCode)
done()
}
this.SubscriptionController.paymentPage(this.req, this.res)
})
it('should use the geo ip currency if not valid', function (done) {
this.req.query.currency = 'WAT'
this.GeoIpLookup.isValidCurrencyParam.returns(false)
this.res.render = (page, opts) => {
opts.currency.should.equal(this.stubbedCurrencyCode)
done()
}
this.SubscriptionController.paymentPage(this.req, this.res)
})
})
describe('with a recurly subscription already', function () {
it('should redirect to the subscription dashboard', function (done) {
this.PlansLocator.findLocalPlanInSettings.returns({})
this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(
false
)
this.SubscriptionHandler.promises.validateNoSubscriptionInRecurly.resolves(
false
)
this.res.redirect = url => {
url.should.equal('/user/subscription?hasSubscription=true')
done()
}
this.SubscriptionController.paymentPage(this.req, this.res)
})
})
describe('with a user from a restricted country', function () {
beforeEach(function () {
this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(
false
)
this.PlansLocator.findLocalPlanInSettings.returns({})
this.GeoIpLookup.promises.getCurrencyCode.resolves({
currencyCode: this.stubbedCurrencyCode,
countryCode: 'KP',
})
})
it('should render the restricted country page', function (done) {
this.res.render = (page, opts) => {
page.should.equal('subscriptions/restricted-country')
done()
}
this.SubscriptionController.paymentPage(this.req, this.res, done)
})
})
})
describe('successfulSubscription', function () {
it('without a personal subscription', function (done) {
this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
@@ -550,128 +400,6 @@ describe('SubscriptionController', function () {
})
})
describe('createSubscription', function () {
beforeEach(function (done) {
this.res = {
sendStatus() {
done()
},
}
sinon.spy(this.res, 'sendStatus')
this.subscriptionDetails = {
card: '1234',
cvv: '123',
}
this.recurlyTokenIds = {
billing: '1234',
threeDSecureActionResult: '5678',
}
this.req.body.recurly_token_id = this.recurlyTokenIds.billing
this.req.body.recurly_three_d_secure_action_result_token_id =
this.recurlyTokenIds.threeDSecureActionResult
this.req.body.subscriptionDetails = this.subscriptionDetails
this.LimitationsManager.userHasV1OrV2Subscription.yields(null, false)
this.SubscriptionController.createSubscription(this.req, this.res)
})
it('should send the user and subscriptionId to the handler', function (done) {
this.SubscriptionHandler.promises.createSubscription
.calledWithMatch(
this.user,
this.subscriptionDetails,
this.recurlyTokenIds
)
.should.equal(true)
done()
})
it('should redirect to the subscription page', function (done) {
this.res.sendStatus.calledWith(201).should.equal(true)
done()
})
})
describe('createSubscription with errors', function () {
it('should handle users with subscription', function (done) {
this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(true)
this.SubscriptionController.createSubscription(this.req, {
sendStatus: status => {
expect(status).to.equal(409)
this.SubscriptionHandler.promises.createSubscription.called.should.equal(
false
)
done()
},
})
})
it('should handle restricted country', function (done) {
this.GeoIpLookup.promises.getCurrencyCode.resolves({
countryCode: 'KP',
})
this.res.callback = () => {
expect(this.res.statusCode).to.equal(422)
expect(this.res.body).to.include(
'sorry_detected_sales_restricted_region'
)
this.SubscriptionHandler.promises.createSubscription.called.should.equal(
false
)
done()
}
this.SubscriptionController.createSubscription(this.req, this.res)
})
it('should handle 3DSecure errors/recurly transaction errors', function (done) {
this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(false)
this.SubscriptionHandler.promises.createSubscription.rejects(
new SubscriptionErrors.RecurlyTransactionError({})
)
this.HttpErrorHandler.unprocessableEntity = sinon.spy(
(req, res, message) => {
expect(req).to.exist
expect(res).to.exist
expect(message).to.deep.equal('Unknown transaction error')
done()
}
)
this.SubscriptionController.createSubscription(this.req, this.res)
})
it('should handle validation errors', function (done) {
this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(false)
this.SubscriptionHandler.promises.createSubscription.rejects(
new Errors.InvalidError('invalid error test')
)
this.HttpErrorHandler.unprocessableEntity = sinon.spy(
(req, res, message) => {
expect(req).to.exist
expect(res).to.exist
expect(message).to.deep.equal('invalid error test')
done()
}
)
this.SubscriptionController.createSubscription(this.req, this.res)
})
it('should throw errors from createSubscription that are not handled', function (done) {
const genericError = new Error('generic error')
this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(false)
this.SubscriptionHandler.promises.createSubscription.rejects(genericError)
this.SubscriptionController.createSubscription(
this.req,
this.res,
error => {
expect(error).to.be.instanceof(Error)
expect(error.message).to.equal(genericError.message)
done()
}
)
})
})
describe('updateSubscription via post', function () {
beforeEach(function (done) {
this.res = {
@@ -1035,28 +763,4 @@ describe('SubscriptionController', function () {
this.SubscriptionController.processUpgradeToAnnualPlan(this.req, this.res)
})
})
describe('requireConfirmedPrimaryEmailAddress', function () {
describe('when user does not have confirmed email address', function () {
beforeEach(function () {
this.req.user = { _id: 'testing' }
this.UserGetter.promises.getUser.resolves({
email: 'test@example.com',
emails: [{ email: 'test@example.com' }],
})
})
it('should show unconfirmed primary email page', function (done) {
this.res.render = (page, opts) => {
page.should.equal('subscriptions/unconfirmed-primary-email')
opts.email.should.equal('test@example.com')
done()
}
this.SubscriptionController.requireConfirmedPrimaryEmailAddress(
this.req,
this.res
)
})
})
})
})