Skip to content

Commit 4963eed

Browse files
committed
feat(cloud): billing
1 parent cfe8076 commit 4963eed

File tree

20 files changed

+699
-56
lines changed

20 files changed

+699
-56
lines changed
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { faCheck, faPlus, Icon, type IconProp } from "@rivet-gg/icons";
2+
import type { ReactNode } from "react";
3+
import { Button, cn } from "@/components";
4+
5+
type PlanCardProps = {
6+
title: string;
7+
price: string;
8+
features: { icon: IconProp; label: ReactNode }[];
9+
usageBased?: boolean;
10+
custom?: boolean;
11+
current?: boolean;
12+
buttonProps?: React.ComponentProps<typeof Button>;
13+
} & React.ComponentProps<"div">;
14+
15+
function PlanCard({
16+
title,
17+
price,
18+
features,
19+
usageBased,
20+
current,
21+
custom,
22+
className,
23+
buttonProps,
24+
...props
25+
}: PlanCardProps) {
26+
return (
27+
<div
28+
className={cn(
29+
"border rounded-lg p-6 h-full flex flex-col hover:bg-secondary/20 transition-colors",
30+
current && "border-primary",
31+
className,
32+
)}
33+
{...props}
34+
>
35+
<h3 className="text-lg font-medium mb-2">{title}</h3>
36+
<div className="min-h-20">
37+
<p className="">
38+
<span className="text-4xl font-bold">{price}</span>
39+
{custom ? null : (
40+
<span className="text-muted-foreground ml-1">/mo</span>
41+
)}
42+
</p>
43+
{usageBased ? (
44+
<p className="text-xs text-muted-foreground">+ Usage</p>
45+
) : null}
46+
</div>
47+
<div className="text-sm text-primary-foreground border-t pt-2 flex-1">
48+
<p>Includes:</p>
49+
<ul className="text-muted-foreground mt-2 space-y-1">
50+
{features?.map((feature, index) => (
51+
<li key={feature.label}>
52+
<Icon icon={feature.icon} /> {feature.label}
53+
</li>
54+
))}
55+
</ul>
56+
</div>
57+
{current ? (
58+
<Button
59+
variant="secondary"
60+
className="w-full mt-4"
61+
{...buttonProps}
62+
>
63+
Current Plan
64+
</Button>
65+
) : (
66+
<Button className="w-full mt-4" {...buttonProps}>
67+
{custom ? "Contact Us" : "Upgrade"}
68+
</Button>
69+
)}
70+
</div>
71+
);
72+
}
73+
74+
export const CommunityPlan = (props: Partial<PlanCardProps>) => {
75+
return (
76+
<PlanCard
77+
title="Community"
78+
price="$0"
79+
features={[
80+
{ icon: faPlus, label: "$5 Free credits" },
81+
{ icon: faCheck, label: "Community Support" },
82+
]}
83+
{...props}
84+
/>
85+
);
86+
};
87+
88+
export const ProPlan = (props: Partial<PlanCardProps>) => {
89+
return (
90+
<PlanCard
91+
title="Pro"
92+
price="$20"
93+
usageBased
94+
features={[
95+
{ icon: faPlus, label: "$20 Free credits" },
96+
{ icon: faCheck, label: "Everything in Community" },
97+
{ icon: faCheck, label: "No Usage Limits" },
98+
{ icon: faCheck, label: "Unlimited Seats" },
99+
{ icon: faCheck, label: "Email Support" },
100+
]}
101+
{...props}
102+
/>
103+
);
104+
};
105+
106+
export const TeamPlan = (props: Partial<PlanCardProps>) => {
107+
return (
108+
<PlanCard
109+
title="Team"
110+
price="$200"
111+
usageBased
112+
features={[
113+
{ icon: faPlus, label: "$200 Free credits" },
114+
{ icon: faCheck, label: "Everything in Pro" },
115+
{ icon: faCheck, label: "Dedicated Hardware" },
116+
{ icon: faCheck, label: "Custom Regions" },
117+
{ icon: faCheck, label: "Slack Support" },
118+
]}
119+
{...props}
120+
/>
121+
);
122+
};
123+
124+
export const EnterprisePlan = (props: Partial<PlanCardProps>) => {
125+
return (
126+
<PlanCard
127+
title="Enterprise"
128+
price="Custom"
129+
custom
130+
features={[
131+
{ icon: faPlus, label: "Everything in Team" },
132+
{ icon: faCheck, label: "Priority Support" },
133+
{ icon: faCheck, label: "SLA" },
134+
{ icon: faCheck, label: "OIDC SSO provider" },
135+
{ icon: faCheck, label: "ON-Prem Deployment" },
136+
{
137+
icon: faCheck,
138+
label: "Custom Storage Reads, Writes and Stored Data",
139+
},
140+
{ icon: faCheck, label: "Custom Log Retention" },
141+
]}
142+
{...props}
143+
/>
144+
);
145+
};

frontend/src/app/context-switcher.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ function Breadcrumbs() {
7676

7777
const matchProject = match({
7878
to: "/orgs/$organization/projects/$project",
79+
fuzzy: true,
7980
});
8081

8182
if (matchProject) {

frontend/src/app/data-providers/cloud-data-provider.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,11 @@ export const createProjectContext = ({
240240
},
241241
};
242242
},
243+
currentProjectQueryOptions: () => {
244+
return parent.currentOrgProjectQueryOptions({
245+
project,
246+
});
247+
},
243248
currentProjectNamespacesQueryOptions: () => {
244249
return parent.orgProjectNamespacesQueryOptions({
245250
organization,
@@ -258,6 +263,31 @@ export const createProjectContext = ({
258263
namespace: opts.namespace,
259264
});
260265
},
266+
currentProjectBillingDetailsQueryOptions() {
267+
return queryOptions({
268+
queryKey: [{ organization, project }, "billing-details"],
269+
queryFn: async ({ signal: abortSignal }) => {
270+
const response = await client.billing.details(
271+
project,
272+
{org: organization },
273+
{ abortSignal },
274+
);
275+
return response;
276+
},
277+
});
278+
},
279+
changeCurrentProjectBillingPlanMutationOptions() {
280+
return {
281+
mutationKey: [{ organization, project }, "billing"],
282+
mutationFn: async (data: Rivet.BillingSetPlanRequest) => {
283+
const response = await client.billing.setPlan(project, {
284+
plan: data.plan,
285+
org: organization,
286+
});
287+
return response;
288+
},
289+
};
290+
},
261291
};
262292
};
263293

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { useMutation, useSuspenseQueries, useSuspenseQuery } from "@tanstack/react-query";
2+
import { useRouteContext } from "@tanstack/react-router";
3+
import { DocsSheet, Frame } from "@/components";
4+
import {
5+
CommunityPlan,
6+
EnterprisePlan,
7+
ProPlan,
8+
TeamPlan,
9+
} from "../billing/plan-card";
10+
import { queryClient } from "@/queries/global";
11+
12+
export default function BillingFrameContent() {
13+
const { dataProvider } = useRouteContext({
14+
from: "/_context/_cloud/orgs/$organization/projects/$project",
15+
});
16+
17+
const [{ data: project }, {data: {billing}}] = useSuspenseQueries({queries:[
18+
dataProvider.currentProjectQueryOptions(),
19+
dataProvider.currentProjectBillingDetailsQueryOptions(),
20+
]});
21+
22+
const { mutate, isPending } = useMutation({
23+
...dataProvider.changeCurrentProjectBillingPlanMutationOptions(),
24+
onSuccess: async () => {
25+
await queryClient.invalidateQueries(dataProvider.currentProjectBillingDetailsQueryOptions());
26+
}
27+
});
28+
29+
return (
30+
<>
31+
<Frame.Header>
32+
<Frame.Title>{project.name} billing</Frame.Title>
33+
<Frame.Description>
34+
Manage billing for your Rivet Cloud project.{" "}
35+
<DocsSheet
36+
path="https://www.rivet.gg/pricing"
37+
title="Billing"
38+
>
39+
<a className="cursor-pointer">
40+
Learn more about billing.
41+
</a>
42+
</DocsSheet>
43+
</Frame.Description>
44+
</Frame.Header>
45+
<Frame.Content>
46+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
47+
<CommunityPlan
48+
current={billing?.activePlan === "free"}
49+
buttonProps={{
50+
isLoading: isPending,
51+
onClick: () => mutate({ plan: "community" }),
52+
}}
53+
/>
54+
<ProPlan
55+
current={billing?.activePlan === "pro"}
56+
buttonProps={{
57+
isLoading: isPending,
58+
onClick: () => mutate({ plan: "pro" }),
59+
}}
60+
/>
61+
<TeamPlan
62+
current={billing?.activePlan === "team"}
63+
buttonProps={{
64+
isLoading: isPending,
65+
onClick: () => mutate({ plan: "team" }),
66+
}}
67+
/>
68+
<EnterprisePlan />
69+
</div>
70+
</Frame.Content>
71+
</>
72+
);
73+
}

frontend/src/app/use-dialog.tsx

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,20 @@
11
import { createDialogHook, useDialog } from "@/components";
22

3-
const d = useDialog as typeof useDialog &
4-
Record<string, ReturnType<typeof createDialogHook>>;
5-
d.CreateNamespace = createDialogHook(
6-
() => import("@/app/dialogs/create-namespace-frame"),
7-
);
8-
9-
d.CreateProject = createDialogHook(
10-
() => import("@/app/dialogs/create-project-frame"),
11-
);
12-
13-
d.ConnectVercel = createDialogHook(
14-
() => import("@/app/dialogs/connect-vercel-frame"),
15-
);
16-
17-
d.ConnectRailway = createDialogHook(
18-
() => import("@/app/dialogs/connect-railway-frame"),
19-
);
3+
const d = {
4+
...useDialog,
5+
CreateNamespace: createDialogHook(
6+
() => import("@/app/dialogs/create-namespace-frame"),
7+
),
8+
CreateProject: createDialogHook(
9+
() => import("@/app/dialogs/create-project-frame"),
10+
),
11+
ConnectVercel: createDialogHook(
12+
() => import("@/app/dialogs/connect-vercel-frame"),
13+
),
14+
ConnectRailway: createDialogHook(
15+
() => import("@/app/dialogs/connect-railway-frame"),
16+
),
17+
Billing: createDialogHook(() => import("@/app/dialogs/billing-frame")),
18+
};
2019

2120
export { d as useDialog };

frontend/src/app/user-dropdown.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,18 @@ import { useCloudDataProvider } from "@/components/actors";
2323
import { VisibilitySensor } from "@/components/visibility-sensor";
2424

2525
export function UserDropdown() {
26-
const org = useParams({
26+
const params = useParams({
2727
strict: false,
28-
select: (p) => p.organization,
2928
});
3029

3130
const clerk = useClerk();
32-
33-
const { data: url } = useQuery(
34-
useCloudDataProvider().billingCustomerPortalSessionQueryOptions(),
35-
);
36-
31+
const navigate = useNavigate();
3732
return (
3833
<DropdownMenu>
39-
<DropdownMenuTrigger asChild={!org}>
40-
{org ? <Preview org={org} /> : null}
34+
<DropdownMenuTrigger asChild={!params.organization}>
35+
{params.organization ? (
36+
<Preview org={params.organization} />
37+
) : null}
4138
</DropdownMenuTrigger>
4239
<DropdownMenuContent>
4340
<DropdownMenuItem
@@ -58,7 +55,14 @@ export function UserDropdown() {
5855
</DropdownMenuItem>
5956
<DropdownMenuItem
6057
onSelect={() => {
61-
window.open(url, "_blank");
58+
console.log(params);
59+
if (!params.organization || !params.project) {
60+
return;
61+
}
62+
navigate({
63+
to: ".",
64+
search: (old) => ({ ...old, modal: "billing" }),
65+
});
6266
}}
6367
>
6468
Billing
@@ -70,7 +74,7 @@ export function UserDropdown() {
7074
</DropdownMenuSubTrigger>
7175
<DropdownMenuPortal>
7276
<DropdownMenuSubContent>
73-
<OrganizationSwitcher value={org} />
77+
<OrganizationSwitcher value={params.organization} />
7478
</DropdownMenuSubContent>
7579
</DropdownMenuPortal>
7680
</DropdownMenuSub>

frontend/src/components/hooks/isomorphic-frame.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { DialogDescription } from "@radix-ui/react-dialog";
12
import { createContext, useContext } from "react";
23
import {
34
CardContent,
5+
CardDescription,
46
CardFooter,
57
CardHeader,
68
CardTitle,
@@ -21,6 +23,17 @@ export const Title = (props: React.ComponentProps<typeof DialogTitle>) => {
2123
return isInModal ? <DialogTitle {...props} /> : <CardTitle {...props} />;
2224
};
2325

26+
export const Description = (
27+
props: React.HTMLAttributes<HTMLParagraphElement>,
28+
) => {
29+
const isInModal = useContext(IsInModalContext);
30+
return isInModal ? (
31+
<DialogDescription {...props} />
32+
) : (
33+
<CardDescription {...props} />
34+
);
35+
};
36+
2437
export const Content = (props: React.HTMLAttributes<HTMLDivElement>) => {
2538
const isInModal = useContext(IsInModalContext);
2639
return isInModal ? (

0 commit comments

Comments
 (0)