Skip to content

Commit

Permalink
Implement support for customer portal (#201)
Browse files Browse the repository at this point in the history
* Implement support for customer portal

* yay types

* Border for pricing table
  • Loading branch information
wilkes-stripe authored Dec 3, 2024
1 parent 3138a4e commit 74d7dd8
Show file tree
Hide file tree
Showing 5 changed files with 126 additions and 23 deletions.
18 changes: 11 additions & 7 deletions app/(dashboard)/billing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import Stripe from '@stripe/stripe';
import {SubscriptionPortalWidget} from '@/app/components//SubscriptionPortalWidget';
import {SubscriptionNextBillWidget} from '@/app/components/SubscriptionNextBillWidget';
import {useQueries} from 'react-query';
import {useRouter} from 'next/router';
import {usePathname, useSearchParams} from 'next/navigation';
import EmbeddedComponentContainer from '@/app/components/EmbeddedComponentContainer';

const PRICING_TABLE_LIGHTMODE = 'prctbl_1QPsgcPohO0XT1fpB7GNfR0w';
const PRICING_TABLE_DARKMODE = 'prctbl_1QReWBPohO0XT1fppR505n6b';
Expand All @@ -30,12 +30,16 @@ const StripePricingTable = ({
};
}, []);

return React.createElement('stripe-pricing-table', {
'pricing-table-id':
theme === 'dark' ? PRICING_TABLE_DARKMODE : PRICING_TABLE_LIGHTMODE,
'publishable-key': process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY,
'customer-session-client-secret': customerSessionSecret,
});
return (
<EmbeddedComponentContainer componentName="PricingTable">
{React.createElement('stripe-pricing-table', {
'pricing-table-id':
theme === 'dark' ? PRICING_TABLE_DARKMODE : PRICING_TABLE_LIGHTMODE,
'publishable-key': process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY,
'customer-session-client-secret': customerSessionSecret,
})}
</EmbeddedComponentContainer>
);
};

export default function Billing() {
Expand Down
40 changes: 40 additions & 0 deletions app/api/customer_portal/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {getServerSession} from 'next-auth/next';
import {authOptions} from '@/lib/auth';
import {stripe} from '@/lib/stripe';
import {NextRequest} from 'next/server';

export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);

const accountId = session?.user?.stripeAccount?.id;

if (!accountId) {
console.error('No account ID found for user');
return new Response('No account ID found for user', {
status: 400,
});
}

const returnUrl =
process.env.NODE_ENV === 'development'
? 'http://localhost:3000/billing'
: 'https://stripe-connect-furever-v2.onrender.com/billing';

const billingPortalSession = await stripe.billingPortal.sessions.create({
customer: accountId,
return_url: returnUrl,
});

return new Response(JSON.stringify(billingPortalSession), {
status: 200,
headers: {'Content-Type': 'application/json'},
});
} catch (error: any) {
console.error(
'An error occurred when calling the Stripe API to create an account session',
error
);
return new Response(error.message, {status: 500});
}
}
1 change: 1 addition & 0 deletions app/components/EmbeddedComponentContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const EmbeddedComponentContainer = ({
'https://docs.stripe.com/connect/supported-embedded-components/tax-registrations',
TaxSettings:
'https://docs.stripe.com/connect/supported-embedded-components/tax-settings',
PricingTable: 'https://docs.stripe.com/no-code/pricing-table',
};

if (!enableBorder) {
Expand Down
28 changes: 25 additions & 3 deletions app/components/SubscriptionNextBillWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import Stripe from '@stripe/stripe';
import Container from './Container';
import {PaymentIcon, PaymentType} from 'react-svg-credit-card-payment-icons';
import React from 'react';
import {ChevronRight} from 'lucide-react';
import {ChevronRight, LoaderCircle} from 'lucide-react';
import {useRouter} from 'next/navigation';

const TimestampFormatter = ({timestamp}: {timestamp: number}) => {
return new Intl.DateTimeFormat('en-US', {
Expand All @@ -17,13 +18,34 @@ export const SubscriptionNextBillWidget = ({
}: {
subscription: Stripe.Subscription;
}) => {
const router = useRouter();

const [loading, setLoading] = React.useState(false);

const handleCustomerPortal = async () => {
if (loading) {
return;
}
setLoading(true);
const resp = await fetch('/api/customer_portal');
const body = (await resp.json()) as Stripe.BillingPortal.Session;
router.push(body.url);
};

return (
<Container className="mt-4 flex h-min flex-col xl:ml-4 xl:mt-0 xl:w-[30%]">
<div className="flex flex-row items-center pb-2">
<h1 className="flex-grow text-lg font-bold ">Next bill</h1>
<span className="flex cursor-pointer flex-row text-sm text-accent hover:opacity-80">
<span
className="flex cursor-pointer flex-row text-sm text-accent hover:opacity-80"
onClick={handleCustomerPortal}
>
See history
<ChevronRight className="ml-1" />
{loading ? (
<LoaderCircle className="ml-2 animate-spin text-accent" size={20} />
) : (
<ChevronRight className="ml-1" />
)}
</span>
</div>
<span className="text-lg font-medium text-subdued">
Expand Down
62 changes: 49 additions & 13 deletions app/components/SubscriptionPortalWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import Stripe from '@stripe/stripe';
import Container from './Container';
import {PaymentIcon, PaymentType} from 'react-svg-credit-card-payment-icons';
import React from 'react';
import {ChevronRight} from 'lucide-react';
import {ChevronRight, LoaderCircle} from 'lucide-react';
import {useRouter} from 'next/navigation';

const stripeBrandToIcon: Record<string, PaymentType> = {
amex: 'Amex',
Expand Down Expand Up @@ -31,19 +32,28 @@ const CurrencyFormatter = ({
}).format(value);
};

const RowButton = React.forwardRef<
HTMLAnchorElement,
React.LinkHTMLAttributes<HTMLAnchorElement>
>(({children, ...props}, ref) => {
type RowButtonProps = {
onClick: () => void;
loading: boolean;
};
const RowButton = (props: React.PropsWithChildren<RowButtonProps>) => {
return (
<a className="flex flex-row py-5 hover:opacity-80" {...props} ref={ref}>
<a
className="flex cursor-pointer flex-row py-5 hover:opacity-80"
{...props}
onClick={props.onClick}
>
<span className="flex-grow text-lg font-medium text-subdued">
{children}
{props.children}
</span>
<ChevronRight className="text-subdued" />
{props.loading ? (
<LoaderCircle className="ml-2 animate-spin" size={20} />
) : (
<ChevronRight className="text-subdued" />
)}
</a>
);
});
};
RowButton.displayName = 'RowButton';

export const SubscriptionPortalWidget = ({
Expand All @@ -55,15 +65,35 @@ export const SubscriptionPortalWidget = ({
plan: Stripe.Plan;
product: Stripe.Product;
}) => {
const router = useRouter();

const [loading, setLoading] = React.useState(false);

const handleCustomerPortal = async () => {
if (loading) {
return;
}
setLoading(true);
const resp = await fetch('/api/customer_portal');
const body = (await resp.json()) as Stripe.BillingPortal.Session;
router.push(body.url);
};

return (
<Container className="flex flex-grow flex-col">
<div className="flex flex-row items-center">
<h1 className="flex-grow text-xl font-bold text-accent">
{product.description}
</h1>
<span className="cursor-pointer text-sm text-accent hover:opacity-80">
<span
className="cursor-pointer text-sm text-accent hover:opacity-80"
onClick={handleCustomerPortal}
>
Plan details
</span>
{loading && (
<LoaderCircle className="ml-2 animate-spin text-accent" size={20} />
)}
</div>
<div className="mt-4 flex-row">
<span className="text-[28px] font-bold">
Expand Down Expand Up @@ -91,9 +121,15 @@ export const SubscriptionPortalWidget = ({
</div>
)}
<div className="flex flex-col divide-y">
<RowButton>Change plans</RowButton>
<RowButton>Saved payment method</RowButton>
<RowButton>Cancel subscription</RowButton>
<RowButton onClick={handleCustomerPortal} loading={loading}>
Change plans
</RowButton>
<RowButton onClick={handleCustomerPortal} loading={loading}>
Saved payment method
</RowButton>
<RowButton onClick={handleCustomerPortal} loading={loading}>
Cancel subscription
</RowButton>
</div>
</Container>
);
Expand Down

0 comments on commit 74d7dd8

Please sign in to comment.