promotionUtils.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import * as Sentry from '@sentry/react';
  2. import type {QueryObserverResult} from '@tanstack/react-query';
  3. import {promptsUpdate} from 'sentry/actionCreators/prompts';
  4. import {Client} from 'sentry/api';
  5. import type {Organization} from 'sentry/types/organization';
  6. import {QueryClient} from 'sentry/utils/queryClient';
  7. import {
  8. openPromotionModal,
  9. openPromotionReminderModal,
  10. } from 'getsentry/actionCreators/modal';
  11. import type {
  12. DiscountInfo,
  13. Plan,
  14. PromotionClaimed,
  15. PromotionData,
  16. Subscription,
  17. } from 'getsentry/types';
  18. import {isBizPlanFamily} from 'getsentry/utils/billing';
  19. import {createPromotionCheckQueryKey} from 'getsentry/utils/usePromotionTriggerCheck';
  20. import trackGetsentryAnalytics from './trackGetsentryAnalytics';
  21. export async function claimAvailablePromotion({
  22. promotionData,
  23. organization,
  24. promptFeature,
  25. }: {
  26. organization: Organization;
  27. promotionData: PromotionData;
  28. promptFeature?: string;
  29. }) {
  30. const api = new Client();
  31. const queryClient = new QueryClient();
  32. let completedPromotions = promotionData.completedPromotions,
  33. activePromotions = promotionData.activePromotions,
  34. availablePromotions = promotionData.availablePromotions;
  35. if (!availablePromotions) {
  36. return;
  37. }
  38. if (promptFeature) {
  39. availablePromotions = availablePromotions.filter(
  40. promo => promo.promptActivityTrigger === promptFeature
  41. );
  42. } else {
  43. // only consider auto-opt-in promotions if no prompt feature is specified
  44. availablePromotions = availablePromotions.filter(promo => promo.autoOptIn);
  45. }
  46. if (availablePromotions.length === 0) {
  47. return;
  48. }
  49. const promo = availablePromotions[availablePromotions.length - 1]!;
  50. const claimedPromo: PromotionClaimed = await api.requestPromise(
  51. `/organizations/${organization.slug}/promotions/${promo.slug}/claim/`,
  52. {
  53. method: 'POST',
  54. }
  55. );
  56. // TODO: avoid mutating the state here
  57. availablePromotions.pop();
  58. // if immediately complete, then add to that array
  59. if (claimedPromo.dateCompleted) {
  60. completedPromotions = completedPromotions || [];
  61. completedPromotions.push(claimedPromo);
  62. } else {
  63. activePromotions = activePromotions || [];
  64. activePromotions.push(claimedPromo);
  65. }
  66. // note this does not work but but we avoid the problem by mutating the input state
  67. queryClient.setQueryData<PromotionData>(
  68. createPromotionCheckQueryKey(organization.slug),
  69. {
  70. availablePromotions,
  71. completedPromotions,
  72. activePromotions,
  73. }
  74. );
  75. }
  76. export function showSubscriptionDiscount({
  77. activePlan,
  78. discountInfo,
  79. }: {
  80. activePlan: Plan;
  81. discountInfo?: DiscountInfo;
  82. }): boolean {
  83. return !!(
  84. discountInfo?.durationText &&
  85. discountInfo.discountType === 'percentPoints' &&
  86. activePlan.billingInterval === discountInfo.billingInterval &&
  87. discountInfo.creditCategory === 'subscription'
  88. );
  89. }
  90. export function showChurnDiscount({
  91. activePlan,
  92. discountInfo,
  93. }: {
  94. activePlan: Plan;
  95. discountInfo?: DiscountInfo;
  96. }) {
  97. // for now, only show discouns for percentPoints that are for the same billing interval
  98. if (
  99. discountInfo?.discountType !== 'percentPoints' ||
  100. activePlan.billingInterval !== discountInfo?.billingInterval
  101. ) {
  102. return false;
  103. }
  104. switch (discountInfo.planRequirement) {
  105. case 'business':
  106. return isBizPlanFamily(activePlan);
  107. case 'paid':
  108. // can't select a free plan on the checkout page
  109. return true;
  110. default:
  111. return false;
  112. }
  113. }
  114. export async function checkForPromptBasedPromotion({
  115. organization,
  116. refetch,
  117. promptFeature,
  118. subscription,
  119. promotionData,
  120. onAcceptConditions,
  121. }: {
  122. onAcceptConditions: () => void;
  123. organization: Organization;
  124. promotionData: PromotionData;
  125. promptFeature: string;
  126. refetch: () => Promise<QueryObserverResult<PromotionData, unknown>>;
  127. subscription: Subscription;
  128. }) {
  129. // from the existing promotion data, check if the user has already claimed the prompt-based promotion
  130. const completedPromotion = promotionData.completedPromotions?.find(
  131. promoClaimed => promoClaimed.promotion.promptActivityTrigger === promptFeature
  132. );
  133. if (completedPromotion) {
  134. // add tracking to the modal
  135. openPromotionReminderModal(
  136. completedPromotion,
  137. () => {
  138. trackGetsentryAnalytics('growth.promo_reminder_modal_keep', {
  139. organization,
  140. promo: completedPromotion.promotion.slug,
  141. });
  142. onAcceptConditions();
  143. },
  144. () => {
  145. trackGetsentryAnalytics('growth.promo_reminder_modal_continue_downgrade', {
  146. organization,
  147. promo: completedPromotion.promotion.slug,
  148. });
  149. }
  150. );
  151. return;
  152. }
  153. // if no completed promo, trigger the prompt endpoint and see if one is available
  154. try {
  155. const api = new Client();
  156. await promptsUpdate(api, {
  157. organization,
  158. feature: promptFeature,
  159. status: 'dismissed',
  160. });
  161. const result = await refetch();
  162. // find the matching available promotion based on prompt features
  163. const promotion = result?.data?.availablePromotions?.find(
  164. promo => promo.promptActivityTrigger === promptFeature
  165. );
  166. if (!promotion) {
  167. return;
  168. }
  169. const intervalPrice = subscription.customPrice
  170. ? subscription.customPrice
  171. : subscription.planDetails.price || 0;
  172. openPromotionModal({
  173. promotion,
  174. organization,
  175. price: intervalPrice,
  176. promptFeature,
  177. onAccept: onAcceptConditions,
  178. });
  179. } catch (err) {
  180. Sentry.captureException(err);
  181. return;
  182. }
  183. }