Skip to content

Instantly share code, notes, and snippets.

@mmikhan
Created February 21, 2025 19:29
Show Gist options
  • Save mmikhan/7836ddf3e5c9c612da0f2042f9dd0162 to your computer and use it in GitHub Desktop.
Save mmikhan/7836ddf3e5c9c612da0f2042f9dd0162 to your computer and use it in GitHub Desktop.
Stripe plan upgrade downgrade dialog with Hook form implementation
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Form } from "@/components/ui/form";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ToastAction } from "@/components/ui/toast";
import { toast } from "@/hooks/use-toast";
import { api } from "@/trpc/react";
import { TRPCClientError } from "@trpc/client";
import { Loader2, Package } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
interface FormValues {
productId: string;
}
export default function PlanUpgradeDowngradeDialog() {
const form = useForm<FormValues>();
const utils = api.useUtils();
const router = useRouter();
const [open, setOpen] = useState(false);
const { data: products, isLoading: isLoadingProducts } =
api.products.active.useQuery();
const { data: currentBilling, isLoading: isLoadingBilling } =
api.payments.getCurrentBilling.useQuery();
const { mutate: changePlan, isPending: isChangingPlan } =
api.payments.changePlan.useMutation({
onSuccess: async () => {
setOpen(false);
await utils.payments.getCurrentBilling.invalidate();
toast({
title: "Success",
description: "Your plan has been updated successfully.",
});
},
onError: (error) => {
if (error instanceof TRPCClientError) {
toast({
title: "Error",
description: error.message,
variant: "destructive",
action: <ToastAction altText="Try again">Try again</ToastAction>,
});
}
},
});
const { mutate: checkout, isPending: isCheckingOut } =
api.payments.createCheckout.useMutation({
onSuccess: async ({ url, type }) => {
if (type === "free") {
await utils.payments.getCurrentBilling.invalidate();
setOpen(false);
return;
}
router.push(url);
},
onError: (error) => {
if (error instanceof TRPCClientError) {
toast({
title: "Error",
description: error.message,
variant: "destructive",
action: <ToastAction altText="Try again">Try again</ToastAction>,
});
}
},
});
const onSubmit = (formData: FormValues) => {
if (
!currentBilling ||
currentBilling.product?.isFree ||
!currentBilling.providerId ||
products?.find((p) => p.id === formData.productId)?.isFree
) {
checkout({ productId: formData.productId });
return;
}
changePlan({
newProductId: formData.productId,
subscriptionId: currentBilling.providerId,
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={isLoadingProducts || isLoadingBilling}
>
{isLoadingProducts || isLoadingBilling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : !currentBilling?.providerId ? (
"Upgrade Plan"
) : (
"Change Plan"
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Change Your Plan</DialogTitle>
<DialogDescription>
Select a new plan to upgrade or downgrade your current subscription.
</DialogDescription>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4 py-4"
>
{!products?.length ? (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-8 text-center">
<Package className="h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 font-semibold">No Plans Available</h3>
<p className="mt-2 text-sm text-muted-foreground">
There are currently no plans available for subscription.
</p>
</div>
) : (
<RadioGroup
defaultValue={currentBilling?.productId}
className="space-y-4"
>
{products.map((product) => (
<div
key={product.id}
className={`rounded-lg border p-4 transition-colors ${
form.getValues().productId === product.id
? "border-primary bg-primary/5"
: "hover:border-primary/50"
} ${
currentBilling?.productId === product.id
? "border-primary/50 bg-muted"
: ""
}`}
>
<div className="flex items-center space-x-4">
<RadioGroupItem
value={product.id}
id={product.id}
{...form.register("productId")}
/>
<Label
htmlFor={product.id}
className="flex flex-1 cursor-pointer items-center justify-between"
>
<div>
<h3 className="font-medium">{product.name}</h3>
<p className="text-sm text-muted-foreground">
{product.description}
</p>
</div>
<div className="text-right">
<p className="font-medium">
${Number(product.price).toFixed(2)}
</p>
<p className="text-sm text-muted-foreground">
per {product.type}
</p>
</div>
</Label>
</div>
{currentBilling?.productId === product.id && (
<p className="mt-2 text-sm text-primary">Current Plan</p>
)}
</div>
))}
</RadioGroup>
)}
<div className="flex justify-end space-x-2">
<Button
type="button"
size={"sm"}
variant="ghost"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
size={"sm"}
variant={"outline"}
disabled={
isChangingPlan ||
isCheckingOut ||
!form.getValues().productId ||
form.getValues().productId === currentBilling?.productId ||
((products?.find((p) => p.id === form.getValues().productId)
?.isFree ??
false) &&
currentBilling?.status === "active")
}
>
{isChangingPlan || isCheckingOut ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{!currentBilling?.providerId
? "Upgrading..."
: "Changing..."}
</>
) : !currentBilling?.providerId ? (
"Upgrade"
) : (
"Confirm Change"
)}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment