DeleteProjectModal.tsx201 lines · main
1import { LOCAL_STORAGE_KEYS } from 'common'
2import { useRouter } from 'next/router'
3import { useEffect, useState } from 'react'
4import { toast } from 'sonner'
5import { TextArea } from 'ui'
6import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
7
8import { CANCELLATION_REASONS } from '@/components/interfaces/Billing/Billing.constants'
9import { LogicalBackupCliInstructions } from '@/components/layouts/ProjectLayout/LogicalBackupCliInstructions'
10import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
11import { useSendDowngradeFeedbackMutation } from '@/data/feedback/exit-survey-send'
12import type { OrgProject } from '@/data/projects/org-projects-infinite-query'
13import { useProjectDeleteMutation } from '@/data/projects/project-delete-mutation'
14import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
15import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
16import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
17import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
18import type { Organization } from '@/types'
19
20export const DeleteProjectModal = ({
21 visible,
22 onClose,
23 project: projectProp,
24 organization: organizationProp,
25}: {
26 visible: boolean
27 onClose: () => void
28 project?: OrgProject
29 organization?: Organization
30}) => {
31 const router = useRouter()
32 const { data: projectFromQuery } = useSelectedProjectQuery()
33 const { data: organizationFromQuery } = useSelectedOrganizationQuery()
34
35 // Use props if provided, otherwise fall back to hooks
36 const project = projectProp || projectFromQuery
37 const organization = organizationProp || organizationFromQuery
38
39 const [lastVisitedOrganization] = useLocalStorageQuery(
40 LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
41 ''
42 )
43
44 const projectRef = project?.ref
45 const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
46 const projectPlan = subscription?.plan?.id ?? 'free'
47 const isFree = projectPlan === 'free'
48
49 const [message, setMessage] = useState<string>('')
50 const [selectedReason, setSelectedReason] = useState<string[]>([])
51
52 // Single select for cancellation reason
53 const onSelectCancellationReason = (reason: string) => {
54 setSelectedReason([reason])
55 }
56
57 // Helper to get label for selected reason
58 const getReasonLabel = (reason: string | undefined) => {
59 const found = CANCELLATION_REASONS.find((r) => r.value === reason)
60 return found?.label || 'What can we improve on?'
61 }
62
63 const textareaLabel = getReasonLabel(selectedReason[0])
64
65 const [shuffledReasons] = useState(() => [
66 ...CANCELLATION_REASONS.sort(() => Math.random() - 0.5),
67 { value: 'None of the above' },
68 ])
69
70 const { mutate: deleteProject, isPending: isDeleting } = useProjectDeleteMutation({
71 onSuccess: async () => {
72 if (!isFree) {
73 try {
74 await sendExitSurvey({
75 orgSlug: organization?.slug,
76 projectRef,
77 message,
78 reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''),
79 exitAction: 'delete',
80 })
81 } catch (error) {
82 // [Joshen] In this case we don't raise any errors if the exit survey fails to send since it shouldn't block the user
83 }
84 }
85
86 toast.success(`Successfully deleted ${project?.name}`)
87
88 // Only redirect if still viewing the deleted project
89 if (router.asPath.startsWith(`/project/${projectRef}`)) {
90 if (lastVisitedOrganization) {
91 router.push(`/org/${lastVisitedOrganization}`)
92 } else {
93 router.push('/organizations')
94 }
95 }
96 },
97 })
98 const { mutateAsync: sendExitSurvey, isPending: isSending } = useSendDowngradeFeedbackMutation()
99 const isSubmitting = isDeleting || isSending
100
101 async function handleDeleteProject() {
102 if (project === undefined) return
103 if (!isFree && selectedReason.length === 0) {
104 return toast.error('Please select a reason for deleting your project')
105 }
106
107 deleteProject({ projectRef: project.ref, organizationSlug: organization?.slug })
108 }
109
110 useEffect(() => {
111 if (visible) {
112 setSelectedReason([])
113 setMessage('')
114 }
115 }, [visible])
116
117 return (
118 <TextConfirmModal
119 visible={visible}
120 loading={isSubmitting}
121 size={isFree ? 'medium' : 'xlarge'}
122 title={`Confirm deletion of ${project?.name}`}
123 variant="destructive"
124 alert={{
125 title: isFree
126 ? 'This action cannot be undone.'
127 : `This will permanently delete the ${project?.name}`,
128 description: !isFree ? `All project data will be lost, and cannot be undone` : '',
129 }}
130 text={
131 isFree
132 ? `This will permanently delete the ${project?.name} project and all of its data.`
133 : undefined
134 }
135 confirmPlaceholder="Type the project name in here"
136 confirmString={project?.name || ''}
137 confirmLabel="I understand, delete this project"
138 onConfirm={handleDeleteProject}
139 onCancel={() => {
140 if (!isSubmitting) onClose()
141 }}
142 >
143 <div className="space-y-6">
144 <LogicalBackupCliInstructions enabled={visible} showResetPassword={false} />
145
146 {/*
147 [Joshen] This is basically ExitSurvey.tsx, ideally we have one shared component but the one
148 in ExitSurvey has a Form wrapped around it already. Will probably need some effort to refactor
149 but leaving that for the future.
150 */}
151 {!isFree && (
152 <div className="flex flex-col gap-y-6">
153 <FormItemLayout
154 isReactForm={false}
155 label="What made you decide to delete your project?"
156 >
157 <div className="flex flex-wrap gap-2" data-toggle="buttons">
158 {shuffledReasons.map((option) => {
159 const active = selectedReason[0] === option.value
160 return (
161 <label
162 key={option.value}
163 className={[
164 'flex cursor-pointer items-center space-x-2 rounded-md py-1',
165 'pl-2 pr-3 text-center text-sm shadow-xs transition-all duration-100',
166 `${
167 active
168 ? ` bg-foreground text-background opacity-100 hover:bg-foreground/75`
169 : ` bg-border-strong text-foreground opacity-50 hover:opacity-75`
170 }`,
171 ].join(' ')}
172 >
173 <input
174 type="radio"
175 name="options"
176 value={option.value}
177 className="hidden"
178 checked={active}
179 onChange={() => onSelectCancellationReason(option.value)}
180 />
181 <div>{option.value}</div>
182 </label>
183 )
184 })}
185 </div>
186 </FormItemLayout>
187
188 <FormItemLayout isReactForm={false} label={textareaLabel}>
189 <TextArea
190 name="message"
191 rows={3}
192 value={message}
193 onChange={(event) => setMessage(event.target.value)}
194 />
195 </FormItemLayout>
196 </div>
197 )}
198 </div>
199 </TextConfirmModal>
200 )
201}