diff --git a/apps/studio/components/interfaces/Billing/Billing.constants.ts b/apps/studio/components/interfaces/Billing/Billing.constants.ts index c4a48de2d74b2..3ccc2dd1e5945 100644 --- a/apps/studio/components/interfaces/Billing/Billing.constants.ts +++ b/apps/studio/components/interfaces/Billing/Billing.constants.ts @@ -1,18 +1,44 @@ export const USAGE_APPROACHING_THRESHOLD = 0.8 export const CANCELLATION_REASONS = [ - 'Pricing', - "My project isn't getting traction", - 'Poor customer service', - 'Missing feature', - "I didn't see the value", - "Supabase didn't meet my needs", - 'Dashboard is too complicated', - 'Postgres is too complicated', - 'Problem not solved', - 'Too many bugs/issues', - 'I decided to use something else', - 'My work has finished/discontinued', - 'I’m migrating to/starting a new project', - 'None of the above', + { + value: 'I was just exploring, or it was a hobby/student project.', + }, + { + value: 'I was not satisfied with the customer support I received.', + label: 'Could you tell us more about your experience with our support team?', + }, + { + value: 'Supabase is missing a specific feature I need.', + label: 'What specific feature(s) are we missing?', + }, + { + value: 'I found it difficult to use or build with.', + label: 'What specific parts of Supabase did you find difficult or frustrating?', + }, + { + value: 'Performance or reliability insufficient.', + label: + 'Could you tell us more about the specific issues you encountered (e.g., UI bugs, API latency, downtime)?', + }, + { + value: 'My project was cancelled or put on hold.', + }, + { + value: 'Too expensive', + label: 'We appreciate your perspective on our pricing, what aspects of the cost felt too high?', + }, + { + value: 'The pricing is unpredictable and hard to budget for.', + label: + 'Which aspects of our pricing model made it difficult for you to predict your monthly costs?', + }, + { + value: 'My company went out of business or was acquired.', + }, + { + value: 'I lost trust in the company or its future direction.', + label: + 'Building and maintaining your trust is our highest priority, could you please share the specific event or reason that led to this loss of trust?', + }, ] diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.constants.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.constants.ts deleted file mode 100644 index d2f5238e5f4f1..0000000000000 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const CANCELLATION_REASONS = [ - 'Pricing', - "My project isn't getting traction", - 'Poor customer service', - 'Missing feature', - "I didn't see the value", - "Supabase didn't meet my needs", - 'Dashboard is too complicated', - 'Postgres is too complicated', - 'Problem not solved', - 'Too many bugs/issues', - 'I decided to use something else', - 'My work has finished/discontinued', - 'I’m migrating to/starting a new project', - 'None of the above', -] diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx index 2a10e1bf6ae22..ca96700cee511 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx @@ -1,14 +1,13 @@ -import { includes, without } from 'lodash' -import { useReducer, useState } from 'react' +import { useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' +import { CANCELLATION_REASONS } from 'components/interfaces/Billing/Billing.constants' import { useSendDowngradeFeedbackMutation } from 'data/feedback/exit-survey-send' +import { ProjectInfo } from 'data/projects/projects-query' import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' import { useFlag } from 'hooks/ui/useFlag' -import { Alert, Button, Input, Modal } from 'ui' -import type { ProjectInfo } from '../../../../../data/projects/projects-query' -import { CANCELLATION_REASONS } from '../BillingSettings.constants' +import { Alert, Button, cn, Input, Modal } from 'ui' import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' export interface ExitSurveyModalProps { @@ -18,11 +17,11 @@ export interface ExitSurveyModalProps { } // [Joshen] For context - Exit survey is only when going to Free Plan from a paid plan -const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) => { +export const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) => { const { slug } = useParams() const [message, setMessage] = useState('') - const [selectedReasons, dispatchSelectedReasons] = useReducer(reducer, []) + const [selectedReason, setSelectedReason] = useState([]) const subscriptionUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const { mutate: updateOrgSubscription, isLoading: isUpdating } = useOrgSubscriptionUpdateMutation( @@ -42,17 +41,26 @@ const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) = const hasProjectsWithComputeDowngrade = projectsWithComputeDowngrade.length > 0 - function reducer(state: any, action: any) { - if (includes(state, action.target.value)) { - return without(state, action.target.value) - } else { - return [...state, action.target.value] - } + const [shuffledReasons] = useState(() => [ + ...CANCELLATION_REASONS.sort(() => Math.random() - 0.5), + { value: 'None of the above' }, + ]) + + const onSelectCancellationReason = (reason: string) => { + setSelectedReason([reason]) + } + + // Helper to get label for selected reason + const getReasonLabel = (reason: string | undefined) => { + const found = CANCELLATION_REASONS.find((r) => r.value === reason) + return found?.label || 'What can we improve on?' } + const textareaLabel = getReasonLabel(selectedReason[0]) + const onSubmit = async () => { - if (selectedReasons.length === 0) { - return toast.error('Please select at least one reason for canceling your subscription') + if (selectedReason.length === 0) { + return toast.error('Please select a reason for canceling your subscription') } await downgradeOrganization() @@ -70,7 +78,7 @@ const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) = try { await sendExitSurvey({ orgSlug: slug, - reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''), + reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''), message, exitAction: 'downgrade', }) @@ -92,99 +100,87 @@ const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) = } return ( - <> - - -
-

- We always strive to improve Supabase as much as we can. Please let us know the reasons - you are canceling your subscription so that we can improve in the future. -

-
-
- {CANCELLATION_REASONS.map((option) => { - const active = selectedReasons.find((x) => x === option) - return ( - - ) - })} -
-
- setMessage(event.target.value)} - label="Anything else that we can improve on?" - /> -
+ + +
+

+ Share with us why you're downgrading your plan. +

+
+
+ {shuffledReasons.map((option) => { + const active = selectedReason[0] === option.value + return ( + + ) + })} +
+
+ + setMessage(event.target.value)} + rows={3} + />
- {hasProjectsWithComputeDowngrade && ( - - This is due to changes in compute instances from the downgrade. Affected projects - include {projectsWithComputeDowngrade.map((project) => project.name).join(', ')}. - - )}
- - -
-

- The unused amount for the remaining time of your billing cycle will be refunded as - credits -

- -
-
+ + +
+

+ The unused amount for the remaining time of your billing cycle will be refunded as credits +

+ +
+ + + - - - -
+
- - +
+ ) } - -export default ExitSurveyModal diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index 4102d6f65bbc7..a91fa6368eb39 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -5,7 +5,9 @@ import { useRouter } from 'next/router' import { useEffect, useRef, useState } from 'react' import { StudioPricingSidePanelOpenedEvent } from 'common/telemetry-constants' +import { getPlanChangeType } from 'components/interfaces/Billing/Subscription/Subscription.utils' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import PartnerManagedResource from 'components/ui/PartnerManagedResource' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' import { useOrganizationBillingSubscriptionPreview } from 'data/organizations/organization-billing-subscription-preview' @@ -23,12 +25,10 @@ import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' import { Button, SidePanel, cn } from 'ui' import DowngradeModal from './DowngradeModal' import { EnterpriseCard } from './EnterpriseCard' -import ExitSurveyModal from './ExitSurveyModal' +import { ExitSurveyModal } from './ExitSurveyModal' import MembersExceedLimitModal from './MembersExceedLimitModal' import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog' import UpgradeSurveyModal from './UpgradeModal' -import PartnerManagedResource from 'components/ui/PartnerManagedResource' -import { getPlanChangeType } from 'components/interfaces/Billing/Subscription/Subscription.utils' const PlanUpdateSidePanel = () => { const router = useRouter() diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx index 427a7d664225f..961805f901352 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import DeleteProjectModal from './DeleteProjectModal' +import { DeleteProjectModal } from './DeleteProjectModal' export interface DeleteProjectButtonProps { type?: 'danger' | 'default' diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx index bc8c94655cf5f..966e6d481e6e6 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx @@ -1,4 +1,3 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -9,13 +8,18 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte import { useSendDowngradeFeedbackMutation } from 'data/feedback/exit-survey-send' import { useProjectDeleteMutation } from 'data/projects/project-delete-mutation' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { Input } from 'ui' import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' -const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: () => void }) => { +export const DeleteProjectModal = ({ + visible, + onClose, +}: { + visible: boolean + onClose: () => void +}) => { const router = useRouter() const { project } = useProjectContext() const organization = useSelectedOrganization() @@ -31,7 +35,25 @@ const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: ( const isFree = projectPlan === 'free' const [message, setMessage] = useState('') - const [selectedReasons, setSelectedReasons] = useState([]) + const [selectedReason, setSelectedReason] = useState([]) + + // Single select for cancellation reason + const onSelectCancellationReason = (reason: string) => { + setSelectedReason([reason]) + } + + // Helper to get label for selected reason + const getReasonLabel = (reason: string | undefined) => { + const found = CANCELLATION_REASONS.find((r) => r.value === reason) + return found?.label || 'What can we improve on?' + } + + const textareaLabel = getReasonLabel(selectedReason[0]) + + const [shuffledReasons] = useState(() => [ + ...CANCELLATION_REASONS.sort(() => Math.random() - 0.5), + { value: 'None of the above' }, + ]) const { mutate: deleteProject, isLoading: isDeleting } = useProjectDeleteMutation({ onSuccess: async () => { @@ -41,7 +63,7 @@ const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: ( orgSlug: organization?.slug, projectRef, message, - reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''), + reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''), exitAction: 'delete', }) } catch (error) { @@ -58,122 +80,102 @@ const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: ( const { mutateAsync: sendExitSurvey, isLoading: isSending } = useSendDowngradeFeedbackMutation() const isSubmitting = isDeleting || isSending - useEffect(() => { - if (visible) { - setSelectedReasons([]) - setMessage('') - } - }, [visible]) - - const canDeleteProject = useCheckPermissions(PermissionAction.UPDATE, 'projects', { - resource: { - project_id: project?.id, - }, - }) - - const onSelectCancellationReason = (reason: string) => { - const existingSelection = selectedReasons.find((x) => x === reason) - const updatedSelection = - existingSelection === undefined - ? selectedReasons.concat([reason]) - : selectedReasons.filter((x) => x !== reason) - setSelectedReasons(updatedSelection) - } - async function handleDeleteProject() { if (project === undefined) return - if (!isFree && selectedReasons.length === 0) { - return toast.error('Please select at least one reason for deleting your project') + if (!isFree && selectedReason.length === 0) { + return toast.error('Please select a reason for deleting your project') } deleteProject({ projectRef: project.ref, organizationSlug: organization?.slug }) } + useEffect(() => { + if (visible) { + setSelectedReason([]) + setMessage('') + } + }, [visible]) + return ( - <> - { - if (!isSubmitting) onClose() - }} - > - {/* + { + if (!isSubmitting) onClose() + }} + > + {/* [Joshen] This is basically ExitSurvey.tsx, ideally we have one shared component but the one in ExitSurvey has a Form wrapped around it already. Will probably need some effort to refactor but leaving that for the future. */} - {!isFree && ( - <> -
-

Help us improve.

-

- We always strive to improve Supabase as much as we can. Please let us know the - reasons you are deleting your project so that we can improve in the future. -

+ {!isFree && ( + <> +
+

+ Help us improve by sharing why you're deleting your project. +

+
+
+
+ {shuffledReasons.map((option) => { + const active = selectedReason[0] === option.value + return ( + + ) + })}
-
-
- {CANCELLATION_REASONS.map((option) => { - const active = selectedReasons.find((x) => x === option) - return ( - - ) - })} -
-
- setMessage(event.target.value)} - /> -
+
+ + setMessage(event.target.value)} + />
- - )} - - +
+ + )} + ) } - -export default DeleteProjectModal diff --git a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx index 9e8e36faf38e9..63caa017d88bc 100644 --- a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx @@ -1,20 +1,16 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Download, MoreVertical, Trash } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { useParams } from 'common' -import DeleteProjectModal from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal' +import { DeleteProjectModal } from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import { useDownloadableBackupQuery } from 'data/database/backup-query' -import { - Button, - CriticalIcon, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from 'ui' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { Button, CriticalIcon, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui' import { useProjectContext } from './ProjectContext' const PauseFailedState = () => { @@ -22,6 +18,10 @@ const PauseFailedState = () => { const { project } = useProjectContext() const [visible, setVisible] = useState(false) + const canDeleteProject = useCheckPermissions(PermissionAction.UPDATE, 'projects', { + resource: { project_id: project?.id }, + }) + const { data } = useDownloadableBackupQuery({ projectRef: ref }) const backups = data?.backups ?? [] @@ -90,9 +90,18 @@ const PauseFailedState = () => {
diff --git a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx index 6ba94a4833b12..bf20923f15779 100644 --- a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx @@ -1,20 +1,16 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Download, MoreVertical, Trash } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { useParams } from 'common' -import DeleteProjectModal from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal' +import { DeleteProjectModal } from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import { useDownloadableBackupQuery } from 'data/database/backup-query' -import { - Button, - CriticalIcon, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from 'ui' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { Button, CriticalIcon, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui' import { useProjectContext } from './ProjectContext' const RestoreFailedState = () => { @@ -22,6 +18,10 @@ const RestoreFailedState = () => { const { project } = useProjectContext() const [visible, setVisible] = useState(false) + const canDeleteProject = useCheckPermissions(PermissionAction.UPDATE, 'projects', { + resource: { project_id: project?.id }, + }) + const { data } = useDownloadableBackupQuery({ projectRef: ref }) const backups = data?.backups ?? [] @@ -90,9 +90,18 @@ const RestoreFailedState = () => {