Skip to content

Commit af0bf29

Browse files
authored
Merge pull request #389 from captableinc/fix-stripe-ux
feat: improve stripe billing ui/ux
2 parents 7f2ddb5 + 814ccb9 commit af0bf29

File tree

16 files changed

+457
-194
lines changed

16 files changed

+457
-194
lines changed

src/app/(authenticated)/(dashboard)/[publicId]/settings/billing/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Pricing } from "@/components/billing/pricing";
1+
import { PlanDetails } from "@/components/billing/plan-details";
22
import { PageLayout } from "@/components/dashboard/page-layout";
33
import { api } from "@/trpc/server";
44
import type { Metadata } from "next";
@@ -14,7 +14,7 @@ const BillingPage = async () => {
1414

1515
return (
1616
<PageLayout title="Billing" description="manage your billing">
17-
<Pricing products={products} subscription={subscription} />
17+
<PlanDetails products={products} subscription={subscription} />
1818
</PageLayout>
1919
);
2020
};

src/app/api/stripe/webhook/route.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,16 +84,20 @@ export async function POST(req: Request) {
8484
}
8585
} catch (error) {
8686
console.log(error);
87+
// We dealing with webhooks return a 200 to acknowledge receipt of the event
8788
return new Response(
8889
"Webhook handler failed. View your Next.js function logs.",
8990
{
90-
status: 400,
91+
status: 200,
9192
},
9293
);
9394
}
9495
} else {
96+
console.log(`❌ Unhandled event type: ${event.type}`);
97+
98+
// We dealing with webhooks return a 200 to acknowledge receipt of the event
9599
return new Response(`Unsupported event type: ${event.type}`, {
96-
status: 400,
100+
status: 200,
97101
});
98102
}
99103
return new Response(JSON.stringify({ received: true }));
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { dayjsExt } from "@/common/dayjs";
2+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
3+
import { badgeVariants } from "@/components/ui/badge";
4+
import { buttonVariants } from "@/components/ui/button";
5+
import Link from "next/link";
6+
import { PricingModal, type PricingModalProps } from "../pricing-modal";
7+
8+
interface PlanDetailsProps extends PricingModalProps {}
9+
10+
export function PlanDetails({ subscription, products }: PlanDetailsProps) {
11+
return (
12+
<div>
13+
<Alert>
14+
<AlertTitle>Plan Details</AlertTitle>
15+
<AlertDescription>
16+
<div className="flex flex-col gap-y-3">
17+
<p>
18+
You are currently on the{" "}
19+
<span className={badgeVariants()}>
20+
{subscription ? subscription.price.product.name : "Free"}
21+
</span>{" "}
22+
plan.{" "}
23+
{subscription ? (
24+
<>
25+
Current billing cycle:{" "}
26+
{dayjsExt(subscription?.currentPeriodStart).format("ll")} -{" "}
27+
{dayjsExt(subscription?.currentPeriodEnd).format("ll")}
28+
</>
29+
) : null}
30+
</p>
31+
32+
<div className="flex items-center justify-center">
33+
<Link
34+
className={buttonVariants({ variant: "secondary" })}
35+
href="?upgrade=true"
36+
>
37+
Upgrade or manage plans
38+
</Link>
39+
<PricingModal subscription={subscription} products={products} />
40+
</div>
41+
</div>
42+
</AlertDescription>
43+
</Alert>
44+
</div>
45+
);
46+
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"use client";
2+
3+
import { getStripeClient } from "@/client-only/stripe";
4+
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogHeader,
9+
DialogTitle,
10+
} from "@/components/ui/dialog";
11+
import type { PricingPlanInterval, PricingType } from "@/prisma/enums";
12+
import { api } from "@/trpc/react";
13+
import type { TypeZodStripePortalMutationSchema } from "@/trpc/routers/billing-router/schema";
14+
import type { RouterOutputs } from "@/trpc/shared";
15+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
16+
import { useEffect, useState } from "react";
17+
import { EmptyPlans } from "./empty-plans";
18+
import { PricingButton } from "./pricing-button";
19+
import { PricingCard } from "./pricing-card";
20+
21+
type Products = RouterOutputs["billing"]["getProducts"]["products"];
22+
type TSubscription =
23+
RouterOutputs["billing"]["getSubscription"]["subscription"];
24+
25+
interface PricingProps {
26+
products: Products;
27+
subscription: TSubscription;
28+
}
29+
30+
interface handleStripeCheckoutOptions {
31+
priceId: string;
32+
priceType: PricingType;
33+
}
34+
35+
function Plans({ products, subscription }: PricingProps) {
36+
const router = useRouter();
37+
const intervals = Array.from(
38+
new Set(
39+
products.flatMap((product) =>
40+
product?.prices?.map((price) => price?.interval),
41+
),
42+
),
43+
);
44+
const [billingInterval, setBillingInterval] =
45+
useState<PricingPlanInterval>("month");
46+
47+
const { mutateAsync: checkoutWithStripe, isLoading: checkoutLoading } =
48+
api.billing.checkout.useMutation({
49+
onSuccess: async ({ stripeSessionId }) => {
50+
const stripe = await getStripeClient();
51+
await stripe?.redirectToCheckout({ sessionId: stripeSessionId });
52+
},
53+
});
54+
55+
const { mutateAsync: stripePortal, isLoading: stripePortalLoading } =
56+
api.billing.stripePortal.useMutation({
57+
onSuccess: ({ url }) => {
58+
router.push(url);
59+
},
60+
});
61+
62+
const handleBilling = (interval: PricingPlanInterval) => {
63+
setBillingInterval(interval);
64+
};
65+
66+
const handleStripeCheckout = async (price: handleStripeCheckoutOptions) => {
67+
await checkoutWithStripe(price);
68+
};
69+
70+
const handleBillingPortal = async (
71+
data: TypeZodStripePortalMutationSchema,
72+
) => {
73+
await stripePortal(data);
74+
};
75+
76+
const isSubmitting = checkoutLoading || stripePortalLoading;
77+
78+
return (
79+
<div className="flex flex-col items-center justify-center">
80+
<div className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
81+
{intervals.includes("month") && (
82+
<PricingButton
83+
onClick={() => handleBilling("month")}
84+
active={billingInterval === "month"}
85+
label="monthly"
86+
/>
87+
)}
88+
{intervals.includes("year") && (
89+
<PricingButton
90+
onClick={() => handleBilling("year")}
91+
active={billingInterval === "year"}
92+
label="yearly"
93+
/>
94+
)}
95+
</div>
96+
97+
<section className="flex flex-col sm:flex-row sm:flex-wrap gap-8 pt-8">
98+
<PricingCard
99+
title="Free"
100+
description=""
101+
price="$0"
102+
interval="month"
103+
subscribedUnitAmount={subscription?.price.unitAmount}
104+
unitAmount={0}
105+
isSubmitting={isSubmitting}
106+
{...(subscription && {
107+
handleClick: () => {
108+
return handleBillingPortal({
109+
type: "cancel",
110+
subscription: subscription.id,
111+
});
112+
},
113+
})}
114+
/>
115+
{products.map((product) => {
116+
const price = product?.prices?.find(
117+
(price) => price.interval === billingInterval,
118+
);
119+
if (!price) return null;
120+
121+
const unitAmount = Number(price?.unitAmount) ?? 0;
122+
123+
const priceString = new Intl.NumberFormat("en-US", {
124+
style: "currency",
125+
currency: price.currency,
126+
minimumFractionDigits: 0,
127+
}).format(unitAmount / 100);
128+
129+
const active = subscription?.priceId === price.id;
130+
return (
131+
<PricingCard
132+
key={product.id}
133+
title={product.name}
134+
description={product.description}
135+
price={priceString}
136+
interval={billingInterval}
137+
handleClick={() => {
138+
if (subscription) {
139+
return handleBillingPortal({
140+
...(active
141+
? {
142+
type: "cancel",
143+
subscription: subscription.id,
144+
}
145+
: {
146+
type: "update",
147+
subscription: subscription.id,
148+
}),
149+
});
150+
}
151+
return handleStripeCheckout({
152+
priceId: price.id,
153+
priceType: price.type,
154+
});
155+
}}
156+
subscribedUnitAmount={subscription?.price.unitAmount}
157+
unitAmount={unitAmount}
158+
isSubmitting={isSubmitting}
159+
/>
160+
);
161+
})}
162+
</section>
163+
</div>
164+
);
165+
}
166+
167+
export function Pricing({ products, subscription }: PricingProps) {
168+
return products.length ? (
169+
<Plans products={products} subscription={subscription} />
170+
) : (
171+
<EmptyPlans />
172+
);
173+
}
174+
175+
export type PricingModalProps = PricingProps;
176+
177+
export function PricingModal({ products, subscription }: PricingModalProps) {
178+
const [open, setOpen] = useState(false);
179+
const router = useRouter();
180+
const searchParams = useSearchParams();
181+
const path = usePathname();
182+
const upgrade = searchParams.get("upgrade");
183+
184+
useEffect(() => {
185+
const isOpen = upgrade === "true";
186+
if (isOpen) {
187+
setOpen(true);
188+
}
189+
return () => {
190+
setOpen(false);
191+
};
192+
}, [upgrade]);
193+
194+
return (
195+
<Dialog
196+
open={open}
197+
onOpenChange={() => {
198+
router.push(path);
199+
}}
200+
>
201+
<DialogContent className="max-w-[95vw]">
202+
<DialogHeader className="flex items-center justify-center">
203+
<DialogTitle>Upgrade or manage plans</DialogTitle>
204+
</DialogHeader>
205+
<div className="overflow-scroll no-scrollbar max-h-[80vh]">
206+
<Pricing products={products} subscription={subscription} />
207+
</div>
208+
</DialogContent>
209+
</Dialog>
210+
);
211+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { Button, type ButtonProps } from "@/components/ui/button";
2+
import {
3+
Card,
4+
CardDescription,
5+
CardFooter,
6+
CardHeader,
7+
CardTitle,
8+
} from "@/components/ui/card";
9+
import { cn } from "@/lib/utils";
10+
import type { PricingPlanInterval } from "@/prisma/enums";
11+
import { useState } from "react";
12+
13+
interface PricingCardProps {
14+
title: string;
15+
description?: string | null;
16+
price: string;
17+
interval: PricingPlanInterval;
18+
subscribedUnitAmount?: bigint | null;
19+
unitAmount: number;
20+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
21+
handleClick?: () => Promise<any>;
22+
isSubmitting: boolean;
23+
}
24+
25+
const humanizedInterval: Record<PricingPlanInterval, string> = {
26+
day: "Daily",
27+
month: "Monthly",
28+
week: "Weekly",
29+
year: "Yearly",
30+
};
31+
32+
export function PricingCard({
33+
description,
34+
title,
35+
interval,
36+
price,
37+
subscribedUnitAmount: subscribedUnitAmount_,
38+
unitAmount,
39+
handleClick,
40+
isSubmitting,
41+
}: PricingCardProps) {
42+
const [isLoading, setIsLoading] = useState(false);
43+
const subscribedUnitAmount = subscribedUnitAmount_
44+
? Number(subscribedUnitAmount_)
45+
: null;
46+
47+
const active = unitAmount === subscribedUnitAmount;
48+
49+
return (
50+
<Card>
51+
<CardHeader>
52+
<CardTitle className="text-lg">{title}</CardTitle>
53+
<div className="flex gap-0.5">
54+
<h3 className="text-3xl font-bold">{price}</h3>
55+
{unitAmount !== 0 && (
56+
<span className="flex flex-col justify-end text-sm mb-1">
57+
/{humanizedInterval[interval]}
58+
</span>
59+
)}
60+
</div>
61+
<CardDescription>{description}</CardDescription>
62+
</CardHeader>
63+
<CardFooter>
64+
<Button
65+
className={cn(!active && "bg-teal-500 hover:bg-teal-500/80")}
66+
{...(active && { variant: "destructive" })}
67+
onClick={async () => {
68+
if (handleClick) {
69+
setIsLoading(true);
70+
await handleClick();
71+
setIsLoading(false);
72+
}
73+
}}
74+
loading={isLoading}
75+
{...(!isLoading && { disabled: isSubmitting })}
76+
{...(unitAmount === 0 && !subscribedUnitAmount && { disabled: true })}
77+
>
78+
{subscribedUnitAmount
79+
? unitAmount < subscribedUnitAmount
80+
? "Downgrade Plan"
81+
: unitAmount > subscribedUnitAmount
82+
? "Upgrade Plan"
83+
: "Cancel Current Plan"
84+
: !subscribedUnitAmount && unitAmount === 0
85+
? "Active plan"
86+
: "Subscribe"}
87+
</Button>
88+
</CardFooter>
89+
</Card>
90+
);
91+
}

0 commit comments

Comments
 (0)