payment-context.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  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(initiallySelectedCurrencyCode)
  116. .coupon(initialCouponCode)
  117. .catch(function (err) {
  118. if (
  119. initiallySelectedCurrencyCode !== 'USD' &&
  120. err.name === 'invalid-currency'
  121. ) {
  122. setCurrencyCode('USD')
  123. setupPricing()
  124. } else if (err.name === 'api-error' && err.code === 'not-found') {
  125. // not-found here should refer to the coupon code, plan_code should be valid
  126. setCouponError(t('coupon_code_is_not_valid_for_selected_plan'))
  127. } else {
  128. // Bail out on other errors, form state will not be correct
  129. setRecurlyLoadError(true)
  130. throw err
  131. }
  132. })
  133. .done(() => {
  134. setRecurlyLoading(false)
  135. })
  136. }
  137. setupPricing()
  138. }, [
  139. initialCountry,
  140. initialCouponCode,
  141. initiallySelectedCurrencyCode,
  142. planCode,
  143. publicKey,
  144. currencyCode,
  145. t,
  146. ])
  147. useEffect(() => {
  148. pricing.current?.on('change', function () {
  149. if (!pricing.current) return
  150. const planName = pricing.current.items.plan?.name
  151. if (planName) {
  152. setPlanName(planName)
  153. }
  154. const trialLength = pricing.current.items.plan?.trial?.length
  155. setTrialLength(trialLength)
  156. const recurlyPrice = trialLength
  157. ? pricing.current.price.next
  158. : pricing.current.price.now
  159. setRecurlyPrice(recurlyPrice)
  160. const monthlyBilling = pricing.current.items.plan?.period.length === 1
  161. setMonthlyBilling(monthlyBilling)
  162. setTaxes(pricing.current.price.taxes)
  163. const couponData = (() => {
  164. if (pricing.current.items.coupon?.discount.type === 'percent') {
  165. const coupon = pricing.current.items.coupon
  166. const basePrice = parseInt(pricing.current.price.base.plan.unit, 10)
  167. const discountData =
  168. coupon.applies_for_months > 0 && coupon.discount.rate
  169. ? {
  170. discountMonths: coupon.applies_for_months,
  171. discountRate: coupon.discount.rate * 100,
  172. }
  173. : {}
  174. const couponData = {
  175. singleUse: coupon.single_use,
  176. normalPrice: basePrice,
  177. name: coupon.name,
  178. normalPriceWithoutTax: basePrice,
  179. ...discountData,
  180. }
  181. if (pricing.current.price.taxes[0]?.rate) {
  182. couponData.normalPrice +=
  183. basePrice * parseFloat(pricing.current.price.taxes[0].rate)
  184. }
  185. return couponData
  186. }
  187. })()
  188. setCoupon(couponData)
  189. })
  190. }, [])
  191. const addCoupon = useCallback(
  192. (coupon: PricingFormState['coupon']) => {
  193. setRecurlyLoading(true)
  194. setCouponError('')
  195. pricing.current
  196. ?.coupon(coupon)
  197. .catch(function (err) {
  198. if (err.name === 'api-error' && err.code === 'not-found') {
  199. setCouponError(t('coupon_code_is_not_valid_for_selected_plan'))
  200. } else {
  201. setCouponError(
  202. t('an_error_occurred_when_verifying_the_coupon_code')
  203. )
  204. throw err
  205. }
  206. })
  207. .done(() => {
  208. setRecurlyLoading(false)
  209. })
  210. },
  211. [t]
  212. )
  213. const updateCountry = useCallback(
  214. (country: PricingFormState['country']) => {
  215. setRecurlyLoading(true)
  216. pricing.current
  217. ?.address({
  218. country,
  219. first_name: pricingFormState.first_name,
  220. last_name: pricingFormState.last_name,
  221. })
  222. .done(() => {
  223. setRecurlyLoading(false)
  224. })
  225. },
  226. [pricingFormState.first_name, pricingFormState.last_name]
  227. )
  228. const applyVatNumber = useCallback(
  229. (vatNumber: PricingFormState['vat_number']) => {
  230. setRecurlyLoading(true)
  231. pricing.current
  232. ?.tax({ tax_code: 'digital', vat_number: vatNumber })
  233. .done(() => {
  234. setRecurlyLoading(false)
  235. })
  236. },
  237. []
  238. )
  239. const changeCurrency = useCallback(
  240. (newCurrency: CurrencyCode) => {
  241. setRecurlyLoading(true)
  242. setCurrencyCode(newCurrency)
  243. pricing.current
  244. ?.currency(newCurrency)
  245. .catch(function (err) {
  246. if (currencyCode !== 'USD' && err.name === 'invalid-currency') {
  247. setCurrencyCode('USD')
  248. } else {
  249. throw err
  250. }
  251. })
  252. .done(() => {
  253. setRecurlyLoading(false)
  254. })
  255. },
  256. [currencyCode]
  257. )
  258. const value = useMemo<PaymentContextValue>(
  259. () => ({
  260. currencyCode,
  261. setCurrencyCode,
  262. currencySymbol,
  263. limitedCurrencies,
  264. pricingFormState,
  265. setPricingFormState,
  266. plan,
  267. planCode,
  268. planName,
  269. pricing,
  270. recurlyLoading,
  271. recurlyLoadError,
  272. recurlyPrice,
  273. monthlyBilling,
  274. taxes,
  275. coupon,
  276. couponError,
  277. trialLength,
  278. addCoupon,
  279. applyVatNumber,
  280. changeCurrency,
  281. updateCountry,
  282. }),
  283. [
  284. currencyCode,
  285. setCurrencyCode,
  286. currencySymbol,
  287. limitedCurrencies,
  288. pricingFormState,
  289. setPricingFormState,
  290. plan,
  291. planCode,
  292. planName,
  293. pricing,
  294. recurlyLoading,
  295. recurlyLoadError,
  296. recurlyPrice,
  297. monthlyBilling,
  298. taxes,
  299. coupon,
  300. couponError,
  301. trialLength,
  302. addCoupon,
  303. applyVatNumber,
  304. changeCurrency,
  305. updateCountry,
  306. ]
  307. )
  308. return { value }
  309. }
  310. export const PaymentContext = createContext<PaymentContextValue | undefined>(
  311. undefined
  312. )
  313. type PaymentProviderProps = {
  314. publicKey: string
  315. children?: React.ReactNode
  316. }
  317. export function PaymentProvider({ publicKey, ...props }: PaymentProviderProps) {
  318. const { value } = usePayment({ publicKey })
  319. return <PaymentContext.Provider value={value} {...props} />
  320. }
  321. export function usePaymentContext() {
  322. const context = useContext(PaymentContext)
  323. if (!context) {
  324. throw new Error('PaymentContext is only available inside PaymentProvider')
  325. }
  326. return context
  327. }