memberInviteModalCustomization.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import styled from '@emotion/styled';
  2. import Link from 'sentry/components/links/link';
  3. import LoadingIndicator from 'sentry/components/loadingIndicator';
  4. import {IconBusiness, IconCheckmark, IconWarning} from 'sentry/icons';
  5. import {t, tct} from 'sentry/locale';
  6. import {space} from 'sentry/styles/space';
  7. import type {Organization} from 'sentry/types/organization';
  8. import {OrganizationContext} from 'sentry/views/organizationContext';
  9. import TrialStarter from 'getsentry/components/trialStarter';
  10. import UpgradeOrTrialButton from 'getsentry/components/upgradeOrTrialButton';
  11. import type {Subscription} from 'getsentry/types';
  12. import {getTrialLength, hasJustStartedPlanTrial} from 'getsentry/utils/billing';
  13. import withSubscription from './withSubscription';
  14. type MemberInviteProps = {
  15. children: (opts: {
  16. canSend: boolean;
  17. isOverMemberLimit: boolean;
  18. sendInvites: () => void;
  19. headerInfo?: React.ReactNode;
  20. }) => React.ReactElement;
  21. onSendInvites: () => void;
  22. organization: Organization;
  23. subscription: Subscription;
  24. willInvite: boolean;
  25. };
  26. function MemberInviteModalCustomization({
  27. organization,
  28. willInvite,
  29. onSendInvites,
  30. children,
  31. subscription,
  32. }: MemberInviteProps) {
  33. const {totalMembers, canTrial, isTrial, totalLicenses} = subscription;
  34. const usedSeats = totalMembers ?? 0;
  35. const isOverMemberLimit: boolean = totalLicenses > 0 && usedSeats >= totalLicenses;
  36. const renderPassthrough = () =>
  37. children({
  38. sendInvites: onSendInvites,
  39. canSend: true,
  40. isOverMemberLimit,
  41. });
  42. // We don't need to do anything if the modal isn't going to actually sent
  43. // member invites.
  44. if (!willInvite) {
  45. return renderPassthrough();
  46. }
  47. type RenderProps = Parameters<React.ComponentProps<typeof TrialStarter>['children']>[0];
  48. const trialStarterRenderer = ({
  49. trialStarting,
  50. trialStarted,
  51. trialFailed,
  52. }: RenderProps) => {
  53. // maxMembers is null for paid plans
  54. const hasSeats = !totalLicenses || usedSeats < totalLicenses;
  55. // If we just started the trial continue rendering the trial banner in the modal header.
  56. if (!(hasJustStartedPlanTrial(subscription) && trialStarted) && hasSeats) {
  57. return renderPassthrough();
  58. }
  59. const allowedToStartTrial = organization.access.includes('org:billing');
  60. const isExpired = !canTrial && !isTrial;
  61. const trialLength = getTrialLength(organization);
  62. const trialStartText = t('Start your %s day Business Plan trial today!', trialLength);
  63. const upgradeOrTrialButton = (
  64. <UpgradeOrTrialButton
  65. source="member_invite_modal"
  66. subscription={subscription}
  67. organization={organization}
  68. upgradePriority="default"
  69. />
  70. );
  71. function getHeaderInfo() {
  72. if (isOverMemberLimit) {
  73. return (
  74. <TrialInfo status="error">
  75. <IconWarning />
  76. {tct(
  77. 'You have reached your [totalLicenses] member limit. Upgrade to invite more members.',
  78. {totalLicenses}
  79. )}
  80. {upgradeOrTrialButton}
  81. </TrialInfo>
  82. );
  83. }
  84. // hasJustStartedPlanTrial based on isTrial (plan is a trial plan) and isTrialStarted which comes from updating the subscription after a trial, trialStarted comes from the trial starter widget
  85. if (hasJustStartedPlanTrial(subscription) || trialStarted) {
  86. return (
  87. <TrialInfo status="success">
  88. <IconCheckmark />
  89. {t('Your %s day Business Plan Trial has been activated!', trialLength)}
  90. </TrialInfo>
  91. );
  92. }
  93. if (isExpired) {
  94. return (
  95. <TrialInfo status="error">
  96. <IconWarning />
  97. {t(
  98. 'Your %s day Business Plan Trial has expired. Upgrade to invite more members.',
  99. trialLength
  100. )}
  101. {upgradeOrTrialButton}
  102. </TrialInfo>
  103. );
  104. }
  105. if (trialStarting) {
  106. return (
  107. <TrialInfo>
  108. <LoadingIndicator mini relative hideMessage size={16} />
  109. {trialStartText}
  110. </TrialInfo>
  111. );
  112. }
  113. if (trialFailed) {
  114. return (
  115. <TrialInfo status="error">
  116. <IconWarning />
  117. {tct(
  118. `There was a problem starting your trial. Check your
  119. [settings:subscription settings].`,
  120. {settings: <Link to={`/settings/${organization.slug}/billing/`} />}
  121. )}
  122. </TrialInfo>
  123. );
  124. }
  125. if (!allowedToStartTrial) {
  126. return (
  127. <TrialInfo status="error">
  128. <IconWarning />
  129. {t(
  130. `You do not have permission to upgrade or start a trial to invite
  131. members. Contact your organization owner or billing manager.`
  132. )}
  133. {upgradeOrTrialButton}
  134. </TrialInfo>
  135. );
  136. }
  137. return (
  138. <TrialInfo>
  139. <IconBusiness gradient withShine size="md" />
  140. {trialStartText}
  141. {upgradeOrTrialButton}
  142. </TrialInfo>
  143. );
  144. }
  145. return children({
  146. headerInfo: getHeaderInfo(),
  147. canSend: true,
  148. sendInvites: onSendInvites,
  149. isOverMemberLimit,
  150. });
  151. };
  152. return (
  153. <TrialStarter
  154. organization={organization}
  155. source="invite_modal"
  156. onTrialStarted={onSendInvites}
  157. >
  158. {trialStarterRenderer}
  159. </TrialStarter>
  160. );
  161. }
  162. const TrialInfo = styled('div')<{status?: string}>`
  163. display: grid;
  164. min-height: 50px;
  165. grid-template-columns: 20px 1fr max-content;
  166. gap: ${space(1.5)};
  167. padding: ${space(1.5)};
  168. margin: ${space(2)} 0;
  169. align-items: center;
  170. font-size: ${p => p.theme.fontSizeMedium};
  171. background: ${p => p.theme.backgroundSecondary};
  172. border-radius: 3px;
  173. ${p => p.status === 'error' && `color: ${p.theme.red300}`};
  174. > :first-child {
  175. justify-self: center;
  176. ${p => p.status === 'success' && `color: ${p.theme.green300}`};
  177. }
  178. `;
  179. // wraps the component to add the organization context
  180. function MemberInviteModalCustomizationWrapper(props: MemberInviteProps) {
  181. return (
  182. <OrganizationContext.Provider value={props.organization}>
  183. <MemberInviteModalCustomization {...props} />
  184. </OrganizationContext.Provider>
  185. );
  186. }
  187. export default withSubscription(MemberInviteModalCustomizationWrapper);