Skip to content

Commit 48cdd3b

Browse files
committed
🚸 Add confirm dialog before plan upgrade with existing customer
1 parent 65a698a commit 48cdd3b

File tree

13 files changed

+443
-60
lines changed

13 files changed

+443
-60
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { getSubscriptionPreview as getSubscriptionPreviewHandler } from "@typebot.io/billing/api/getSubscriptionPreview";
2+
import { Plan } from "@typebot.io/prisma/enum";
3+
import { z } from "@typebot.io/zod";
4+
import { authenticatedProcedure } from "@/helpers/server/trpc";
5+
6+
export const getSubscriptionPreview = authenticatedProcedure
7+
.meta({
8+
openapi: {
9+
method: "GET",
10+
path: "/v1/billing/subscription/preview",
11+
protect: true,
12+
summary: "Get subscription upgrade preview",
13+
tags: ["Billing"],
14+
},
15+
})
16+
.input(
17+
z.object({
18+
workspaceId: z.string(),
19+
plan: z.enum([Plan.STARTER, Plan.PRO]),
20+
}),
21+
)
22+
.output(
23+
z.object({
24+
amountDue: z.number(),
25+
currency: z.enum(["usd", "eur"]),
26+
}),
27+
)
28+
.query(async ({ input, ctx: { user } }) =>
29+
getSubscriptionPreviewHandler({
30+
...input,
31+
user,
32+
}),
33+
);

apps/builder/src/features/billing/api/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createCheckoutSession } from "./createCheckoutSession";
33
import { createCustomCheckoutSession } from "./createCustomCheckoutSession";
44
import { getBillingPortalUrl } from "./getBillingPortalUrl";
55
import { getSubscription } from "./getSubscription";
6+
import { getSubscriptionPreview } from "./getSubscriptionPreview";
67
import { getUsage } from "./getUsage";
78
import { listInvoices } from "./listInvoices";
89
import { updateSubscription } from "./updateSubscription";
@@ -13,6 +14,7 @@ export const billingRouter = router({
1314
createCheckoutSession,
1415
updateSubscription,
1516
getSubscription,
17+
getSubscriptionPreview,
1618
getUsage,
1719
createCustomCheckoutSession,
1820
});

apps/builder/src/features/billing/api/updateSubscription.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,21 @@ export const updateSubscription = authenticatedProcedure
2222
}),
2323
)
2424
.output(
25-
z.object({
26-
workspace: workspaceSchema.nullish(),
27-
checkoutUrl: z.string().nullish(),
28-
}),
25+
z.discriminatedUnion("type", [
26+
z.object({
27+
type: z.literal("success"),
28+
workspace: workspaceSchema,
29+
}),
30+
z.object({
31+
type: z.literal("error"),
32+
title: z.string(),
33+
description: z.string().nullish(),
34+
}),
35+
z.object({
36+
type: z.literal("checkoutUrl"),
37+
checkoutUrl: z.string(),
38+
}),
39+
]),
2940
)
3041
.mutation(async ({ input, ctx: { user } }) =>
3142
updateSubscriptionHandler({

apps/builder/src/features/billing/components/ChangePlanForm.tsx

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { PreCheckoutDialogProps } from "./PreCheckoutDialog";
1313
import { PreCheckoutDialog } from "./PreCheckoutDialog";
1414
import { ProPlanPricingCard } from "./ProPlanPricingCard";
1515
import { StarterPlanPricingCard } from "./StarterPlanPricingCard";
16+
import { UpgradeConfirmationDialog } from "./UpgradeConfirmationDialog";
1617

1718
type Props = {
1819
workspace: WorkspaceInApp;
@@ -30,15 +31,24 @@ export const ChangePlanForm = ({
3031
const { user } = useUser();
3132
const [preCheckoutPlan, setPreCheckoutPlan] =
3233
useState<PreCheckoutDialogProps["selectedSubscription"]>();
34+
const [pendingUpgrade, setPendingUpgrade] = useState<"STARTER" | "PRO">();
3335

3436
const { data, refetch } = useSubscriptionQuery(workspace.id);
3537

36-
const { mutate: updateSubscription, status: updateSubscriptionStatus } =
38+
const { mutateAsync: updateSubscription, status: updateSubscriptionStatus } =
3739
useMutation(
3840
trpc.billing.updateSubscription.mutationOptions({
39-
onSuccess: ({ workspace, checkoutUrl }) => {
40-
if (checkoutUrl) {
41-
window.location.href = checkoutUrl;
41+
onSuccess: (data) => {
42+
if (data.type === "checkoutUrl") {
43+
window.location.href = data.checkoutUrl;
44+
return;
45+
}
46+
if (data.type === "error") {
47+
toast({
48+
type: "error",
49+
title: data.title,
50+
description: data.description ?? undefined,
51+
});
4252
return;
4353
}
4454
refetch();
@@ -50,7 +60,7 @@ export const ChangePlanForm = ({
5060
toast({
5161
type: "success",
5262
description: t("billing.updateSuccessToast.description", {
53-
plan: workspace?.plan,
63+
plan: pendingUpgrade,
5464
}),
5565
});
5666
},
@@ -65,15 +75,29 @@ export const ChangePlanForm = ({
6575
workspaceId: workspace.id,
6676
} as const;
6777
if (workspace.stripeId) {
68-
updateSubscription({
69-
...newSubscription,
70-
returnUrl: window.location.href,
71-
});
78+
const isUpgrade = isUpgradingPlan(workspace.plan, plan);
79+
if (isUpgrade) {
80+
setPendingUpgrade(plan);
81+
} else {
82+
updateSubscription({
83+
...newSubscription,
84+
returnUrl: window.location.href,
85+
});
86+
}
7287
} else {
7388
setPreCheckoutPlan(newSubscription);
7489
}
7590
};
7691

92+
const handleConfirmUpgrade = async () => {
93+
if (!pendingUpgrade) return;
94+
await updateSubscription({
95+
plan: pendingUpgrade,
96+
workspaceId: workspace.id,
97+
returnUrl: window.location.href,
98+
});
99+
};
100+
77101
if (
78102
data?.subscription?.cancelDate ||
79103
data?.subscription?.status === "past_due"
@@ -113,9 +137,16 @@ export const ChangePlanForm = ({
113137
onClose={() => setPreCheckoutPlan(undefined)}
114138
/>
115139
)}
140+
<UpgradeConfirmationDialog
141+
isOpen={!!pendingUpgrade}
142+
workspaceId={workspace.id}
143+
targetPlan={pendingUpgrade}
144+
onConfirm={handleConfirmUpgrade}
145+
onClose={() => setPendingUpgrade(undefined)}
146+
/>
116147
{data && (
117148
<div className="flex flex-col items-end gap-6">
118-
<div className="flex items-center items-stretch gap-4 w-full">
149+
<div className="flex items-stretch gap-4 w-full">
119150
{excludedPlans?.includes("STARTER") ? null : (
120151
<StarterPlanPricingCard
121152
currentPlan={workspace.plan}
@@ -145,3 +176,16 @@ export const ChangePlanForm = ({
145176
</div>
146177
);
147178
};
179+
180+
const isUpgradingPlan = (
181+
currentPlan: Plan,
182+
targetPlan: "STARTER" | "PRO",
183+
): boolean => {
184+
if (currentPlan === Plan.FREE) {
185+
return targetPlan === Plan.STARTER || targetPlan === Plan.PRO;
186+
}
187+
if (currentPlan === Plan.STARTER) {
188+
return targetPlan === Plan.PRO;
189+
}
190+
return false;
191+
};

apps/builder/src/features/billing/components/PreCheckoutDialog.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,13 @@ export const PreCheckoutDialog = ({
101101
const vatType = taxIdTypes.find(
102102
(taxIdType) => taxIdType.code === vat.code,
103103
)?.type;
104-
if (!vatType) throw new Error("Could not find VAT type");
105104
createCheckoutSession({
106105
...selectedSubscription,
107106
email,
108107
company,
109108
returnUrl: window.location.href,
110109
vat:
111-
vat.value && vat.code ? { type: vatType, value: vat.value } : undefined,
110+
vatType && vat.value ? { type: vatType, value: vat.value } : undefined,
112111
});
113112
};
114113

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { useTranslate } from "@tolgee/react";
3+
import { formatPrice } from "@typebot.io/billing/helpers/formatPrice";
4+
import { isDefined } from "@typebot.io/lib/utils";
5+
import { AlertDialog } from "@typebot.io/ui/components/AlertDialog";
6+
import { Button } from "@typebot.io/ui/components/Button";
7+
import { LoaderCircleIcon } from "@typebot.io/ui/icons/LoaderCircleIcon";
8+
import { useRef, useState } from "react";
9+
import { trpc } from "@/lib/queryClient";
10+
11+
type Props = {
12+
isOpen: boolean;
13+
workspaceId: string;
14+
targetPlan: "STARTER" | "PRO" | undefined;
15+
onConfirm: () => Promise<unknown> | unknown;
16+
onClose: () => void;
17+
};
18+
19+
export const UpgradeConfirmationDialog = ({
20+
isOpen,
21+
workspaceId,
22+
targetPlan,
23+
onConfirm,
24+
onClose,
25+
}: Props) => {
26+
const { t } = useTranslate();
27+
const [confirmLoading, setConfirmLoading] = useState(false);
28+
const cancelRef = useRef<HTMLButtonElement | null>(null);
29+
30+
const { data: preview, isLoading: isLoadingPreview } = useQuery(
31+
trpc.billing.getSubscriptionPreview.queryOptions(
32+
{
33+
workspaceId,
34+
plan: targetPlan!,
35+
},
36+
{
37+
enabled: isOpen && isDefined(targetPlan),
38+
},
39+
),
40+
);
41+
42+
const onConfirmClick = async () => {
43+
setConfirmLoading(true);
44+
try {
45+
await onConfirm();
46+
} catch (_e) {
47+
setConfirmLoading(false);
48+
return;
49+
}
50+
setConfirmLoading(false);
51+
onClose();
52+
};
53+
54+
return (
55+
<AlertDialog.Root isOpen={isOpen} onClose={onClose}>
56+
<AlertDialog.Popup initialFocus={cancelRef}>
57+
<AlertDialog.Title>
58+
{t("billing.upgradeModal.title", { plan: targetPlan })}
59+
</AlertDialog.Title>
60+
<div className="flex flex-col gap-4">
61+
{isLoadingPreview ? (
62+
<div className="flex items-center gap-2">
63+
<LoaderCircleIcon className="animate-spin" />
64+
<p>{t("billing.upgradeModal.loading")}</p>
65+
</div>
66+
) : preview ? (
67+
<div className="flex flex-col gap-2">
68+
<p>
69+
{t("billing.upgradeModal.description", {
70+
plan: targetPlan,
71+
})}
72+
</p>
73+
<div className="flex items-center gap-2 text-lg font-semibold">
74+
<span>{t("billing.upgradeModal.amountLabel")}:</span>
75+
<span>
76+
{formatPrice(preview.amountDue / 100, {
77+
currency: preview.currency,
78+
maxFractionDigits: 2,
79+
})}
80+
</span>
81+
</div>
82+
<p className="text-sm text-gray-500">
83+
{t("billing.upgradeModal.prorationNote")}
84+
</p>
85+
</div>
86+
) : null}
87+
</div>
88+
<AlertDialog.Footer>
89+
<AlertDialog.CloseButton variant="secondary" ref={cancelRef}>
90+
{t("cancel")}
91+
</AlertDialog.CloseButton>
92+
<Button
93+
variant="default"
94+
onClick={onConfirmClick}
95+
disabled={confirmLoading || isLoadingPreview}
96+
>
97+
{t("billing.upgradeModal.confirmButton")}
98+
</Button>
99+
</AlertDialog.Footer>
100+
</AlertDialog.Popup>
101+
</AlertDialog.Root>
102+
);
103+
};

apps/builder/src/features/workspace/WorkspaceProvider.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,9 @@ export const WorkspaceProvider = ({
181181
if (!workspaces) return;
182182
const name = parseNewName(userFullName, workspaces);
183183
const { workspace } = await createWorkspaceMutation.mutateAsync({ name });
184-
setWorkspaceId(workspace.id);
184+
setTimeout(() => {
185+
switchWorkspace(workspace.id);
186+
}, 1000);
185187
};
186188

187189
const updateWorkspace = (updates: WorkspaceUpdateProps) => {

apps/builder/src/i18n/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@
109109
"billing.pricingCard.upgradeButton.current": "Your current plan",
110110
"billing.tiersModal.heading": "Chats pricing table",
111111
"billing.updateSuccessToast.description": "Workspace {plan} plan successfully updated",
112+
"billing.upgradeModal.amountLabel": "Amount to pay now",
113+
"billing.upgradeModal.confirmButton": "Confirm upgrade",
114+
"billing.upgradeModal.description": "You are about to upgrade to the {plan} plan.",
115+
"billing.upgradeModal.loading": "Calculating price...",
116+
"billing.upgradeModal.prorationNote": "This amount is prorated based on your current billing cycle.",
117+
"billing.upgradeModal.title": "Upgrade to {plan}",
112118
"billing.upgradeLimitLabel": "You need to upgrade your plan in order to {type}",
113119
"billing.usage.chats.alert.soonReach": "Your typebots are popular! You will soon reach your plan's chats limit.",
114120
"billing.usage.chats.alert.updatePlan": "Make sure to update your plan to increase this limit and continue chatting with your users.",

0 commit comments

Comments
 (0)