Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/dashboard/src/@/actions/stripe-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function getStripe() {

export async function getTeamInvoices(
team: Team,
options?: { cursor?: string },
options?: { cursor?: string; status?: "open" },
) {
try {
const customerId = team.stripeCustomerId;
Expand All @@ -37,6 +37,8 @@ export async function getTeamInvoices(
customer: customerId,
limit: 10,
starting_after: options?.cursor,
// Only return open invoices if the status is open
status: options?.status,
});

return invoices;
Expand Down
87 changes: 61 additions & 26 deletions apps/dashboard/src/@/components/billing.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"use client";

import { useMutation } from "@tanstack/react-query";
import { AlertTriangleIcon } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import type {
GetBillingCheckoutUrlAction,
GetBillingCheckoutUrlOptions,
GetBillingPortalUrlAction,
GetBillingPortalUrlOptions,
} from "../actions/billing";
import type { Team } from "../api/team";
import { cn } from "../lib/utils";
import { Spinner } from "./ui/Spinner/Spinner";
import { Button, type ButtonProps } from "./ui/button";
Expand All @@ -16,6 +19,7 @@ type CheckoutButtonProps = Omit<GetBillingCheckoutUrlOptions, "redirectUrl"> & {
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
buttonProps?: Omit<ButtonProps, "children">;
children: React.ReactNode;
billingStatus: Team["billingStatus"];
};

export function CheckoutButton({
Expand All @@ -25,6 +29,7 @@ export function CheckoutButton({
getBillingCheckoutUrl,
children,
buttonProps,
billingStatus,
}: CheckoutButtonProps) {
const getUrlMutation = useMutation({
mutationFn: async () => {
Expand All @@ -40,35 +45,65 @@ export function CheckoutButton({
const errorMessage = "Failed to open checkout page";

return (
<Button
{...buttonProps}
className={cn(buttonProps?.className, "gap-2")}
disabled={getUrlMutation.isPending || buttonProps?.disabled}
onClick={async (e) => {
buttonProps?.onClick?.(e);
getUrlMutation.mutate(undefined, {
onSuccess: (res) => {
if (!res.url) {
toast.error(errorMessage);
return;
}
<div className="flex w-full flex-col items-center gap-2">
{/* show warning if the team has an invalid payment method */}
{billingStatus === "invalidPayment" && (
<BillingWarning teamSlug={teamSlug} />
)}
<Button
{...buttonProps}
className={cn(buttonProps?.className, "w-full gap-2")}
disabled={
// disable button if the team has an invalid payment method
// api will return 402 error if the team has an invalid payment method
billingStatus === "invalidPayment" ||
getUrlMutation.isPending ||
buttonProps?.disabled
}
onClick={async (e) => {
buttonProps?.onClick?.(e);
getUrlMutation.mutate(undefined, {
onSuccess: (res) => {
if (!res.url) {
toast.error(errorMessage);
return;
}

const tab = window.open(res.url, "_blank");
const tab = window.open(res.url, "_blank");

if (!tab) {
if (!tab) {
toast.error(errorMessage);
return;
}
},
onError: () => {
toast.error(errorMessage);
return;
}
},
onError: () => {
toast.error(errorMessage);
},
});
}}
>
{getUrlMutation.isPending && <Spinner className="size-4" />}
{children}
</Button>
},
});
}}
>
{getUrlMutation.isPending && <Spinner className="size-4" />}
{children}
</Button>
</div>
);
}

function BillingWarning({ teamSlug }: { teamSlug: string }) {
return (
<div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/30 dark:text-amber-400">
<AlertTriangleIcon className="h-5 w-5 flex-shrink-0" />
<p className="text-sm">
You have outstanding invoices. Please{" "}
<Link
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
className="font-medium text-amber-700 underline transition-colors hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-300"
>
pay them
</Link>{" "}
to continue.
</p>
</div>
);
}

Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/src/@/components/blocks/pricing-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type PricingCardCta = {

type PricingCardProps = {
teamSlug: string;
billingStatus: Team["billingStatus"];
billingPlan: keyof typeof TEAM_PLANS;
cta?: PricingCardCta;
ctaHint?: string;
Expand All @@ -41,6 +42,7 @@ type PricingCardProps = {

export const PricingCard: React.FC<PricingCardProps> = ({
teamSlug,
billingStatus,
billingPlan,
cta,
highlighted = false,
Expand Down Expand Up @@ -131,6 +133,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
<div className="flex flex-col gap-3">
{billingPlanToSkuMap[billingPlan] && cta.type === "checkout" && (
<CheckoutButton
billingStatus={billingStatus}
buttonProps={{
variant: highlighted ? "default" : "outline",
className: highlighted ? undefined : "bg-background",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function InviteTeamMembersUI(props: {
<Sheet open={showPlanModal} onOpenChange={setShowPlanModal}>
<SheetContent className="!max-w-[1300px] w-full overflow-auto">
<InviteModalContent
billingStatus={props.team.billingStatus}
teamSlug={props.team.slug}
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
trackEvent={props.trackEvent}
Expand Down Expand Up @@ -144,6 +145,7 @@ export function InviteTeamMembersUI(props: {

function InviteModalContent(props: {
teamSlug: string;
billingStatus: Team["billingStatus"];
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
trackEvent: (params: TrackingParams) => void;
}) {
Expand All @@ -154,6 +156,7 @@ function InviteModalContent(props: {
const starterPlan = (
<PricingCard
billingPlan="starter"
billingStatus={props.billingStatus}
teamSlug={props.teamSlug}
cta={{
title: "Get Started",
Expand All @@ -174,6 +177,7 @@ function InviteModalContent(props: {
const growthPlan = (
<PricingCard
billingPlan="growth"
billingStatus={props.billingStatus}
teamSlug={props.teamSlug}
cta={{
title: "Get Started",
Expand All @@ -195,6 +199,7 @@ function InviteModalContent(props: {
const acceleratePlan = (
<PricingCard
billingPlan="accelerate"
billingStatus={props.billingStatus}
teamSlug={props.teamSlug}
cta={{
title: "Get started",
Expand All @@ -215,6 +220,7 @@ function InviteModalContent(props: {
const scalePlan = (
<PricingCard
billingPlan="scale"
billingStatus={props.billingStatus}
teamSlug={props.teamSlug}
cta={{
title: "Get started",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ export function PlanInfoCardUI(props: {
</Button>

<CancelPlanButton
teamSlug={props.team.slug}
billingStatus={props.team.billingStatus}
cancelPlan={props.cancelPlan}
currentPlan={props.team.billingPlan}
getTeam={props.getTeam}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use client";

import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useQueryStates } from "nuqs";
import { startTransition } from "react";
import { searchParams } from "../search-params";

export function BillingFilter() {
const [{ status }, setStates] = useQueryStates(
{
cursor: searchParams.cursor,
status: searchParams.status,
},
{
history: "push",
shallow: false,
startTransition,
},
);
return (
<Select
value={status ?? "all"}
onValueChange={(v) => {
setStates({
cursor: null,
// only set the status if it's "open", otherwise clear it
status: v === "open" ? "open" : null,
});
}}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Invoices</SelectItem>
<SelectItem value="open">Open Invoices</SelectItem>
</SelectContent>
</Select>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { searchParams } from "../search-params";

export function BillingHistory(props: {
invoices: Stripe.Invoice[];
status: "all" | "past_due" | "open";
hasMore: boolean;
}) {
const [isLoading, startTransition] = useTransition();
Expand Down Expand Up @@ -74,6 +75,14 @@ export function BillingHistory(props: {
};

if (props.invoices.length === 0) {
if (props.status === "open") {
return (
<div className="py-6 text-center">
<Receipt className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-2 font-medium text-lg">No open invoices</h3>
</div>
);
}
return (
<div className="py-6 text-center">
<Receipt className="mx-auto h-12 w-12 text-muted-foreground" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getTeamBySlug } from "@/api/team";
import { redirect } from "next/navigation";
import type { SearchParams } from "nuqs/server";
import { getValidAccount } from "../../../../../../account/settings/getAccount";
import { BillingFilter } from "./components/billing-filter";
import { BillingHistory } from "./components/billing-history";
import { searchParamLoader } from "./search-params";

Expand Down Expand Up @@ -31,19 +32,28 @@ export default async function Page(props: {

const invoices = await getTeamInvoices(team, {
cursor: searchParams.cursor ?? undefined,
status: searchParams.status ?? undefined,
});

return (
<div className="overflow-hidden rounded-lg border bg-card">
<div className="p-6">
<h2 className="font-semibold text-2xl leading-none tracking-tight">
Invoice History
</h2>
<p className="mt-1 text-muted-foreground text-sm">
View your past invoices and payment history
</p>
<div className="flex items-center justify-between p-6">
<div className="flex flex-col gap-1">
<h2 className="font-semibold text-2xl leading-none tracking-tight">
Invoice History
</h2>
<p className="text-muted-foreground text-sm">
View your past invoices and payment history
</p>
</div>
<BillingFilter />
</div>
<BillingHistory invoices={invoices.data} hasMore={invoices.has_more} />
<BillingHistory
invoices={invoices.data}
hasMore={invoices.has_more}
// fall back to "all" if the status is not set
status={searchParams.status ?? "all"}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createLoader, parseAsString } from "nuqs/server";
import { createLoader, parseAsString, parseAsStringEnum } from "nuqs/server";

export const searchParams = {
cursor: parseAsString,
status: parseAsStringEnum(["open"]),
};

export const searchParamLoader = createLoader(searchParams);
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,10 @@ const cancelReasons: Array<{
];

export function CancelPlanButton(props: {
teamSlug: string;
cancelPlan: CancelPlan;
currentPlan: Team["billingPlan"];
billingStatus: Team["billingStatus"];
getTeam: () => Promise<Team>;
}) {
return (
Expand All @@ -102,7 +104,9 @@ export function CancelPlanButton(props: {
</SheetTitle>
</SheetHeader>

{props.currentPlan === "pro" ? (
{props.billingStatus === "invalidPayment" ? (
<UnpaidInvoicesWarning teamSlug={props.teamSlug} />
) : props.currentPlan === "pro" ? (
<ProPlanCancelPlanSheetContent />
) : (
<CancelPlanSheetContent
Expand All @@ -115,6 +119,28 @@ export function CancelPlanButton(props: {
);
}

function UnpaidInvoicesWarning({ teamSlug }: { teamSlug: string }) {
return (
<div>
<h2 className="mb-1 font-semibold text-2xl tracking-tight">
Cancel Plan
</h2>
<p className="mb-5 text-muted-foreground text-sm">
You have unpaid invoices. Please pay them before cancelling your plan.
</p>

<Button variant="outline" asChild className="w-full gap-2 bg-card">
<Link
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
target="_blank"
>
See Invoices
</Link>
</Button>
</div>
);
}

function ProPlanCancelPlanSheetContent() {
return (
<div>
Expand Down
Loading
Loading