ExitSurveyModal.tsx192 lines · main
1import { useFlag, useParams } from 'common'
2import { useState } from 'react'
3import { toast } from 'sonner'
4import { Button, cn, Modal, TextArea } from 'ui'
5import { Admonition } from 'ui-patterns/admonition'
6
7import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip'
8import { CANCELLATION_REASONS } from '@/components/interfaces/Billing/Billing.constants'
9import { useSendDowngradeFeedbackMutation } from '@/data/feedback/exit-survey-send'
10import { getComputeSize, OrgProject } from '@/data/projects/org-projects-infinite-query'
11import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation'
12
13export 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
20export 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}