Skip to content

Commit 893673b

Browse files
committed
Billing V2 UI changes
1 parent f9306cf commit 893673b

File tree

35 files changed

+633
-1382
lines changed

35 files changed

+633
-1382
lines changed

apps/dashboard/src/@/api/team.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export type Team = {
1313
deletedAt?: string;
1414
bannedAt?: string;
1515
image?: string;
16-
billingPlan: "pro" | "growth" | "free";
16+
billingPlan: "pro" | "growth" | "free" | "starter";
1717
billingStatus: "validPayment" | (string & {}) | null;
1818
billingEmail: string | null;
1919
};

apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -498,51 +498,6 @@ export function useUpdateAccount() {
498498
});
499499
}
500500

501-
export function useUpdateAccountPlan(waitForWebhook?: boolean) {
502-
const { user } = useLoggedInUser();
503-
const queryClient = useQueryClient();
504-
505-
return useMutation({
506-
mutationFn: async (input: { plan: string; feedback?: string }) => {
507-
invariant(user?.address, "walletAddress is required");
508-
509-
const res = await fetch(`${THIRDWEB_API_HOST}/v1/account/plan`, {
510-
method: "PUT",
511-
512-
headers: {
513-
"Content-Type": "application/json",
514-
},
515-
body: JSON.stringify(input),
516-
});
517-
518-
const json = await res.json();
519-
520-
if (json.error) {
521-
throw new Error(json.error.message);
522-
}
523-
524-
// Wait for account plan to update via stripe webhook
525-
// TODO: find a better way to notify the client that the plan has been updated
526-
if (waitForWebhook) {
527-
await new Promise((resolve) => setTimeout(resolve, 1000 * 10));
528-
}
529-
530-
return json.data;
531-
},
532-
onSuccess: async () => {
533-
return Promise.all([
534-
// invalidate usage data as limits are different
535-
queryClient.invalidateQueries({
536-
queryKey: accountKeys.me(user?.address as string),
537-
}),
538-
queryClient.invalidateQueries({
539-
queryKey: accountKeys.usage(user?.address as string),
540-
}),
541-
]);
542-
},
543-
});
544-
}
545-
546501
export function useUpdateNotifications() {
547502
const { user } = useLoggedInUser();
548503
const queryClient = useQueryClient();

apps/dashboard/src/app/account/settings/getAccount.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,8 @@ import { getAuthToken } from "../../api/lib/getAuthToken";
44

55
export async function getAccount() {
66
const authToken = await getAuthToken();
7-
const apiServerURL = new URL(API_SERVER_URL);
87

9-
apiServerURL.pathname = "/v1/account/me";
10-
11-
const res = await fetch(apiServerURL, {
8+
const res = await fetch(`${API_SERVER_URL}/v1/account/me`, {
129
method: "GET",
1310
headers: {
1411
Authorization: `Bearer ${authToken}`,

apps/dashboard/src/app/components/TeamPlanBadge.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { Badge } from "@/components/ui/badge";
22
import { cn } from "@/lib/utils";
3+
import type { Team } from "../../@/api/team";
34

45
export function TeamPlanBadge(props: {
5-
plan: "free" | "growth" | "pro";
6+
plan: Team["billingPlan"];
67
className?: string;
78
}) {
89
return (
910
<Badge
1011
variant={
11-
props.plan === "free"
12+
props.plan === "free" || props.plan === "starter"
1213
? "secondary"
1314
: props.plan === "growth"
1415
? "success"

apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useDashboardRouter } from "@/lib/DashboardRouter";
44
import { accountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
55
import { ConfirmEngineTierDialog } from "app/team/[team_slug]/(team)/~/engine/(general)/create/ConfirmEngineTierDialog";
66
import { EngineTierCard } from "app/team/[team_slug]/(team)/~/engine/(general)/create/tier-card";
7-
import { LazyOnboardingBilling } from "components/onboarding/LazyOnboardingBilling";
7+
import { LazyAddPaymentMethod } from "components/onboarding/LazyOnboardingBilling";
88
import { OnboardingModal } from "components/onboarding/Modal";
99
import { THIRDWEB_API_HOST } from "constants/urls";
1010
import { useTrack } from "hooks/analytics/useTrack";
@@ -94,7 +94,7 @@ export default function Page() {
9494
)}
9595

9696
<OnboardingModal isOpen={isBillingModalOpen}>
97-
<LazyOnboardingBilling
97+
<LazyAddPaymentMethod
9898
onSave={async () => {
9999
if (!selectedTier) {
100100
return;

apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/BillingSettingsPage.tsx

Lines changed: 0 additions & 28 deletions
This file was deleted.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import type { Team } from "@/api/team";
2+
import { Button } from "@/components/ui/button";
3+
import { Separator } from "@/components/ui/separator";
4+
import { TrackedLinkTW } from "@/components/ui/tracked-link";
5+
import type {
6+
Account,
7+
UsageBillableByService,
8+
} from "@3rdweb-sdk/react/hooks/useApi";
9+
import { format } from "date-fns/format";
10+
import { CircleAlertIcon } from "lucide-react";
11+
import { ManageBillingButton } from "../../../../../../../../components/settings/Account/Billing/ManageButton";
12+
import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getValidTeamPlan";
13+
14+
export function PlanInfoCard(props: {
15+
account: Account;
16+
accountUsage: UsageBillableByService;
17+
team: Team;
18+
}) {
19+
const { account, accountUsage, team } = props;
20+
const validPlan = getValidTeamPlan(team);
21+
const isActualFreePlan = team.billingPlan === "free";
22+
return (
23+
<div className="rounded-lg border border-border bg-muted/50">
24+
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
25+
<h3 className="font-semibold text-2xl capitalize tracking-tight">
26+
{validPlan} Plan
27+
</h3>
28+
29+
{isActualFreePlan && (
30+
<div>
31+
<ManageBillingButton
32+
variant="outline"
33+
account={account}
34+
onlyRenderIfLink
35+
/>
36+
37+
<Button asChild variant="outline">
38+
<TrackedLinkTW
39+
category="account"
40+
href="/pricing"
41+
label="pricing-plans"
42+
target="_blank"
43+
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
44+
>
45+
View Pricing
46+
</TrackedLinkTW>
47+
</Button>
48+
</div>
49+
)}
50+
</div>
51+
52+
<Separator />
53+
54+
<div className="p-6 lg:p-6">
55+
{isActualFreePlan ? (
56+
<div className="flex flex-col items-center py-8 text-center max-sm:gap-4">
57+
<CircleAlertIcon className="mb-3 text-muted-foreground lg:size-6" />
58+
<p>Your plan includes a fixed amount of free usage</p>
59+
<p>
60+
To unlock additional usage and add team members, Upgrade your plan
61+
to Starer or Growth
62+
</p>
63+
</div>
64+
) : (
65+
<BillingInfo account={account} usage={accountUsage} />
66+
)}
67+
</div>
68+
</div>
69+
);
70+
}
71+
72+
function BillingInfo({
73+
account,
74+
usage,
75+
}: {
76+
account: Account;
77+
usage: UsageBillableByService;
78+
}) {
79+
if (
80+
!account.currentBillingPeriodStartsAt ||
81+
!account.currentBillingPeriodEndsAt
82+
) {
83+
return null;
84+
}
85+
86+
const totalUsd = getBillingAmountInUSD(usage);
87+
88+
return (
89+
<div>
90+
<div>
91+
<h5 className="font-medium">Current Billing Cycle</h5>
92+
<p className="text-muted-foreground">
93+
{format(
94+
new Date(account.currentBillingPeriodStartsAt),
95+
"MMMM dd yyyy",
96+
)}{" "}
97+
-{" "}
98+
{format(
99+
new Date(account.currentBillingPeriodEndsAt),
100+
"MMMM dd yyyy",
101+
)}{" "}
102+
</p>
103+
</div>
104+
105+
<Separator className="my-4" />
106+
107+
<div className="flex items-center gap-2">
108+
<h5 className="font-medium">Total Upcoming Bill</h5>
109+
<p className="text-lg text-muted-foreground">{totalUsd}</p>
110+
</div>
111+
</div>
112+
);
113+
}
114+
115+
function getBillingAmountInUSD(usage: UsageBillableByService) {
116+
let total = 0;
117+
118+
if (usage.billableUsd) {
119+
for (const amount of Object.values(usage.billableUsd)) {
120+
total += amount;
121+
}
122+
}
123+
124+
return new Intl.NumberFormat(undefined, {
125+
style: "currency",
126+
currency: "USD",
127+
}).format(total);
128+
}
Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,38 @@
11
import { getTeamBySlug } from "@/api/team";
2-
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
3-
import { notFound } from "next/navigation";
4-
import { SettingsBillingPage } from "./BillingSettingsPage";
2+
import { redirect } from "next/navigation";
3+
import { Billing } from "../../../../../../../components/settings/Account/Billing";
4+
import { getAccount } from "../../../../../../account/settings/getAccount";
5+
import { getAccountUsage } from "../../usage/getAccountUsage";
56

67
export default async function Page(props: {
78
params: Promise<{
89
team_slug: string;
910
}>;
1011
}) {
11-
const team = await getTeamBySlug((await props.params).team_slug);
12+
const params = await props.params;
13+
14+
const account = await getAccount();
15+
if (!account) {
16+
redirect(
17+
`/login?next=${encodeURIComponent(`/team/${params.team_slug}/settings/billing`)}`,
18+
);
19+
}
20+
21+
const team = await getTeamBySlug(params.team_slug);
1222

1323
if (!team) {
14-
notFound();
24+
redirect("/team");
25+
}
26+
27+
const accountUsage = await getAccountUsage();
28+
29+
if (!accountUsage) {
30+
return (
31+
<div className="flex min-h-[350px] items-center justify-center rounded-lg border p-4 text-destructive-text">
32+
Something went wrong. Please try again later.
33+
</div>
34+
);
1535
}
1636

17-
return (
18-
<ChakraProviderSetup>
19-
<SettingsBillingPage teamId={team.id} />
20-
</ChakraProviderSetup>
21-
);
37+
return <Billing team={team} account={account} accountUsage={accountUsage} />;
2238
}

0 commit comments

Comments
 (0)