Skip to content

Commit 3c4dd22

Browse files
committed
self-serve infra deployment
1 parent 9f88b5c commit 3c4dd22

File tree

13 files changed

+835
-25
lines changed

13 files changed

+835
-25
lines changed

apps/dashboard/src/@/actions/billing.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"use server";
2+
import "server-only";
23

34
import { getAuthToken } from "@/api/auth-token";
45
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
6+
import type { ChainInfraSKU } from "@/types/billing";
7+
import { getAbsoluteUrl } from "@/utils/vercel";
58

69
export async function reSubscribePlan(options: {
710
teamId: string;
@@ -35,3 +38,77 @@ export async function reSubscribePlan(options: {
3538
status: 200,
3639
};
3740
}
41+
42+
export async function getChainInfraCheckoutURL(options: {
43+
teamSlug: string;
44+
skus: ChainInfraSKU[];
45+
chainId: number;
46+
annual: boolean;
47+
}) {
48+
const token = await getAuthToken();
49+
50+
if (!token) {
51+
return {
52+
error: "You are not logged in",
53+
status: "error",
54+
} as const;
55+
}
56+
57+
const redirectTo = new URL(getAbsoluteUrl());
58+
redirectTo.pathname = `/team/${options.teamSlug}/chain-infra/${options.chainId}`;
59+
60+
const res = await fetch(
61+
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamSlug}/checkout/create-link`,
62+
{
63+
body: JSON.stringify({
64+
annual: options.annual,
65+
chainId: options.chainId,
66+
redirectTo: redirectTo.toString(),
67+
skus: options.skus,
68+
}),
69+
headers: {
70+
Authorization: `Bearer ${token}`,
71+
"Content-Type": "application/json",
72+
},
73+
method: "POST",
74+
},
75+
);
76+
if (!res.ok) {
77+
const text = await res.text();
78+
console.error("Failed to create checkout link", text, res.status);
79+
switch (res.status) {
80+
case 402: {
81+
return {
82+
error:
83+
"You have outstanding invoices, please pay these first before re-subscribing.",
84+
status: "error",
85+
} as const;
86+
}
87+
case 429: {
88+
return {
89+
error: "Too many requests, please try again later.",
90+
status: "error",
91+
} as const;
92+
}
93+
default: {
94+
return {
95+
error: "An unknown error occurred, please try again later.",
96+
status: "error",
97+
} as const;
98+
}
99+
}
100+
}
101+
102+
const json = await res.json();
103+
if (!json.result) {
104+
return {
105+
error: "An unknown error occurred, please try again later.",
106+
status: "error",
107+
} as const;
108+
}
109+
110+
return {
111+
data: json.result as string,
112+
status: "success",
113+
} as const;
114+
}

apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export function SingleNetworkSelector(props: {
152152
disableChainId?: boolean;
153153
align?: "center" | "start" | "end";
154154
disableTestnets?: boolean;
155+
disableDeprecated?: boolean;
155156
placeholder?: string;
156157
client: ThirdwebClient;
157158
}) {
@@ -169,8 +170,17 @@ export function SingleNetworkSelector(props: {
169170
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
170171
}
171172

173+
if (props.disableDeprecated) {
174+
chains = chains.filter((chain) => chain.status !== "deprecated");
175+
}
176+
172177
return chains;
173-
}, [allChains, props.chainIds, props.disableTestnets]);
178+
}, [
179+
allChains,
180+
props.chainIds,
181+
props.disableTestnets,
182+
props.disableDeprecated,
183+
]);
174184

175185
const options = useMemo(() => {
176186
return chainsToShow.map((chain) => {

apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,18 @@ const ACCENT = {
3838
type UpsellBannerCardProps = {
3939
title: React.ReactNode;
4040
description: React.ReactNode;
41-
cta: {
42-
text: React.ReactNode;
43-
icon?: React.ReactNode;
44-
target?: "_blank";
45-
link: string;
46-
};
41+
cta?:
42+
| {
43+
text: React.ReactNode;
44+
icon?: React.ReactNode;
45+
target?: "_blank";
46+
link: string;
47+
}
48+
| {
49+
text: React.ReactNode;
50+
icon?: React.ReactNode;
51+
onClick: () => void;
52+
};
4753
accentColor?: keyof typeof ACCENT;
4854
icon?: React.ReactNode;
4955
};
@@ -93,25 +99,41 @@ export function UpsellBannerCard(props: UpsellBannerCardProps) {
9399
</div>
94100
</div>
95101

96-
<Button
97-
asChild
98-
className={cn(
99-
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
100-
color.btn,
101-
)}
102-
size="sm"
103-
>
104-
<Link
105-
href={props.cta.link}
106-
rel={
107-
props.cta.target === "_blank" ? "noopener noreferrer" : undefined
108-
}
109-
target={props.cta.target}
102+
{props.cta && "target" in props.cta ? (
103+
<Button
104+
asChild
105+
className={cn(
106+
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
107+
color.btn,
108+
)}
109+
size="sm"
110+
>
111+
<Link
112+
href={props.cta.link}
113+
rel={
114+
props.cta.target === "_blank"
115+
? "noopener noreferrer"
116+
: undefined
117+
}
118+
target={props.cta.target}
119+
>
120+
{props.cta.text}
121+
{props.cta.icon && <span className="ml-2">{props.cta.icon}</span>}
122+
</Link>
123+
</Button>
124+
) : props.cta && "onClick" in props.cta ? (
125+
<Button
126+
className={cn(
127+
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
128+
color.btn,
129+
)}
130+
onClick={props.cta.onClick}
131+
size="sm"
110132
>
111133
{props.cta.text}
112134
{props.cta.icon && <span className="ml-2">{props.cta.icon}</span>}
113-
</Link>
114-
</Button>
135+
</Button>
136+
) : null}
115137
</div>
116138
</div>
117139
);

apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,14 @@ function RenderSidebarMenu(props: { links: ShadcnSidebarLink[] }) {
279279
// subnav
280280
if ("subMenu" in link) {
281281
return (
282-
<RenderSidebarSubmenu links={link.links} subMenu={link.subMenu} />
282+
<RenderSidebarSubmenu
283+
key={`submenu_$${
284+
// biome-ignore lint/suspicious/noArrayIndexKey: index is fine here
285+
idx
286+
}`}
287+
links={link.links}
288+
subMenu={link.subMenu}
289+
/>
283290
);
284291
}
285292

apps/dashboard/src/@/icons/ChainIcon.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export const ChainIconClient = ({
3030
fallback={<img alt="" src={fallbackChainIcon} />}
3131
key={resolvedSrc}
3232
loading={restProps.loading || "lazy"}
33-
skeleton={<div className="animate-pulse rounded-full bg-border" />}
33+
skeleton={<span className="animate-pulse rounded-full bg-border" />}
3434
src={resolvedSrc}
3535
/>
3636
);

apps/dashboard/src/@/types/billing.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,8 @@ export type ProductSKU =
1414
| "usage:aa_sponsorship"
1515
| "usage:aa_sponsorship_op_grant"
1616
| null;
17+
18+
export type ChainInfraSKU =
19+
| "chain:infra:rpc"
20+
| "chain:infra:insight"
21+
| "chain:infra:account_abstraction";
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { getChain } from "../../../../../../(dashboard)/(chain)/utils";
2+
3+
export default async function InfrastructurePage(props: {
4+
params: Promise<{
5+
team_slug: string;
6+
chain_id: string;
7+
}>;
8+
}) {
9+
const params = await props.params;
10+
const chain = await getChain(params.chain_id);
11+
return <div>Infrastructure for: {chain.name}</div>;
12+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { ArrowUpDownIcon } from "lucide-react";
2+
import Link from "next/link";
3+
import { notFound, redirect } from "next/navigation";
4+
import { Badge } from "@/components/ui/badge";
5+
import { Button } from "@/components/ui/button";
6+
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
7+
import { ChainIconClient } from "@/icons/ChainIcon";
8+
import { getChain } from "../../../../../../../(dashboard)/(chain)/utils";
9+
import { DeployInfrastructureForm } from "../_components/deploy-infrastructure-form.client";
10+
11+
/**
12+
* This page is the second step of the 2 step process to deploy infrastructure on a chain, the available services are displayed here and the customer can select them
13+
* The available sercices are:
14+
* - RPC: $1500/mo | $15300/yr (15% off) [always required, cannot be removed]
15+
* - Insight: $1500/mo | $15300/yr (15% off)
16+
* - Account Abstraction: $600/mo | $6120/yr (15% off)
17+
*
18+
* There are discounts for annual payments (indicated above), the customer can select the payment frequency and the price will be adjusted accordingly
19+
* There are discounts for bundles of services:
20+
* - 2 services: 10% off
21+
* - 3 services: 15% off
22+
*
23+
* the customer should be shown a clear breakdown of the prices and discounts applied
24+
* for finalization we'll use the `getChainInfraCheckoutURL()` server action to create a stripe checkout link
25+
*/
26+
27+
export default async function DeployInfrastructureOnChainPage(props: {
28+
params: Promise<{ chain_id: string; team_slug: string }>;
29+
}) {
30+
const params = await props.params;
31+
const chain = await getChain(params.chain_id);
32+
33+
if (!chain) {
34+
notFound();
35+
}
36+
if (chain.slug !== params.chain_id) {
37+
// redirect to the slug version of the page
38+
redirect(`/team/${params.team_slug}/~/infrastructure/deploy/${chain.slug}`);
39+
}
40+
41+
const client = getClientThirdwebClient();
42+
43+
return (
44+
<div className="flex flex-col gap-8">
45+
<div className="flex flex-col items-center gap-4 md:flex-row">
46+
<h2 className="text-2xl font-bold flex items-center gap-2">
47+
Deploy Infrastructure on
48+
</h2>
49+
<div className="bg-accent/50 rounded-md p-4 border">
50+
<div className="flex gap-4 items-center">
51+
<span className="flex gap-2 truncate text-left items-center">
52+
{chain.icon && (
53+
<ChainIconClient
54+
className="size-6"
55+
client={client}
56+
loading="lazy"
57+
src={chain.icon?.url}
58+
/>
59+
)}
60+
{cleanChainName(chain.name)}
61+
</span>
62+
63+
<Badge className="gap-2" variant="outline">
64+
<span className="text-muted-foreground">Chain ID</span>
65+
{chain.chainId}
66+
</Badge>
67+
<Button
68+
asChild
69+
className="text-muted-foreground p-0 hover:text-foreground size-4"
70+
size="icon"
71+
variant="link"
72+
>
73+
<Link href={`/team/${params.team_slug}/~/infrastructure/deploy`}>
74+
<ArrowUpDownIcon className="size-4" />
75+
</Link>
76+
</Button>
77+
</div>
78+
</div>
79+
</div>
80+
<DeployInfrastructureForm chain={chain} teamSlug={params.team_slug} />
81+
</div>
82+
);
83+
}
84+
85+
function cleanChainName(chainName: string) {
86+
return chainName.replace("Mainnet", "");
87+
}

0 commit comments

Comments
 (0)