Skip to content

Commit fae444d

Browse files
committed
[Dashboard] Feature: Add Nebula Waitlist page (#5406)
## Problem solved Short description of the bug fixed or feature added <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces a new feature for managing a "Nebula" waitlist within a team dashboard. It adds new routes, components, and API functions to facilitate joining the waitlist and fetching its status. ### Detailed summary - Added a new route for `Nebula` under the team and project slugs. - Implemented `JoinNebulaWaitlistPage` component for handling waitlist interactions. - Created `joinTeamWaitlist` API function for server-side waitlist management. - Added `getTeamNebulaWaitList` API function to fetch waitlist status. - Updated existing pages to integrate the new waitlist functionality. - Enhanced UI components for displaying waitlist status and actions. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent b30c4d0 commit fae444d

File tree

9 files changed

+430
-5
lines changed

9 files changed

+430
-5
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"use server";
2+
3+
import { getAuthToken } from "../../app/api/lib/getAuthToken";
4+
import { API_SERVER_URL } from "../constants/env";
5+
6+
export async function joinTeamWaitlist(options: {
7+
teamSlug: string;
8+
// currently only 'nebula' is supported
9+
scope: "nebula";
10+
}) {
11+
const { teamSlug, scope } = options;
12+
const token = await getAuthToken();
13+
14+
if (!token) {
15+
throw new Error("No Auth token");
16+
}
17+
18+
const res = await fetch(`${API_SERVER_URL}/v1/teams/${teamSlug}/waitlist`, {
19+
method: "POST",
20+
headers: {
21+
"Content-Type": "application/json",
22+
Authorization: `Bearer ${token}`,
23+
},
24+
body: JSON.stringify({
25+
scope,
26+
}),
27+
});
28+
29+
if (!res.ok) {
30+
throw new Error("Failed to join waitlist");
31+
}
32+
33+
return true;
34+
}

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

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import "server-only";
22
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
33
import { API_SERVER_URL } from "@/constants/env";
44
import { cookies } from "next/headers";
5+
import { getAuthToken } from "../../app/api/lib/getAuthToken";
56

67
export type Team = {
78
id: string;
@@ -18,11 +19,7 @@ export type Team = {
1819
};
1920

2021
export async function getTeamBySlug(slug: string) {
21-
const cookiesManager = await cookies();
22-
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
23-
const token = activeAccount
24-
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
25-
: null;
22+
const token = await getAuthToken();
2623

2724
if (!token) {
2825
return null;
@@ -60,3 +57,31 @@ export async function getTeams() {
6057
}
6158
return [];
6259
}
60+
61+
type TeamNebulWaitList = {
62+
onWaitlist: boolean;
63+
createdAt: null | string;
64+
};
65+
66+
export async function getTeamNebulaWaitList(teamSlug: string) {
67+
const token = await getAuthToken();
68+
69+
if (!token) {
70+
return null;
71+
}
72+
73+
const res = await fetch(
74+
`${API_SERVER_URL}/v1/teams/${teamSlug}/waitlist?scope=nebula`,
75+
{
76+
headers: {
77+
Authorization: `Bearer ${token}`,
78+
},
79+
},
80+
);
81+
82+
if (res.ok) {
83+
return (await res.json()).result as TeamNebulWaitList;
84+
}
85+
86+
return null;
87+
}

apps/dashboard/src/app/team/[team_slug]/(team)/layout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ export default async function TeamLayout(props: {
5555
path: `/team/${params.team_slug}/~/ecosystem`,
5656
name: "Ecosystems",
5757
},
58+
{
59+
path: `/team/${params.team_slug}/~/nebula`,
60+
name: "Nebula",
61+
},
5862
{
5963
path: `/team/${params.team_slug}/~/usage`,
6064
name: "Usage",
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { getTeamBySlug, getTeamNebulaWaitList } from "@/api/team";
2+
import { redirect } from "next/navigation";
3+
import { JoinNebulaWaitlistPage } from "../../../[project_slug]/nebula/components/nebula-waitlist-page.client";
4+
5+
export default async function Page(props: {
6+
params: Promise<{
7+
team_slug: string;
8+
}>;
9+
}) {
10+
const params = await props.params;
11+
const team = await getTeamBySlug(params.team_slug);
12+
13+
if (!team) {
14+
redirect(
15+
`/login?next=${encodeURIComponent(`/team/${params.team_slug}/~/nebula`)}`,
16+
);
17+
}
18+
19+
const nebulaWaitList = await getTeamNebulaWaitList(team.slug);
20+
21+
// this should never happen
22+
if (!nebulaWaitList) {
23+
return (
24+
<div className="container flex grow flex-col py-8">
25+
<div className="flex min-h-[300px] grow flex-col items-center justify-center rounded-lg border p-6 text-destructive-text">
26+
Something went wrong trying to fetch the nebula waitlist
27+
</div>
28+
</div>
29+
);
30+
}
31+
32+
return (
33+
<JoinNebulaWaitlistPage
34+
onWaitlist={nebulaWaitList.onWaitlist}
35+
teamSlug={team.slug}
36+
/>
37+
);
38+
}

apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ export default async function TeamLayout(props: {
5858
path: `/team/${params.team_slug}/${params.project_slug}/contracts`,
5959
name: "Contracts",
6060
},
61+
{
62+
path: `/team/${params.team_slug}/${params.project_slug}/nebula`,
63+
name: "Nebula",
64+
},
6165
{
6266
path: `/team/${params.team_slug}/${params.project_slug}/settings`,
6367
name: "Settings",
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"use client";
2+
3+
import { Spinner } from "@/components/ui/Spinner/Spinner";
4+
import { Button } from "@/components/ui/button";
5+
import { ToolTipLabel } from "@/components/ui/tooltip";
6+
import { useDashboardRouter } from "@/lib/DashboardRouter";
7+
import { useMutation } from "@tanstack/react-query";
8+
import { ArrowRightIcon, CheckIcon, OrbitIcon, ShareIcon } from "lucide-react";
9+
import Link from "next/link";
10+
import { useState } from "react";
11+
import { toast } from "sonner";
12+
13+
export function JoinNebulaWaitlistPageUI(props: {
14+
onWaitlist: boolean;
15+
joinWaitList: () => Promise<void>;
16+
}) {
17+
const router = useDashboardRouter();
18+
19+
return (
20+
<div className="flex grow flex-col">
21+
{/* Header */}
22+
<div className="border-b py-10">
23+
<div className="container flex items-center justify-between">
24+
<h1 className="font-semibold text-3xl tracking-tight"> Nebula </h1>
25+
<Button asChild variant="outline">
26+
<Link href="/contact-us" target="_blank">
27+
Contact Us
28+
</Link>
29+
</Button>
30+
</div>
31+
</div>
32+
33+
<div className="container flex grow flex-col overflow-hidden pt-32 pb-48">
34+
{props.onWaitlist ? (
35+
<CenteredCard
36+
key="on-waitlist"
37+
title="You're on the waitlist"
38+
description="You should receive access to Nebula soon!"
39+
footer={<ShareButton />}
40+
/>
41+
) : (
42+
<CenteredCard
43+
key="not-on-waitlist"
44+
title="Nebula"
45+
description="Blockchain-first AI that can read & write onchain in realtime."
46+
footer={
47+
<JoinWaitingListButton
48+
joinWaitList={props.joinWaitList}
49+
onSuccess={() => {
50+
router.refresh();
51+
}}
52+
/>
53+
}
54+
/>
55+
)}
56+
</div>
57+
</div>
58+
);
59+
}
60+
61+
function ShareButton() {
62+
const [isCopied, setIsCopied] = useState(false);
63+
64+
return (
65+
<ToolTipLabel label="Copy Page Link">
66+
<Button
67+
variant="outline"
68+
className="gap-2"
69+
onClick={() => {
70+
navigator.clipboard.writeText("https://thirdweb.com/team/~/~/nebula");
71+
setIsCopied(true);
72+
setTimeout(() => setIsCopied(false), 1000);
73+
}}
74+
>
75+
Share
76+
{isCopied ? (
77+
<CheckIcon className="size-4 text-green-500" />
78+
) : (
79+
<ShareIcon className="size-4" />
80+
)}
81+
</Button>
82+
</ToolTipLabel>
83+
);
84+
}
85+
86+
function JoinWaitingListButton(props: {
87+
joinWaitList: () => Promise<void>;
88+
onSuccess: () => void;
89+
}) {
90+
const joinWaitListMutation = useMutation({
91+
mutationFn: props.joinWaitList,
92+
onSuccess: props.onSuccess,
93+
});
94+
95+
return (
96+
<Button
97+
className="gap-2 rounded-full"
98+
variant="primary"
99+
onClick={() => {
100+
const promise = joinWaitListMutation.mutateAsync();
101+
toast.promise(promise, {
102+
success: "Joined the waitlist!",
103+
error: "Failed to join waitlist",
104+
});
105+
}}
106+
>
107+
Join the waitlist
108+
{joinWaitListMutation.isPending ? (
109+
<Spinner className="size-4" />
110+
) : (
111+
<ArrowRightIcon className="size-4" />
112+
)}
113+
</Button>
114+
);
115+
}
116+
117+
function CenteredCard(props: {
118+
footer: React.ReactNode;
119+
title: React.ReactNode;
120+
description: string;
121+
}) {
122+
return (
123+
<div className="flex grow flex-col items-center justify-center max-sm:px-4">
124+
<div className="relative flex min-h-[480px] w-full flex-col rounded-xl border bg-muted/50 p-2 lg:w-[480px]">
125+
{/* fancy borders */}
126+
<div className="">
127+
{/* top */}
128+
<DashedBgDiv
129+
className="-translate-x-1/2 -translate-y-5 absolute top-0 right-0 left-1/2 h-[1px] w-[calc(100%+200px)]"
130+
type="horizontal"
131+
/>
132+
{/* bottom */}
133+
<DashedBgDiv
134+
className="-translate-x-1/2 absolute right-0 bottom-0 left-1/2 h-[1px] w-[calc(100%+200px)] translate-y-5"
135+
type="horizontal"
136+
/>
137+
{/* left */}
138+
<DashedBgDiv
139+
className="-translate-x-5 -translate-y-1/2 absolute top-1/2 left-0 h-[calc(100%+200px)] w-[1px]"
140+
type="vertical"
141+
/>
142+
{/* right */}
143+
<DashedBgDiv
144+
className="-translate-y-1/2 absolute top-1/2 right-0 h-[calc(100%+200px)] w-[1px] translate-x-5"
145+
type="vertical"
146+
/>
147+
</div>
148+
149+
<div className="flex grow items-center justify-center rounded-lg border p-4">
150+
<div className="flex flex-col items-center">
151+
{/* Icon */}
152+
<div className="rounded-xl border p-1">
153+
<div className="rounded-lg border bg-muted/50 p-2">
154+
<OrbitIcon className="size-5 text-muted-foreground" />
155+
</div>
156+
</div>
157+
158+
<div className="h-4" />
159+
160+
<h2 className="text-balance text-center font-semibold text-2xl tracking-tight md:text-3xl">
161+
{props.title}
162+
</h2>
163+
164+
<div className="h-2" />
165+
166+
<p className="text-center text-muted-foreground lg:px-8">
167+
{props.description}
168+
</p>
169+
170+
<div className="h-6" />
171+
172+
{props.footer}
173+
</div>
174+
</div>
175+
</div>
176+
</div>
177+
);
178+
}
179+
180+
function DashedBgDiv(props: {
181+
className?: string;
182+
type: "horizontal" | "vertical";
183+
}) {
184+
return (
185+
<div
186+
className={props.className}
187+
style={{
188+
backgroundImage: `linear-gradient(${props.type === "horizontal" ? "90deg" : "180deg"}, hsl(var(--foreground)/20%) 0 30%, transparent 0 100%)`,
189+
backgroundRepeat: "repeat",
190+
backgroundSize: "10px 10px",
191+
maskImage: `linear-gradient(${
192+
props.type === "horizontal" ? "to right" : "to bottom"
193+
}, rgba(0,0,0,0.1), black 20%, black 80%, rgba(0,0,0,0.1))`,
194+
}}
195+
/>
196+
);
197+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"use client";
2+
3+
import { joinTeamWaitlist } from "@/actions/joinWaitlist";
4+
import { JoinNebulaWaitlistPageUI } from "./nebula-waitlist-page-ui.client";
5+
6+
export function JoinNebulaWaitlistPage(props: {
7+
onWaitlist: boolean;
8+
teamSlug: string;
9+
}) {
10+
return (
11+
<JoinNebulaWaitlistPageUI
12+
onWaitlist={props.onWaitlist}
13+
joinWaitList={async () => {
14+
await joinTeamWaitlist({
15+
scope: "nebula",
16+
teamSlug: props.teamSlug,
17+
});
18+
}}
19+
/>
20+
);
21+
}

0 commit comments

Comments
 (0)