Kaynağa Gözat

Merge pull request #11530 from overleaf/ii-payment-page-migration-checkout-panel

[web] Payment page migration checkout panel

GitOrigin-RevId: 04edf9961e0032d6fe3631c255e49dc1d4c3e0ca
ilkin-overleaf 3 yıl önce
ebeveyn
işleme
7f8d2e37d5
18 değiştirilmiş dosya ile 1076 ekleme ve 1 silme
  1. 64 0
      services/web/frontend/js/features/subscription/components/new/checkout/address-first-line.tsx
  2. 53 0
      services/web/frontend/js/features/subscription/components/new/checkout/address-second-line.tsx
  3. 58 0
      services/web/frontend/js/features/subscription/components/new/checkout/card-element.tsx
  4. 371 1
      services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx
  5. 82 0
      services/web/frontend/js/features/subscription/components/new/checkout/company-details.tsx
  6. 68 0
      services/web/frontend/js/features/subscription/components/new/checkout/country-select.tsx
  7. 34 0
      services/web/frontend/js/features/subscription/components/new/checkout/coupon-code.tsx
  8. 46 0
      services/web/frontend/js/features/subscription/components/new/checkout/first-name.tsx
  9. 46 0
      services/web/frontend/js/features/subscription/components/new/checkout/last-name.tsx
  10. 57 0
      services/web/frontend/js/features/subscription/components/new/checkout/payment-method-toggle.tsx
  11. 46 0
      services/web/frontend/js/features/subscription/components/new/checkout/postal-code.tsx
  12. 32 0
      services/web/frontend/js/features/subscription/components/new/checkout/price-switch-header.tsx
  13. 32 0
      services/web/frontend/js/features/subscription/components/new/checkout/submit-button.tsx
  14. 20 0
      services/web/frontend/js/features/subscription/components/new/checkout/three-d-secure.tsx
  15. 17 0
      services/web/frontend/js/features/subscription/components/new/checkout/tos-agreement-notice.tsx
  16. 21 0
      services/web/frontend/js/features/subscription/hooks/use-validate-field.tsx
  17. 6 0
      services/web/frontend/js/utils/functions.ts
  18. 23 0
      services/web/types/recurly/elements.ts

+ 64 - 0
services/web/frontend/js/features/subscription/components/new/checkout/address-first-line.tsx

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

+ 53 - 0
services/web/frontend/js/features/subscription/components/new/checkout/address-second-line.tsx

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

+ 58 - 0
services/web/frontend/js/features/subscription/components/new/checkout/card-element.tsx

@@ -0,0 +1,58 @@
+import { useState, useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { FormGroup, ControlLabel } from 'react-bootstrap'
+import classnames from 'classnames'
+import { CardElementChangeState } from '../../../../../../../types/recurly/elements'
+import { ElementsInstance } from 'recurly__recurly-js'
+
+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)
+
+  // Card initialization
+  useEffect(() => {
+    if (!cardRef.current) return
+
+    const card = elements.CardElement({
+      displayIcon: true,
+      inputType: 'mobileSelect',
+      style: {
+        fontColor: '#5d6879',
+        placeholder: {},
+        invalid: {
+          fontColor: '#a93529',
+        },
+      },
+    })
+
+    card.attach(cardRef.current)
+    card.on('change', state => {
+      setShowCardElementInvalid(!state.focus && !state.empty && !state.valid)
+      onChange(state)
+    })
+  }, [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

+ 371 - 1
services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx

@@ -1,5 +1,375 @@
+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 getMeta from '../../../../../utils/meta'
+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'
+
 function CheckoutPanel() {
-  return <h3>Checkout panel</h3>
+  const { t } = useTranslation()
+  const {
+    couponError,
+    plan,
+    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 isCreditCardPaymentMethod = paymentMethod === 'credit_card'
+  const isPayPalPaymentMethod = paymentMethod === 'paypal'
+  const isAddCompanyDetailsChecked = Boolean(
+    formRef.current?.querySelector<HTMLInputElement>(
+      '#add-company-details-checkbox'
+    )?.checked
+  )
+
+  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,
+        })
+        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',
+            plan.planCode
+          )
+          window.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)
+          }
+        }
+      }
+    },
+    [
+      ITMCampaign,
+      ITMContent,
+      ITMReferrer,
+      isAddCompanyDetailsChecked,
+      isPayPalPaymentMethod,
+      plan.planCode,
+      pricing,
+      pricingFormState,
+      t,
+    ]
+  )
+
+  const payPal = useRef<PayPalInstance>()
+
+  useEffect(() => {
+    payPal.current = recurly.PayPal({
+      display: { displayName: plan.name },
+    })
+
+    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, plan.name])
+
+  const handleCardChange = useCallback((state: CardElementChangeState) => {
+    setCardIsValid(state.valid)
+  }, [])
+
+  if (recurlyLoadError) {
+    return (
+      <Alert bsStyle="danger">
+        <strong>{t('payment_provider_unreachable_error')}</strong>
+      </Alert>
+    )
+  }
+
+  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 (
+    <>
+      <div className={classnames({ hidden: threeDSecureActionTokenId })}>
+        <PriceSwitchHeader
+          planCode={plan.planCode}
+          planCodes={[
+            'student-annual',
+            'student-monthly',
+            'student_free_trial_7_days',
+          ]}
+        />
+        <form
+          noValidate
+          onSubmit={handleSubmit}
+          onChange={handleFormValidation}
+          ref={formRef}
+        >
+          {genericError && (
+            <Alert bsStyle="warning" className="small">
+              <strong>{genericError}</strong>
+            </Alert>
+          )}
+          {couponError && (
+            <Alert bsStyle="warning" className="small">
+              <strong>{couponError}</strong>
+            </Alert>
+          )}
+          <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

+ 82 - 0
services/web/frontend/js/features/subscription/components/new/checkout/company-details.tsx

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

+ 68 - 0
services/web/frontend/js/features/subscription/components/new/checkout/country-select.tsx

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

+ 34 - 0
services/web/frontend/js/features/subscription/components/new/checkout/coupon-code.tsx

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

+ 46 - 0
services/web/frontend/js/features/subscription/components/new/checkout/first-name.tsx

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

+ 46 - 0
services/web/frontend/js/features/subscription/components/new/checkout/last-name.tsx

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

+ 57 - 0
services/web/frontend/js/features/subscription/components/new/checkout/payment-method-toggle.tsx

@@ -0,0 +1,57 @@
+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">
+      <hr className="thin" />
+      <div className="radio">
+        <Col xs={8}>
+          <ControlLabel>
+            <input
+              type="radio"
+              name="payment_method"
+              value="credit_card"
+              onChange={props.onChange}
+              checked={props.paymentMethod === 'credit_card'}
+            />
+            <strong>
+              {t('card_payment')}&nbsp;
+              <span className="hidden-xs">
+                <Icon type="cc-visa" /> <Icon type="cc-mastercard" />{' '}
+                <Icon type="cc-amex" />
+              </span>
+            </strong>
+          </ControlLabel>
+        </Col>
+        <Col xs={4}>
+          <ControlLabel>
+            <input
+              type="radio"
+              name="payment_method"
+              value="paypal"
+              onChange={props.onChange}
+              checked={props.paymentMethod === 'paypal'}
+            />
+            <strong>
+              PayPal&nbsp;
+              <span className="hidden-xs">
+                <Icon type="cc-paypal" />
+              </span>
+            </strong>
+          </ControlLabel>
+        </Col>
+      </div>
+    </FormGroup>
+  )
+}
+
+export default PaymentMethodToggle

+ 46 - 0
services/web/frontend/js/features/subscription/components/new/checkout/postal-code.tsx

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

+ 32 - 0
services/web/frontend/js/features/subscription/components/new/checkout/price-switch-header.tsx

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

+ 32 - 0
services/web/frontend/js/features/subscription/components/new/checkout/submit-button.tsx

@@ -0,0 +1,32 @@
+import { useTranslation } from 'react-i18next'
+import { Button } from 'react-bootstrap'
+import Icon from '../../../../../shared/components/icon'
+
+type CardSubmitButtonProps = {
+  isProcessing: boolean
+  isFormValid: boolean
+  children: React.ReactNode
+}
+
+function SubmitButton(props: CardSubmitButtonProps) {
+  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

+ 20 - 0
services/web/frontend/js/features/subscription/components/new/checkout/three-d-secure.tsx

@@ -0,0 +1,20 @@
+import { useTranslation } from 'react-i18next'
+import { Alert } from 'react-bootstrap'
+
+function ThreeDSecure() {
+  const { t } = useTranslation()
+
+  return (
+    <div className="three-d-secure-container--react">
+      <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">
+        {/* {threeDSecureFlowError && <>{threeDSecureFlowError.message}</>} */}
+        {/* <ThreeDSecureAction {...props} /> */}
+      </div>
+    </div>
+  )
+}
+
+export default ThreeDSecure

+ 17 - 0
services/web/frontend/js/features/subscription/components/new/checkout/tos-agreement-notice.tsx

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

+ 21 - 0
services/web/frontend/js/features/subscription/hooks/use-validate-field.tsx

@@ -0,0 +1,21 @@
+import { useState } from 'react'
+
+type Target = HTMLInputElement | HTMLSelectElement
+
+function useValidateField<T extends { target: Target }>() {
+  const [isValid, setIsValid] = useState(true)
+
+  const validate = (e: T) => {
+    let isValid = e.target.checkValidity()
+
+    if (e.target.required) {
+      isValid = isValid && Boolean(e.target.value.trim().length)
+    }
+
+    setIsValid(isValid)
+  }
+
+  return { validate, isValid }
+}
+
+export default useValidateField

+ 6 - 0
services/web/frontend/js/utils/functions.ts

@@ -0,0 +1,6 @@
+export function callFnsInSequence<
+  Args,
+  Fn extends ((...args: Args[]) => void) | void
+>(...fns: Fn[]) {
+  return (...args: Args[]) => fns.forEach(fn => fn?.(...args))
+}

+ 23 - 0
services/web/types/recurly/elements.ts

@@ -0,0 +1,23 @@
+export interface CardElementChangeState {
+  brand: string
+  cvv: {
+    empty: boolean
+    focus: boolean
+    valid: boolean
+  }
+  empty: boolean
+  expiry: {
+    empty: boolean
+    focus: boolean
+    valid: boolean
+  }
+  firstSix: string
+  focus: boolean
+  lastFour: string
+  number: {
+    empty: boolean
+    focus: boolean
+    valid: boolean
+  }
+  valid: boolean
+}