root.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import { useCallback } 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 { 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. function PreviewSubscriptionChange() {
  24. const preview = getMeta(
  25. 'ol-subscriptionChangePreview'
  26. ) as SubscriptionChangePreview
  27. const { t } = useTranslation()
  28. const payNowTask = useAsync()
  29. const location = useLocation()
  30. const handlePayNowClick = useCallback(() => {
  31. eventTracking.sendMB('assistant-add-on-purchase')
  32. payNowTask
  33. .runAsync(payNow(preview))
  34. .then(() => {
  35. location.replace('/user/subscription/thank-you')
  36. })
  37. .catch(debugConsole.error)
  38. }, [location, payNowTask, preview])
  39. const aiAddOnChange =
  40. preview.change.type === 'add-on-purchase' &&
  41. preview.change.addOn.code === 'assistant'
  42. // the driver of the change, which we can display as the immediate charge
  43. const changeName =
  44. preview.change.type === 'add-on-purchase'
  45. ? (preview.change as AddOnPurchase).addOn.name
  46. : (preview.change as PremiumSubscriptionChange).plan.name
  47. return (
  48. <div className="container">
  49. <OLRow>
  50. <OLCol md={{ offset: 2, span: 8 }}>
  51. <OLCard className="p-3">
  52. {preview.change.type === 'add-on-purchase' ? (
  53. <h1>
  54. {t('add_add_on_to_your_plan', {
  55. addOnName: preview.change.addOn.name,
  56. })}
  57. </h1>
  58. ) : preview.change.type === 'premium-subscription' ? (
  59. <h1>
  60. {t('subscribe_to_plan', { planName: preview.change.plan.name })}
  61. </h1>
  62. ) : null}
  63. {payNowTask.isError && (
  64. <Notification
  65. type="error"
  66. aria-live="polite"
  67. content={
  68. <>
  69. {t('generic_something_went_wrong')}. {t('try_again')}.{' '}
  70. {t('generic_if_problem_continues_contact_us')}.
  71. </>
  72. }
  73. />
  74. )}
  75. {aiAddOnChange && (
  76. <div>
  77. <Trans
  78. i18nKey="add_error_assist_to_your_projects"
  79. components={{
  80. sparkle: (
  81. <img
  82. alt="sparkle"
  83. className="ai-error-assistant-sparkle"
  84. src={sparkleText}
  85. aria-hidden="true"
  86. key="sparkle"
  87. />
  88. ),
  89. }}
  90. />
  91. </div>
  92. )}
  93. <OLCard className="payment-summary-card mt-5">
  94. <h3>{t('due_today')}:</h3>
  95. <OLRow>
  96. <OLCol xs={9}>{changeName}</OLCol>
  97. <OLCol xs={3} className="text-end">
  98. <strong>
  99. {formatCurrency(
  100. preview.immediateCharge.subtotal,
  101. preview.currency
  102. )}
  103. </strong>
  104. </OLCol>
  105. </OLRow>
  106. {preview.immediateCharge.tax > 0 && (
  107. <OLRow className="mt-1">
  108. <OLCol xs={9}>
  109. {t('vat')} {preview.nextInvoice.tax.rate * 100}%
  110. </OLCol>
  111. <OLCol xs={3} className="text-end">
  112. {formatCurrency(
  113. preview.immediateCharge.tax,
  114. preview.currency
  115. )}
  116. </OLCol>
  117. </OLRow>
  118. )}
  119. <OLRow className="mt-1">
  120. <OLCol xs={9}>{t('total_today')}</OLCol>
  121. <OLCol xs={3} className="text-end">
  122. <strong>
  123. {formatCurrency(
  124. preview.immediateCharge.total,
  125. preview.currency
  126. )}
  127. </strong>
  128. </OLCol>
  129. </OLRow>
  130. </OLCard>
  131. <div className="mt-5">
  132. <Trans
  133. i18nKey="this_total_reflects_the_amount_due_until"
  134. values={{ date: moment(preview.nextInvoice.date).format('LL') }}
  135. components={{ strong: <strong /> }}
  136. shouldUnescape
  137. tOptions={{ interpolation: { escapeValue: true } }}
  138. />{' '}
  139. <Trans
  140. i18nKey="we_will_use_your_existing_payment_method"
  141. values={{ paymentMethod: preview.paymentMethod }}
  142. components={{ strong: <strong /> }}
  143. shouldUnescape
  144. tOptions={{ interpolation: { escapeValue: true } }}
  145. />
  146. </div>
  147. <div className="mt-5">
  148. <OLButton
  149. variant="primary"
  150. size="lg"
  151. onClick={handlePayNowClick}
  152. disabled={payNowTask.isLoading || payNowTask.isSuccess}
  153. >
  154. {t('pay_now')}
  155. </OLButton>
  156. </div>
  157. <OLCard className="payment-summary-card mt-5">
  158. <h3>{t('future_payments')}:</h3>
  159. <OLRow className="mt-1">
  160. <OLCol xs={9}>{preview.nextInvoice.plan.name}</OLCol>
  161. <OLCol xs={3} className="text-end">
  162. {formatCurrency(
  163. preview.nextInvoice.plan.amount,
  164. preview.currency
  165. )}
  166. </OLCol>
  167. </OLRow>
  168. {preview.nextInvoice.addOns.map(addOn => (
  169. <OLRow className="mt-1" key={addOn.code}>
  170. <OLCol xs={9}>
  171. {addOn.name}
  172. {addOn.quantity > 1 ? ` ×${addOn.quantity}` : ''}
  173. </OLCol>
  174. <OLCol xs={3} className="text-end">
  175. {formatCurrency(addOn.amount, preview.currency)}
  176. </OLCol>
  177. </OLRow>
  178. ))}
  179. {preview.nextInvoice.tax.rate > 0 && (
  180. <OLRow className="mt-1">
  181. <OLCol xs={9}>
  182. {t('vat')} {preview.nextInvoice.tax.rate * 100}%
  183. </OLCol>
  184. <OLCol xs={3} className="text-end">
  185. {formatCurrency(
  186. preview.nextInvoice.tax.amount,
  187. preview.currency
  188. )}
  189. </OLCol>
  190. </OLRow>
  191. )}
  192. <OLRow className="mt-1">
  193. <OLCol xs={9}>
  194. {preview.nextPlan.annual
  195. ? t('total_per_year')
  196. : t('total_per_month')}
  197. </OLCol>
  198. <OLCol xs={3} className="text-end">
  199. {formatCurrency(preview.nextInvoice.total, preview.currency)}
  200. </OLCol>
  201. </OLRow>
  202. </OLCard>
  203. <div className="mt-5">
  204. <Trans
  205. i18nKey="the_next_payment_will_be_collected_on"
  206. values={{ date: moment(preview.nextInvoice.date).format('LL') }}
  207. components={{ strong: <strong /> }}
  208. shouldUnescape
  209. tOptions={{ interpolation: { escapeValue: true } }}
  210. />
  211. </div>
  212. </OLCard>
  213. </OLCol>
  214. </OLRow>
  215. </div>
  216. )
  217. }
  218. async function payNow(preview: SubscriptionChangePreview) {
  219. if (preview.change.type === 'add-on-purchase') {
  220. await postJSON(`/user/subscription/addon/${preview.change.addOn.code}/add`)
  221. } else if (preview.change.type === 'premium-subscription') {
  222. await postJSON(subscriptionUpdateUrl, {
  223. body: { plan_code: preview.change.plan.code },
  224. })
  225. } else {
  226. throw new Error(
  227. `Unknown subscription change preview type: ${preview.change}`
  228. )
  229. }
  230. }
  231. export default PreviewSubscriptionChange