subscriptionCard.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import BusinessBundleArt from 'getsentry-images/bundles/business-bundle-art-plain.svg';
  4. import CustomBundleArt from 'getsentry-images/bundles/custom-bundle-art-plain.svg';
  5. import TeamBundleArt from 'getsentry-images/bundles/team-bundle-art-plain.svg';
  6. import moment from 'moment-timezone';
  7. import {LinkButton} from 'sentry/components/button';
  8. import {Tag} from 'sentry/components/core/badge/tag';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import type {Organization} from 'sentry/types/organization';
  12. import {ANNUAL} from 'getsentry/constants';
  13. import type {Subscription} from 'getsentry/types';
  14. import {isDeveloperPlan, isEnterprise, isTeamPlan} from 'getsentry/utils/billing';
  15. import formatCurrency from 'getsentry/utils/formatCurrency';
  16. import {shouldSeeSpendVisibility} from 'getsentry/views/subscriptionPage/utils';
  17. interface SubscriptionCardProps {
  18. organization: Organization;
  19. subscription: Subscription;
  20. }
  21. function PlanImage({subscription}: {subscription: Subscription}) {
  22. if (isDeveloperPlan(subscription.planDetails)) {
  23. return null;
  24. }
  25. let tierImage: any | null = null;
  26. if (isEnterprise(subscription)) {
  27. tierImage = BusinessBundleArt;
  28. } else if (isTeamPlan(subscription.plan)) {
  29. tierImage = TeamBundleArt;
  30. } else {
  31. tierImage = CustomBundleArt;
  32. }
  33. return (
  34. <img
  35. src={tierImage}
  36. alt={`${subscription.planDetails.name} logo`}
  37. width={25}
  38. style={{marginTop: space(0.25)}}
  39. />
  40. );
  41. }
  42. function PriceAndInterval({subscription}: {subscription: Subscription}) {
  43. if (isDeveloperPlan(subscription.planDetails)) {
  44. return <PlanSubheader>($0)</PlanSubheader>;
  45. }
  46. // Hide price and interval for managed, sponsored, or mm plans
  47. if (!shouldSeeSpendVisibility(subscription) || !subscription.planDetails?.basePrice) {
  48. return null;
  49. }
  50. return (
  51. <PlanSubheader>
  52. ({formatCurrency(subscription.planDetails.basePrice)}/
  53. {subscription.planDetails.billingInterval === ANNUAL ? t('yr') : t('mo')})
  54. </PlanSubheader>
  55. );
  56. }
  57. export function SubscriptionCard({subscription, organization}: SubscriptionCardProps) {
  58. const renewalDate = subscription.cancelAtPeriodEnd
  59. ? moment(subscription.contractPeriodEnd)
  60. : moment(subscription.contractPeriodEnd).add(1, 'days');
  61. const renewalFormattedDate =
  62. subscription.billingInterval === 'annual'
  63. ? renewalDate.format('L')
  64. : renewalDate
  65. .toDate()
  66. .toLocaleDateString(undefined, {month: 'numeric', day: 'numeric'});
  67. const renewalText = subscription.cancelAtPeriodEnd
  68. ? t('Cancels on: %s', renewalFormattedDate)
  69. : t('Renews on: %s', renewalFormattedDate);
  70. const hasBillingPerms = organization.access?.includes('org:billing');
  71. return (
  72. <SubscriptionCardBody data-test-id="subscription-card">
  73. <PlanHeaderWrapper>
  74. <PlanImage subscription={subscription} />
  75. <PlanHeaderCardWrapper>
  76. <PlanHeader>
  77. {t('%s Plan', subscription.planDetails?.name)}
  78. <PriceAndInterval subscription={subscription} />
  79. </PlanHeader>
  80. <PaymentDetails>
  81. {subscription.isPastDue && (
  82. <div>
  83. <Tag type="error">{t('Payment Failed')}</Tag>
  84. </div>
  85. )}
  86. {renewalText}
  87. {hasBillingPerms && subscription.paymentSource ? (
  88. <Fragment>
  89. <div>{`CC: **** ${subscription.paymentSource.last4}`}</div>
  90. <div>
  91. Exp: {String(subscription.paymentSource.expMonth).padStart(2, '0')}/
  92. {String(subscription.paymentSource.expYear).slice(2)}
  93. </div>
  94. </Fragment>
  95. ) : (
  96. // Hide 'No Card on File' for VC partner accounts
  97. subscription.partner?.partnership?.id !== 'VC' && (
  98. <div>{t('No Card on File')}</div>
  99. )
  100. )}
  101. </PaymentDetails>
  102. {subscription.isPastDue && (
  103. <PastDueWrapper>
  104. <LinkButton
  105. to={`/settings/${organization.slug}/billing/details/`}
  106. size="xs"
  107. >
  108. {t('Manage Billing Details')}
  109. </LinkButton>
  110. </PastDueWrapper>
  111. )}
  112. </PlanHeaderCardWrapper>
  113. </PlanHeaderWrapper>
  114. </SubscriptionCardBody>
  115. );
  116. }
  117. const PlanHeader = styled('div')<{isPastDue?: boolean}>`
  118. display: flex;
  119. gap: ${space(0.5)};
  120. align-items: center;
  121. color: ${p => (p.isPastDue ? p.theme.red300 : p.theme.textColor)};
  122. font-size: ${p => p.theme.headerFontSize};
  123. font-weight: bold;
  124. white-space: nowrap;
  125. `;
  126. const PlanHeaderWrapper = styled('div')`
  127. display: flex;
  128. gap: ${space(1.5)};
  129. align-items: flex-start;
  130. `;
  131. const PlanSubheader = styled('div')`
  132. color: ${p => p.theme.subText};
  133. font-weight: normal;
  134. `;
  135. const PlanHeaderCardWrapper = styled('div')`
  136. display: flex;
  137. flex-direction: column;
  138. `;
  139. const PaymentDetails = styled('div')`
  140. line-height: 1.5;
  141. font-size: ${p => p.theme.fontSizeSmall};
  142. color: ${p => p.theme.subText};
  143. font-weight: 500;
  144. `;
  145. const SubscriptionCardBody = styled('div')`
  146. padding: ${space(2)} ${space(2)} ${space(1.5)};
  147. `;
  148. const PastDueWrapper = styled('div')`
  149. margin-top: ${space(0.25)};
  150. `;