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