payment-context.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. import {
  2. useState,
  3. useEffect,
  4. useLayoutEffect,
  5. useMemo,
  6. useCallback,
  7. useRef,
  8. useContext,
  9. createContext,
  10. } from 'react'
  11. import { currencies, CurrencyCode, CurrencySymbol } from '../data/currency'
  12. import { useTranslation } from 'react-i18next'
  13. import getMeta from '../../../utils/meta'
  14. import { getSplitTestVariant } from '../../../../../frontend/js/utils/splitTestUtils'
  15. import * as eventTracking from '../../../infrastructure/event-tracking'
  16. import {
  17. PaymentContextValue,
  18. PricingFormState,
  19. } from './types/payment-context-value'
  20. import { Plan } from '../../../../../types/subscription/plan'
  21. import {
  22. RecurlyOptions,
  23. SubscriptionPricingStateTax,
  24. } from 'recurly__recurly-js'
  25. import { SubscriptionPricingInstanceCustom } from '../../../../../types/recurly/pricing/subscription'
  26. function usePayment({ publicKey }: RecurlyOptions) {
  27. const { t } = useTranslation()
  28. const plan: Plan = getMeta('ol-plan')
  29. const initialCountry: PricingFormState['country'] = getMeta(
  30. 'ol-countryCode',
  31. ''
  32. )
  33. const initialCouponCode: PricingFormState['coupon'] = getMeta(
  34. 'ol-couponCode',
  35. ''
  36. )
  37. const initiallySelectedCurrencyCode: CurrencyCode = getMeta(
  38. 'ol-recommendedCurrency'
  39. )
  40. const planCode: string = getMeta('ol-planCode')
  41. const designSystemUpdatesVariant = getSplitTestVariant(
  42. 'design-system-updates',
  43. 'default'
  44. )
  45. const [planName, setPlanName] = useState(plan.name)
  46. const [recurlyLoading, setRecurlyLoading] = useState(true)
  47. const [recurlyLoadError, setRecurlyLoadError] = useState(false)
  48. const [recurlyPrice, setRecurlyPrice] = useState<{
  49. subtotal: string
  50. plan: string
  51. addons: string
  52. setup_fee: string
  53. discount: string
  54. tax: string
  55. total: string
  56. }>()
  57. const [monthlyBilling, setMonthlyBilling] = useState<boolean>()
  58. const [taxes, setTaxes] = useState<SubscriptionPricingStateTax[]>([])
  59. const [coupon, setCoupon] = useState<{
  60. discountMonths?: number
  61. discountRate?: number
  62. singleUse: boolean
  63. normalPrice: number
  64. name: string
  65. normalPriceWithoutTax: number
  66. }>()
  67. const [couponError, setCouponError] = useState('')
  68. const [trialLength, setTrialLength] = useState<number>()
  69. const [currencyCode, setCurrencyCode] = useState(
  70. initiallySelectedCurrencyCode
  71. )
  72. const [pricingFormState, setPricingFormState] = useState<PricingFormState>({
  73. first_name: '',
  74. last_name: '',
  75. postal_code: '',
  76. address1: '',
  77. address2: '',
  78. state: '',
  79. city: '',
  80. company: '',
  81. vat_number: '',
  82. country: initialCountry,
  83. coupon: initialCouponCode,
  84. })
  85. const pricing = useRef<SubscriptionPricingInstanceCustom>()
  86. const limitedCurrencyCodes = Array.from(
  87. new Set<CurrencyCode>([initiallySelectedCurrencyCode, 'USD', 'EUR', 'GBP'])
  88. )
  89. const limitedCurrencies = limitedCurrencyCodes.reduce((prev, cur) => {
  90. return { ...prev, [cur]: currencies[cur] }
  91. }, {} as Partial<typeof currencies>)
  92. const currencySymbol = limitedCurrencies[currencyCode] as CurrencySymbol
  93. useLayoutEffect(() => {
  94. if (typeof recurly === 'undefined' || !recurly) {
  95. setRecurlyLoadError(true)
  96. return
  97. }
  98. eventTracking.sendMB('payment-page-view', {
  99. plan: planCode,
  100. currency: currencyCode,
  101. 'split-test-design-system-updates': designSystemUpdatesVariant,
  102. })
  103. eventTracking.send(
  104. 'subscription-funnel',
  105. 'subscription-form-viewed',
  106. planCode
  107. )
  108. recurly.configure({ publicKey })
  109. pricing.current =
  110. recurly.Pricing.Subscription() as SubscriptionPricingInstanceCustom
  111. const setupPricing = () => {
  112. setRecurlyLoading(true)
  113. pricing.current
  114. ?.plan(planCode, { quantity: 1 })
  115. .address({
  116. first_name: '',
  117. last_name: '',
  118. country: initialCountry,
  119. })
  120. .tax({ tax_code: 'digital', vat_number: '' })
  121. .currency(currencyCode)
  122. .coupon(initialCouponCode)
  123. .catch(function (err) {
  124. if (currencyCode !== 'USD' && err.name === 'invalid-currency') {
  125. setCurrencyCode('USD')
  126. setupPricing()
  127. } else if (err.name === 'api-error' && err.code === 'not-found') {
  128. // not-found here should refer to the coupon code, plan_code should be valid
  129. setCouponError(t('coupon_code_is_not_valid_for_selected_plan'))
  130. } else {
  131. // Bail out on other errors, form state will not be correct
  132. setRecurlyLoadError(true)
  133. throw err
  134. }
  135. })
  136. .done(() => {
  137. setRecurlyLoading(false)
  138. })
  139. }
  140. setupPricing()
  141. }, [
  142. designSystemUpdatesVariant,
  143. initialCountry,
  144. initialCouponCode,
  145. initiallySelectedCurrencyCode,
  146. planCode,
  147. publicKey,
  148. currencyCode,
  149. t,
  150. ])
  151. useEffect(() => {
  152. pricing.current?.on('change', function () {
  153. if (!pricing.current) return
  154. const planName = pricing.current.items.plan?.name
  155. if (planName) {
  156. setPlanName(planName)
  157. }
  158. const trialLength = pricing.current.items.plan?.trial?.length
  159. setTrialLength(trialLength)
  160. const recurlyPrice = trialLength
  161. ? pricing.current.price.next
  162. : pricing.current.price.now
  163. setRecurlyPrice(recurlyPrice)
  164. const monthlyBilling = pricing.current.items.plan?.period.length === 1
  165. setMonthlyBilling(monthlyBilling)
  166. setTaxes(pricing.current.price.taxes)
  167. const couponData = (() => {
  168. if (pricing.current.items.coupon?.discount.type === 'percent') {
  169. const coupon = pricing.current.items.coupon
  170. const basePrice = parseInt(pricing.current.price.base.plan.unit, 10)
  171. const discountData =
  172. coupon.applies_for_months > 0 && coupon.discount.rate
  173. ? {
  174. discountMonths: coupon.applies_for_months,
  175. discountRate: coupon.discount.rate * 100,
  176. }
  177. : {}
  178. const couponData = {
  179. singleUse: coupon.single_use,
  180. normalPrice: basePrice,
  181. name: coupon.name,
  182. normalPriceWithoutTax: basePrice,
  183. ...discountData,
  184. }
  185. if (pricing.current.price.taxes[0]?.rate) {
  186. couponData.normalPrice +=
  187. basePrice * parseFloat(pricing.current.price.taxes[0].rate)
  188. }
  189. return couponData
  190. }
  191. })()
  192. setCoupon(couponData)
  193. })
  194. }, [currencyCode])
  195. const addCoupon = useCallback(
  196. (coupon: PricingFormState['coupon']) => {
  197. setRecurlyLoading(true)
  198. setCouponError('')
  199. pricing.current
  200. ?.coupon(coupon)
  201. .catch(function (err) {
  202. if (err.name === 'api-error' && err.code === 'not-found') {
  203. setCouponError(t('coupon_code_is_not_valid_for_selected_plan'))
  204. } else {
  205. setCouponError(
  206. t('an_error_occurred_when_verifying_the_coupon_code')
  207. )
  208. throw err
  209. }
  210. })
  211. .done(() => {
  212. setRecurlyLoading(false)
  213. })
  214. },
  215. [t]
  216. )
  217. const updateCountry = useCallback(
  218. (country: PricingFormState['country']) => {
  219. setRecurlyLoading(true)
  220. pricing.current
  221. ?.address({
  222. country,
  223. first_name: pricingFormState.first_name,
  224. last_name: pricingFormState.last_name,
  225. })
  226. .done(() => {
  227. setRecurlyLoading(false)
  228. })
  229. },
  230. [pricingFormState.first_name, pricingFormState.last_name]
  231. )
  232. const applyVatNumber = useCallback(
  233. (vatNumber: PricingFormState['vat_number']) => {
  234. setRecurlyLoading(true)
  235. pricing.current
  236. ?.tax({ tax_code: 'digital', vat_number: vatNumber })
  237. .done(() => {
  238. setRecurlyLoading(false)
  239. })
  240. },
  241. []
  242. )
  243. const changeCurrency = useCallback(
  244. (newCurrency: CurrencyCode) => {
  245. setRecurlyLoading(true)
  246. setCurrencyCode(newCurrency)
  247. pricing.current
  248. ?.currency(newCurrency)
  249. .catch(function (err) {
  250. if (currencyCode !== 'USD' && err.name === 'invalid-currency') {
  251. setCurrencyCode('USD')
  252. } else {
  253. throw err
  254. }
  255. })
  256. .done(() => {
  257. setRecurlyLoading(false)
  258. })
  259. },
  260. [currencyCode]
  261. )
  262. const value = useMemo<PaymentContextValue>(
  263. () => ({
  264. currencyCode,
  265. setCurrencyCode,
  266. currencySymbol,
  267. limitedCurrencies,
  268. pricingFormState,
  269. setPricingFormState,
  270. plan,
  271. planCode,
  272. planName,
  273. pricing,
  274. recurlyLoading,
  275. recurlyLoadError,
  276. recurlyPrice,
  277. monthlyBilling,
  278. taxes,
  279. coupon,
  280. couponError,
  281. trialLength,
  282. addCoupon,
  283. applyVatNumber,
  284. changeCurrency,
  285. updateCountry,
  286. }),
  287. [
  288. currencyCode,
  289. setCurrencyCode,
  290. currencySymbol,
  291. limitedCurrencies,
  292. pricingFormState,
  293. setPricingFormState,
  294. plan,
  295. planCode,
  296. planName,
  297. pricing,
  298. recurlyLoading,
  299. recurlyLoadError,
  300. recurlyPrice,
  301. monthlyBilling,
  302. taxes,
  303. coupon,
  304. couponError,
  305. trialLength,
  306. addCoupon,
  307. applyVatNumber,
  308. changeCurrency,
  309. updateCountry,
  310. ]
  311. )
  312. return { value }
  313. }
  314. export const PaymentContext = createContext<PaymentContextValue | undefined>(
  315. undefined
  316. )
  317. type PaymentProviderProps = {
  318. publicKey: string
  319. children?: React.ReactNode
  320. }
  321. export function PaymentProvider({ publicKey, ...props }: PaymentProviderProps) {
  322. const { value } = usePayment({ publicKey })
  323. return <PaymentContext.Provider value={value} {...props} />
  324. }
  325. export function usePaymentContext() {
  326. const context = useContext(PaymentContext)
  327. if (!context) {
  328. throw new Error('PaymentContext is only available inside PaymentProvider')
  329. }
  330. return context
  331. }