feat(billing): cancel flow with reason capture + book-a-call (#1077)

Co-authored-by: ved015 <vedant.04.mahajan@gmail.com>
This commit is contained in:
Mahesh Sanikommu 2026-06-10 01:04:33 -07:00 committed by GitHub
parent 9ab2e7e64f
commit 9a1bab7182
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 254 additions and 80 deletions

View file

@ -11,8 +11,15 @@ import {
DialogContent,
DialogTrigger,
} from "@ui/components/dialog"
import { Logo } from "@ui/assets/Logo"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useCustomer } from "autumn-js/react"
import { usePostHog } from "@lib/posthog"
import {
CANCEL_REASONS,
cancelReasonNeedsDetail,
type CancelReasonValue,
} from "./cancel-reasons"
import {
Check,
ChevronLeft,
@ -31,6 +38,38 @@ import { toast } from "sonner"
const API_BASE =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min"
function GoogleMeetIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 87.5 72"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fill="#00832d"
d="M49.5 36l8.53 9.75 11.47 7.33 2-17.02-2-16.64-11.69 6.44z"
/>
<path
fill="#0066da"
d="M0 51.5V66c0 3.315 2.685 6 6 6h14.5l3-10.96-3-9.54-9.95-3z"
/>
<path fill="#e94235" d="M20.5 0L0 20.5l10.55 3 9.95-3 2.95-9.41z" />
<path fill="#2684fc" d="M20.5 20.5H0v31h20.5z" />
<path
fill="#00ac47"
d="M82.6 8.68L69.5 19.42v33.66l13.16 10.79c1.97 1.54 4.85.135 4.85-2.37V11c0-2.535-2.945-3.925-4.91-2.32zM49.5 36v15.5h-29V72h43c3.315 0 6-2.685 6-6V53.08z"
/>
<path
fill="#ffba00"
d="M63.5 0h-43v20.5h29V36l20-16.57V6c0-3.315-2.685-6-6-6z"
/>
</svg>
)
}
const CREDIT_FEATURE_ID = "usd_credits"
const TOP_UP_PLAN_ID = "credits_topup"
const TOP_UP_AMOUNTS = [10, 25, 50, 100] as const
@ -437,11 +476,15 @@ export default function Billing() {
const queryClient = useQueryClient()
const { user, org } = useAuth()
const autumn = useCustomer()
const posthog = usePostHog()
const [isUpgrading, setIsUpgrading] = useState(false)
const [isCancelling, setIsCancelling] = useState(false)
const [isResuming, setIsResuming] = useState(false)
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false)
const [cancelConfirmText, setCancelConfirmText] = useState("")
const [cancelReason, setCancelReason] = useState<CancelReasonValue | null>(
null,
)
const [cancelDetail, setCancelDetail] = useState("")
const [isCreditsDialogOpen, setIsCreditsDialogOpen] = useState(false)
const [isPlanCarouselActive, setIsPlanCarouselActive] = useState(false)
const [planPage, setPlanPage] = useState<0 | 1>(0)
@ -578,16 +621,11 @@ export default function Billing() {
? (`api_${currentPlan}` as const)
: null
const currentPlanCard = [...PLAN_CARDS, ...ADVANCED_PLAN_CARDS].find(
(p) => p.id === currentPlan,
)
const cancelLossItems = currentPlanCard
? [
`${currentPlanCard.credits}/mo included credits`,
...currentPlanCard.features.filter((f) => !/credit/i.test(f)),
]
: []
const canConfirmCancel = cancelConfirmText.trim().toUpperCase() === "CANCEL"
const cancelNeedsDetail =
cancelReason != null && cancelReasonNeedsDetail(cancelReason)
const canConfirmCancel =
cancelReason != null &&
(!cancelNeedsDetail || cancelDetail.trim().length > 0)
const canceledSub = getCanceledSubscription(autumn.data?.subscriptions)
const isPlanCanceling = canceledSub != null
@ -627,6 +665,11 @@ export default function Billing() {
}
}
const resetCancelForm = () => {
setCancelReason(null)
setCancelDetail("")
}
const handleCancelSubscription = async () => {
if (!cancellablePlanId) return
setIsCancelling(true)
@ -635,9 +678,18 @@ export default function Billing() {
planId: cancellablePlanId,
cancelAction: "cancel_end_of_cycle",
})
if (posthog?.__loaded) {
posthog.capture("subscription_cancelled", {
reason: cancelReason,
reason_detail: cancelDetail.trim() || null,
plan: currentPlan,
plan_id: cancellablePlanId,
surface: "nova",
})
}
autumn.refetch?.()
setIsCancelDialogOpen(false)
setCancelConfirmText("")
resetCancelForm()
toast.success(
`Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`,
)
@ -934,7 +986,7 @@ export default function Billing() {
open={isCancelDialogOpen}
onOpenChange={(open) => {
setIsCancelDialogOpen(open)
if (!open) setCancelConfirmText("")
if (!open) resetCancelForm()
}}
>
<DialogTrigger asChild>
@ -950,7 +1002,7 @@ export default function Billing() {
</DialogTrigger>
<DialogContent
showCloseButton={false}
className="w-[min(420px,calc(100vw-32px))] rounded-[18px] border border-white/10 bg-[#14161A] p-5 shadow-[0px_16px_60px_rgba(0,0,0,0.55)]"
className="w-[min(760px,calc(100vw-32px))] rounded-[18px] border border-white/10 bg-[#14161A] p-5 shadow-[0px_16px_60px_rgba(0,0,0,0.55)] sm:max-w-[760px]"
>
<div className="flex items-start justify-between gap-4">
<div>
@ -985,74 +1037,177 @@ export default function Billing() {
</button>
</DialogClose>
</div>
{cancelLossItems.length > 0 ? (
<div className="mt-4 rounded-[10px] border border-white/[0.06] bg-[#0D121A] p-3.5">
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.18em] text-[#737373]">
You'll lose
</p>
<ul className="mt-2.5 flex flex-col gap-1.5">
{cancelLossItems.map((item) => (
<li
className="flex items-start gap-2 text-[13px] leading-snug text-[#C8D0DA]"
key={item}
<div className="@container">
<div className="grid gap-4 @xl:grid-cols-[1fr_auto_1fr] @xl:gap-5">
<div className="relative flex flex-col overflow-hidden rounded-[14px] border border-[#1E4FA0]/25 bg-gradient-to-b from-[#121826] to-[#0C0E13] p-5">
<Logo className="pointer-events-none absolute -right-8 -bottom-10 z-0 h-56 w-auto opacity-[0.03]" />
<div className="relative z-10 flex flex-1 flex-col">
<div className="flex items-center gap-2.5">
<GoogleMeetIcon className="h-[18px] w-auto" />
<p
className={cn(
dmSans125ClassName(),
"text-[15px] font-semibold tracking-[-0.01em] text-white",
)}
>
Talk to us first
</p>
</div>
<p
className={cn(
dmSans125ClassName(),
"mt-3 text-[13px] leading-relaxed text-[#AEBCD8]",
)}
>
<X className="mt-0.5 size-3.5 shrink-0 text-[#C73B1B]" />
<span>{item}</span>
</li>
))}
</ul>
Give us 15 minutes before you go a quick call
with the team where we'll:
</p>
<ul className="mt-3 flex flex-col gap-2.5">
{[
"Fix anything that's not working for you",
"Help you integrate Supermemory the right way",
"Get a discount to stay",
].map((benefit) => (
<li
className={cn(
dmSans125ClassName(),
"flex items-start gap-2 text-[13px] leading-snug text-[#D4DEEE]",
)}
key={benefit}
>
<Check
className="mt-0.5 size-[15px] shrink-0 text-[#3FD37E]"
strokeWidth={2.5}
/>
<span>{benefit}</span>
</li>
))}
</ul>
<a
href={BOOK_CALL_HREF}
target="_blank"
rel="noreferrer"
onClick={() => {
if (posthog?.__loaded) {
posthog.capture(
"cancel_book_call_clicked",
{
plan: currentPlan,
surface: "nova",
},
)
}
}}
className={cn(
dmSans125ClassName(),
"group mt-auto inline-flex h-10 items-center justify-center gap-1.5 rounded-[10px] bg-[#0054AD] px-4 text-[13px] font-semibold text-white transition-colors hover:bg-[#0B65C9]",
)}
>
Book a call
<ChevronRight className="size-4 transition-transform group-hover:translate-x-0.5" />
</a>
</div>
</div>
<div className="flex items-center justify-center">
<span className="rounded-full px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[#737373]">
or
</span>
</div>
<div className="flex flex-1 flex-col rounded-[14px] border border-white/10 bg-gradient-to-b from-[#12151B] to-[#0C0E13] p-5">
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.18em] text-[#737373]">
Why are you leaving?
</p>
<div className="mt-2 flex flex-col gap-0.5">
{CANCEL_REASONS.map((option) => {
const selected = cancelReason === option.value
return (
<button
key={option.value}
type="button"
onClick={() =>
setCancelReason(option.value)
}
className={cn(
dmSans125ClassName(),
"flex items-center gap-2.5 rounded-[8px] px-2 py-1.5 text-left text-[13px] transition-colors",
selected
? "text-[#FAFAFA]"
: "text-[#A3A3A3] hover:bg-white/[0.03]",
)}
>
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full border",
selected
? "border-[#0054AD]"
: "border-white/25",
)}
>
{selected ? (
<span className="size-2 rounded-full bg-[#0054AD]" />
) : null}
</span>
{option.label}
</button>
)
})}
</div>
{cancelReason !== null ? (
<div className="mt-3">
<label
className={cn(
dmSans125ClassName(),
"text-[13px] text-[#A3A3A3]",
)}
htmlFor="cancel-detail"
>
{cancelNeedsDetail
? "Tell us more (required)"
: "Anything else? (optional)"}
</label>
<textarea
className="mt-2 min-h-[72px] w-full resize-none rounded-[9px] border border-white/10 bg-[#0D121A] px-3 py-2 text-[13px] text-[#FAFAFA] outline-none placeholder:text-[#3F4651] focus:border-white/25"
id="cancel-detail"
onChange={(e) =>
setCancelDetail(e.target.value)
}
placeholder="What could we have done better?"
value={cancelDetail}
/>
</div>
) : null}
<div className="mt-auto pt-5">
<DialogClose asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"h-9 w-full text-[13px] font-medium text-[#A3A3A3] transition-colors hover:text-[#FAFAFA]",
)}
>
Keep plan
</button>
</DialogClose>
<button
type="button"
onClick={() => void handleCancelSubscription()}
disabled={isCancelling || !canConfirmCancel}
className={cn(
dmSans125ClassName(),
"mt-1.5 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] border border-[#C73B1B]/25 bg-[#290F0A] text-[13px] font-semibold text-[#E5604A] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{isCancelling ? (
<LoaderIcon className="size-3.5 animate-spin" />
) : null}
Cancel subscription
</button>
</div>
</div>
</div>
) : null}
<div className="mt-4">
<label
className={cn(
dmSans125ClassName(),
"text-[13px] text-[#A3A3A3]",
)}
htmlFor="cancel-confirm"
>
Type{" "}
<span className="font-semibold text-[#FAFAFA]">
CANCEL
</span>{" "}
to confirm
</label>
<input
autoComplete="off"
className="mt-2 h-9 w-full rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] text-[#FAFAFA] outline-none placeholder:text-[#3F4651] focus:border-white/25"
id="cancel-confirm"
onChange={(e) => setCancelConfirmText(e.target.value)}
placeholder="CANCEL"
type="text"
value={cancelConfirmText}
/>
</div>
<div className="mt-5 flex items-center justify-end gap-3">
<DialogClose asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"h-9 px-3 text-[13px] font-medium text-[#A3A3A3] transition-colors hover:text-[#FAFAFA]",
)}
>
Keep plan
</button>
</DialogClose>
<button
type="button"
onClick={() => void handleCancelSubscription()}
disabled={isCancelling || !canConfirmCancel}
className={cn(
dmSans125ClassName(),
"inline-flex h-9 items-center gap-2 rounded-[9px] bg-[#290F0A] px-3 text-[13px] font-medium text-[#C73B1B] transition-opacity disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{isCancelling ? (
<LoaderIcon className="size-3.5 animate-spin" />
) : null}
Cancel subscription
</button>
</div>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,19 @@
export const CANCEL_REASONS = [
{ value: "too_expensive", label: "Too expensive" },
{ value: "missing_features", label: "Missing features I need" },
{ value: "switching", label: "Found a better alternative" },
{ value: "not_using", label: "Not using it enough" },
{ value: "other", label: "Other" },
] as const
export type CancelReasonValue = (typeof CANCEL_REASONS)[number]["value"]
const NEEDS_DETAIL: CancelReasonValue[] = [
"missing_features",
"switching",
"other",
]
export function cancelReasonNeedsDetail(value: CancelReasonValue): boolean {
return NEEDS_DETAIL.includes(value)
}