root.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  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 Notification from '@/shared/components/notification'
  16. import OLCard from '@/features/ui/components/ol/ol-card'
  17. import OLRow from '@/features/ui/components/ol/ol-row'
  18. import OLCol from '@/features/ui/components/ol/ol-col'
  19. import OLButton from '@/features/ui/components/ol/ol-button'
  20. import { subscriptionUpdateUrl } from '@/features/subscription/data/subscription-url'
  21. import * as eventTracking from '@/infrastructure/event-tracking'
  22. import sparkleText from '@/shared/svgs/ai-sparkle-text.svg'
  23. import { useFeatureFlag } from '@/shared/context/split-test-context'
  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. <Notification
  92. type="error"
  93. aria-live="polite"
  94. content={
  95. <>
  96. {t('generic_something_went_wrong')}. {t('try_again')}.{' '}
  97. {t('generic_if_problem_continues_contact_us')}.
  98. </>
  99. }
  100. />
  101. )}
  102. {aiAddOnChange && (
  103. <div>
  104. {aiAssistEnabled ? (
  105. <Trans
  106. i18nKey="add_ai_assist_to_your_plan"
  107. components={{
  108. sparkle: (
  109. <img
  110. alt="sparkle"
  111. className="ai-error-assistant-sparkle"
  112. src={sparkleText}
  113. aria-hidden="true"
  114. key="sparkle"
  115. />
  116. ),
  117. }}
  118. />
  119. ) : (
  120. <Trans
  121. i18nKey="add_error_assist_to_your_projects"
  122. components={{
  123. sparkle: (
  124. <img
  125. alt="sparkle"
  126. className="ai-error-assistant-sparkle"
  127. src={sparkleText}
  128. aria-hidden="true"
  129. key="sparkle"
  130. />
  131. ),
  132. }}
  133. />
  134. )}
  135. </div>
  136. )}
  137. <OLCard className="payment-summary-card mt-5">
  138. <h3>{t('due_today')}:</h3>
  139. <OLRow>
  140. <OLCol xs={9}>{changeName}</OLCol>
  141. <OLCol xs={3} className="text-end">
  142. <strong>
  143. {formatCurrency(
  144. preview.immediateCharge.subtotal,
  145. preview.currency
  146. )}
  147. </strong>
  148. </OLCol>
  149. </OLRow>
  150. {preview.immediateCharge.tax > 0 && (
  151. <OLRow className="mt-1">
  152. <OLCol xs={9}>
  153. {t('vat')} {preview.nextInvoice.tax.rate * 100}%
  154. </OLCol>
  155. <OLCol xs={3} className="text-end">
  156. {formatCurrency(
  157. preview.immediateCharge.tax,
  158. preview.currency
  159. )}
  160. </OLCol>
  161. </OLRow>
  162. )}
  163. <OLRow className="mt-1">
  164. <OLCol xs={9}>{t('total_today')}</OLCol>
  165. <OLCol xs={3} className="text-end">
  166. <strong>
  167. {formatCurrency(
  168. preview.immediateCharge.total,
  169. preview.currency
  170. )}
  171. </strong>
  172. </OLCol>
  173. </OLRow>
  174. </OLCard>
  175. <div className="mt-5">
  176. <Trans
  177. i18nKey="this_total_reflects_the_amount_due_until"
  178. values={{ date: moment(preview.nextInvoice.date).format('LL') }}
  179. components={{ strong: <strong /> }}
  180. shouldUnescape
  181. tOptions={{ interpolation: { escapeValue: true } }}
  182. />{' '}
  183. <Trans
  184. i18nKey="we_will_use_your_existing_payment_method"
  185. values={{ paymentMethod: preview.paymentMethod }}
  186. components={{ strong: <strong /> }}
  187. shouldUnescape
  188. tOptions={{ interpolation: { escapeValue: true } }}
  189. />
  190. </div>
  191. <div className="mt-5">
  192. <OLButton
  193. variant="primary"
  194. size="lg"
  195. onClick={handlePayNowClick}
  196. disabled={payNowTask.isLoading || payNowTask.isSuccess}
  197. >
  198. {t('pay_now')}
  199. </OLButton>
  200. </div>
  201. <OLCard className="payment-summary-card mt-5">
  202. <h3>{t('future_payments')}:</h3>
  203. <OLRow className="mt-1">
  204. <OLCol xs={9}>{preview.nextInvoice.plan.name}</OLCol>
  205. <OLCol xs={3} className="text-end">
  206. {formatCurrency(
  207. preview.nextInvoice.plan.amount,
  208. preview.currency
  209. )}
  210. </OLCol>
  211. </OLRow>
  212. {preview.nextInvoice.addOns.map(addOn => (
  213. <OLRow className="mt-1" key={addOn.code}>
  214. <OLCol xs={9}>
  215. {addOn.name}
  216. {addOn.quantity > 1 ? ` ×${addOn.quantity}` : ''}
  217. </OLCol>
  218. <OLCol xs={3} className="text-end">
  219. {formatCurrency(addOn.amount, preview.currency)}
  220. </OLCol>
  221. </OLRow>
  222. ))}
  223. {preview.nextInvoice.tax.rate > 0 && (
  224. <OLRow className="mt-1">
  225. <OLCol xs={9}>
  226. {t('vat')} {preview.nextInvoice.tax.rate * 100}%
  227. </OLCol>
  228. <OLCol xs={3} className="text-end">
  229. {formatCurrency(
  230. preview.nextInvoice.tax.amount,
  231. preview.currency
  232. )}
  233. </OLCol>
  234. </OLRow>
  235. )}
  236. <OLRow className="mt-1">
  237. <OLCol xs={9}>
  238. {preview.nextPlan.annual
  239. ? t('total_per_year')
  240. : t('total_per_month')}
  241. </OLCol>
  242. <OLCol xs={3} className="text-end">
  243. {formatCurrency(preview.nextInvoice.total, preview.currency)}
  244. </OLCol>
  245. </OLRow>
  246. </OLCard>
  247. <div className="mt-5">
  248. <Trans
  249. i18nKey="the_next_payment_will_be_collected_on"
  250. values={{ date: moment(preview.nextInvoice.date).format('LL') }}
  251. components={{ strong: <strong /> }}
  252. shouldUnescape
  253. tOptions={{ interpolation: { escapeValue: true } }}
  254. />
  255. </div>
  256. </OLCard>
  257. </OLCol>
  258. </OLRow>
  259. </div>
  260. )
  261. }
  262. async function payNow(preview: SubscriptionChangePreview) {
  263. try {
  264. if (preview.change.type === 'add-on-purchase') {
  265. await postJSON(
  266. `/user/subscription/addon/${preview.change.addOn.code}/add`
  267. )
  268. } else if (preview.change.type === 'premium-subscription') {
  269. await postJSON(subscriptionUpdateUrl, {
  270. body: { plan_code: preview.change.plan.code },
  271. })
  272. } else {
  273. throw new Error(
  274. `Unknown subscription change preview type: ${preview.change}`
  275. )
  276. }
  277. } catch (e) {
  278. const { handled } = await handleStripePaymentAction(e as FetchError)
  279. if (!handled) {
  280. throw e
  281. }
  282. }
  283. }
  284. export default PreviewSubscriptionChange