mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-30 20:31:34 +02:00
Move checkout to subscriptions module (#15022)
* Move checkout to subscriptions module GitOrigin-RevId: 0ad6587ddd7042aed7f2e18d9d0668e02942eb1e
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'])}
|
||||
@@ -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")}
|
||||
@@ -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")}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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')}
|
||||
<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
|
||||
<span className="hidden-xs">
|
||||
<Icon type="cc-paypal" />
|
||||
</span>
|
||||
</strong>
|
||||
</ControlLabel>
|
||||
</Col>
|
||||
</div>
|
||||
</FormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentMethodToggle
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
–{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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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 you’re 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
/we’re confident that you’ll love Overleaf, but if not you can cancel anytime/i
|
||||
).contains(
|
||||
/we’ll give you your money back, no questions asked, if you let us know within 30 days/i
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user