Skip to content

Commit a91b348

Browse files
committed
feat(cloud): billing
1 parent 8fedabe commit a91b348

26 files changed

+893
-83
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: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ const shouldRetryAllExpect403 = (failureCount: number, error: Error) => {
3838
return true;
3939
};
4040

41-
export function createClient(opts: { token?: (() => string) | string } = {}) {
41+
export function createClient(baseUrl = engineEnv().VITE_APP_API_URL, opts: { token?: (() => string) | string } = {}) {
4242
return new RivetClient({
43-
baseUrl: () => engineEnv().VITE_APP_API_URL,
43+
baseUrl: () => baseUrl,
4444
environment: "",
4545
...opts,
4646
});
@@ -49,7 +49,7 @@ export function createClient(opts: { token?: (() => string) | string } = {}) {
4949
export const createGlobalContext = (
5050
opts: { engineToken?: (() => string) | string } = {},
5151
) => {
52-
const client = createClient({ token: opts.engineToken });
52+
const client = createClient(engineEnv().VITE_APP_API_URL, { token: opts.engineToken });
5353
return {
5454
client,
5555
namespacesQueryOptions() {
@@ -126,6 +126,10 @@ export const createNamespaceContext = ({
126126
statusQueryOptions() {
127127
return queryOptions({
128128
...def.statusQueryOptions(),
129+
queryKey: [
130+
{ namespace, namespaceId },
131+
...def.statusQueryOptions().queryKey,
132+
],
129133
enabled: true,
130134
queryFn: async () => {
131135
return true;
@@ -140,6 +144,10 @@ export const createNamespaceContext = ({
140144
return infiniteQueryOptions({
141145
...def.regionsQueryOptions(),
142146
enabled: true,
147+
queryKey: [
148+
{ namespace, namespaceId },
149+
...def.regionsQueryOptions().queryKey,
150+
],
143151
queryFn: async () => {
144152
const data = await client.datacenters.list();
145153
return {
@@ -159,7 +167,10 @@ export const createNamespaceContext = ({
159167
regionQueryOptions(regionId: string | undefined) {
160168
return queryOptions({
161169
...def.regionQueryOptions(regionId),
162-
queryKey: ["region", regionId],
170+
queryKey: [
171+
{ namespace, namespaceId },
172+
...def.regionQueryOptions(regionId).queryKey,
173+
],
163174
queryFn: async ({ client }) => {
164175
const regions = await client.ensureInfiniteQueryData(
165176
this.regionsQueryOptions(),
@@ -184,7 +195,10 @@ export const createNamespaceContext = ({
184195
actorQueryOptions(actorId) {
185196
return queryOptions({
186197
...def.actorQueryOptions(actorId),
187-
queryKey: [namespace, "actor", actorId],
198+
queryKey: [
199+
{ namespace, namespaceId },
200+
...def.actorQueryOptions(actorId).queryKey,
201+
],
188202
enabled: true,
189203
queryFn: async ({ signal: abortSignal }) => {
190204
const data = await client.actorsGet(
@@ -204,7 +218,10 @@ export const createNamespaceContext = ({
204218
actorsQueryOptions(opts) {
205219
return infiniteQueryOptions({
206220
...def.actorsQueryOptions(opts),
207-
queryKey: [namespace, "actors", opts],
221+
queryKey: [
222+
{ namespace, namespaceId },
223+
...def.actorsQueryOptions(opts).queryKey,
224+
],
208225
enabled: true,
209226
initialPageParam: undefined,
210227
queryFn: async ({
@@ -275,7 +292,10 @@ export const createNamespaceContext = ({
275292
buildsQueryOptions() {
276293
return infiniteQueryOptions({
277294
...def.buildsQueryOptions(),
278-
queryKey: [namespace, "builds"],
295+
queryKey: [
296+
{ namespace, namespaceId },
297+
...def.buildsQueryOptions().queryKey,
298+
],
279299
enabled: true,
280300
queryFn: async ({ signal: abortSignal, pageParam }) => {
281301
const data = await client.actorsListNames(
@@ -460,16 +480,14 @@ export const createNamespaceContext = ({
460480
initialPageParam: undefined as string | undefined,
461481
queryFn: async ({ signal: abortSignal, pageParam }) => {
462482
const response = await client.namespacesRunnerConfigs.list(
463-
namespaceId,
483+
namespace,
464484
{
465485
cursor: pageParam ?? undefined,
466486
limit: RECORDS_PER_PAGE,
467487
},
468488
{ abortSignal },
469489
);
470490

471-
console.log(response);
472-
473491
return response;
474492
},
475493

0 commit comments

Comments
 (0)