root.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import { useCallback, useEffect } from 'react'
  2. import moment from 'moment'
  3. import { useTranslation, Trans } from 'react-i18next'
  4. import {
  5. SubscriptionChangePreview,
  6. AddOnPurchase,
  7. PremiumSubscriptionChange,
  8. } from '../../../../../../types/subscription/subscription-change-preview'
  9. import getMeta from '@/utils/meta'
  10. import { formatCurrency } from '@/shared/utils/currency'
  11. import useAsync from '@/shared/hooks/use-async'
  12. import { useLocation } from '@/shared/hooks/use-location'
  13. import { debugConsole } from '@/utils/debugging'
  14. import { FetchError, postJSON } from '@/infrastructure/fetch-json'
  15. import OLCard from '@/features/ui/components/ol/ol-card'
  16. import OLRow from '@/features/ui/components/ol/ol-row'
  17. import OLCol from '@/features/ui/components/ol/ol-col'
  18. import OLButton from '@/features/ui/components/ol/ol-button'
  19. import { subscriptionUpdateUrl } from '@/features/subscription/data/subscription-url'
  20. import * as eventTracking from '@/infrastructure/event-tracking'
  21. import sparkleText from '@/shared/svgs/ai-sparkle-text.svg'
  22. import { useFeatureFlag } from '@/shared/context/split-test-context'
  23. import PaymentErrorNotification from '@/features/subscription/components/shared/payment-error-notification'
  24. import handleStripePaymentAction from '../../util/handle-stripe-payment-action'
  25. function PreviewSubscriptionChange() {
  26. const preview = getMeta(
  27. 'ol-subscriptionChangePreview'
  28. ) as SubscriptionChangePreview
  29. const purchaseReferrer = getMeta('ol-purchaseReferrer')
  30. const { t } = useTranslation()
  31. const payNowTask = useAsync()
  32. const location = useLocation()
  33. const aiAssistEnabled = useFeatureFlag('overleaf-assist-bundle')
  34. useEffect(() => {
  35. if (preview.change.type === 'add-on-purchase') {
  36. eventTracking.sendMB('preview-subscription-change-view', {
  37. plan: preview.change.addOn.code,
  38. upgradeType: 'add-on',
  39. referrer: purchaseReferrer,
  40. })
  41. }
  42. }, [preview.change, purchaseReferrer])
  43. const handlePayNowClick = useCallback(() => {
  44. if (preview.change.type === 'add-on-purchase') {
  45. eventTracking.sendMB('subscription-change-form-submit', {
  46. plan: preview.change.addOn.code,
  47. upgradeType: 'add-on',
  48. referrer: purchaseReferrer,
  49. })
  50. }
  51. eventTracking.sendMB('assistant-add-on-purchase')
  52. payNowTask
  53. .runAsync(payNow(preview))
  54. .then(() => {
  55. if (preview.change.type === 'add-on-purchase') {
  56. eventTracking.sendMB('subscription-change-form-success', {
  57. plan: preview.change.addOn.code,
  58. upgradeType: 'add-on',
  59. referrer: purchaseReferrer,
  60. })
  61. }
  62. location.replace('/user/subscription/thank-you')
  63. })
  64. .catch(debugConsole.error)
  65. }, [purchaseReferrer, location, payNowTask, preview])
  66. const aiAddOnChange =
  67. preview.change.type === 'add-on-purchase' &&
  68. preview.change.addOn.code === 'assistant'
  69. // the driver of the change, which we can display as the immediate charge
  70. const changeName =
  71. preview.change.type === 'add-on-purchase'
  72. ? (preview.change as AddOnPurchase).addOn.name
  73. : (preview.change as PremiumSubscriptionChange).plan.name
  74. return (
  75. <div className="container">
  76. <OLRow>
  77. <OLCol md={{ offset: 2, span: 8 }}>
  78. <OLCard className="p-3">
  79. {preview.change.type === 'add-on-purchase' ? (
  80. <h1>
  81. {t('add_add_on_to_your_plan', {
  82. addOnName: preview.change.addOn.name,
  83. })}
  84. </h1>
  85. ) : preview.change.type === 'premium-subscription' ? (
  86. <h1>
  87. {t('subscribe_to_plan', { planName: preview.change.plan.name })}
  88. </h1>
  89. ) : null}
  90. {payNowTask.isError && (
  91. <PaymentErrorNotification
  92. error={payNowTask.error as FetchError}
  93. />
  94. )}
  95. {aiAddOnChange && (
  96. <div>
  97. {aiAssistEnabled ? (
  98. <Trans
  99. i18nKey="add_ai_assist_to_your_plan"
  100. components={{
  101. sparkle: (
  102. <img
  103. alt="sparkle"
  104. className="ai-error-assistant-sparkle"
  105. src={sparkleText}
  106. aria-hidden="true"
  107. key="sparkle"
  108. />
  109. ),
  110. }}
  111. />
  112. ) : (
  113. <Trans
  114. i18nKey="add_error_assist_to_your_projects"
  115. components={{
  116. sparkle: (
  117. <img
  118. alt="sparkle"
  119. className="ai-error-assistant-sparkle"
  120. src={sparkleText}
  121. aria-hidden="true"
  122. key="sparkle"
  123. />
  124. ),
  125. }}
  126. />
  127. )}
  128. </div>
  129. )}
  130. <OLCard className="payment-summary-card mt-5">
  131. <h3>{t('due_today')}:</h3>
  132. <OLRow>
  133. <OLCol xs={9}>{changeName}</OLCol>
  134. <OLCol xs={3} className="text-end">
  135. <strong>
  136. {formatCurrency(
  137. preview.immediateCharge.subtotal,
  138. preview.currency
  139. )}
  140. </strong>
  141. </OLCol>
  142. </OLRow>
  143. {preview.immediateCharge.tax > 0 && (
  144. <OLRow className="mt-1">
  145. <OLCol xs={9}>
  146. {t('vat')} {preview.nextInvoice.tax.rate * 100}%
  147. </OLCol>
  148. <OLCol xs={3} className="text-end">
  149. {formatCurrency(
  150. preview.immediateCharge.tax,
  151. preview.currency
  152. )}
  153. </OLCol>
  154. </OLRow>
  155. )}
  156. <OLRow className="mt-1">
  157. <OLCol xs={9}>{t('total_today')}</OLCol>
  158. <OLCol xs={3} className="text-end">
  159. <strong>
  160. {formatCurrency(
  161. preview.immediateCharge.total,
  162. preview.currency
  163. )}
  164. </strong>
  165. </OLCol>
  166. </OLRow>
  167. </OLCard>
  168. <div className="mt-5">
  169. <Trans
  170. i18nKey="this_total_reflects_the_amount_due_until"
  171. values={{ date: moment(preview.nextInvoice.date).format('LL') }}
  172. components={{ strong: <strong /> }}
  173. shouldUnescape
  174. tOptions={{ interpolation: { escapeValue: true } }}
  175. />{' '}
  176. <Trans
  177. i18nKey="we_will_use_your_existing_payment_method"
  178. values={{ paymentMethod: preview.paymentMethod }}
  179. components={{ strong: <strong /> }}
  180. shouldUnescape
  181. tOptions={{ interpolation: { escapeValue: true } }}
  182. />
  183. </div>
  184. <div className="mt-5">
  185. <OLButton
  186. variant="primary"
  187. size="lg"
  188. onClick={handlePayNowClick}
  189. disabled={payNowTask.isLoading || payNowTask.isSuccess}
  190. >
  191. {t('pay_now')}
  192. </OLButton>
  193. </div>
  194. <OLCard className="payment-summary-card mt-5">
  195. <h3>{t('future_payments')}:</h3>
  196. <OLRow className="mt-1">
  197. <OLCol xs={9}>{preview.nextInvoice.plan.name}</OLCol>
  198. <OLCol xs={3} className="text-end">
  199. {formatCurrency(
  200. preview.nextInvoice.plan.amount,
  201. preview.currency
  202. )}
  203. </OLCol>
  204. </OLRow>
  205. {preview.nextInvoice.addOns.map(addOn => (
  206. <OLRow className="mt-1" key={addOn.code}>
  207. <OLCol xs={9}>
  208. {addOn.name}
  209. {addOn.quantity > 1 ? ` ×${addOn.quantity}` : ''}
  210. </OLCol>
  211. <OLCol xs={3} className="text-end">
  212. {formatCurrency(addOn.amount, preview.currency)}
  213. </OLCol>
  214. </OLRow>
  215. ))}
  216. {preview.nextInvoice.tax.rate > 0 && (
  217. <OLRow className="mt-1">
  218. <OLCol xs={9}>
  219. {t('vat')} {preview.nextInvoice.tax.rate * 100}%
  220. </OLCol>
  221. <OLCol xs={3} className="text-end">
  222. {formatCurrency(
  223. preview.nextInvoice.tax.amount,
  224. preview.currency
  225. )}
  226. </OLCol>
  227. </OLRow>
  228. )}
  229. <OLRow className="mt-1">
  230. <OLCol xs={9}>
  231. {preview.nextPlan.annual
  232. ? t('total_per_year')
  233. : t('total_per_month')}
  234. </OLCol>
  235. <OLCol xs={3} className="text-end">
  236. {formatCurrency(preview.nextInvoice.total, preview.currency)}
  237. </OLCol>
  238. </OLRow>
  239. </OLCard>
  240. <div className="mt-5">
  241. <Trans
  242. i18nKey="the_next_payment_will_be_collected_on"
  243. values={{ date: moment(preview.nextInvoice.date).format('LL') }}
  244. components={{ strong: <strong /> }}
  245. shouldUnescape
  246. tOptions={{ interpolation: { escapeValue: true } }}
  247. />
  248. </div>
  249. </OLCard>
  250. </OLCol>
  251. </OLRow>
  252. </div>
  253. )
  254. }
  255. async function payNow(preview: SubscriptionChangePreview) {
  256. try {
  257. if (preview.change.type === 'add-on-purchase') {
  258. await postJSON(
  259. `/user/subscription/addon/${preview.change.addOn.code}/add`
  260. )
  261. } else if (preview.change.type === 'premium-subscription') {
  262. await postJSON(subscriptionUpdateUrl, {
  263. body: { plan_code: preview.change.plan.code },
  264. })
  265. } else {
  266. throw new Error(
  267. `Unknown subscription change preview type: ${preview.change}`
  268. )
  269. }
  270. } catch (e) {
  271. const { handled } = await handleStripePaymentAction(e as FetchError)
  272. if (!handled) {
  273. throw e
  274. }
  275. }
  276. }
  277. export default PreviewSubscriptionChange