Skip to content

Commit 4b44311

Browse files
committed
self-serve infra deployment
1 parent 9f88b5c commit 4b44311

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1300
-201
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+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import "server-only";
2+
3+
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
4+
import { getAuthToken } from "./auth-token";
5+
16
export const authOptions = [
27
"email",
38
"phone",
@@ -18,6 +23,8 @@ export const authOptions = [
1823
"line",
1924
] as const;
2025

26+
export type AuthOption = (typeof authOptions)[number];
27+
2128
export type Ecosystem = {
2229
name: string;
2330
imageUrl?: string;
@@ -47,6 +54,54 @@ export type Ecosystem = {
4754
updatedAt: string;
4855
};
4956

57+
export async function fetchEcosystemList(teamIdOrSlug: string) {
58+
const token = await getAuthToken();
59+
60+
if (!token) {
61+
return [];
62+
}
63+
64+
const res = await fetch(
65+
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet`,
66+
{
67+
headers: {
68+
Authorization: `Bearer ${token}`,
69+
},
70+
},
71+
);
72+
73+
if (!res.ok) {
74+
return [];
75+
}
76+
77+
return (await res.json()).result as Ecosystem[];
78+
}
79+
80+
export async function fetchEcosystem(slug: string, teamIdOrSlug: string) {
81+
const token = await getAuthToken();
82+
83+
if (!token) {
84+
return null;
85+
}
86+
87+
const res = await fetch(
88+
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
89+
{
90+
headers: {
91+
Authorization: `Bearer ${token}`,
92+
},
93+
},
94+
);
95+
if (!res.ok) {
96+
const data = await res.json();
97+
console.error(data);
98+
return null;
99+
}
100+
101+
const data = (await res.json()) as { result: Ecosystem };
102+
return data.result;
103+
}
104+
50105
type PartnerPermission = "PROMPT_USER_V1" | "FULL_CONTROL_V1";
51106
export type Partner = {
52107
id: string;

apps/dashboard/src/@/api/team-subscription.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getAuthToken } from "@/api/auth-token";
22
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
3-
import type { ProductSKU } from "@/types/billing";
3+
import type { ChainInfraSKU, ProductSKU } from "@/types/billing";
44

55
type InvoiceLine = {
66
// amount for this line item
@@ -22,7 +22,7 @@ type Invoice = {
2222

2323
export type TeamSubscription = {
2424
id: string;
25-
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT";
25+
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT" | "CHAIN";
2626
status:
2727
| "incomplete"
2828
| "incomplete_expired"
@@ -37,6 +37,13 @@ export type TeamSubscription = {
3737
trialStart: string | null;
3838
trialEnd: string | null;
3939
upcomingInvoice: Invoice;
40+
skus: (ProductSKU | ChainInfraSKU)[];
41+
};
42+
43+
type ChainTeamSubscription = Omit<TeamSubscription, "skus"> & {
44+
chainId: string;
45+
skus: ChainInfraSKU[];
46+
isLegacy: boolean;
4047
};
4148

4249
export async function getTeamSubscriptions(slug: string) {
@@ -60,3 +67,60 @@ export async function getTeamSubscriptions(slug: string) {
6067
}
6168
return null;
6269
}
70+
71+
const CHAIN_PLAN_TO_INFRA = {
72+
"chain:plan:gold": ["chain:infra:rpc", "chain:infra:account_abstraction"],
73+
"chain:plan:platinum": [
74+
"chain:infra:rpc",
75+
"chain:infra:insight",
76+
"chain:infra:account_abstraction",
77+
],
78+
"chain:plan:ultimate": [
79+
"chain:infra:rpc",
80+
"chain:infra:insight",
81+
"chain:infra:account_abstraction",
82+
],
83+
};
84+
85+
export async function getChainSubscriptions(slug: string) {
86+
const allSubscriptions = await getTeamSubscriptions(slug);
87+
if (!allSubscriptions) {
88+
return null;
89+
}
90+
91+
// first replace any sku that MIGHT match a chain plan
92+
const updatedSubscriptions = allSubscriptions
93+
.filter((s) => s.type === "CHAIN")
94+
.map((s) => {
95+
const skus = s.skus;
96+
const updatedSkus = skus.flatMap((sku) => {
97+
const plan =
98+
CHAIN_PLAN_TO_INFRA[sku as keyof typeof CHAIN_PLAN_TO_INFRA];
99+
return plan ? plan : sku;
100+
});
101+
return {
102+
...s,
103+
skus: updatedSkus,
104+
};
105+
});
106+
107+
return updatedSubscriptions.filter(
108+
(s): s is ChainTeamSubscription =>
109+
"chainId" in s && typeof s.chainId === "string",
110+
);
111+
}
112+
113+
export async function getChainSubscriptionForChain(
114+
slug: string,
115+
chainId: number,
116+
) {
117+
const chainSubscriptions = await getChainSubscriptions(slug);
118+
119+
if (!chainSubscriptions) {
120+
return null;
121+
}
122+
123+
return (
124+
chainSubscriptions.find((s) => s.chainId === chainId.toString()) ?? null
125+
);
126+
}

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
);

0 commit comments

Comments
 (0)