Skip to content

Commit d8ea0ce

Browse files
committed
Billing V2 UI changes
1 parent f9306cf commit d8ea0ce

40 files changed

+730
-1527
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: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export const accountPlan = {
3030
} as const;
3131

3232
type AccountStatus = (typeof accountStatus)[keyof typeof accountStatus];
33-
export type AccountPlan = (typeof accountPlan)[keyof typeof accountPlan];
33+
type AccountPlan = (typeof accountPlan)[keyof typeof accountPlan];
3434

3535
export type AuthorizedWallet = {
3636
id: string;
@@ -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 @@
1+
import type { Team } from "@/api/team";
12
import { Badge } from "@/components/ui/badge";
23
import { cn } from "@/lib/utils";
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: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ 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";
8-
import { OnboardingModal } from "components/onboarding/Modal";
7+
import { LazyAddPaymentMethod } from "components/onboarding/LazyOnboardingBilling";
8+
import { TWModal } from "components/onboarding/Modal";
99
import { THIRDWEB_API_HOST } from "constants/urls";
1010
import { useTrack } from "hooks/analytics/useTrack";
1111
import { useState } from "react";
@@ -93,8 +93,8 @@ export default function Page() {
9393
/>
9494
)}
9595

96-
<OnboardingModal isOpen={isBillingModalOpen}>
97-
<LazyOnboardingBilling
96+
<TWModal isOpen={isBillingModalOpen}>
97+
<LazyAddPaymentMethod
9898
onSave={async () => {
9999
if (!selectedTier) {
100100
return;
@@ -105,7 +105,7 @@ export default function Page() {
105105
}}
106106
onCancel={() => setIsBillingModalOpen(false)}
107107
/>
108-
</OnboardingModal>
108+
</TWModal>
109109

110110
<h1 className="mb-2 font-semibold text-2xl tracking-tight">
111111
Choose an Engine deployment

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
}
Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
11
"use client";
2-
3-
import { Spinner } from "@/components/ui/Spinner/Spinner";
4-
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
5-
import { ApplyForOpCreditsModal } from "components/onboarding/ApplyForOpCreditsModal";
2+
import type { Team } from "@/api/team";
3+
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
4+
import { ApplyForOpCredits } from "components/onboarding/ApplyForOpCreditsModal";
65
import { Heading, LinkButton } from "tw-components";
76

8-
export const SettingsGasCreditsPage = () => {
9-
const { isPending } = useLoggedInUser();
10-
11-
if (isPending) {
12-
return (
13-
<div className="grid min-h-[400px] w-full place-items-center">
14-
<Spinner className="size-10" />
15-
</div>
16-
);
17-
}
18-
7+
export const SettingsGasCreditsPage = (props: {
8+
team: Team;
9+
account: Account;
10+
}) => {
1911
return (
2012
<div className="flex flex-col gap-8">
2113
<div className="flex flex-row items-center gap-4">
@@ -33,7 +25,7 @@ export const SettingsGasCreditsPage = () => {
3325
</LinkButton>
3426
</div>
3527

36-
<ApplyForOpCreditsModal />
28+
<ApplyForOpCredits team={props.team} account={props.account} />
3729
</div>
3830
);
3931
};

0 commit comments

Comments
 (0)