payment-context.tsx 9.3 KB

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