Skip to content

Commit d0fa589

Browse files
committed
feat(cloud): billing
1 parent ffe9567 commit d0fa589

26 files changed

+901
-80
lines changed

frontend/src/app.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,21 @@ function CloudApp() {
6464
return (
6565
<ClerkProvider
6666
Clerk={clerk}
67-
appearance={{ baseTheme: dark }}
67+
appearance={{
68+
baseTheme: dark,
69+
variables: {
70+
colorPrimary: "hsl(var(--primary))",
71+
colorPrimaryForeground: "hsl(var(--primary-foreground))",
72+
colorTextOnPrimaryBackground:
73+
"hsl(var(--primary-foreground))",
74+
colorBackground: "hsl(var(--background))",
75+
colorInput: "hsl(var(--input))",
76+
colorText: "hsl(var(--text))",
77+
colorTextSecondary: "hsl(var(--muted-foreground))",
78+
borderRadius: "var(--radius)",
79+
colorModalBackdrop: "rgb(0 0 0 / 0.8)",
80+
},
81+
}}
6882
publishableKey={cloudEnv().VITE_CLERK_PUBLISHABLE_KEY}
6983
>
7084
<RouterProvider router={router} />
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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-24">
37+
{usageBased ? (
38+
<p className="text-xs text-muted-foreground">From</p>
39+
) : null}
40+
<p className="">
41+
<span className="text-4xl font-bold">{price}</span>
42+
{custom ? null : (
43+
<span className="text-muted-foreground ml-1">/mo</span>
44+
)}
45+
</p>
46+
{usageBased ? (
47+
<p className="text-sm text-muted-foreground">+ Usage</p>
48+
) : null}
49+
</div>
50+
<div className="text-sm text-primary-foreground border-t pt-2 flex-1">
51+
<p>Includes:</p>
52+
<ul className="text-muted-foreground mt-2 space-y-1">
53+
{features?.map((feature, index) => (
54+
<li key={feature.label}>
55+
<Icon icon={feature.icon} /> {feature.label}
56+
</li>
57+
))}
58+
</ul>
59+
</div>
60+
{current ? (
61+
<Button
62+
variant="secondary"
63+
className="w-full mt-4"
64+
children="Current Plan"
65+
{...buttonProps}
66+
>
67+
</Button>
68+
) : (
69+
<Button className="w-full mt-4" children={<>{custom ? "Contact Us" : "Upgrade"}</>} {...buttonProps}/>
70+
)}
71+
</div>
72+
);
73+
}
74+
75+
export const CommunityPlan = (props: Partial<PlanCardProps>) => {
76+
return (
77+
<PlanCard
78+
title="Free"
79+
price="$0"
80+
features={[
81+
{ icon: faCheck, label: "5GB Limit" },
82+
{ icon: faCheck, label: "5 Million Writes /mo" },
83+
{ icon: faCheck, label: "200 Million Reads /mo" },
84+
{ icon: faCheck, label: "Community Support" },
85+
]}
86+
{...props}
87+
/>
88+
);
89+
};
90+
91+
export const ProPlan = (props: Partial<PlanCardProps>) => {
92+
return (
93+
<PlanCard
94+
title="Hobby"
95+
price="$5"
96+
usageBased
97+
features={[
98+
{
99+
icon: faPlus,
100+
label: "20 Billion Read /mo",
101+
},
102+
{
103+
icon: faPlus,
104+
label: "50 Million Read /mo",
105+
},
106+
{
107+
icon: faPlus,
108+
label: "5GB Storage",
109+
},
110+
{ icon: faCheck, label: "Unlimited Seats" },
111+
{ icon: faCheck, label: "Email Support" },
112+
]}
113+
{...props}
114+
/>
115+
);
116+
};
117+
118+
export const TeamPlan = (props: Partial<PlanCardProps>) => {
119+
return (
120+
<PlanCard
121+
title="Team"
122+
price="$200"
123+
usageBased
124+
features={[
125+
{ icon: faPlus, label: "25 Billion Reads /mo" },
126+
{ icon: faPlus, label: "50 Million Writes /mo" },
127+
{ icon: faPlus, label: "5GB Storage" },
128+
{ icon: faCheck, label: "Unlimited Seats" },
129+
{ icon: faCheck, label: "MFA" },
130+
{ icon: faCheck, label: "Slack Support" },
131+
]}
132+
{...props}
133+
/>
134+
);
135+
};
136+
137+
export const EnterprisePlan = (props: Partial<PlanCardProps>) => {
138+
return (
139+
<PlanCard
140+
title="Enterprise"
141+
price="Custom"
142+
custom
143+
features={[
144+
{ icon: faCheck, label: "Everything in Team" },
145+
{ icon: faCheck, label: "Priority Support" },
146+
{ icon: faCheck, label: "SLA" },
147+
{ icon: faCheck, label: "OIDC SSO provider" },
148+
{ icon: faCheck, label: "On-Prem Deployment" },
149+
{ icon: faCheck, label: "Audit logs" },
150+
{ icon: faCheck, label: "Custom Roles" },
151+
{ icon: faCheck, label: "Device Tracking" },
152+
]}
153+
{...props}
154+
/>
155+
);
156+
};

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: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ export const createOrganizationContext = ({
201201
const response = await client.projects.create({
202202
displayName: data.displayName,
203203
name: data.nameId,
204-
org: organization,
204+
organizationId: organization,
205205
});
206206

207207
return response;
@@ -236,10 +236,22 @@ export const createProjectContext = ({
236236
displayName: data.displayName,
237237
org: organization,
238238
});
239-
return response.namespace;
239+
return {
240+
id: response.namespace.id,
241+
name: response.namespace.name,
242+
displayName: response.namespace.displayName,
243+
createdAt: new Date(
244+
response.namespace.createdAt,
245+
).toISOString(),
246+
};
240247
},
241248
};
242249
},
250+
currentProjectQueryOptions: () => {
251+
return parent.currentOrgProjectQueryOptions({
252+
project,
253+
});
254+
},
243255
currentProjectNamespacesQueryOptions: () => {
244256
return parent.orgProjectNamespacesQueryOptions({
245257
organization,
@@ -258,18 +270,45 @@ export const createProjectContext = ({
258270
namespace: opts.namespace,
259271
});
260272
},
273+
currentProjectBillingDetailsQueryOptions() {
274+
return queryOptions({
275+
queryKey: [{ organization, project }, "billing-details"],
276+
queryFn: async ({ signal: abortSignal }) => {
277+
const response = await client.billing.details(
278+
project,
279+
{ org: organization },
280+
{ abortSignal },
281+
);
282+
return response;
283+
},
284+
});
285+
},
286+
changeCurrentProjectBillingPlanMutationOptions() {
287+
return {
288+
mutationKey: [{ organization, project }, "billing"],
289+
mutationFn: async (data: Rivet.BillingSetPlanRequest) => {
290+
const response = await client.billing.setPlan(project, {
291+
plan: data.plan,
292+
org: organization,
293+
});
294+
return response;
295+
},
296+
};
297+
},
261298
};
262299
};
263300

264301
export const createNamespaceContext = ({
265302
namespace,
266303
engineNamespaceName,
267304
engineNamespaceId,
305+
engineToken,
268306
...parent
269307
}: {
270308
namespace: string;
271309
engineNamespaceName: string;
272310
engineNamespaceId: string;
311+
engineToken?: (() => string) | string;
273312
} & ReturnType<typeof createProjectContext> &
274313
ReturnType<typeof createOrganizationContext> &
275314
ReturnType<typeof createGlobalContext>) => {
@@ -278,7 +317,9 @@ export const createNamespaceContext = ({
278317
...parent,
279318
namespace: engineNamespaceName,
280319
namespaceId: engineNamespaceId,
281-
client: createEngineClient(),
320+
client: createEngineClient(cloudEnv().VITE_APP_CLOUD_ENGINE_URL, {
321+
token: engineToken,
322+
}),
282323
}),
283324
namespaceQueryOptions() {
284325
return parent.currentProjectNamespaceQueryOptions({ namespace });

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

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ export type Namespace = {
2828
createdAt: string;
2929
};
3030

31-
export function createClient(opts: { token: (() => string) | string }) {
31+
export function createClient(
32+
baseUrl = engineEnv().VITE_APP_API_URL,
33+
opts: { token: (() => string) | string },
34+
) {
3235
return new RivetClient({
33-
baseUrl: () => engineEnv().VITE_APP_API_URL,
36+
baseUrl: () => baseUrl,
3437
environment: "",
3538
...opts,
3639
});
@@ -39,7 +42,9 @@ export function createClient(opts: { token: (() => string) | string }) {
3942
export const createGlobalContext = (opts: {
4043
engineToken: (() => string) | string;
4144
}) => {
42-
const client = createClient({ token: opts.engineToken });
45+
const client = createClient(engineEnv().VITE_APP_API_URL, {
46+
token: opts.engineToken,
47+
});
4348
return {
4449
client,
4550
namespacesQueryOptions() {
@@ -116,6 +121,10 @@ export const createNamespaceContext = ({
116121
statusQueryOptions() {
117122
return queryOptions({
118123
...def.statusQueryOptions(),
124+
queryKey: [
125+
{ namespace, namespaceId },
126+
...def.statusQueryOptions().queryKey,
127+
],
119128
enabled: true,
120129
queryFn: async () => {
121130
return true;
@@ -131,6 +140,10 @@ export const createNamespaceContext = ({
131140
return infiniteQueryOptions({
132141
...def.regionsQueryOptions(),
133142
enabled: true,
143+
queryKey: [
144+
{ namespace, namespaceId },
145+
...def.regionsQueryOptions().queryKey,
146+
],
134147
queryFn: async () => {
135148
const data = await client.datacenters.list();
136149
return {
@@ -151,7 +164,10 @@ export const createNamespaceContext = ({
151164
regionQueryOptions(regionId: string | undefined) {
152165
return queryOptions({
153166
...def.regionQueryOptions(regionId),
154-
queryKey: ["region", regionId],
167+
queryKey: [
168+
{ namespace, namespaceId },
169+
...def.regionQueryOptions(regionId).queryKey,
170+
],
155171
queryFn: async ({ client }) => {
156172
const regions = await client.ensureInfiniteQueryData(
157173
this.regionsQueryOptions(),
@@ -177,7 +193,10 @@ export const createNamespaceContext = ({
177193
actorQueryOptions(actorId) {
178194
return queryOptions({
179195
...def.actorQueryOptions(actorId),
180-
queryKey: [namespace, "actor", actorId],
196+
queryKey: [
197+
{ namespace, namespaceId },
198+
...def.actorQueryOptions(actorId).queryKey,
199+
],
181200
enabled: true,
182201
queryFn: async ({ signal: abortSignal }) => {
183202
const data = await client.actorsList(
@@ -201,7 +220,10 @@ export const createNamespaceContext = ({
201220
actorsQueryOptions(opts) {
202221
return infiniteQueryOptions({
203222
...def.actorsQueryOptions(opts),
204-
queryKey: [namespace, "actors", opts],
223+
queryKey: [
224+
{ namespace, namespaceId },
225+
...def.actorsQueryOptions(opts).queryKey,
226+
],
205227
enabled: true,
206228
initialPageParam: undefined,
207229
queryFn: async ({
@@ -273,7 +295,10 @@ export const createNamespaceContext = ({
273295
buildsQueryOptions() {
274296
return infiniteQueryOptions({
275297
...def.buildsQueryOptions(),
276-
queryKey: [namespace, "builds"],
298+
queryKey: [
299+
{ namespace, namespaceId },
300+
...def.buildsQueryOptions().queryKey,
301+
],
277302
enabled: true,
278303
queryFn: async ({ signal: abortSignal, pageParam }) => {
279304
const data = await client.actorsListNames(
@@ -493,16 +518,14 @@ export const createNamespaceContext = ({
493518
initialPageParam: undefined as string | undefined,
494519
queryFn: async ({ signal: abortSignal, pageParam }) => {
495520
const response = await client.namespacesRunnerConfigs.list(
496-
namespaceId,
521+
namespace,
497522
{
498523
cursor: pageParam ?? undefined,
499524
limit: RECORDS_PER_PAGE,
500525
},
501526
{ abortSignal },
502527
);
503528

504-
console.log(response);
505-
506529
return response;
507530
},
508531

0 commit comments

Comments
 (0)