ExitSurveyModal.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. import { useFlag, useParams } from 'common'
  2. import { useState } from 'react'
  3. import { toast } from 'sonner'
  4. import { Button, cn, Modal, TextArea } from 'ui'
  5. import { Admonition } from 'ui-patterns/admonition'
  6. import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip'
  7. import { CANCELLATION_REASONS } from '@/components/interfaces/Billing/Billing.constants'
  8. import { useSendDowngradeFeedbackMutation } from '@/data/feedback/exit-survey-send'
  9. import { getComputeSize, OrgProject } from '@/data/projects/org-projects-infinite-query'
  10. import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation'
  11. export interface ExitSurveyModalProps {
  12. visible: boolean
  13. projects: OrgProject[]
  14. onClose: (success?: boolean) => void
  15. }
  16. // [Joshen] For context - Exit survey is only when going to Free Plan from a paid plan
  17. export const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) => {
  18. const { slug } = useParams()
  19. const [message, setMessage] = useState('')
  20. const [selectedReason, setSelectedReason] = useState<string[]>([])
  21. const subscriptionUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
  22. const { mutate: updateOrgSubscription, isPending: isUpdating } = useOrgSubscriptionUpdateMutation(
  23. {
  24. onError: (error) => {
  25. toast.error(`Failed to downgrade project: ${error.message}`)
  26. },
  27. }
  28. )
  29. const { mutateAsync: sendExitSurvey, isPending: isSubmittingFeedback } =
  30. useSendDowngradeFeedbackMutation()
  31. const isSubmitting = isUpdating || isSubmittingFeedback
  32. const projectsWithComputeDowngrade = projects.filter((project) => {
  33. const computeSize = getComputeSize(project)
  34. return computeSize !== 'nano'
  35. })
  36. const hasProjectsWithComputeDowngrade = projectsWithComputeDowngrade.length > 0
  37. const [shuffledReasons] = useState(() => [
  38. ...CANCELLATION_REASONS.sort(() => Math.random() - 0.5),
  39. { value: 'None of the above' },
  40. ])
  41. const onSelectCancellationReason = (reason: string) => {
  42. setSelectedReason([reason])
  43. }
  44. // Helper to get label for selected reason
  45. const getReasonLabel = (reason: string | undefined) => {
  46. const found = CANCELLATION_REASONS.find((r) => r.value === reason)
  47. return found?.label || 'What can we improve on?'
  48. }
  49. const textareaLabel = getReasonLabel(selectedReason[0])
  50. const onSubmit = async () => {
  51. if (selectedReason.length === 0) {
  52. return toast.error('Please select a reason for canceling your subscription')
  53. }
  54. await downgradeOrganization()
  55. }
  56. const downgradeOrganization = async () => {
  57. // Update the subscription first, followed by posting the exit survey if successful
  58. // If compute instance is present within the existing subscription, then a restart will be triggered
  59. if (!slug) return console.error('Slug is required')
  60. updateOrgSubscription(
  61. { slug, tier: 'tier_free' },
  62. {
  63. onSuccess: async () => {
  64. try {
  65. await sendExitSurvey({
  66. orgSlug: slug,
  67. reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''),
  68. message,
  69. exitAction: 'downgrade',
  70. })
  71. } catch (error) {
  72. // [Joshen] In this case we don't raise any errors if the exit survey fails to send since it shouldn't block the user
  73. } finally {
  74. toast.success(
  75. hasProjectsWithComputeDowngrade
  76. ? 'Successfully downgraded organization to the Free Plan. Your projects are currently restarting to update their compute instances.'
  77. : 'Successfully downgraded organization to the Free Plan',
  78. { duration: hasProjectsWithComputeDowngrade ? 8000 : 4000 }
  79. )
  80. onClose(true)
  81. window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
  82. }
  83. },
  84. }
  85. )
  86. }
  87. return (
  88. <Modal hideFooter size="xlarge" visible={visible} onCancel={onClose} header="Help us improve">
  89. <Modal.Content>
  90. <div className="space-y-4">
  91. <p className="text-sm text-foreground-light">
  92. What made you decide to downgrade your plan?
  93. </p>
  94. <div className="space-y-8 mt-6">
  95. <div className="flex flex-wrap gap-2" data-toggle="buttons">
  96. {shuffledReasons.map((option) => {
  97. const active = selectedReason[0] === option.value
  98. return (
  99. <label
  100. key={option.value}
  101. className={cn(
  102. 'flex cursor-pointer items-center space-x-2 rounded-md py-1',
  103. 'pl-2 pr-3 text-center text-sm',
  104. 'shadow-xs transition-all duration-100',
  105. active
  106. ? `bg-foreground text-background opacity-100 hover:bg-foreground/75`
  107. : `bg-border-strong text-foreground opacity-75 hover:opacity-100`
  108. )}
  109. >
  110. <input
  111. type="radio"
  112. name="options"
  113. value={option.value}
  114. className="hidden"
  115. checked={active}
  116. onChange={() => onSelectCancellationReason(option.value)}
  117. />
  118. <div>{option.value}</div>
  119. </label>
  120. )
  121. })}
  122. </div>
  123. <div className="text-area-text-sm flex flex-col gap-y-2">
  124. <label htmlFor="message" className="text-sm whitespace-pre-line wrap-break-word">
  125. {textareaLabel}
  126. </label>
  127. <TextArea
  128. id="message"
  129. name="message"
  130. value={message}
  131. onChange={(event: any) => setMessage(event.target.value)}
  132. rows={3}
  133. />
  134. </div>
  135. </div>
  136. {hasProjectsWithComputeDowngrade && (
  137. <Admonition
  138. type="warning"
  139. layout="horizontal"
  140. title={`${projectsWithComputeDowngrade.length} of your projects will be restarted upon clicking confirm,`}
  141. description={
  142. <>
  143. This is due to changes in compute instances from the downgrade. Affected projects
  144. include {projectsWithComputeDowngrade.map((project) => project.name).join(', ')}.
  145. </>
  146. }
  147. />
  148. )}
  149. </div>
  150. </Modal.Content>
  151. <div className="flex items-center justify-between border-t px-4 py-4">
  152. <p className="text-xs text-foreground-lighter">
  153. The unused amount for the remaining time of your billing cycle will be refunded as credits
  154. </p>
  155. <div className="flex items-center space-x-2">
  156. <Button type="default" onClick={() => onClose()}>
  157. Cancel
  158. </Button>
  159. <ProjectUpdateDisabledTooltip projectUpdateDisabled={subscriptionUpdateDisabled}>
  160. <Button
  161. type="danger"
  162. className="pointer-events-auto"
  163. loading={isSubmitting}
  164. disabled={subscriptionUpdateDisabled || isSubmitting}
  165. onClick={onSubmit}
  166. >
  167. Confirm downgrade
  168. </Button>
  169. </ProjectUpdateDisabledTooltip>
  170. </div>
  171. </div>
  172. </Modal>
  173. )
  174. }