diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 85cd58644e..7f6627872b 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -56,7 +56,21 @@ function CloudApp() { return ( diff --git a/frontend/src/app/billing/plan-card.tsx b/frontend/src/app/billing/plan-card.tsx new file mode 100644 index 0000000000..dfb6c108a1 --- /dev/null +++ b/frontend/src/app/billing/plan-card.tsx @@ -0,0 +1,156 @@ +import { faCheck, faPlus, Icon, type IconProp } from "@rivet-gg/icons"; +import type { ReactNode } from "react"; +import { Button, cn } from "@/components"; + +type PlanCardProps = { + title: string; + price: string; + features: { icon: IconProp; label: ReactNode }[]; + usageBased?: boolean; + custom?: boolean; + current?: boolean; + buttonProps?: React.ComponentProps; +} & React.ComponentProps<"div">; + +function PlanCard({ + title, + price, + features, + usageBased, + current, + custom, + className, + buttonProps, + ...props +}: PlanCardProps) { + return ( +
+

{title}

+
+ {usageBased ? ( +

From

+ ) : null} +

+ {price} + {custom ? null : ( + /mo + )} +

+ {usageBased ? ( +

+ Usage

+ ) : null} +
+
+

Includes:

+
    + {features?.map((feature, index) => ( +
  • + {feature.label} +
  • + ))} +
+
+ {current ? ( + + ) : ( +
+ ); +} + +export const CommunityPlan = (props: Partial) => { + return ( + + ); +}; + +export const ProPlan = (props: Partial) => { + return ( + + ); +}; + +export const TeamPlan = (props: Partial) => { + return ( + + ); +}; + +export const EnterprisePlan = (props: Partial) => { + return ( + + ); +}; diff --git a/frontend/src/app/context-switcher.tsx b/frontend/src/app/context-switcher.tsx index 3aeda0fff5..21a8a76294 100644 --- a/frontend/src/app/context-switcher.tsx +++ b/frontend/src/app/context-switcher.tsx @@ -76,6 +76,7 @@ function Breadcrumbs() { const matchProject = match({ to: "/orgs/$organization/projects/$project", + fuzzy: true, }); if (matchProject) { diff --git a/frontend/src/app/data-providers/cloud-data-provider.tsx b/frontend/src/app/data-providers/cloud-data-provider.tsx index 47bef2e1d0..04695583e4 100644 --- a/frontend/src/app/data-providers/cloud-data-provider.tsx +++ b/frontend/src/app/data-providers/cloud-data-provider.tsx @@ -201,7 +201,7 @@ export const createOrganizationContext = ({ const response = await client.projects.create({ displayName: data.displayName, name: data.nameId, - org: organization, + organizationId: organization, }); return response; @@ -236,10 +236,22 @@ export const createProjectContext = ({ displayName: data.displayName, org: organization, }); - return response.namespace; + return { + id: response.namespace.id, + name: response.namespace.name, + displayName: response.namespace.displayName, + createdAt: new Date( + response.namespace.createdAt, + ).toISOString(), + }; }, }; }, + currentProjectQueryOptions: () => { + return parent.currentOrgProjectQueryOptions({ + project, + }); + }, currentProjectNamespacesQueryOptions: () => { return parent.orgProjectNamespacesQueryOptions({ organization, @@ -258,6 +270,31 @@ export const createProjectContext = ({ namespace: opts.namespace, }); }, + currentProjectBillingDetailsQueryOptions() { + return queryOptions({ + queryKey: [{ organization, project }, "billing-details"], + queryFn: async ({ signal: abortSignal }) => { + const response = await client.billing.details( + project, + {org: organization }, + { abortSignal }, + ); + return response; + }, + }); + }, + changeCurrentProjectBillingPlanMutationOptions() { + return { + mutationKey: [{ organization, project }, "billing"], + mutationFn: async (data: Rivet.BillingSetPlanRequest) => { + const response = await client.billing.setPlan(project, { + plan: data.plan, + org: organization, + }); + return response; + }, + }; + }, }; }; @@ -278,7 +315,7 @@ export const createNamespaceContext = ({ ...parent, namespace: engineNamespaceName, namespaceId: engineNamespaceId, - client: createEngineClient(), + client: createEngineClient(cloudEnv().VITE_APP_CLOUD_ENGINE_URL), }), namespaceQueryOptions() { return parent.currentProjectNamespaceQueryOptions({ namespace }); diff --git a/frontend/src/app/data-providers/engine-data-provider.tsx b/frontend/src/app/data-providers/engine-data-provider.tsx index 87a1491398..25bcd5ccb8 100644 --- a/frontend/src/app/data-providers/engine-data-provider.tsx +++ b/frontend/src/app/data-providers/engine-data-provider.tsx @@ -23,9 +23,9 @@ export type Namespace = { createdAt: string; }; -export function createClient() { +export function createClient(baseUrl = engineEnv().VITE_APP_API_URL) { return new RivetClient({ - baseUrl: () => engineEnv().VITE_APP_API_URL, + baseUrl: () => baseUrl, environment: "", }); } @@ -108,6 +108,10 @@ export const createNamespaceContext = ({ statusQueryOptions() { return queryOptions({ ...def.statusQueryOptions(), + queryKey: [ + { namespace, namespaceId }, + ...def.statusQueryOptions().queryKey, + ], enabled: true, queryFn: async () => { return true; @@ -118,6 +122,10 @@ export const createNamespaceContext = ({ return infiniteQueryOptions({ ...def.regionsQueryOptions(), enabled: true, + queryKey: [ + { namespace, namespaceId }, + ...def.regionsQueryOptions().queryKey, + ], queryFn: async () => { const data = await client.datacenters.list(); return { @@ -133,7 +141,10 @@ export const createNamespaceContext = ({ regionQueryOptions(regionId: string | undefined) { return queryOptions({ ...def.regionQueryOptions(regionId), - queryKey: ["region", regionId], + queryKey: [ + { namespace, namespaceId }, + ...def.regionQueryOptions(regionId).queryKey, + ], queryFn: async ({ client }) => { const regions = await client.ensureInfiniteQueryData( this.regionsQueryOptions(), @@ -154,7 +165,10 @@ export const createNamespaceContext = ({ actorQueryOptions(actorId) { return queryOptions({ ...def.actorQueryOptions(actorId), - queryKey: [namespace, "actor", actorId], + queryKey: [ + { namespace, namespaceId }, + ...def.actorQueryOptions(actorId).queryKey, + ], enabled: true, queryFn: async ({ signal: abortSignal }) => { const data = await client.actorsGet( @@ -170,7 +184,10 @@ export const createNamespaceContext = ({ actorsQueryOptions(opts) { return infiniteQueryOptions({ ...def.actorsQueryOptions(opts), - queryKey: [namespace, "actors", opts], + queryKey: [ + { namespace, namespaceId }, + ...def.actorsQueryOptions(opts).queryKey, + ], enabled: true, initialPageParam: undefined, queryFn: async ({ @@ -237,7 +254,10 @@ export const createNamespaceContext = ({ buildsQueryOptions() { return infiniteQueryOptions({ ...def.buildsQueryOptions(), - queryKey: [namespace, "builds"], + queryKey: [ + { namespace, namespaceId }, + ...def.buildsQueryOptions().queryKey, + ], enabled: true, queryFn: async ({ signal: abortSignal, pageParam }) => { const data = await client.actorsListNames( @@ -402,7 +422,7 @@ export const createNamespaceContext = ({ initialPageParam: undefined as string | undefined, queryFn: async ({ signal: abortSignal, pageParam }) => { const response = await client.namespacesRunnerConfigs.list( - namespaceId, + namespace, { cursor: pageParam ?? undefined, limit: RECORDS_PER_PAGE, @@ -410,8 +430,6 @@ export const createNamespaceContext = ({ { abortSignal }, ); - console.log(response); - return response; }, diff --git a/frontend/src/app/dialogs/billing-frame.tsx b/frontend/src/app/dialogs/billing-frame.tsx new file mode 100644 index 0000000000..f5995191a5 --- /dev/null +++ b/frontend/src/app/dialogs/billing-frame.tsx @@ -0,0 +1,188 @@ +import { + useMutation, + useQuery, + useSuspenseQueries, +} from "@tanstack/react-query"; +import { useRouteContext } from "@tanstack/react-router"; +import type { ComponentProps } from "react"; +import { Button, DocsSheet, Frame, Link } from "@/components"; +import { queryClient } from "@/queries/global"; +import { + CommunityPlan, + EnterprisePlan, + ProPlan, + TeamPlan, +} from "../billing/plan-card"; + +export default function BillingFrameContent() { + const { dataProvider } = useRouteContext({ + from: "/_context/_cloud/orgs/$organization/projects/$project", + }); + + const [ + { data: project }, + { + data: { billing }, + }, + ] = useSuspenseQueries({ + queries: [ + dataProvider.currentProjectQueryOptions(), + dataProvider.currentProjectBillingDetailsQueryOptions(), + ], + }); + + const { mutate, isPending, variables } = useMutation({ + ...dataProvider.changeCurrentProjectBillingPlanMutationOptions(), + onSuccess: async () => { + await queryClient.invalidateQueries( + dataProvider.currentProjectBillingDetailsQueryOptions(), + ); + }, + }); + + return ( + <> + + {project.name} billing + + Manage billing for your Rivet Cloud project.{" "} + + + Learn more about billing. + + + + + +
+
+

+ You are currently on the{" "} + + + {" "} + plan.{" "} + {billing?.futurePlan && + billing.activePlan !== billing?.futurePlan && + billing.currentPeriodEnd ? ( + <> + Your plan will change to{" "} + + + {" "} + on{" "} + {new Date( + billing.currentPeriodEnd, + ).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + })} + .{" "} + + ) : null} + {!billing?.canChangePlan ? ( + // organization does not have a payment method, ask them to add one + + You cannot change plans until you add a + payment method to your organization. + + ) : null} +

+
+ + + Manage billing details + +
+
+ mutate({ plan: "community" }), + disabled: + billing?.activePlan === "free" || + !billing?.activePlan || + !billing?.canChangePlan, + }} + /> + { + if (billing?.activePlan === "pro") { + return mutate({ plan: "free" }); + } + return mutate({ plan: "pro" }); + }, + disabled: !billing?.canChangePlan, + ...(billing?.activePlan === "pro" && + billing?.futurePlan !== "free" + ? { children: "Cancel" } + : {}), + }} + /> + { + if (billing?.activePlan === "team") { + return mutate({ plan: "free" }); + } + return mutate({ plan: "team" }); + }, + disabled: !billing?.canChangePlan, + ...(billing?.activePlan === "team" && + billing?.futurePlan !== "free" + ? { children: "Cancel" } + : {}), + }} + /> + +
+
+ + ); +} + +function CurrentPlan({ plan }: { plan?: string }) { + if (!plan || plan === "free") return <>Free; + if (plan === "pro") return <>Hobby; + if (plan === "team") return <>Team; + return <>Enterprise; +} + +function BillingDetailsButton(props: ComponentProps) { + const { dataProvider } = useRouteContext({ + from: "/_context/_cloud/orgs/$organization/projects/$project", + }); + + const { data, refetch } = useQuery( + dataProvider.billingCustomerPortalSessionQueryOptions(), + ); + + return ( + + ))} + {hasNextPage ? : null} + + + ); +} + +function ListItemSkeleton() { + return ( +
+ + +
+ ); +} diff --git a/frontend/vendor/rivet-cloud.tgz b/frontend/vendor/rivet-cloud.tgz index b60caca157..318104e903 100644 --- a/frontend/vendor/rivet-cloud.tgz +++ b/frontend/vendor/rivet-cloud.tgz @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:69dc49dceb40db5c7884ee70f3e006229fa549697ba0df835710d5e6c7fd1d33 -size 183796 +oid sha256:1a290026823473217c79efd0f6341c4becd8cdd766bb253db7e4dd6cce397e73 +size 188883 diff --git a/out/openapi.json b/out/openapi.json index b31291a09a..a18d724e20 100644 --- a/out/openapi.json +++ b/out/openapi.json @@ -1575,12 +1575,6 @@ } }, "additionalProperties": false - }, - "RunnerConfigVariant": { - "type": "string", - "enum": [ - "serverless" - ] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce4c1af304..1a9508b075 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3192,7 +3192,7 @@ packages: resolution: {integrity: sha512-aGJVImxsmz8fLLzeZHUlFRJ/7Y/xBrke9bOvMpooVaJpHor/XmiP19QeEtB2hmQUOPlgS3dz5o8UtCZ5+LcQGg==} '@rivet-gg/cloud@file:frontend/vendor/rivet-cloud.tgz': - resolution: {integrity: sha512-O2b6Rm4HDGHkFEZMasXzQ08wyMw12CwSsGZzwXFLCq5oaulJdxoPA7U1V8EhwUykI3+2ur1EQGiiC2v2K2Fqvg==, tarball: file:frontend/vendor/rivet-cloud.tgz} + resolution: {integrity: sha512-kfR9nz6QqN0Mp0U28C8gGm5cX5o1lc4KciDjRXnnxsAwIYRxBe6SKvhsa1eSbnSZKSG1MmCAs8ISp81CjCvQRA==, tarball: file:frontend/vendor/rivet-cloud.tgz} version: 0.0.0 '@rivetkit/actor@file:frontend/vendor/rivetkit-actor.tgz':