Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
d350f8cb13 Version Packages (#6440)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-09 18:44:17 -07:00
MananTank 4b200503b8 [TOOL-3639] Dashboard: Pay > Universal Bridge renaming (#6431)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on renaming and rebranding the `Pay` feature to `Universal Bridge`, updating related paths, descriptions, and components accordingly.

### Detailed summary
- Renamed `Pay` to `Universal Bridge` in various components and files.
- Updated links and routes to reflect the new branding.
- Changed descriptions to align with the new feature purpose.
- Replaced `TrackedLinkTW` with `TrackedUnderlineLink` in multiple components.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-03-08 15:16:08 +00:00
MananTank f8eb86da47 [TOOL-3640] Dashboard: Add sponsored transactions table in team usage page (#6434)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the functionality and user experience of the dashboard by adding new features, improving existing components, and refining data handling, particularly around sponsored transactions and usage analytics.

### Detailed summary
- Added `placeholder` prop to `SingleNetworkSelector`.
- Integrated `SponsoredTransactionsTable` in `AccountAbstractionAnalytics`.
- Enhanced `Usage` component with `client` and `projects` props.
- Improved `ExportToCSVButton` to handle different data types.
- Introduced `SponsoredTransactionsTable` for displaying sponsored transaction data.
- Updated `SponsoredTransactionsTableUI` with pagination and filter options.
- Added error handling and loading states in various components.
- Refactored proxy actions to support optional search parameters and text parsing.
- Introduced `getAuthToken` for authentication in several pages.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-03-08 12:57:58 +00:00
gregandGitHub 2ae778ed7f Feature: Adds OTP cooldown timer (#6439) 2025-03-07 15:55:52 -08:00
0xFirekeeper 1c135b0f65 Improve .NET/Unity Documentation (#6438)
Also adds Insight and Engine
Closes TOOL-3570

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing documentation and sidebar navigation for the Thirdweb .NET SDK, introducing new wallet integrations, and updating metadata and descriptions for clarity.

### Detailed summary
- Updated `PrivateKeyWallet` description to markdown format.
- Changed `title` in `nebula/quickstart` metadata.
- Added utility functions description in `utils/page.mdx`.
- Removed `Constants.DECIMALS_18`.
- Added `EngineWallet` documentation with usage example.
- Updated sidebar links for better organization and added new sections.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-03-07 22:26:33 +00:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
286114ab81 Version Packages (#6435)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-07 14:08:27 -08:00
gregandGitHub cbc4e80330 fix: onramp improvements (#6437) 2025-03-07 13:49:15 -08:00
joaquim-verges efceadd3c1 [SDK] test: Update smart wallet tests to use Arbitrum Sepolia (#6436)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the test file `smart-wallet-integration-v07.test.ts` to switch the blockchain network from `sepolia` to `arbitrumSepolia`, modifies the contract address, and adds a new expectation for contract deployment verification.

### Detailed summary
- Added import for `arbitrumSepolia`.
- Changed the blockchain network from `sepolia` to `arbitrumSepolia`.
- Updated the contract address from `0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8` to `0x6A7a26c9a595E6893C255C9dF0b593e77518e0c3`.
- Added expectation to verify if the contract is deployed.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-03-07 20:37:57 +00:00
Manan TankandGitHub c653d7033d [TOOL-3446] Dashboard: Update account onboarding UI, Add Team onboarding, Open stripe links in new tab (#6354) 2025-03-08 08:40:09 +13:00
YashandGitHub 77029a2e01 Interop module support (#6325) 2025-03-06 17:27:41 -08:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
b0b3b7f8f6 Version Packages (#6433)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-06 16:58:32 -08:00
Joaquim VergesandGitHub 617fe68c32 [SDK] feat: Allow custom storage for inApp and ecosystem wallets (#6432) 2025-03-06 16:16:32 -08:00
saminacodes 01d0666208 [Portal] Renames (#6430)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the terminology and references in the codebase from `thirdweb Pay` to `thirdweb Universal Bridge`, reflecting a rebranding and restructuring of the payment system.

### Detailed summary
- Updated sidebar links, changing "Testing Pay" to "Developer Mode" and renamed "Guides" to "Tutorials".
- Replaced instances of `thirdweb Pay` with `thirdweb Universal Bridge` in multiple files.
- Updated metadata titles and descriptions across various `.mdx` files.
- Modified FAQs to reflect new terminology for the Universal Bridge service.
- Adjusted documentation on customization and integration guides to use the new branding.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-03-06 20:53:34 +00:00
MananTank 621357a5c6 [TOOL-3635] Dashboard: Fix AA factories page crash (#6428) 2025-03-06 16:11:27 +00:00
MananTank 0527ee26b7 [TOOL-3630] Dashboard: Add cancel subscriptions option in delete account flow (#6429) 2025-03-06 15:59:30 +00:00
Waren GonzagaandGitHub 834ed8337b Update page.mdx (#6425)
Signed-off-by: Waren Gonzaga <[email protected]>
2025-03-06 02:44:50 -08:00
Phillip HoandGitHub 11bb4f5fee chore: update mainnet AA message (#6427) 2025-03-06 02:44:28 -08:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
c547164320 Version Packages (#6417)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-05 17:37:39 -08:00
Joaquim VergesandGitHub 38ae2d3c6b [SDK] expose getUserOpHash utility function (#6424) 2025-03-05 17:08:15 -08:00
jnsdls d75320191d update ecosystem API routes to include team slug (#6422)
Updates ecosystem API endpoints to be scoped under team routes

The ecosystem wallet API endpoints have been updated to be scoped under team routes, ensuring proper data isolation between teams. All ecosystem-related API calls now include the team ID or slug in the path.

Key changes:
- Updated API endpoints from `/v1/ecosystem-wallet/*` to `/v1/teams/{teamIdOrSlug}/ecosystem-wallet/*`
- Added team slug parameter to ecosystem fetch and list operations
- Modified React hooks to include team context in query keys
- Updated component props to pass team information through the component tree

This change ensures ecosystem wallets are properly scoped to their respective teams and maintains data isolation between different team contexts.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates various functions and components within the ecosystem module to include `team_slug` as a parameter. This change enhances the ability to fetch ecosystems and their details specific to a team, improving data handling across the application.

### Detailed summary
- Updated `fetchEcosystem` to accept `team_slug`.
- Modified `fetchEcosystemList` to include `team_slug`.
- Adjusted the `useEcosystemList` hook to utilize `teamIdOrSlug`.
- Enhanced `useEcosystem` to accept `teamIdOrSlug`.
- Updated components to pass `team_slug` where necessary.
- Changed redirect logic to incorporate team-specific ecosystems.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-03-05 19:00:54 +00:00
saminacodes 6046d4510d [Portal] Universal Bridge Docs (#6421)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on renaming and updating the documentation related to `thirdweb Pay`, transitioning to the `Universal Bridge` terminology. It enhances clarity and alignment with the new features and guides for web3 payments.

### Detailed summary
- Updated titles and descriptions in various `.mdx` files to reflect `Universal Bridge`.
- Changed sidebar navigation names and links to align with `Universal Bridge`.
- Revised content in `page.mdx` files to include new functionalities and guides.
- Added sections on fee sharing and supported chains in the `Overview` documentation.
- Modified FAQ entries to clarify `Universal Bridge` specifics.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-03-05 18:28:34 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f1d32261f0 chore(deps-dev): bump eslint-plugin-react-compiler from 19.0.0-beta-e1e972c-20250221 to 19.0.0-beta-40c6c23-20250301 (#6395)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-05 09:19:51 -08:00
Manan TankandGitHub 04baa74ad7 [TOOL-3610] Dashboard: Fix z-index issue with network selector on chakra modal (#6418) 2025-03-05 07:51:33 -08:00
Phillip HoandGitHub 44bb0034a6 [service-utils] Add pay as a usage source (#6416) 2025-03-05 18:51:39 +08:00
169 changed files with 5000 additions and 1936 deletions
+32 -3
View File
@@ -7,6 +7,7 @@ import { Inter as interFont } from "next/font/google";
// biome-ignore lint/style/useImportType: <explanation>
import React from "react";
import { useEffect } from "react";
import { Toaster } from "sonner";
import { Button } from "../src/@/components/ui/button";
const queryClient = new QueryClient();
@@ -16,8 +17,30 @@ const fontSans = interFont({
variable: "--font-sans",
});
const customViewports = {
xs: {
// Regular sized phones (iphone 15 / 15 pro)
name: "iPhone",
styles: {
width: "390px",
height: "844px",
},
},
sm: {
// Larger phones (iphone 15 plus / 15 pro max)
name: "iPhone Plus",
styles: {
width: "430px",
height: "932px",
},
},
};
const preview: Preview = {
parameters: {
viewport: {
viewports: customViewports,
},
controls: {
matchers: {
color: /(background|color)$/i,
@@ -57,13 +80,13 @@ function StoryLayout(props: {
return (
<QueryClientProvider client={queryClient}>
<div className="flex min-h-screen min-w-0 flex-col bg-background text-foreground">
<div className="flex min-h-dvh min-w-0 flex-col bg-background text-foreground">
<div className="flex justify-end gap-2 border-b p-4">
<Button
onClick={() => setTheme("dark")}
size="sm"
variant={theme === "dark" ? "default" : "outline"}
className="h-auto w-auto rounded-full p-2"
className="h-auto w-auto shrink-0 rounded-full p-2"
>
<MoonIcon className="size-4" />
</Button>
@@ -72,14 +95,20 @@ function StoryLayout(props: {
onClick={() => setTheme("light")}
size="sm"
variant={theme === "light" ? "default" : "outline"}
className="h-auto w-auto rounded-full p-2"
className="h-auto w-auto shrink-0 rounded-full p-2"
>
<SunIcon className="size-4" />
</Button>
</div>
<div className="flex min-w-0 grow flex-col">{props.children}</div>
<ToasterSetup />
</div>
</QueryClientProvider>
);
}
function ToasterSetup() {
const { theme } = useTheme();
return <Toaster richColors theme={theme === "light" ? "light" : "dark"} />;
}
+1 -1
View File
@@ -133,7 +133,7 @@
"checkly": "5.0.1",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.0.0-beta-e1e972c-20250221",
"eslint-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
"eslint-plugin-storybook": "0.11.3",
"knip": "5.45.0",
"next-sitemap": "^4.2.3",
+7
View File
@@ -354,6 +354,13 @@ async function redirects() {
destination: "/",
permanent: false,
},
// pay > universal-bridge redirect
{
source: "/team/:team_slug/:project_slug/connect/pay/:path*",
destination:
"/team/:team_slug/:project_slug/connect/universal-bridge/:path*",
permanent: false,
},
...legacyDashboardToTeamRedirects,
];
}
+19 -15
View File
@@ -2,20 +2,19 @@
import "server-only";
import { API_SERVER_URL } from "@/constants/env";
import { redirect } from "next/navigation";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
import type { ProductSKU } from "../lib/billing";
export type RedirectCheckoutOptions = {
export type GetBillingCheckoutUrlOptions = {
teamSlug: string;
sku: ProductSKU;
redirectUrl: string;
metadata?: Record<string, string>;
};
export async function redirectToCheckout(
options: RedirectCheckoutOptions,
): Promise<{ status: number }> {
export async function getBillingCheckoutUrl(
options: GetBillingCheckoutUrlOptions,
): Promise<{ status: number; url?: string }> {
if (!options.teamSlug) {
return {
status: 400,
@@ -49,6 +48,7 @@ export async function redirectToCheckout(
status: res.status,
};
}
const json = await res.json();
if (!json.result) {
return {
@@ -56,20 +56,22 @@ export async function redirectToCheckout(
};
}
// redirect to the stripe checkout session
redirect(json.result);
return {
status: 200,
url: json.result as string,
};
}
export type RedirectBillingCheckoutAction = typeof redirectToCheckout;
export type GetBillingCheckoutUrlAction = typeof getBillingCheckoutUrl;
export type BillingPortalOptions = {
export type GetBillingPortalUrlOptions = {
teamSlug: string | undefined;
redirectUrl: string;
};
export async function redirectToBillingPortal(
options: BillingPortalOptions,
): Promise<{ status: number }> {
export async function getBillingPortalUrl(
options: GetBillingPortalUrlOptions,
): Promise<{ status: number; url?: string }> {
if (!options.teamSlug) {
return {
status: 400,
@@ -110,8 +112,10 @@ export async function redirectToBillingPortal(
};
}
// redirect to the stripe billing portal
redirect(json.result);
return {
status: 200,
url: json.result as string,
};
}
export type BillingBillingPortalAction = typeof redirectToBillingPortal;
export type GetBillingPortalUrlAction = typeof getBillingPortalUrl;
+15 -11
View File
@@ -5,13 +5,14 @@ import { API_SERVER_URL } from "../constants/env";
type ProxyActionParams = {
pathname: string;
searchParams?: Record<string, string>;
searchParams?: Record<string, string | undefined>;
method: "GET" | "POST" | "PUT" | "DELETE";
body?: string;
headers?: Record<string, string>;
parseAsText?: boolean;
};
type ProxyActionResult<T extends object> =
type ProxyActionResult<T> =
| {
status: number;
ok: true;
@@ -23,7 +24,7 @@ type ProxyActionResult<T extends object> =
error: string;
};
async function proxy<T extends object>(
async function proxy<T>(
baseUrl: string,
params: ProxyActionParams,
): Promise<ProxyActionResult<T>> {
@@ -34,7 +35,10 @@ async function proxy<T extends object>(
url.pathname = params.pathname;
if (params.searchParams) {
for (const key in params.searchParams) {
url.searchParams.append(key, params.searchParams[key] as string);
const value = params.searchParams[key];
if (value) {
url.searchParams.append(key, value);
}
}
}
@@ -67,19 +71,15 @@ async function proxy<T extends object>(
return {
status: res.status,
ok: true,
data: await res.json(),
data: params.parseAsText ? await res.text() : await res.json(),
};
}
export async function apiServerProxy<T extends object = object>(
params: ProxyActionParams,
) {
export async function apiServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(API_SERVER_URL, params);
}
export async function payServerProxy<T extends object = object>(
params: ProxyActionParams,
) {
export async function payServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(
process.env.NEXT_PUBLIC_PAY_URL
? `https://${process.env.NEXT_PUBLIC_PAY_URL}`
@@ -87,3 +87,7 @@ export async function payServerProxy<T extends object = object>(
params,
);
}
export async function analyticsServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(process.env.ANALYTICS_SERVICE_URL || "", params);
}
@@ -1,19 +0,0 @@
import { cn } from "@/lib/utils";
export function TextDivider(props: {
text: string;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center text-muted-foreground text-sm",
props.className,
)}
>
<span className="h-[1px] flex-1 bg-border" />
<span className="mx-4">{props.text}</span>
<span className="h-[1px] flex-1 bg-border" />
</div>
);
}
+93 -35
View File
@@ -1,78 +1,136 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import type {
BillingBillingPortalAction,
BillingPortalOptions,
RedirectBillingCheckoutAction,
RedirectCheckoutOptions,
GetBillingCheckoutUrlAction,
GetBillingCheckoutUrlOptions,
GetBillingPortalUrlAction,
GetBillingPortalUrlOptions,
} from "../actions/billing";
import { cn } from "../lib/utils";
import { Spinner } from "./ui/Spinner/Spinner";
import { Button, type ButtonProps } from "./ui/button";
type CheckoutButtonProps = Omit<RedirectCheckoutOptions, "redirectUrl"> &
ButtonProps & {
redirectPath: string;
redirectToCheckout: RedirectBillingCheckoutAction;
};
type CheckoutButtonProps = Omit<GetBillingCheckoutUrlOptions, "redirectUrl"> & {
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
buttonProps?: Omit<ButtonProps, "children">;
children: React.ReactNode;
};
export function CheckoutButton({
onClick,
teamSlug,
sku,
metadata,
redirectPath,
getBillingCheckoutUrl,
children,
redirectToCheckout,
...restProps
buttonProps,
}: CheckoutButtonProps) {
const getUrlMutation = useMutation({
mutationFn: async () => {
return getBillingCheckoutUrl({
teamSlug,
sku,
metadata,
redirectUrl: getAbsoluteUrl("/stripe-redirect"),
});
},
});
const errorMessage = "Failed to open checkout page";
return (
<Button
{...restProps}
{...buttonProps}
className={cn(buttonProps?.className, "gap-2")}
disabled={getUrlMutation.isPending || buttonProps?.disabled}
onClick={async (e) => {
onClick?.(e);
await redirectToCheckout({
teamSlug,
sku,
metadata,
redirectUrl: getRedirectUrl(redirectPath),
buttonProps?.onClick?.(e);
getUrlMutation.mutate(undefined, {
onSuccess: (res) => {
if (!res.url) {
toast.error(errorMessage);
return;
}
const tab = window.open(res.url, "_blank");
if (!tab) {
toast.error(errorMessage);
return;
}
},
onError: () => {
toast.error(errorMessage);
},
});
}}
>
{getUrlMutation.isPending && <Spinner className="size-4" />}
{children}
</Button>
);
}
type BillingPortalButtonProps = Omit<BillingPortalOptions, "redirectUrl"> &
ButtonProps & {
redirectPath: string;
redirectToBillingPortal: BillingBillingPortalAction;
};
type BillingPortalButtonProps = Omit<
GetBillingPortalUrlOptions,
"redirectUrl"
> & {
getBillingPortalUrl: GetBillingPortalUrlAction;
buttonProps?: Omit<ButtonProps, "children">;
children: React.ReactNode;
};
export function BillingPortalButton({
onClick,
teamSlug,
redirectPath,
children,
redirectToBillingPortal,
...restProps
getBillingPortalUrl,
buttonProps,
}: BillingPortalButtonProps) {
const getUrlMutation = useMutation({
mutationFn: async () => {
return getBillingPortalUrl({
teamSlug,
redirectUrl: getAbsoluteUrl("/stripe-redirect"),
});
},
});
const errorMessage = "Failed to open billing portal";
return (
<Button
{...restProps}
{...buttonProps}
className={cn(buttonProps?.className, "gap-2")}
disabled={getUrlMutation.isPending || buttonProps?.disabled}
onClick={async (e) => {
onClick?.(e);
await redirectToBillingPortal({
teamSlug,
redirectUrl: getRedirectUrl(redirectPath),
buttonProps?.onClick?.(e);
getUrlMutation.mutate(undefined, {
onSuccess(res) {
if (!res.url) {
toast.error(errorMessage);
return;
}
const tab = window.open(res.url, "_blank");
if (!tab) {
toast.error(errorMessage);
return;
}
},
onError: () => {
toast.error(errorMessage);
},
});
}}
>
{getUrlMutation.isPending && <Spinner className="size-4" />}
{children}
</Button>
);
}
function getRedirectUrl(path: string) {
function getAbsoluteUrl(path: string) {
const url = new URL(window.location.origin);
url.pathname = path;
return url.toString();
@@ -2,11 +2,12 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { useMutation } from "@tanstack/react-query";
import { DownloadIcon } from "lucide-react";
import Papa from "papaparse";
import { toast } from "sonner";
import { cn } from "../../lib/utils";
export function ExportToCSVButton(props: {
getData: () => Promise<{ header: string[]; rows: string[][] }>;
getData: () => Promise<{ header: string[]; rows: string[][] } | string>;
fileName: string;
disabled?: boolean;
className?: string;
@@ -14,7 +15,12 @@ export function ExportToCSVButton(props: {
const exportMutation = useMutation({
mutationFn: async () => {
const data = await props.getData();
exportToCSV(props.fileName, data);
if (typeof data === "string") {
exportToCSV(props.fileName, data);
} else {
const fileContent = convertToCSVFormat(data);
exportToCSV(props.fileName, fileContent);
}
},
onError: () => {
toast.error("Failed to download CSV");
@@ -29,7 +35,7 @@ export function ExportToCSVButton(props: {
return (
<Button
variant="outline"
disabled={props.disabled}
disabled={props.disabled || exportMutation.isPending}
className={cn("flex items-center gap-2 border text-xs", props.className)}
onClick={async () => {
exportMutation.mutate();
@@ -50,15 +56,15 @@ export function ExportToCSVButton(props: {
);
}
function exportToCSV(
fileName: string,
data: { header: string[]; rows: string[][] },
) {
const { header, rows } = data;
const csvContent = `data:text/csv;charset=utf-8,${header.join(",")}\n${rows
.map((e) => e.join(","))
.join("\n")}`;
function convertToCSVFormat(data: { header: string[]; rows: string[][] }) {
return Papa.unparse({
fields: data.header,
data: data.rows,
});
}
function exportToCSV(fileName: string, fileContent: string) {
const csvContent = `data:text/csv;charset=utf-8,${fileContent}`;
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
@@ -47,7 +47,7 @@ export function Img(props: imgElementProps) {
}, []);
return (
<div className="relative">
<div className="relative shrink-0">
<img
{...restProps}
// avoid setting empty src string to prevent request to the entire page
@@ -104,6 +104,7 @@ export function SingleNetworkSelector(props: {
side?: "left" | "right" | "top" | "bottom";
disableChainId?: boolean;
align?: "center" | "start" | "end";
placeholder?: string;
}) {
const { allChains, idToChain } = useAllChainsData();
@@ -180,7 +181,11 @@ export function SingleNetworkSelector(props: {
props.onChange(Number(chainId));
}}
closeOnSelect={true}
placeholder={isLoadingChains ? "Loading Chains..." : "Select Chain"}
placeholder={
isLoadingChains
? "Loading Chains..."
: props.placeholder || "Select Chain"
}
overrideSearchFn={searchFn}
renderOption={renderOption}
className={props.className}
@@ -1,3 +1,4 @@
"use client";
import type { Team } from "@/api/team";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -8,7 +9,7 @@ import { CheckIcon, CircleAlertIcon, CircleDollarSignIcon } from "lucide-react";
import type React from "react";
import { TEAM_PLANS } from "utils/pricing";
import { remainingDays } from "../../../utils/date-utils";
import type { RedirectBillingCheckoutAction } from "../../actions/billing";
import type { GetBillingCheckoutUrlAction } from "../../actions/billing";
import { CheckoutButton } from "../billing";
type ButtonProps = React.ComponentProps<typeof Button>;
@@ -27,14 +28,13 @@ type PricingCardProps = {
label?: string;
};
variant?: ButtonProps["variant"];
onClick?: () => void;
};
ctaHint?: string;
highlighted?: boolean;
current?: boolean;
canTrialGrowth?: boolean;
activeTrialEndsAt?: string;
redirectPath: string;
redirectToCheckout: RedirectBillingCheckoutAction;
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
};
export const PricingCard: React.FC<PricingCardProps> = ({
@@ -43,10 +43,8 @@ export const PricingCard: React.FC<PricingCardProps> = ({
cta,
highlighted = false,
current = false,
canTrialGrowth = false,
activeTrialEndsAt,
redirectPath,
redirectToCheckout,
getBillingCheckoutUrl,
}) => {
const plan = TEAM_PLANS[billingPlan];
const isCustomPrice = typeof plan.price === "string";
@@ -88,18 +86,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="font-semibold text-3xl text-foreground tracking-tight">
{isCustomPrice ? (
plan.price
) : canTrialGrowth ? (
<>
<span className="text-muted-foreground line-through">
${plan.price}
</span>{" "}
$0
</>
) : (
`$${plan.price}`
)}
${plan.price}
</span>
{!isCustomPrice && (
@@ -135,7 +122,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
</div>
</div>
<div className="flex grow flex-col items-start gap-2.5 text-foreground">
<div className="flex grow flex-col items-start gap-2 text-foreground">
{plan.subTitle && (
<p className="font-medium text-foreground">{plan.subTitle}</p>
)}
@@ -149,11 +136,14 @@ export const PricingCard: React.FC<PricingCardProps> = ({
<div className="flex flex-col gap-3">
{billingPlan !== "pro" ? (
<CheckoutButton
variant={cta.variant || "outline"}
buttonProps={{
variant: cta.variant || "outline",
className: "gap-2",
onClick: cta.onClick,
}}
teamSlug={teamSlug}
sku={billingPlan === "starter" ? "plan:starter" : "plan:growth"}
redirectPath={redirectPath}
redirectToCheckout={redirectToCheckout}
getBillingCheckoutUrl={getBillingCheckoutUrl}
>
{cta.title}
</CheckoutButton>
@@ -189,7 +179,7 @@ function FeatureItem({ text }: FeatureItemProps) {
const titleStr = Array.isArray(text) ? text[0] : text;
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-sm">
<CheckIcon className="size-4 shrink-0 text-green-500" />
{Array.isArray(text) ? (
<div className="flex items-center gap-2">
@@ -8,7 +8,7 @@ import {
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { useClipboard } from "hooks/useClipboard";
import { Check, Copy } from "lucide-react";
import { Check, Copy, XIcon } from "lucide-react";
import { useMemo } from "react";
import { type ThirdwebClient, isAddress } from "thirdweb";
import { ZERO_ADDRESS } from "thirdweb";
@@ -16,6 +16,7 @@ import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
import { cn } from "../../lib/utils";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { ToolTipLabel } from "../ui/tooltip";
import { Img } from "./Img";
export function WalletAddress(props: {
@@ -44,7 +45,16 @@ export function WalletAddress(props: {
const { onCopy, hasCopied } = useClipboard(address, 2000);
if (!isAddress(address)) {
return <span>Invalid Address ({address})</span>;
return (
<ToolTipLabel label={address} hoverable>
<span className="flex items-center gap-2 underline-offset-4 hover:underline">
<div className="flex size-6 items-center justify-center rounded-full border bg-background">
<XIcon className="size-4 text-muted-foreground" />
</div>
Invalid Address
</span>
</ToolTipLabel>
);
}
// special case for zero address
+1 -1
View File
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:selection:bg-foreground/20",
className,
)}
ref={ref}
+7
View File
@@ -172,3 +172,10 @@ input:-webkit-autofill:active {
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
/* Remove this when chakra is removed */
/* Make chakra modals have same z-index as shadcn dialog */
body {
--chakra-zIndices-modal: 50;
--chakra-zIndices-overlay: 50;
}
@@ -44,14 +44,6 @@ export type Account = {
// TODO - add image URL
};
interface UpdateAccountInput {
name?: string;
email?: string;
linkWallet?: boolean;
subscribeToUpdates?: boolean;
onboardSkipped?: boolean;
}
interface UpdateAccountNotificationsInput {
billing: "email" | "none";
updates: "email" | "none";
@@ -140,45 +132,40 @@ export function useAccountCredits() {
});
}
export function useUpdateAccount() {
const queryClient = useQueryClient();
const address = useActiveAccount()?.address;
type UpdateAccountParams = {
name?: string;
email?: string;
linkWallet?: boolean;
subscribeToUpdates?: boolean;
onboardSkipped?: boolean;
};
return useMutation({
mutationFn: async (input: UpdateAccountInput) => {
type Result = {
data: object;
error?: { message: string };
};
export async function updateAccountClient(input: UpdateAccountParams) {
type Result = {
data: object;
error?: { message: string };
};
const res = await apiServerProxy<Result>({
pathname: "/v1/account",
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error(res.error);
}
const json = res.data;
if (json.error) {
throw new Error(json.error.message);
}
return json.data;
},
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: accountKeys.me(address || ""),
});
const res = await apiServerProxy<Result>({
pathname: "/v1/account",
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error(res.error);
}
const json = res.data;
if (json.error) {
throw new Error(json.error.message);
}
return json.data;
}
export function useUpdateNotifications() {
@@ -221,77 +208,61 @@ export function useUpdateNotifications() {
});
}
export function useConfirmEmail() {
return useMutation({
mutationFn: async (input: ConfirmEmailInput) => {
type Result = {
error?: { message: string };
data: { team: Team; account: Account };
};
export const verifyEmailClient = async (input: ConfirmEmailInput) => {
type Result = {
error?: { message: string };
data: { team: Team; account: Account };
};
const res = await apiServerProxy<Result>({
pathname: "/v1/account/confirmEmail",
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error(res.error);
}
const json = res.data;
if (json.error) {
throw new Error(json.error.message);
}
return json.data;
const res = await apiServerProxy<Result>({
pathname: "/v1/account/confirmEmail",
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
}
export function useResendEmailConfirmation() {
const address = useActiveAccount()?.address;
const queryClient = useQueryClient();
if (!res.ok) {
throw new Error(res.error);
}
return useMutation({
mutationFn: async () => {
type Result = {
error?: { message: string };
data: object;
};
const json = res.data;
const res = await apiServerProxy<Result>({
pathname: "/v1/account/resendEmailConfirmation",
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
if (json.error) {
throw new Error(json.error.message);
}
if (!res.ok) {
throw new Error(res.error);
}
return json.data;
};
const json = res.data;
export const resendEmailClient = async () => {
type Result = {
error?: { message: string };
data: object;
};
if (json.error) {
throw new Error(json.error.message);
}
return json.data;
},
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: accountKeys.me(address || ""),
});
const res = await apiServerProxy<Result>({
pathname: "/v1/account/resendEmailConfirmation",
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
}
if (!res.ok) {
throw new Error(res.error);
}
const json = res.data;
if (json.error) {
throw new Error(json.error.message);
}
return json.data;
};
export async function createProjectClient(
teamId: string,
@@ -52,7 +52,7 @@ import {
useWalletBalance,
} from "thirdweb/react";
import { z } from "zod";
import { isOnboardingComplete } from "../../../../../../login/onboarding/isOnboardingRequired";
import { isAccountOnboardingComplete } from "../../../../../../login/onboarding/isOnboardingRequired";
function formatTime(seconds: number) {
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
@@ -210,7 +210,7 @@ export function FaucetButton({
);
}
if (!isOnboardingComplete(twAccount)) {
if (!isAccountOnboardingComplete(twAccount)) {
return (
<Button asChild className="w-full">
<Link
@@ -1,7 +1,14 @@
"use client";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
@@ -11,6 +18,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
@@ -33,7 +41,9 @@ import { useForm } from "react-hook-form";
import {
defineChain,
eth_getCode,
getContract,
getRpcClient,
prepareContractCall,
prepareTransaction,
sendAndConfirmTransaction,
} from "thirdweb";
@@ -57,19 +67,21 @@ type CrossChain = {
status: "DEPLOYED" | "NOT_DEPLOYED";
};
const interopChains = ["420120000", "420120001"];
type ChainId = "420120000" | "420120001";
const formSchema = z.object({
amounts: z.object({
"84532": z.string(),
"11155420": z.string(),
"919": z.string(),
"111557560": z.string(),
"999999999": z.string(),
"11155111": z.string(),
"421614": z.string(),
"420120000": z.string(),
"420120001": z.string(),
}),
});
type FormSchema = z.output<typeof formSchema>;
const positiveIntegerRegex = /^[0-9]\d*$/;
const superchainBridgeAddress = "0x4200000000000000000000000000000000000028";
export function DataTable({
data,
coreMetadata,
@@ -97,13 +109,21 @@ export function DataTable({
"Failed to deploy contract",
);
const isCrosschain = !!modulesMetadata?.find(
(m) => m.name === "SuperChainInterop",
);
const addRowMutation = useMutation({
mutationFn: async (chain: { chainId: number; name: string }) => {
if (coreContract.chain.id === chain.chainId) {
return;
}
// eslint-disable-next-line no-restricted-syntax
const c = defineChain(chain.chainId);
const code = await eth_getCode(
getRpcClient({
client: getThirdwebClient(),
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(chain.chainId),
chain: c,
}),
{ address: coreContract.address },
);
@@ -155,19 +175,59 @@ export function DataTable({
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
values: {
defaultValues: {
amounts: {
"84532": "", // Base
"11155420": "", // OP testnet
"919": "", // Mode Network
"111557560": "", // Cyber
"999999999": "", // Zora
"11155111": "", // Sepolia
"421614": "",
"420120000": "",
"420120001": "",
},
},
});
const crossChainTransfer = async (chainId: ChainId) => {
if (!activeAccount) {
throw new Error("Account not connected");
}
const amount = form.getValues().amounts[chainId];
if (!positiveIntegerRegex.test(amount)) {
form.setError(`amounts.${chainId}`, { message: "Invalid Amount" });
return;
}
const superChainBridge = getContract({
address: superchainBridgeAddress,
chain: coreContract.chain,
client: coreContract.client,
});
const sendErc20Tx = prepareContractCall({
contract: superChainBridge,
method:
"function sendERC20(address _token, address _to, uint256 _amount, uint256 _chainId)",
params: [
coreContract.address,
activeAccount.address,
BigInt(amount),
BigInt(chainId),
],
});
await sendAndConfirmTransaction({
account: activeAccount,
transaction: sendErc20Tx,
});
};
const crossChainTransferNotifications = useTxNotifications(
"Successfully submitted cross chain transfer",
"Failed to submit cross chain transfer",
);
const crossChainTransferMutation = useMutation({
mutationFn: crossChainTransfer,
onSuccess: crossChainTransferNotifications.onSuccess,
onError: crossChainTransferNotifications.onError,
});
const columns: ColumnDef<CrossChain>[] = [
{
accessorKey: "network",
@@ -208,6 +268,54 @@ export function DataTable({
);
},
},
{
accessorKey: "transfer",
header: "",
cell: ({ row }) => {
const chain = row.getValue("chainId");
if (
row.getValue("status") === "DEPLOYED" &&
interopChains.includes(String(chain)) &&
isCrosschain
) {
return (
<FormField
disabled={false}
control={form.control}
name={`amounts.${row.getValue("chainId") as ChainId}`}
render={({ field }) => (
<FormItem>
<FormControl>
<ToolTipLabel label="Bridge tokens">
<div className="flex">
<Input
className="w-22 rounded-r-none border-r-0"
placeholder="amount"
{...field}
/>
<Button
type="button"
disabled={false}
onClick={() =>
crossChainTransferMutation.mutate(
row.getValue("chainId"),
)
}
className="rounded-lg rounded-l-none border border-l-0"
>
Bridge
</Button>
</div>
</ToolTipLabel>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
},
},
];
const table = useReactTable({
@@ -268,6 +376,18 @@ export function DataTable({
crosschainContractAddress = coreContract.address;
}
} else {
if (modulesMetadata) {
for (const m of modulesMetadata) {
await getOrDeployInfraForPublishedContract({
chain,
client,
account: activeAccount,
contractId: m.name,
publisher: m.publisher,
});
}
}
crosschainContractAddress = await deployContractfromDeployMetadata({
account: activeAccount,
chain,
@@ -283,17 +403,6 @@ export function DataTable({
chain,
client,
});
if (modulesMetadata) {
for (const m of modulesMetadata) {
await getOrDeployInfraForPublishedContract({
chain,
client,
account: activeAccount,
contractId: m.name,
publisher: m.publisher,
});
}
}
}
deployStatusModal.nextStep();
deployStatusModal.setViewContractLink(
@@ -2,6 +2,7 @@ import { fetchPublishedContractsFromDeploy } from "components/contract-component
import { notFound } from "next/navigation";
import {
type ContractOptions,
eth_blockNumber,
eth_getTransactionByHash,
eth_getTransactionReceipt,
getContractEvents,
@@ -110,10 +111,16 @@ export default async function Page(props: {
let creationBlockNumber: bigint | undefined;
if (twCloneFactoryContract) {
const latestBlockNumber = await eth_blockNumber(
getRpcClient({
client: contract.client,
chain: contract.chain,
}),
);
const events = await getContractEvents({
contract: twCloneFactoryContract,
events: [ProxyDeployedEvent],
blockRange: 123456n,
blockRange: latestBlockNumber < 2000000n ? latestBlockNumber : 2000000n,
});
const event = events.find(
(e) =>
@@ -165,7 +172,7 @@ export default async function Page(props: {
}
}),
)
).filter((c) => c.status === "DEPLOYED");
).filter((c) => c.chainId !== contract.chain.id);
let coreMetadata: FetchDeployMetadataResult | undefined;
try {
@@ -260,10 +267,11 @@ export default async function Page(props: {
<>
<div>
<h2 className="mb-1 font-bold text-2xl tracking-tight">
Deploy Cross-chain
Cross-chain contracts
</h2>
<p className="text-muted-foreground">
Deterministically deploy your contracts on multiple networks.
Deterministically deploy and interact with your contracts on multiple
networks.
</p>
</div>
<div className="h-10" />
@@ -45,7 +45,7 @@ export const products = [
link: "https://portal.thirdweb.com/connect/account-abstraction/overview",
},
{
name: "Pay",
name: "Universal Bridge",
id: "pay",
icon: PayIcon,
description: "Point of sale solution for bridging, onramping & swapping",
@@ -1,6 +1,6 @@
"use client";
import { redirectToBillingPortal } from "@/actions/billing";
import { getBillingPortalUrl } from "@/actions/billing";
import { confirmEmailWithOTP } from "@/actions/confirmEmail";
import { apiServerProxy } from "@/actions/proxies";
import { updateAccount } from "@/actions/updateAccount";
@@ -36,6 +36,16 @@ export function AccountSettingsPage(props: {
client={props.client}
defaultTeamSlug={props.defaultTeamSlug}
defaultTeamName={props.defaultTeamName}
cancelSubscriptions={async () => {
const res = await apiServerProxy({
method: "DELETE",
pathname: `/v1/teams/${props.defaultTeamSlug}/subscriptions`,
});
if (!res.ok) {
throw new Error(res.error);
}
}}
onAccountDeleted={async () => {
await doLogout();
if (activeWallet) {
@@ -58,7 +68,7 @@ export function AccountSettingsPage(props: {
return { status: 500 };
}
}}
redirectToBillingPortal={redirectToBillingPortal}
getBillingPortalUrl={getBillingPortalUrl}
updateAccountAvatar={async (file) => {
let uri: string | undefined = undefined;
@@ -109,7 +109,7 @@ function Variants() {
<AccountSettingsPageUI
defaultTeamSlug="foo"
defaultTeamName="Foo"
redirectToBillingPortal={async () => {
getBillingPortalUrl={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { status: 200 };
}}
@@ -143,6 +143,9 @@ function Variants() {
onAccountDeleted={() => {
console.log("Account deleted");
}}
cancelSubscriptions={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
/>
<Toaster richColors />
</div>
@@ -1,6 +1,6 @@
"use client";
import type { BillingBillingPortalAction } from "@/actions/billing";
import type { GetBillingPortalUrlAction } from "@/actions/billing";
import { BillingPortalButton } from "@/components/billing";
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
import { SettingsCard } from "@/components/blocks/SettingsCard";
@@ -38,7 +38,7 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { EllipsisIcon, ExternalLinkIcon } from "lucide-react";
import { CircleXIcon, EllipsisIcon, ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -63,7 +63,8 @@ export function AccountSettingsPageUI(props: {
onAccountDeleted: () => void;
defaultTeamSlug: string;
defaultTeamName: string;
redirectToBillingPortal: BillingBillingPortalAction;
getBillingPortalUrl: GetBillingPortalUrlAction;
cancelSubscriptions: () => Promise<void>;
}) {
return (
<div className="flex flex-col gap-8">
@@ -88,7 +89,8 @@ export function AccountSettingsPageUI(props: {
onAccountDeleted={props.onAccountDeleted}
defaultTeamSlug={props.defaultTeamSlug}
defaultTeamName={props.defaultTeamName}
redirectToBillingPortal={props.redirectToBillingPortal}
getBillingPortalUrl={props.getBillingPortalUrl}
cancelSubscriptions={props.cancelSubscriptions}
/>
</div>
);
@@ -206,7 +208,8 @@ function DeleteAccountCard(props: {
onAccountDeleted: () => void;
defaultTeamSlug: string;
defaultTeamName: string;
redirectToBillingPortal: BillingBillingPortalAction;
getBillingPortalUrl: GetBillingPortalUrlAction;
cancelSubscriptions: () => Promise<void>;
}) {
const title = "Delete Account";
const description = (
@@ -230,6 +233,10 @@ function DeleteAccountCard(props: {
},
});
const cancelSubscriptions = useMutation({
mutationFn: props.cancelSubscriptions,
});
function handleDelete() {
deleteAccount.mutate();
}
@@ -264,25 +271,47 @@ function DeleteAccountCard(props: {
<Alert variant="destructive">
<AlertTitle>Failed to delete account</AlertTitle>
<AlertDescription>
<span className="mb-1 block">
Your default team "{props.defaultTeamName}" has active
subscriptions. These subscriptions have to be cancelled
first before deleting account
</span>
<span className="mb-4 block">
Reach out to support to help you cancel them
</span>
<Button
variant="default"
asChild
size="sm"
className="gap-2"
>
<Link href="/support/create-ticket">
Contact Support
<ExternalLinkIcon className="size-4" />
</Link>
</Button>
<div className="mb-4">
<span className="block">
Your default team "{props.defaultTeamName}" has active
subscriptions. These subscriptions have to be cancelled
before deleting account
</span>
</div>
<div className="flex flex-col gap-3 lg:flex-row">
<Button
variant="default"
size="sm"
className="gap-2"
onClick={() => {
const promise = cancelSubscriptions.mutateAsync();
toast.promise(promise, {
success: "Subscriptions cancelled successfully",
error: "Failed to cancel subscriptions",
});
}}
>
{cancelSubscriptions.isPending ? (
<Spinner className="size-4" />
) : (
<CircleXIcon className="size-4" />
)}
Cancel subscriptions
</Button>
<Button
variant="outline"
asChild
size="sm"
className="gap-2"
>
<Link href="/support/create-ticket" target="_blank">
Contact Support
<ExternalLinkIcon className="size-4" />
</Link>
</Button>
</div>
</AlertDescription>
</Alert>
</div>
@@ -299,11 +328,12 @@ function DeleteAccountCard(props: {
account
</span>
<BillingPortalButton
size="sm"
buttonProps={{
size: "sm",
variant: "default",
}}
teamSlug={props.defaultTeamSlug}
variant="default"
redirectPath="/team"
redirectToBillingPortal={props.redirectToBillingPortal}
getBillingPortalUrl={props.getBillingPortalUrl}
>
Manage Billing
</BillingPortalButton>
@@ -2,7 +2,7 @@ import { API_SERVER_URL } from "@/constants/env";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { getAuthToken } from "../../api/lib/getAuthToken";
import { loginRedirect } from "../../login/loginRedirect";
import { isOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
import { isAccountOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
/**
* Just get the account object without enforcing onboarding.
@@ -40,7 +40,7 @@ export async function getValidAccount(pagePath?: string) {
const account = await getRawAccount();
// enforce login & onboarding
if (!account || !isOnboardingComplete(account)) {
if (!account || !isAccountOnboardingComplete(account)) {
loginRedirect(pagePath);
}
@@ -0,0 +1,21 @@
import { getTeamBySlug } from "@/api/team";
import { notFound } from "next/navigation";
import { TeamOnboardingLayout } from "../../../../login/onboarding/onboarding-layout";
import { InviteTeamMembers } from "../../../../login/onboarding/team-onboarding/team-onboarding";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
}) {
const params = await props.params;
const team = await getTeamBySlug(params.team_slug);
if (!team) {
notFound();
}
return (
<TeamOnboardingLayout currentStep={2}>
<InviteTeamMembers team={team} />
</TeamOnboardingLayout>
);
}
@@ -0,0 +1,60 @@
import { getProjects } from "@/api/projects";
import { getTeamBySlug, getTeams } from "@/api/team";
import { AppFooter } from "@/components/blocks/app-footer";
import { notFound } from "next/navigation";
import { getValidAccount } from "../../../account/settings/getAccount";
import {
getAuthToken,
getAuthTokenWalletAddress,
} from "../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../login/loginRedirect";
import { TeamHeaderLoggedIn } from "../../../team/components/TeamHeader/team-header-logged-in.client";
export default async function Layout(props: {
params: Promise<{ team_slug: string }>;
children: React.ReactNode;
}) {
const params = await props.params;
const [team, account, accountAddress, authToken, teams] = await Promise.all([
getTeamBySlug(params.team_slug),
getValidAccount(`/team/${params.team_slug}`),
getAuthTokenWalletAddress(),
getAuthToken(),
getTeams(),
]);
if (!accountAddress || !account || !teams || !authToken) {
loginRedirect(`/get-started/team/${params.team_slug}`);
}
if (!team) {
notFound();
}
// Note:
// Do not check that team is already onboarded or not and redirect away from /get-started pages
// because the team is marked as onboarded in the first step- instead of after completing all the steps
const teamsAndProjects = await Promise.all(
teams.map(async (team) => ({
team,
projects: await getProjects(team.slug),
})),
);
return (
<div className="flex min-h-dvh grow flex-col">
<div className="border-b bg-card">
<TeamHeaderLoggedIn
account={account}
accountAddress={accountAddress}
currentProject={undefined}
currentTeam={team}
teamsAndProjects={teamsAndProjects}
/>
</div>
{props.children}
<AppFooter />
</div>
);
}
@@ -0,0 +1,35 @@
import { getTeamBySlug } from "@/api/team";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { notFound } from "next/navigation";
import { getAuthToken } from "../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../login/loginRedirect";
import { TeamOnboardingLayout } from "../../../login/onboarding/onboarding-layout";
import { TeamInfoForm } from "../../../login/onboarding/team-onboarding/team-onboarding";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
}) {
const params = await props.params;
const [team, authToken] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
]);
if (!authToken) {
loginRedirect(`/get-started/team/${params.team_slug}`);
}
if (!team) {
notFound();
}
return (
<TeamOnboardingLayout currentStep={1}>
<TeamInfoForm
client={getThirdwebClient(authToken)}
teamId={team.id}
teamSlug={params.team_slug}
/>
</TeamOnboardingLayout>
);
}
+108 -84
View File
@@ -1,7 +1,7 @@
"use client";
import { redirectToCheckout } from "@/actions/billing";
import { getRawAccountAction } from "@/actions/getAccount";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { ToggleThemeButton } from "@/components/color-mode-toggle";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { TURNSTILE_SITE_KEY } from "@/constants/env";
@@ -12,16 +12,20 @@ import { Turnstile } from "@marsidev/react-turnstile";
import { useTheme } from "next-themes";
import Link from "next/link";
import { Suspense, lazy, useEffect, useState } from "react";
import { ConnectEmbed, useActiveWalletConnectionStatus } from "thirdweb/react";
import {
ConnectEmbed,
useActiveAccount,
useActiveWalletConnectionStatus,
} from "thirdweb/react";
import { createWallet, inAppWallet } from "thirdweb/wallets";
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
import { ThirdwebMiniLogo } from "../components/ThirdwebMiniLogo";
import { getSDKTheme } from "../components/sdk-component-theme";
import { doLogin, doLogout, getLoginPayload, isLoggedIn } from "./auth-actions";
import { isOnboardingComplete } from "./onboarding/isOnboardingRequired";
import { isAccountOnboardingComplete } from "./onboarding/isOnboardingRequired";
const LazyOnboardingUI = lazy(
() => import("./onboarding/on-boarding-ui.client"),
const LazyAccountOnboarding = lazy(
() => import("./onboarding/account-onboarding"),
);
const loginOptions = [
@@ -115,28 +119,13 @@ export function LoginAndOnboardingPage(props: {
);
}
export function LoginAndOnboardingPageContent(props: {
account: Account | undefined;
redirectPath: string;
loginWithInAppWallet: boolean;
function LoginPageContainer(props: {
children: React.ReactNode;
}) {
return (
<div className="relative flex grow flex-col">
<main className="container z-10 flex grow flex-col justify-center gap-6 py-12">
<ClientOnly
ssr={
<div className="flex justify-center">
<LoadingCard />
</div>
}
className="flex justify-center"
>
<PageContent
redirectPath={props.redirectPath}
account={props.account}
loginWithInAppWallet={props.loginWithInAppWallet}
/>
</ClientOnly>
<>
<main className="container z-10 flex grow flex-col items-center justify-center gap-6 py-12">
{props.children}
</main>
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -145,23 +134,16 @@ export function LoginAndOnboardingPageContent(props: {
src="/assets/login/background.svg"
className="-bottom-12 -right-12 pointer-events-none fixed lg:right-0 lg:bottom-0"
/>
</div>
</>
);
}
function LoadingCard() {
return (
<div className="flex min-h-[522px] w-full items-center justify-center rounded-xl border border-border bg-card shadow-lg max-sm:max-w-[358px] lg:min-h-[568px] lg:w-[728px]">
<Spinner className="size-10" />
</div>
);
}
function PageContent(props: {
export function LoginAndOnboardingPageContent(props: {
redirectPath: string;
account: Account | undefined;
loginWithInAppWallet: boolean;
}) {
const accountAddress = useActiveAccount()?.address;
const [screen, setScreen] = useState<
| { id: "login" }
| {
@@ -189,7 +171,7 @@ function PageContent(props: {
return;
}
if (!isOnboardingComplete(account)) {
if (!isAccountOnboardingComplete(account)) {
setScreen({
id: "onboarding",
account,
@@ -207,37 +189,55 @@ function PageContent(props: {
}
}, [connectionStatus, screen.id]);
if (connectionStatus === "connecting") {
return <LoadingCard />;
if (screen.id === "complete") {
return <GenericLoadingPage className="border-none" />;
}
if (connectionStatus !== "connected" || screen.id === "login") {
if (connectionStatus === "connecting") {
return (
<CustomConnectEmbed
onLogin={onLogin}
loginWithInAppWallet={props.loginWithInAppWallet}
/>
<LoginPageContainer>
<ConnectEmbedSizedLoadingCard />
</LoginPageContainer>
);
}
if (
connectionStatus !== "connected" ||
screen.id === "login" ||
!accountAddress
) {
return (
<LoginPageContainer>
<CustomConnectEmbed
onLogin={onLogin}
loginWithInAppWallet={props.loginWithInAppWallet}
/>
</LoginPageContainer>
);
}
if (screen.id === "onboarding") {
return (
<Suspense fallback={<LoadingCard />}>
<LazyOnboardingUI
account={screen.account}
<Suspense fallback={<GenericLoadingPage className="border-none" />}>
<LazyAccountOnboarding
onComplete={onComplete}
redirectPath={props.redirectPath}
redirectToCheckout={redirectToCheckout}
onLogout={() => {
setScreen({ id: "login" });
}}
skipShowingPlans={props.redirectPath.startsWith("/join/team")}
accountAddress={accountAddress}
/>
</Suspense>
);
}
return <LoadingCard />;
return (
<LoginPageContainer>
<CustomConnectEmbed
onLogin={onLogin}
loginWithInAppWallet={props.loginWithInAppWallet}
/>
</LoginPageContainer>
);
}
function CustomConnectEmbed(props: {
@@ -260,41 +260,65 @@ function CustomConnectEmbed(props: {
siteKey={TURNSTILE_SITE_KEY}
onSuccess={(token) => setTurnstileToken(token)}
/>
<ConnectEmbed
auth={{
getLoginPayload,
doLogin: async (params) => {
try {
const result = await doLogin(params, turnstileToken);
if (result.error) {
console.error("Failed to login", result.error, result.context);
throw new Error(result.error);
<ClientOnly ssr={<ConnectEmbedSizedLoadingCard />}>
<ConnectEmbed
auth={{
getLoginPayload,
doLogin: async (params) => {
try {
const result = await doLogin(params, turnstileToken);
if (result.error) {
console.error(
"Failed to login",
result.error,
result.context,
);
throw new Error(result.error);
}
props.onLogin();
} catch (e) {
console.error("Failed to login", e);
throw e;
}
props.onLogin();
} catch (e) {
console.error("Failed to login", e);
throw e;
}
},
doLogout,
isLoggedIn: async (x) => {
const isLoggedInResult = await isLoggedIn(x);
if (isLoggedInResult) {
props.onLogin();
}
return isLoggedInResult;
},
}}
wallets={
props.loginWithInAppWallet ? inAppWalletLoginOptions : loginOptions
}
client={client}
modalSize="wide"
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
className="shadow-lg"
privacyPolicyUrl="/privacy-policy"
termsOfServiceUrl="/terms"
/>
},
doLogout,
isLoggedIn: async (x) => {
const isLoggedInResult = await isLoggedIn(x);
if (isLoggedInResult) {
props.onLogin();
}
return isLoggedInResult;
},
}}
wallets={
props.loginWithInAppWallet ? inAppWalletLoginOptions : loginOptions
}
client={client}
modalSize="wide"
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
className="shadow-lg"
privacyPolicyUrl="/privacy-policy"
termsOfServiceUrl="/terms"
/>
</ClientOnly>
</div>
);
}
function ConnectEmbedSizedCard(props: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-[522px] w-full items-center justify-center rounded-xl border border-border bg-card shadow-lg max-sm:max-w-[358px] lg:min-h-[568px] lg:w-[728px]">
{props.children}
</div>
);
}
function ConnectEmbedSizedLoadingCard() {
return (
<ConnectEmbedSizedCard>
<Spinner className="size-10" />
</ConnectEmbedSizedCard>
);
}
@@ -1,175 +0,0 @@
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { type Account, useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTrack } from "hooks/analytics/useTrack";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
type AccountValidationSchema,
accountValidationSchema,
} from "./validations";
interface AccountFormProps {
account: Account;
horizontal?: boolean;
showSubscription?: boolean;
hideName?: boolean;
buttonText?: string;
padded?: boolean;
trackingCategory?: string;
disableUnchanged?: boolean;
onSave?: (email: string) => void;
onDuplicateError?: (email: string) => void;
}
export const AccountForm: React.FC<AccountFormProps> = ({
account,
onSave,
onDuplicateError,
buttonText = "Save",
hideName = false,
showSubscription = false,
disableUnchanged = false,
}) => {
const [isSubscribing, setIsSubscribing] = useState(true);
const trackEvent = useTrack();
const form = useForm<AccountValidationSchema>({
resolver: zodResolver(accountValidationSchema),
defaultValues: {
name: account.name || "",
email: account.unconfirmedEmail || account.email || "",
},
values: {
name: account.name || "",
email: account.unconfirmedEmail || account.email || "",
},
});
const updateMutation = useUpdateAccount();
const handleSubmit = form.handleSubmit((values) => {
const formData = {
...values,
...(showSubscription
? {
subscribeToUpdates: isSubscribing,
}
: {}),
};
trackEvent({
category: "account",
action: "update",
label: "attempt",
data: formData,
});
updateMutation.mutate(formData, {
onSuccess: (data) => {
if (onSave) {
onSave(values.email);
}
trackEvent({
category: "account",
action: "update",
label: "success",
data,
});
},
onError: (error) => {
console.error(error);
if (
onDuplicateError &&
error?.message.match(/email address already exists/)
) {
onDuplicateError(values.email);
return;
} else if (error.message.includes("INVALID_EMAIL_ADDRESS")) {
toast.error("Invalid Email Address");
} else {
toast.error(error.message || "Failed to update account");
}
trackEvent({
category: "account",
action: "update",
label: "error",
error,
fromOnboarding: !!onDuplicateError,
});
},
});
});
return (
<form onSubmit={handleSubmit}>
<div className="flex w-full flex-col items-start gap-8 rounded-xl">
<div className="flex w-full flex-col gap-4">
<FormFieldSetup
isRequired
htmlFor="email"
errorMessage={
form.getFieldState("email", form.formState).error?.message
}
label="Email"
>
<Input
placeholder="[email protected]"
type="email"
{...form.register("email")}
id="email"
/>
</FormFieldSetup>
{!hideName && (
<FormFieldSetup
errorMessage={
form.getFieldState("name", form.formState).error?.message
}
label="Name"
htmlFor="name"
isRequired={false}
>
<Input
placeholder="Company Inc."
type="text"
{...form.register("name")}
id="name"
/>
</FormFieldSetup>
)}
{showSubscription && (
<CheckboxWithLabel>
<Checkbox
checked={isSubscribing}
onCheckedChange={(v) => setIsSubscribing(!!v)}
/>
Subscribe to new features and key product updates
</CheckboxWithLabel>
)}
</div>
<Button
type="button"
onClick={handleSubmit}
disabled={
updateMutation.isPending ||
(disableUnchanged && !form.formState.isDirty)
}
className="w-full gap-2"
>
{updateMutation.isPending && <Spinner className="size-4" />}
{buttonText}
</Button>
</div>
</form>
);
};
@@ -1,74 +0,0 @@
"use client";
import type { RedirectBillingCheckoutAction } from "@/actions/billing";
import { TextDivider } from "@/components/TextDivider";
import { PricingCard } from "@/components/blocks/pricing-card";
import { Button } from "@/components/ui/button";
import { TitleAndDescription } from "./Title";
export function OnboardingChoosePlan(props: {
skipPlan: () => Promise<void>;
canTrialGrowth: boolean;
teamSlug: string;
redirectPath: string;
redirectToCheckout: RedirectBillingCheckoutAction;
}) {
return (
<div>
<TitleAndDescription
heading="Choose your Plan"
description="Get started for free with our Starter plan or subscribe to Growth plan to unlock higher rate limits and advanced features"
/>
<div className="h-4" />
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-5">
<PricingCard
billingPlan="starter"
teamSlug={props.teamSlug}
cta={{
title: "Get Started for free",
tracking: {
category: "account",
label: "starterPlan",
},
}}
redirectPath={props.redirectPath}
redirectToCheckout={props.redirectToCheckout}
/>
<PricingCard
billingPlan="growth"
teamSlug={props.teamSlug}
cta={{
title: "Get Started with Growth",
tracking: {
category: "account",
label: "growthPlan",
},
variant: "default",
}}
canTrialGrowth={false}
highlighted
redirectPath={props.redirectPath}
redirectToCheckout={props.redirectToCheckout}
/>
</div>
<TextDivider text="OR" className="my-4" />
<Button
variant="outline"
onClick={props.skipPlan}
className="relative h-auto w-full items-center gap-2 rounded-xl bg-card py-2.5"
>
<span className="flex flex-col gap-0.5">
<span className="text-base text-foreground">Skip for now</span>
<span className="text-muted-foreground text-sm">
You will have limited access to services
</span>
</span>
</Button>
</div>
);
}
@@ -1,261 +0,0 @@
"use client";
import type { Team } from "@/api/team";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { cn } from "@/lib/utils";
import {
type Account,
useConfirmEmail,
useResendEmailConfirmation,
} from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTrack } from "hooks/analytics/useTrack";
import { useTxNotifications } from "hooks/useTxNotifications";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { useActiveAccount } from "thirdweb/react";
import { shortenString } from "utils/usedapp-external";
import { TitleAndDescription } from "./Title";
import {
type EmailConfirmationValidationSchema,
emailConfirmationValidationSchema,
} from "./validations";
interface OnboardingConfirmEmailProps {
email: string;
linking?: boolean;
onEmailConfirm: (params: {
team: Team;
account: Account;
}) => void;
onComplete: () => void;
onBack: () => void;
}
// TODO - separate out "linking" and "confirmLinking" states into separate components
export const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
email,
linking,
onEmailConfirm,
onBack,
onComplete,
}) => {
const [token, setToken] = useState("");
const [completed, setCompleted] = useState(false);
const [saving, setSaving] = useState(false);
const trackEvent = useTrack();
const address = useActiveAccount()?.address;
const { onSuccess: onResendSuccess, onError: onResendError } =
useTxNotifications(
!linking
? "We've sent you an email confirmation code."
: "We've sent you a wallet linking confirmation code.",
!linking
? "Couldn't send an email confirmation code. Try later!"
: "Couldn't send a wallet linking confirmation code. Try later!",
);
const form = useForm<EmailConfirmationValidationSchema>({
resolver: zodResolver(emailConfirmationValidationSchema),
defaultValues: {
confirmationToken: "",
},
});
const confirmEmail = useConfirmEmail();
const resendMutation = useResendEmailConfirmation();
const handleChange = (value: string) => {
setToken(value.toUpperCase());
form.setValue("confirmationToken", value);
};
const handleSubmit = form.handleSubmit((values) => {
const trackingAction = !linking ? "confirmEmail" : "confirmLinkWallet";
setSaving(true);
trackEvent({
category: "account",
action: trackingAction,
label: "attempt",
});
confirmEmail.mutate(values, {
onSuccess: (response) => {
if (!linking) {
onEmailConfirm(response);
} else {
setCompleted(true);
}
setSaving(false);
trackEvent({
category: "account",
action: trackingAction,
label: "success",
});
},
// biome-ignore lint/suspicious/noExplicitAny: FIXME
onError: (error: any) => {
const message =
"message" in error
? error.message
: "Couldn't verify your email address. Try later!";
toast.error(message);
form.reset();
setToken("");
setSaving(false);
trackEvent({
category: "account",
action: trackingAction,
label: "error",
error: message,
});
},
});
});
const handleResend = () => {
setSaving(true);
trackEvent({
category: "account",
action: "resendEmailConfirmation",
label: "attempt",
});
resendMutation.mutate(undefined, {
onSuccess: () => {
setSaving(false);
onResendSuccess();
trackEvent({
category: "account",
action: "resendEmailConfirmation",
label: "success",
});
},
onError: (error) => {
onResendError(error);
form.reset();
setToken("");
setSaving(false);
trackEvent({
category: "account",
action: "resendEmailConfirmation",
label: "error",
error,
});
},
});
};
return (
<>
<TitleAndDescription
heading={
!linking
? "Verify your email"
: completed
? "Wallet linked"
: "Linking Wallets"
}
description={
!completed ? (
<>
Enter the 6 letter confirmation code sent to{" "}
<span className="text-medium">{email}</span>
</>
) : (
<>
We've linked{" "}
{address && (
<span className="font-mono">{shortenString(address)}</span>
)}
wallet to <span className="font-medium">{email}</span> thirdweb
account.
</>
)
}
/>
<div className="h-5" />
{completed && (
<Button onClick={onComplete} className="w-full">
Continue to Dashboard
</Button>
)}
{!completed && (
<form onSubmit={handleSubmit}>
<InputOTP
maxLength={6}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
inputMode="text"
value={token}
onChange={handleChange}
disabled={saving}
>
<InputOTPGroup className="w-full">
{new Array(6).fill(0).map((_, idx) => (
<InputOTPSlot
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={idx}
index={idx}
className={cn("h-12 grow text-lg", {
"border-red-500": form.getFieldState(
"confirmationToken",
form.formState,
).error,
})}
/>
))}
</InputOTPGroup>
</InputOTP>
<div className="h-6" />
<div className="flex flex-col gap-4">
<Button
type="submit"
onClick={handleSubmit}
disabled={saving}
className="gap-2"
>
{saving && <Spinner className="size-4" />}
Verify
</Button>
<Button onClick={onBack} disabled={saving} variant="outline">
{!linking ? "Update email" : "Use another email"}
</Button>
<Button
variant="link"
onClick={handleResend}
disabled={saving}
className="text-link-foreground hover:text-foreground"
>
Resend Verification Code
</Button>
</div>
</form>
)}
</>
);
};
@@ -1,90 +0,0 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { doLogout } from "../auth-actions";
import { AccountForm } from "./AccountForm";
import { TitleAndDescription } from "./Title";
type OnboardingGeneralProps = {
account: Account;
onSave: (email: string) => void;
onDuplicate: (email: string) => void;
onLogout: () => void;
};
export const OnboardingGeneral: React.FC<OnboardingGeneralProps> = ({
account,
onSave,
onDuplicate,
onLogout,
}) => {
const [existing, setExisting] = useState(false);
const activeWallet = useActiveWallet();
const { disconnect } = useDisconnect();
async function handleLogout() {
await doLogout();
onLogout();
if (activeWallet) {
disconnect(activeWallet);
}
}
const logoutMutation = useMutation({
mutationFn: handleLogout,
});
return (
<div>
<TitleAndDescription
heading={
!existing
? "Create your thirdweb account"
: "Log in to your thirdweb account"
}
description="Start building web3 apps and games, faster."
/>
<div className="h-6" />
<div className="flex flex-col gap-4">
<AccountForm
showSubscription={!existing}
hideName={existing}
account={account}
buttonText={!existing ? "Get Started for Free" : "Login"}
trackingCategory="onboarding"
onSave={onSave}
onDuplicateError={onDuplicate}
/>
{!existing ? (
<>
<Button variant="primary" onClick={() => setExisting(true)}>
I have a thirdweb account
</Button>
<Button
variant="outline"
onClick={() => {
logoutMutation.mutate();
}}
className="gap-2"
>
{logoutMutation.isPending && <Spinner className="size-4" />}
Log out
</Button>
</>
) : (
<Button variant="outline" onClick={() => setExisting(false)}>
I don&apos;t have an account
</Button>
)}
</div>
</div>
);
};
@@ -1,125 +0,0 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useTrack } from "hooks/analytics/useTrack";
import { useActiveAccount } from "thirdweb/react";
import { shortenString } from "utils/usedapp-external";
import { TitleAndDescription } from "./Title";
interface OnboardingLinkWalletProps {
email: string;
onSave: () => void;
onBack: () => void;
}
export const OnboardingLinkWallet: React.FC<OnboardingLinkWalletProps> = ({
email,
onSave,
onBack,
}) => {
const address = useActiveAccount()?.address;
const trackEvent = useTrack();
const updateMutation = useUpdateAccount();
const handleSubmit = () => {
trackEvent({
category: "account",
action: "linkWallet",
label: "attempt",
data: {
email,
},
});
updateMutation.mutate(
{
email,
linkWallet: true,
},
{
onSuccess: (data) => {
if (onSave) {
onSave();
}
trackEvent({
category: "account",
action: "linkWallet",
label: "success",
data,
});
},
onError: (err) => {
const error = err as Error;
trackEvent({
category: "account",
action: "linkWallet",
label: "error",
error,
});
},
},
);
};
return (
<>
<TitleAndDescription
heading="Linking Wallets"
description={
<>
We&apos;ve noticed that there is another account associated with{" "}
<span className="text-foreground">{email}</span>. Would you like to
link your wallet{" "}
{address && (
<span className="font-mono text-foreground">
{shortenString(address)}
</span>
)}{" "}
to the existing account?
<div className="h-2" />
Once you agree, we will email you the details.{" "}
<TrackedLinkTW
href="https://portal.thirdweb.com/account/billing/account-info"
category="account"
label="learn-wallet-linking"
target="_blank"
className="underline hover:text-foreground"
>
Learn more about wallet linking
</TrackedLinkTW>
.
</>
}
/>
<div className="h-8" />
<form>
<div className="flex flex-col gap-3">
<Button
type="button"
onClick={handleSubmit}
disabled={updateMutation.isPending}
className="gap-2"
>
{updateMutation.isPending && <Spinner className="size-4" />}
{updateMutation.isPending ? "Linking" : "Yes, link them"}
</Button>
<Button
size="lg"
variant="outline"
onClick={onBack}
disabled={updateMutation.isPending}
>
Use another email
</Button>
</div>
</form>
</>
);
};
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from "@storybook/react";
import { storybookLog } from "../../../../stories/utils";
import { AccountOnboardingLayout } from "../onboarding-layout";
import { LinkWalletPrompt } from "./LinkWalletPrompt";
const meta = {
title: "Onboarding/AccountOnboarding/LinkWalletPrompt",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SendSuccess: Story = {
args: {
type: "success",
},
};
export const SendError: Story = {
args: {
type: "error",
},
};
function Story(props: {
type: "success" | "error";
}) {
return (
<AccountOnboardingLayout
currentStep={1}
logout={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
storybookLog("logout");
}}
>
<LinkWalletPrompt
onLinkWalletRequestSent={() => {
storybookLog("onLinkWalletRequestSent");
}}
email="[email protected]"
requestLinkWallet={async (email) => {
storybookLog("requestLinkWallet", email);
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.type === "error") {
throw new Error("Example error");
}
}}
onBack={() => {
storybookLog("onBack");
}}
trackEvent={(params) => {
storybookLog("trackEvent", params);
}}
accountAddress="0x1234567890123456789012345678901234567890"
/>
</AccountOnboardingLayout>
);
}
@@ -0,0 +1,123 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { useMutation } from "@tanstack/react-query";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { toast } from "sonner";
import { shortenString } from "utils/usedapp-external";
import type { TrackingParams } from "../../../../hooks/analytics/useTrack";
export function LinkWalletPrompt(props: {
email: string;
accountAddress: string;
onBack: () => void;
requestLinkWallet: (email: string) => Promise<void>;
trackEvent: (params: TrackingParams) => void;
onLinkWalletRequestSent: () => void;
}) {
const requestLinkWallet = useMutation({
mutationFn: props.requestLinkWallet,
});
function handleLinkWalletRequest() {
props.trackEvent({
category: "account",
action: "linkWallet",
label: "attempt",
data: {
email: props.email,
},
});
requestLinkWallet.mutate(props.email, {
onSuccess: (data) => {
props.onLinkWalletRequestSent();
props.trackEvent({
category: "account",
action: "linkWallet",
label: "success",
data,
});
},
onError: (err) => {
const error = err as Error;
console.error(error);
toast.error("Failed to send link wallet request");
props.trackEvent({
category: "account",
action: "linkWallet",
label: "error",
error,
});
},
});
}
return (
<div className="rounded-lg border bg-card">
<h3 className="border-b px-4 py-6 font-semibold text-xl tracking-tight lg:p-6">
Link your wallet
</h3>
<div className="p-4 lg:p-6">
<div className="mb-6 space-y-2 text-muted-foreground">
<p>
An account with{" "}
<span className="text-foreground">{props.email}</span> already
exists, but your wallet is not linked to that account.
</p>
<p>
You can link your wallet with this account to access it. <br />{" "}
Multiple wallets can be linked to the same account.{" "}
<TrackedLinkTW
href="https://portal.thirdweb.com/account/billing/account-info"
category="account"
label="learn-wallet-linking"
target="_blank"
className="underline decoration-muted-foreground/50 decoration-dotted underline-offset-[5px] hover:text-foreground hover:decoration-foreground hover:decoration-solid"
>
Learn more about wallet linking
</TrackedLinkTW>
</p>
</div>
<p className="text-foreground">
Would you like to link your wallet{" "}
<span className="font-mono text-sm tracking-tight">
({shortenString(props.accountAddress)})
</span>{" "}
with this account?
</p>
</div>
<div className="flex flex-col-reverse gap-4 border-t px-4 py-6 md:flex-row lg:justify-between lg:p-6">
<Button
variant="outline"
className="gap-2 bg-card"
onClick={props.onBack}
disabled={requestLinkWallet.isPending}
>
<ArrowLeftIcon className="size-4" />
Change Email
</Button>
<Button
type="button"
onClick={handleLinkWalletRequest}
disabled={requestLinkWallet.isPending}
className="gap-2"
>
Link wallet and continue
{requestLinkWallet.isPending ? (
<Spinner className="size-4" />
) : (
<ArrowRightIcon className="size-4" />
)}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from "@storybook/react";
import { storybookLog } from "../../../../stories/utils";
import { AccountOnboardingLayout } from "../onboarding-layout";
import { LoginOrSignup } from "./LoginOrSignup";
const meta = {
title: "Onboarding/AccountOnboarding/LoginOrSignup",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Success: Story = {
args: {
type: "success",
},
};
export const EmailExists: Story = {
args: {
type: "email-exists",
},
};
export const OtherError: Story = {
args: {
type: "error",
},
};
function Story(props: {
type: "success" | "error" | "email-exists";
}) {
return (
<AccountOnboardingLayout
currentStep={1}
logout={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
storybookLog("logout");
}}
>
<LoginOrSignup
onRequestSent={(params) => {
storybookLog("onRequestSent", params);
}}
loginOrSignup={async (data) => {
storybookLog("loginOrSignup", data);
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.type === "error") {
throw new Error("Error Example");
}
if (props.type === "email-exists") {
throw new Error("email address already exists");
}
}}
trackEvent={(params) => {
storybookLog("trackEvent", params);
}}
/>
</AccountOnboardingLayout>
);
}
@@ -0,0 +1,276 @@
"use client";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { TabButtons } from "@/components/ui/tabs";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { ArrowRightIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import type { TrackingParams } from "../../../../hooks/analytics/useTrack";
import {
type AccountValidationSchema,
accountValidationSchema,
emailSchema,
} from "../validations";
export function LoginOrSignup(props: {
onRequestSent: (options: {
email: string;
isExistingEmail: boolean;
}) => void;
loginOrSignup: (input: {
email: string;
subscribeToUpdates?: true;
name?: string;
}) => Promise<void>;
trackEvent: (params: TrackingParams) => void;
}) {
const [tab, setTab] = useState<"signup" | "login">("signup");
const loginOrSignup = useMutation({
mutationFn: props.loginOrSignup,
});
function handleSubmit(values: {
email: string;
subscribeToUpdates?: true;
name?: string;
}) {
loginOrSignup.mutate(values, {
onSuccess: (data) => {
props.onRequestSent({
email: values.email,
isExistingEmail: false,
});
props.trackEvent({
category: "onboarding",
action: "update",
label: "success",
data,
});
},
onError: (error) => {
if (error?.message.match(/email address already exists/)) {
props.onRequestSent({
email: values.email,
isExistingEmail: true,
});
return;
} else if (error.message.includes("INVALID_EMAIL_ADDRESS")) {
toast.error("Invalid Email Address");
} else {
toast.error("Failed to send confirmation email");
}
console.error(error);
props.trackEvent({
category: "account",
action: "update",
label: "error",
error: error.message,
fromOnboarding: true,
});
},
});
}
return (
<div className="flex flex-col rounded-lg border bg-card">
<TabButtons
tabContainerClassName="px-4 lg:px-6 pt-3 pb-0.5"
tabs={[
{
name: "Create account",
onClick: () => setTab("signup"),
isActive: tab === "signup",
isEnabled: true,
},
{
name: "I already have an account",
onClick: () => setTab("login"),
isActive: tab === "login",
isEnabled: true,
},
]}
/>
{tab === "signup" && (
<SignupForm
key="signup"
onSubmit={handleSubmit}
isSubmitting={loginOrSignup.isPending}
/>
)}
{tab === "login" && (
<LoginForm
key="login"
onSubmit={handleSubmit}
isSubmitting={loginOrSignup.isPending}
/>
)}
</div>
);
}
function SignupForm(props: {
onSubmit: (values: {
name: string;
email: string;
subscribeToUpdates?: true;
}) => void;
isSubmitting: boolean;
}) {
const [subscribeToUpdates, setSubscribeToUpdates] = useState(true);
const form = useForm<AccountValidationSchema>({
resolver: zodResolver(accountValidationSchema),
values: {
name: "",
email: "",
},
});
const handleSubmit = form.handleSubmit((values) => {
props.onSubmit({
...values,
...(subscribeToUpdates
? {
subscribeToUpdates: subscribeToUpdates,
}
: {}),
});
});
return (
<form onSubmit={handleSubmit} className="flex w-full grow flex-col">
<div className="flex w-full flex-col gap-4 px-4 py-6 lg:p-6">
<FormFieldSetup
errorMessage={
form.getFieldState("name", form.formState).error?.message
}
label="Name"
htmlFor="name"
isRequired={false}
>
<Input
placeholder="Company Inc."
className="bg-background"
type="text"
{...form.register("name")}
id="name"
/>
</FormFieldSetup>
<FormFieldSetup
isRequired
htmlFor="email"
errorMessage={
form.getFieldState("email", form.formState).error?.message
}
label="Email"
>
<Input
placeholder="[email protected]"
className="bg-background"
type="email"
{...form.register("email")}
id="email"
/>
</FormFieldSetup>
<CheckboxWithLabel>
<Checkbox
checked={subscribeToUpdates}
onCheckedChange={(v) => setSubscribeToUpdates(!!v)}
/>
Subscribe to new features and key product updates
</CheckboxWithLabel>
</div>
<div className="mt-8 flex justify-end border-t px-4 py-6 lg:p-6">
<Button
type="button"
onClick={handleSubmit}
disabled={props.isSubmitting}
className="gap-2 px-6"
>
Get Started
{props.isSubmitting ? (
<Spinner className="size-4" />
) : (
<ArrowRightIcon className="size-4" />
)}
</Button>
</div>
</form>
);
}
const loginFormSchema = z.object({
email: emailSchema,
});
type LoginFormSchema = z.infer<typeof loginFormSchema>;
function LoginForm(props: {
onSubmit: (values: {
email: string;
}) => void;
isSubmitting: boolean;
}) {
const form = useForm<LoginFormSchema>({
resolver: zodResolver(loginFormSchema),
values: {
email: "",
},
});
const handleSubmit = form.handleSubmit((values) => {
props.onSubmit(values);
});
return (
<form onSubmit={handleSubmit} className="flex w-full grow flex-col">
<div className="flex w-full flex-col gap-4 px-4 py-6 lg:p-6">
<FormFieldSetup
isRequired
htmlFor="email"
errorMessage={
form.getFieldState("email", form.formState).error?.message
}
label="Email"
>
<Input
placeholder="[email protected]"
type="email"
className="bg-background"
{...form.register("email")}
id="email"
/>
</FormFieldSetup>
</div>
<div className="mt-6 flex justify-end border-t px-4 py-6 lg:p-6">
<Button
type="button"
onClick={handleSubmit}
disabled={props.isSubmitting}
className="gap-2 px-6"
>
Login
{props.isSubmitting ? (
<Spinner className="size-4" />
) : (
<ArrowRightIcon className="size-4" />
)}
</Button>
</div>
</form>
);
}
@@ -1,23 +0,0 @@
import type { JSX } from "react";
type TitleAndDescriptionProps = {
heading: string | JSX.Element;
description: string | JSX.Element;
};
export const TitleAndDescription: React.FC<TitleAndDescriptionProps> = ({
heading,
description,
}) => {
return (
<div className="flex flex-col items-start gap-1">
<h3 className="font-semibold text-foreground text-xl tracking-tight">
{heading}
</h3>
{description && (
<div className="text-muted-foreground">{description}</div>
)}
</div>
);
};
@@ -0,0 +1,86 @@
import type { Meta, StoryObj } from "@storybook/react";
import { newAccountStub, teamStub } from "../../../../stories/stubs";
import { storybookLog } from "../../../../stories/utils";
import { AccountOnboardingLayout } from "../onboarding-layout";
import { VerifyEmail } from "./VerifyEmail";
const meta = {
title: "Onboarding/AccountOnboarding/VerifyEmail",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const VerifyEmailSuccess: Story = {
args: {
verifyEmailType: "success",
resendConfirmationEmailType: "success",
},
};
export const VerifyEmailError: Story = {
args: {
verifyEmailType: "error",
resendConfirmationEmailType: "success",
},
};
export const ResendCodeError: Story = {
args: {
verifyEmailType: "success",
resendConfirmationEmailType: "error",
},
};
function Story(props: {
verifyEmailType: "success" | "error";
resendConfirmationEmailType: "success" | "error";
}) {
return (
<AccountOnboardingLayout
currentStep={2}
logout={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
storybookLog("logout");
}}
>
<VerifyEmail
title="Custom Title"
trackingAction="customAction"
accountAddress="0x1234567890123456789012345678901234567890"
verifyEmail={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.verifyEmailType === "error") {
throw new Error("Example error");
}
return {
team: teamStub("foo", "free"),
account: newAccountStub(),
};
}}
resendConfirmationEmail={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.resendConfirmationEmailType === "error") {
throw new Error("Example error");
}
}}
email="[email protected]"
onEmailConfirmed={(params) => {
storybookLog("onEmailConfirmed", params);
}}
onBack={() => {
storybookLog("onBack");
}}
trackEvent={(params) => {
storybookLog("trackEvent", params);
}}
/>
</AccountOnboardingLayout>
);
}
@@ -0,0 +1,225 @@
"use client";
import type { Team } from "@/api/team";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { cn } from "@/lib/utils";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import type { TrackingParams } from "hooks/analytics/useTrack";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
type EmailConfirmationValidationSchema,
emailConfirmationValidationSchema,
} from "../validations";
type VerifyEmailProps = {
email: string;
onEmailConfirmed: (params: {
team: Team;
account: Account;
}) => void;
onBack: () => void;
verifyEmail: (params: {
confirmationToken: string;
}) => Promise<{
team: Team;
account: Account;
}>;
resendConfirmationEmail: () => Promise<void>;
trackEvent: (params: TrackingParams) => void;
accountAddress: string;
title: string;
trackingAction: string;
};
export function VerifyEmail(props: VerifyEmailProps) {
const form = useForm<EmailConfirmationValidationSchema>({
resolver: zodResolver(emailConfirmationValidationSchema),
values: {
confirmationToken: "",
},
});
const verifyEmail = useMutation({
mutationFn: props.verifyEmail,
});
const resendConfirmationEmail = useMutation({
mutationFn: props.resendConfirmationEmail,
});
const handleSubmit = form.handleSubmit((values) => {
props.trackEvent({
category: "account",
action: props.trackingAction,
label: "attempt",
});
verifyEmail.mutate(values, {
onSuccess: (response) => {
props.onEmailConfirmed(response);
props.trackEvent({
category: "account",
action: props.trackingAction,
label: "success",
});
},
onError: (error) => {
console.error(error);
toast.error("Invalid confirmation code");
props.trackEvent({
category: "account",
action: props.trackingAction,
label: "error",
error: error.message,
});
},
});
});
function handleResend() {
form.setValue("confirmationToken", "");
verifyEmail.reset();
props.trackEvent({
category: "account",
action: "resendEmailConfirmation",
label: "attempt",
});
resendConfirmationEmail.mutate(undefined, {
onSuccess: () => {
toast.success("Verification code sent");
props.trackEvent({
category: "account",
action: "resendEmailConfirmation",
label: "success",
});
},
onError: (error) => {
toast.error("Failed to send verification code");
props.trackEvent({
category: "account",
action: "resendEmailConfirmation",
label: "error",
error,
});
},
});
}
return (
<div className="rounded-lg border bg-card">
<form onSubmit={handleSubmit}>
<div className="px-4 py-6 lg:px-6">
<h3 className="mb-1 font-semibold text-foreground text-xl tracking-tight">
{props.title}
</h3>
<p className="mb-6 text-muted-foreground text-sm">
Enter the 6 letter confirmation code sent to{" "}
<span className="text-medium">{props.email}</span>
</p>
<div className="md:flex md:justify-start">
<InputOTP
maxLength={6}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
inputMode="text"
value={form.watch("confirmationToken")}
onChange={(otp) => {
form.setValue("confirmationToken", otp);
}}
disabled={verifyEmail.isPending}
>
<InputOTPGroup className="w-full grow bg-background lg:w-[300px]">
{new Array(6).fill(0).map((_, idx) => (
<InputOTPSlot
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={idx}
index={idx}
className={cn("h-12 grow border-foreground/25 text-lg", {
"border-red-500":
form.getFieldState("confirmationToken", form.formState)
.error || verifyEmail.isError,
})}
/>
))}
</InputOTPGroup>
</InputOTP>
</div>
</div>
<div className="mt-4 flex flex-col-reverse gap-4 border-t px-4 py-6 lg:flex-row lg:justify-between lg:px-6">
<Button
onClick={props.onBack}
variant="outline"
className="gap-2 bg-card"
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
Change Email
</Button>
<div className="flex justify-end gap-4">
<Button
variant="outline"
className="gap-2 bg-background"
onClick={handleResend}
disabled={resendConfirmationEmail.isPending}
>
{resendConfirmationEmail.isPending ? (
<Spinner className="size-4" />
) : (
<RotateCcwIcon className="size-4 text-muted-foreground" />
)}
Resend Code
</Button>
<Button
type="submit"
onClick={handleSubmit}
disabled={verifyEmail.isPending}
className="grow gap-2 px-6"
>
{verifyEmail.isPending && <Spinner className="size-4" />}
Verify
</Button>
</div>
</div>
</form>
</div>
);
}
export function LinkWalletVerifyEmail(
props: Omit<VerifyEmailProps, "title" | "trackingAction">,
) {
return (
<VerifyEmail
{...props}
title="Link Wallet"
trackingAction="confirmLinkWallet"
/>
);
}
export function SignupVerifyEmail(
props: Omit<VerifyEmailProps, "title" | "trackingAction">,
) {
return (
<VerifyEmail
{...props}
title="Verify Email"
trackingAction="confirmEmail"
/>
);
}
@@ -0,0 +1,133 @@
"use client";
import type { Team } from "@/api/team";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { useState } from "react";
import type { TrackingParams } from "../../../hooks/analytics/useTrack";
import { LinkWalletPrompt } from "./LinkWalletPrompt/LinkWalletPrompt";
import { LoginOrSignup } from "./LoginOrSignup/LoginOrSignup";
import {
LinkWalletVerifyEmail,
SignupVerifyEmail,
} from "./VerifyEmail/VerifyEmail";
import { AccountOnboardingLayout } from "./onboarding-layout";
type AccountOnboardingScreen =
| { id: "login-or-signup" }
| { id: "link-wallet"; email: string; backScreen: AccountOnboardingScreen }
| {
id: "signup-verify-email";
email: string;
backScreen: AccountOnboardingScreen;
}
| {
id: "link-wallet-verify-email";
email: string;
backScreen: AccountOnboardingScreen;
};
type AccountOnboardingProps = {
onComplete: (param: {
team: Team;
account: Account;
}) => void;
accountAddress: string;
trackEvent: (params: TrackingParams) => void;
verifyEmail: (params: {
confirmationToken: string;
}) => Promise<{
team: Team;
account: Account;
}>;
resendEmailConfirmation: () => Promise<void>;
loginOrSignup: (input: {
email: string;
subscribeToUpdates?: true;
name?: string;
}) => Promise<void>;
requestLinkWallet: (email: string) => Promise<void>;
logout: () => Promise<void>;
};
export function AccountOnboardingUI(props: AccountOnboardingProps) {
const [screen, setScreen] = useState<AccountOnboardingScreen>({
id: "login-or-signup",
});
return (
<AccountOnboardingLayout
logout={props.logout}
currentStep={
screen.id === "login-or-signup" || screen.id === "link-wallet" ? 1 : 2
}
>
{screen.id === "login-or-signup" && (
<LoginOrSignup
loginOrSignup={props.loginOrSignup}
trackEvent={props.trackEvent}
onRequestSent={(params) => {
if (params.isExistingEmail) {
setScreen({
id: "link-wallet",
email: params.email,
backScreen: screen,
});
} else {
setScreen({
id: "signup-verify-email",
email: params.email,
backScreen: screen,
});
}
}}
/>
)}
{screen.id === "link-wallet" && (
<LinkWalletPrompt
accountAddress={props.accountAddress}
requestLinkWallet={props.requestLinkWallet}
trackEvent={props.trackEvent}
onLinkWalletRequestSent={() => {
setScreen({
id: "link-wallet-verify-email",
email: screen.email,
backScreen: screen,
});
}}
onBack={() => setScreen(screen.backScreen)}
email={screen.email}
/>
)}
{screen.id === "signup-verify-email" && (
<SignupVerifyEmail
accountAddress={props.accountAddress}
verifyEmail={props.verifyEmail}
resendConfirmationEmail={props.resendEmailConfirmation}
trackEvent={props.trackEvent}
onEmailConfirmed={(data) => {
props.onComplete({
team: data.team,
account: data.account,
});
}}
onBack={() => setScreen(screen.backScreen)}
email={screen.email}
/>
)}
{screen.id === "link-wallet-verify-email" && (
<LinkWalletVerifyEmail
accountAddress={props.accountAddress}
verifyEmail={props.verifyEmail}
resendConfirmationEmail={props.resendEmailConfirmation}
trackEvent={props.trackEvent}
onEmailConfirmed={props.onComplete}
onBack={() => setScreen(screen.backScreen)}
email={screen.email}
/>
)}
</AccountOnboardingLayout>
);
}
@@ -0,0 +1,85 @@
import type { Meta, StoryObj } from "@storybook/react";
import { newAccountStub, teamStub } from "../../../stories/stubs";
import { storybookLog } from "../../../stories/utils";
import { AccountOnboardingUI } from "./account-onboarding-ui";
const meta = {
title: "Onboarding/AccountOnboarding/Flow",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const NewEmail: Story = {
args: {
loginOrSignupType: "success",
requestLinkWalletType: "success",
verifyEmailType: "success",
},
};
export const EmailExists: Story = {
args: {
loginOrSignupType: "error-email-exists",
requestLinkWalletType: "success",
verifyEmailType: "success",
},
};
function Story(props: {
loginOrSignupType: "success" | "error-email-exists" | "error-generic";
requestLinkWalletType: "success" | "error";
verifyEmailType: "success" | "error";
}) {
return (
<AccountOnboardingUI
logout={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
storybookLog("logout");
}}
onComplete={() => {
storybookLog("onComplete");
}}
accountAddress=""
trackEvent={(params) => {
storybookLog("trackEvent", params);
}}
loginOrSignup={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.loginOrSignupType === "error-email-exists") {
throw new Error("email address already exists");
}
if (props.loginOrSignupType === "error-generic") {
throw new Error("generic error");
}
}}
requestLinkWallet={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.requestLinkWalletType === "error") {
throw new Error("generic error");
}
}}
verifyEmail={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.verifyEmailType === "error") {
throw new Error("generic error");
}
return {
team: teamStub("foo", "free"),
account: newAccountStub(),
};
}}
resendEmailConfirmation={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
/>
);
}
@@ -0,0 +1,51 @@
"use client";
import {
resendEmailClient,
updateAccountClient,
verifyEmailClient,
} from "@3rdweb-sdk/react/hooks/useApi";
import { useActiveWallet } from "thirdweb/react";
import { useDisconnect } from "thirdweb/react";
import { useTrack } from "../../../hooks/analytics/useTrack";
import { doLogout } from "../auth-actions";
import { AccountOnboardingUI } from "./account-onboarding-ui";
function AccountOnboarding(props: {
onComplete: () => void;
onLogout: () => void;
accountAddress: string;
}) {
const trackEvent = useTrack();
const activeWallet = useActiveWallet();
const { disconnect } = useDisconnect();
return (
<AccountOnboardingUI
onComplete={props.onComplete}
logout={async () => {
if (activeWallet) {
disconnect(activeWallet);
}
await doLogout();
props.onLogout();
}}
accountAddress={props.accountAddress}
loginOrSignup={async (params) => {
await updateAccountClient(params);
}}
verifyEmail={verifyEmailClient}
resendEmailConfirmation={async () => {
await resendEmailClient();
}}
trackEvent={trackEvent}
requestLinkWallet={async (email) => {
await updateAccountClient({
email,
linkWallet: true,
});
}}
/>
);
}
export default AccountOnboarding;
@@ -1,6 +1,11 @@
import type { Team } from "@/api/team";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
export function isOnboardingComplete(account: Account) {
export function isAccountOnboardingComplete(account: Account) {
// if email is confirmed, onboarding is considered complete
return !!account.emailConfirmedAt;
}
export function isTeamOnboardingComplete(team: Team) {
return team.isOnboarded;
}
@@ -1,157 +0,0 @@
"use client";
import type { RedirectBillingCheckoutAction } from "@/actions/billing";
import type { Team } from "@/api/team";
import { cn } from "@/lib/utils";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { useTrack } from "hooks/analytics/useTrack";
import { useState } from "react";
import { OnboardingChoosePlan } from "./ChoosePlan";
import { OnboardingConfirmEmail } from "./ConfirmEmail";
import { OnboardingGeneral } from "./General";
import { OnboardingLinkWallet } from "./LinkWallet";
import { useSkipOnboarding } from "./useSkipOnboarding";
type OnboardingScreen =
| { id: "onboarding" }
| { id: "linking" }
| { id: "confirming" }
| { id: "confirmLinking" }
| { id: "plan"; team: Team };
function OnboardingUI(props: {
account: Account;
onComplete: () => void;
onLogout: () => void;
// path to redirect from stripe
redirectPath: string;
redirectToCheckout: RedirectBillingCheckoutAction;
skipShowingPlans: boolean;
}) {
const { account } = props;
const [screen, setScreen] = useState<OnboardingScreen>({ id: "onboarding" });
const trackEvent = useTrack();
const [updatedEmail, setUpdatedEmail] = useState<string | undefined>();
const skipOnboarding = useSkipOnboarding();
function trackOnboardingStep(params: {
nextStep: OnboardingScreen["id"];
email?: string;
}) {
trackEvent({
category: "account",
action: "onboardingStep",
label: "next",
data: {
email: params.email || account.unconfirmedEmail || updatedEmail,
currentStep: screen,
nextStep: params.nextStep,
},
});
}
const handleDuplicateEmail = (email: string) => {
setScreen({
id: "linking",
});
trackOnboardingStep({
nextStep: "linking",
email,
});
};
return (
<div
className={cn(
"relative w-full rounded-xl border border-border bg-background p-6 shadow-lg",
screen.id === "plan" ? "lg:max-w-[750px]" : "lg:w-[500px]",
)}
>
{screen.id === "onboarding" && (
<OnboardingGeneral
account={account}
onLogout={props.onLogout}
onSave={(email) => {
setUpdatedEmail(email);
setScreen({
id: "confirming",
});
trackOnboardingStep({
nextStep: "confirming",
email,
});
}}
onDuplicate={(email) => {
setUpdatedEmail(email);
handleDuplicateEmail(email);
}}
/>
)}
{screen.id === "linking" && (
<OnboardingLinkWallet
onSave={() => {
setScreen({
id: "confirmLinking",
});
trackOnboardingStep({
nextStep: "confirmLinking",
});
}}
onBack={() => {
setUpdatedEmail(undefined);
setScreen({
id: "onboarding",
});
}}
email={updatedEmail as string}
/>
)}
{/* TODO - separate the confirming and confirmLinking into separate components */}
{(screen.id === "confirming" || screen.id === "confirmLinking") && (
<OnboardingConfirmEmail
linking={screen.id === "confirmLinking"}
onComplete={props.onComplete}
onEmailConfirm={(res) => {
if (screen.id === "confirmLinking") {
props.onComplete();
} else if (screen.id === "confirming") {
if (account.onboardSkipped) {
props.onComplete();
} else {
if (props.skipShowingPlans) {
props.onComplete();
skipOnboarding();
} else {
setScreen({ id: "plan", team: res.team });
}
}
}
}}
onBack={() =>
setScreen({
id: "onboarding",
})
}
email={(account.unconfirmedEmail || updatedEmail) as string}
/>
)}
{screen.id === "plan" && (
<OnboardingChoosePlan
redirectPath={props.redirectPath}
teamSlug={screen.team.slug}
skipPlan={async () => {
props.onComplete();
skipOnboarding();
}}
canTrialGrowth={true}
redirectToCheckout={props.redirectToCheckout}
/>
)}
</div>
);
}
export default OnboardingUI;
@@ -0,0 +1,163 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useMutation } from "@tanstack/react-query";
import {
BoxIcon,
LogOutIcon,
MailIcon,
UserIcon,
UsersIcon,
} from "lucide-react";
type OnboardingStep = {
icon: React.FC<{ className?: string }>;
title: string;
description: string;
number: number;
};
const accountOnboardingSteps: OnboardingStep[] = [
{
icon: UserIcon,
title: "Account Details",
description: "Provide email address",
number: 1,
},
{
icon: MailIcon,
title: "Verify Email",
description: "Enter your verification code",
number: 2,
},
];
export function AccountOnboardingLayout(props: {
children: React.ReactNode;
currentStep: 1 | 2;
logout: () => Promise<void>;
}) {
const logout = useMutation({
mutationFn: props.logout,
});
return (
<OnboardingLayout
steps={accountOnboardingSteps}
currentStep={props.currentStep}
title="Get started with thirdweb"
cta={
<Button
variant="outline"
size="sm"
className="gap-2 bg-card px-4"
onClick={() => {
logout.mutate();
}}
>
{logout.isPending ? (
<Spinner className="size-3 text-muted-foreground" />
) : (
<LogOutIcon className="size-3 text-muted-foreground" />
)}
Logout
</Button>
}
>
{props.children}
</OnboardingLayout>
);
}
const teamOnboardingSteps: OnboardingStep[] = [
{
icon: BoxIcon,
title: "Team Details",
description: "Provide team details",
number: 1,
},
{
icon: UsersIcon,
title: "Team Members",
description: "Invite members to your team",
number: 2,
},
];
export function TeamOnboardingLayout(props: {
children: React.ReactNode;
currentStep: 1 | 2;
}) {
return (
<OnboardingLayout
steps={teamOnboardingSteps}
currentStep={props.currentStep}
title="Setup your team"
>
{props.children}
</OnboardingLayout>
);
}
function OnboardingLayout(props: {
steps: OnboardingStep[];
currentStep: number;
children: React.ReactNode;
title: string;
cta?: React.ReactNode;
}) {
return (
<div className="flex grow flex-col">
<div className="border-border border-b py-10">
<div className="container flex items-center justify-between gap-6">
<h1 className="font-semibold text-2xl tracking-tight lg:text-3xl">
{props.title}
</h1>
{props.cta}
</div>
</div>
<div className="container flex grow flex-col gap-10 xl:flex-row">
{/* Left */}
<div className="flex w-full max-w-[850px] flex-col py-8">
{props.children}
</div>
{/* Right */}
<div className="hidden shrink-0 grow flex-col xl:flex">
{/* Steps */}
<div className="relative flex grow flex-col gap-7 pt-8">
{/* Timeline line */}
<div className="-translate-x-1/2 absolute top-0 bottom-0 left-5 border-border border-l border-dashed" />
{props.steps.map((step) => {
const isInactive = step.number !== props.currentStep;
return (
<div
key={step.number}
className={cn("flex items-center gap-4")}
>
<div className="relative z-10 flex size-10 items-center justify-center rounded-full border bg-card">
<step.icon
className={cn(
"size-5 text-foreground",
isInactive && "opacity-50",
)}
/>
</div>
<div
className={cn("flex flex-col", isInactive && "opacity-50")}
>
<h3 className="font-semibold">{step.title}</h3>
<p className="text-muted-foreground text-sm">
{step.description}
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from "@storybook/react";
import { teamStub } from "../../../../stories/stubs";
import { storybookLog } from "../../../../stories/utils";
import { TeamOnboardingLayout } from "../onboarding-layout";
import { InviteTeamMembersUI } from "./InviteTeamMembers";
const meta = {
title: "Onboarding/TeamOnboarding/InviteTeamMembers",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const FreePlan: Story = {
args: {
plan: "free",
},
};
// This is the case when user returns back to team onboarding flow from stripe
export const StarterPlan: Story = {
args: {
plan: "starter",
},
};
export const GrowthPlan: Story = {
args: {
plan: "growth",
},
};
function Story(props: {
plan: "free" | "growth" | "starter";
}) {
return (
<TeamOnboardingLayout currentStep={2}>
<InviteTeamMembersUI
trackEvent={(params) => {
storybookLog("trackEvent", params);
}}
getTeam={async () => {
return teamStub("foo", props.plan);
}}
team={teamStub("foo", props.plan)}
inviteTeamMembers={async (params) => {
return { results: params.map(() => "fulfilled") };
}}
getBillingCheckoutUrl={async () => {
return { status: 200 };
}}
onComplete={() => {
storybookLog("onComplete");
}}
/>
</TeamOnboardingLayout>
);
}
@@ -0,0 +1,246 @@
"use client";
import type { GetBillingCheckoutUrlAction } from "@/actions/billing";
import type { Team } from "@/api/team";
import { PricingCard } from "@/components/blocks/pricing-card";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { TabButtons } from "@/components/ui/tabs";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { ArrowRightIcon, CircleArrowUpIcon } from "lucide-react";
import { useState, useTransition } from "react";
import type { TrackingParams } from "../../../../hooks/analytics/useTrack";
import { pollWithTimeout } from "../../../../utils/pollWithTimeout";
import { useStripeRedirectEvent } from "../../../stripe-redirect/stripeRedirectChannel";
import {
InviteSection,
type InviteTeamMembersFn,
} from "../../../team/[team_slug]/(team)/~/settings/members/InviteSection";
export function InviteTeamMembersUI(props: {
team: Team;
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
inviteTeamMembers: InviteTeamMembersFn;
onComplete: () => void;
getTeam: () => Promise<Team>;
trackEvent: (params: TrackingParams) => void;
}) {
const [showPlanModal, setShowPlanModal] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useDashboardRouter();
const [isPollingTeam, setIsPollingTeam] = useState(false);
const [hasSentInvites, setHasSentInvites] = useState(false);
const showSpinner = isPollingTeam || isPending;
useStripeRedirectEvent(async () => {
setShowPlanModal(false);
setIsPollingTeam(true);
// poll until the team has a non-free billing plan with a timeout of 5 seconds
await pollWithTimeout({
shouldStop: async () => {
const team = await props.getTeam();
const isNonFreePlan = team.billingPlan !== "free";
if (isNonFreePlan) {
props.trackEvent({
category: "teamOnboarding",
action: "upgradePlan",
label: "success",
plan: team.billingPlan,
});
}
return isNonFreePlan;
},
timeoutMs: 5000,
});
setIsPollingTeam(false);
// refresh the page to get the latest team data
startTransition(() => {
router.refresh();
});
});
return (
<div className="relative flex grow flex-col">
<Dialog open={showPlanModal} onOpenChange={setShowPlanModal}>
<DialogContent className="lg:max-w-[750px]">
<InviteModalContent
teamSlug={props.team.slug}
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
trackEvent={props.trackEvent}
/>
</DialogContent>
</Dialog>
<InviteSection
inviteTeamMembers={props.inviteTeamMembers}
team={props.team}
userHasEditPermission={true}
onInviteSuccess={() => setHasSentInvites(true)}
shouldHideInviteButton={hasSentInvites}
customCTASection={
<div className="flex gap-3">
{props.team.billingPlan === "free" && (
<Button
size="sm"
variant="default"
className="gap-2"
onClick={() => {
setShowPlanModal(true);
props.trackEvent({
category: "teamOnboarding",
action: "upgradePlan",
label: "openModal",
});
}}
>
<CircleArrowUpIcon className="size-4" />
Upgrade
</Button>
)}
<Button
onClick={() => {
props.onComplete();
if (!hasSentInvites) {
props.trackEvent({
category: "teamOnboarding",
action: "inviteTeamMembers",
label: "skip",
});
}
}}
className="gap-2"
size="sm"
variant={hasSentInvites ? "default" : "outline"}
>
{hasSentInvites ? "Continue to dashboard" : "Skip for now"}
{hasSentInvites && <ArrowRightIcon className="size-4" />}
</Button>
</div>
}
/>
{showSpinner && (
<div className="absolute top-6 right-6">
<Spinner className="size-5" />{" "}
</div>
)}
</div>
);
}
function InviteModalContent(props: {
teamSlug: string;
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
trackEvent: (params: TrackingParams) => void;
}) {
const [planToShow, setPlanToShow] = useState<"starter" | "growth">("starter");
const starterPlan = (
<PricingCard
billingPlan="starter"
teamSlug={props.teamSlug}
cta={{
title: "Get Started for free",
onClick() {
props.trackEvent({
category: "teamOnboarding",
action: "upgradePlan",
label: "attempt",
plan: "starter",
});
},
tracking: {
category: "account",
label: "starterPlan",
},
}}
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
/>
);
const growthPlan = (
<PricingCard
billingPlan="growth"
teamSlug={props.teamSlug}
cta={{
title: "Get Started with Growth",
tracking: {
category: "account",
label: "growthPlan",
},
onClick() {
props.trackEvent({
category: "teamOnboarding",
action: "upgradePlan",
label: "attempt",
plan: "growth",
});
},
variant: "default",
}}
highlighted
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
/>
);
return (
<div>
<DialogHeader>
<DialogTitle className="text-xl">Choose a plan</DialogTitle>
<DialogDescription className="leading-relaxed">
Get started with the free Starter plan or upgrade to Growth plan for
increased limits and advanced features.{" "}
<UnderlineLink href="https://thirdweb.com/pricing" target="_blank">
Learn more about pricing
</UnderlineLink>
</DialogDescription>
</DialogHeader>
<div className="h-6" />
{/* Desktop */}
<div className="hidden grid-cols-1 gap-6 md:grid-cols-2 md:gap-4 lg:grid">
{starterPlan}
{growthPlan}
</div>
{/* Mobile */}
<div className="lg:hidden">
<TabButtons
tabs={[
{
name: "Starter",
onClick: () => setPlanToShow("starter"),
isActive: planToShow === "starter",
isEnabled: true,
},
{
name: "Growth",
onClick: () => setPlanToShow("growth"),
isActive: planToShow === "growth",
isEnabled: true,
},
]}
/>
<div className="h-4" />
{planToShow === "starter" && starterPlan}
{planToShow === "growth" && growthPlan}
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import { Toaster } from "@/components/ui/sonner";
import type { Meta, StoryObj } from "@storybook/react";
import { teamStub } from "../../../../stories/stubs";
import { storybookLog } from "../../../../stories/utils";
import { TeamOnboardingLayout } from "../onboarding-layout";
import { TeamInfoFormUI } from "./TeamInfoForm";
const meta = {
title: "Onboarding/TeamOnboarding/TeamInfo",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SendSuccess: Story = {
args: {
sendType: "success",
},
};
export const SendError: Story = {
args: {
sendType: "error",
},
};
function Story(props: {
sendType: "success" | "error";
}) {
return (
<TeamOnboardingLayout currentStep={1}>
<TeamInfoFormUI
onComplete={() => {
storybookLog("onComplete");
}}
teamSlug="foo"
isTeamSlugAvailable={async (slug) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
// taken slugs stub
if (slug === "xyz" || slug === "abc" || slug === "xyz-1") {
return false;
}
return true;
}}
updateTeam={async (formData) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
storybookLog("updateTeam", formData);
if (props.sendType === "error") {
throw new Error("Test Error");
}
return teamStub("foo", "free");
}}
/>
<Toaster richColors />
</TeamOnboardingLayout>
);
}
@@ -0,0 +1,274 @@
import type { Team } from "@/api/team";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { FileInput } from "components/shared/FileInput";
import { ArrowRightIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { useDebounce } from "use-debounce";
import { z } from "zod";
import { teamSlugRegex } from "../../../team/[team_slug]/(team)/~/settings/general/common";
type TeamData = {
name?: string;
slug?: string;
image?: File;
};
const teamSlugSchema = z
.string()
.min(3, {
message: "URL must be at least 3 characters",
})
.max(48, {
message: "URL must be at most 48 characters",
})
.refine((slug) => !teamSlugRegex.test(slug), {
message: "URL can only contain lowercase letters, numbers and hyphens",
});
const formSchema = z.object({
name: z
.string()
.min(3, {
message: "Name must be at least 3 characters",
})
.max(32, {
message: "Name must be at most 32 characters",
}),
slug: teamSlugSchema,
image: z.instanceof(File).optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function TeamInfoFormUI(props: {
updateTeam: (data: TeamData) => Promise<Team>;
onComplete: (updatedTeam: Team) => void;
isTeamSlugAvailable: (slug: string) => Promise<boolean>;
teamSlug: string;
}) {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
slug: "",
image: undefined,
},
});
const [disableSlugSuggestion, setDisableSlugSuggestion] = useState(false);
const name = form.watch("name");
const slug = form.watch("slug");
const [debouncedSlug] = useDebounce(slug, 500);
const [debouncedName] = useDebounce(name, 500);
const { data: suggestedSlug, isFetching: isCalculatingSlug } = useQuery({
queryKey: ["suggest-team-slug", debouncedName],
queryFn: async () => {
for (let attempt = 0; attempt < 3; attempt++) {
const computedSlug = computeSlug(name, attempt);
if (teamSlugSchema.safeParse(computedSlug).error) {
return null;
}
const isAvailable = await props.isTeamSlugAvailable(computedSlug);
if (isAvailable) {
return computedSlug;
}
}
return null;
},
enabled: !!debouncedName && !disableSlugSuggestion,
retry: false,
});
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (suggestedSlug && !disableSlugSuggestion) {
form.setValue("slug", suggestedSlug, {
shouldValidate: true,
});
}
}, [suggestedSlug, form, disableSlugSuggestion]);
const shouldValidateSlug =
!!debouncedSlug &&
!form.getFieldState("slug").invalid &&
debouncedSlug !== props.teamSlug &&
suggestedSlug !== debouncedSlug;
const { data: isSlugAvailable, isFetching: isCheckingSlug } = useQuery({
queryKey: ["checkTeamSlug", debouncedSlug],
queryFn: () => {
return props.isTeamSlugAvailable(debouncedSlug);
},
enabled: shouldValidateSlug,
retry: false,
});
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (isSlugAvailable === undefined) {
return;
}
if (!isSlugAvailable) {
form.setError("slug", {
type: "manual",
message: "This URL is already taken",
});
} else {
const isValidSlugError = form.formState.errors.slug?.type === "manual";
if (isValidSlugError) {
form.clearErrors("slug");
}
}
}, [isSlugAvailable, form]);
const submit = useMutation({
mutationFn: props.updateTeam,
});
async function onSubmit(values: FormValues) {
submit.mutate(values, {
onError(error) {
console.error(error);
toast.error("Failed to submit team details");
},
onSuccess(updatedTeam) {
props.onComplete(updatedTeam);
},
});
}
return (
<div className="rounded-lg border bg-card ">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-6 p-4 lg:p-6">
<FormField
control={form.control}
name="image"
render={({ field: { value, onChange } }) => (
<FormItem className="space-y-0">
<FormLabel>Team Avatar</FormLabel>
<FormDescription>
Enhance your team profile by adding a custom avatar
</FormDescription>
<FormMessage />
<div className="h-3" />
<FormControl>
<FileInput
accept={{ "image/*": [] }}
value={value}
setValue={(file) => onChange(file)}
className="w-28 rounded-full bg-background"
disableHelperText
/>
</FormControl>
<div className="h-3" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Team Name
<span className="ml-1 text-red-500">*</span>
</FormLabel>
<FormControl>
<Input
placeholder="Company Inc."
autoComplete="off"
{...field}
/>
</FormControl>
<FormDescription>
This is your team's name on thirdweb
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>
Team URL
<span className="ml-1 text-red-500">*</span>
</FormLabel>
<FormControl>
<div className="relative flex rounded-lg border border-border">
<div className="flex items-center self-stretch rounded-l-lg border-border border-r bg-card px-3 font-mono text-muted-foreground/80 text-sm">
thirdweb.com/team/
</div>
<Input
{...field}
onChange={(e) => {
setDisableSlugSuggestion(true);
field.onChange(e);
}}
className="truncate border-0 font-mono"
placeholder="my-team"
/>
{(isCheckingSlug || isCalculatingSlug) && (
<div className="-translate-y-1/2 fade-in-0 absolute top-1/2 right-3 duration-300">
<Spinner className="size-4" />
</div>
)}
</div>
</FormControl>
<FormDescription>
This is your team's URL namespace on thirdweb
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end border-t p-4 lg:px-6">
<Button type="submit" className="gap-2" size="sm">
Continue
{submit.isPending ? (
<Spinner className="size-4" />
) : (
<ArrowRightIcon className="size-4" />
)}
</Button>
</div>
</form>
</Form>
</div>
);
}
const computeSlug = (name: string, attempt: number) => {
const baseSlug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return attempt === 0 ? baseSlug : `${baseSlug}-${attempt}`;
};
@@ -0,0 +1,158 @@
"use client";
import { getBillingCheckoutUrl } from "@/actions/billing";
import { sendTeamInvites } from "@/actions/sendTeamInvite";
import type { Team } from "@/api/team";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { upload } from "thirdweb/storage";
import { apiServerProxy } from "../../../../@/actions/proxies";
import { useTrack } from "../../../../hooks/analytics/useTrack";
import { updateTeam } from "../../../team/[team_slug]/(team)/~/settings/general/updateTeam";
import { InviteTeamMembersUI } from "./InviteTeamMembers";
import { TeamInfoFormUI } from "./TeamInfoForm";
export function TeamInfoForm(props: {
client: ThirdwebClient;
teamId: string;
teamSlug: string;
}) {
const router = useDashboardRouter();
const trackEvent = useTrack();
return (
<TeamInfoFormUI
isTeamSlugAvailable={async (slug) => {
const res = await apiServerProxy<{
result: boolean;
}>({
pathname: "/v1/teams/check-slug",
searchParams: {
slug,
},
method: "GET",
});
if (!res.ok) {
throw new Error(res.error);
}
return res.data.result;
}}
teamSlug={props.teamSlug}
onComplete={(updatedTeam) => {
router.replace(`/get-started/team/${updatedTeam.slug}/add-members`);
}}
updateTeam={async (data) => {
const teamValue: Partial<Team> = {
name: data.name,
slug: data.slug,
};
trackEvent({
category: "teamOnboarding",
action: "updateTeam",
label: "attempt",
});
if (data.image) {
try {
teamValue.image = await upload({
client: props.client,
files: [data.image],
});
} catch {
// If image upload fails - ignore image, continue with the rest of the update
toast.error("Failed to upload image");
}
}
const res = await updateTeam({
teamId: props.teamId,
value: teamValue,
});
if (!res.ok) {
trackEvent({
category: "teamOnboarding",
action: "updateTeam",
label: "error",
});
throw new Error(res.error);
}
trackEvent({
category: "teamOnboarding",
action: "updateTeam",
label: "success",
});
return res.data;
}}
/>
);
}
export function InviteTeamMembers(props: {
team: Team;
}) {
const router = useDashboardRouter();
const trackEvent = useTrack();
return (
<InviteTeamMembersUI
trackEvent={trackEvent}
onComplete={() => {
router.replace(`/team/${props.team.slug}`);
}}
getTeam={async () => {
const res = await apiServerProxy<{
result: Team;
}>({
pathname: `/v1/teams/${props.team.slug}`,
method: "GET",
});
if (!res.ok) {
throw new Error(res.error);
}
return res.data.result;
}}
team={props.team}
getBillingCheckoutUrl={getBillingCheckoutUrl}
inviteTeamMembers={async (params) => {
const res = await sendTeamInvites({
teamId: props.team.id,
invites: params,
});
trackEvent({
category: "teamOnboarding",
action: "inviteTeamMembers",
label: "attempt",
});
if (!res.ok) {
trackEvent({
category: "teamOnboarding",
action: "inviteTeamMembers",
label: "error",
});
throw new Error(res.errorMessage);
}
trackEvent({
category: "teamOnboarding",
action: "inviteTeamMembers",
label: "success",
});
return {
results: res.results,
};
}}
/>
);
}
@@ -1,42 +0,0 @@
"use client";
import { useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useTrack } from "hooks/analytics/useTrack";
export function useSkipOnboarding() {
const mutation = useUpdateAccount();
const trackEvent = useTrack();
async function skipOnboarding() {
trackEvent({
category: "account",
action: "onboardSkippedBilling",
label: "attempt",
});
return mutation.mutateAsync(
{
onboardSkipped: true,
},
{
onSuccess: () => {
trackEvent({
category: "account",
action: "onboardSkippedBilling",
label: "success",
});
},
onError: (error) => {
trackEvent({
category: "account",
action: "onboardSkippedBilling",
label: "error",
error,
});
},
},
);
}
return skipOnboarding;
}
@@ -1,4 +1,3 @@
import { RE_EMAIL } from "utils/regex";
import { z } from "zod";
const nameValidation = z
@@ -6,12 +5,10 @@ const nameValidation = z
.min(3, { message: "Must be at least 3 chars" })
.max(64, { message: "Must be max 64 chars" });
const emailValidation = z.string().refine((str) => RE_EMAIL.test(str), {
message: "Email address is not valid",
});
export const emailSchema = z.string().email("Invalid email address");
export const accountValidationSchema = z.object({
email: emailValidation,
email: emailSchema,
name: nameValidation.or(z.literal("")),
});
@@ -0,0 +1,22 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useEffect } from "react";
import { stripeRedirectPageChannel } from "./stripeRedirectChannel";
export default function ClosePage() {
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
stripeRedirectPageChannel.postMessage("close");
// this will only work if this page is opened as a new tab
window.close();
}, []);
return (
<div className="flex min-h-dvh items-center justify-center">
<div className="flex flex-col items-center text-center text-sm">
<Spinner className="size-8" />
</div>
</div>
);
}
@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
export const stripeRedirectPageChannel = new BroadcastChannel(
"stripe-redirect",
);
export function useStripeRedirectEvent(cb?: () => void) {
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!cb) {
return;
}
function handleMessage(event: MessageEvent) {
if (!cb) {
return;
}
if (event.data === "close") {
cb();
}
}
stripeRedirectPageChannel.addEventListener("message", handleMessage);
return () => {
stripeRedirectPageChannel.removeEventListener("message", handleMessage);
};
}, [cb]);
return null;
}
@@ -8,6 +8,9 @@ const meta = {
title: "Banners/Billing Alerts",
parameters: {
layout: "centered",
nextjs: {
appDirectory: true,
},
},
} satisfies Meta;
@@ -20,12 +23,12 @@ export const PaymentAlerts: Story = {
<div className="space-y-10">
<PastDueBannerUI
teamSlug="foo"
redirectToBillingPortal={() => Promise.resolve({ status: 200 })}
getBillingPortalUrl={() => Promise.resolve({ status: 200 })}
/>
<ServiceCutOffBannerUI
teamSlug="foo"
redirectToBillingPortal={() => Promise.resolve({ status: 200 })}
getBillingPortalUrl={() => Promise.resolve({ status: 200 })}
/>
</div>
),
@@ -1,6 +1,6 @@
"use client";
import { redirectToBillingPortal } from "@/actions/billing";
import { getBillingPortalUrl } from "@/actions/billing";
import {
PastDueBannerUI,
ServiceCutOffBannerUI,
@@ -9,7 +9,7 @@ import {
export function PastDueBanner(props: { teamSlug: string }) {
return (
<PastDueBannerUI
redirectToBillingPortal={redirectToBillingPortal}
getBillingPortalUrl={getBillingPortalUrl}
teamSlug={props.teamSlug}
/>
);
@@ -18,7 +18,7 @@ export function PastDueBanner(props: { teamSlug: string }) {
export function ServiceCutOffBanner(props: { teamSlug: string }) {
return (
<ServiceCutOffBannerUI
redirectToBillingPortal={redirectToBillingPortal}
getBillingPortalUrl={getBillingPortalUrl}
teamSlug={props.teamSlug}
/>
);
@@ -1,10 +1,12 @@
"use client";
import type { BillingBillingPortalAction } from "@/actions/billing";
import type { GetBillingPortalUrlAction } from "@/actions/billing";
import { BillingPortalButton } from "@/components/billing";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { useTransition } from "react";
import { useStripeRedirectEvent } from "../../../../stripe-redirect/stripeRedirectChannel";
function BillingAlertBanner(props: {
title: string;
@@ -12,40 +14,51 @@ function BillingAlertBanner(props: {
teamSlug: string;
variant: "error" | "warning";
ctaLabel: string;
redirectToBillingPortal: BillingBillingPortalAction;
getBillingPortalUrl: GetBillingPortalUrlAction;
}) {
const [isRouteLoading, setIsRouteLoading] = useState(false);
const router = useDashboardRouter();
const [isPending, startTransition] = useTransition();
useStripeRedirectEvent(() => {
setTimeout(() => {
startTransition(() => {
router.refresh();
});
}, 1000);
});
return (
<div
className={cn(
"flex flex-col border-b bg-card px-4 py-6 lg:items-center lg:text-center",
"relative flex flex-col border-b bg-card px-4 py-6 lg:items-center lg:text-center",
props.variant === "warning" &&
"border-yellow-600 bg-yellow-50 text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950 dark:text-yellow-100",
props.variant === "error" &&
"border-red-600 bg-red-50 text-red-800 dark:border-red-700 dark:bg-red-950 dark:text-red-100",
)}
>
{isPending && (
<div className="absolute top-4 right-4">
<Spinner className="size-5" />
</div>
)}
<h3 className="font-semibold text-xl tracking-tight">{props.title}</h3>
<p className="mt-1 mb-4 text-sm">{props.description}</p>
<BillingPortalButton
className={cn(
"gap-2",
props.variant === "warning" &&
"border border-yellow-600 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-100 dark:hover:bg-yellow-800",
props.variant === "error" &&
"border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800",
)}
size="sm"
teamSlug={props.teamSlug}
redirectPath={`/team/${props.teamSlug}`}
redirectToBillingPortal={props.redirectToBillingPortal}
onClick={() => {
setIsRouteLoading(true);
buttonProps={{
size: "sm",
className: cn(
"gap-2",
props.variant === "warning" &&
"border border-yellow-600 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-100 dark:hover:bg-yellow-800",
props.variant === "error" &&
"border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800",
),
}}
teamSlug={props.teamSlug}
getBillingPortalUrl={props.getBillingPortalUrl}
>
{props.ctaLabel}
{isRouteLoading ? <Spinner className="size-4" /> : null}
</BillingPortalButton>
</div>
);
@@ -53,14 +66,14 @@ function BillingAlertBanner(props: {
export function PastDueBannerUI(props: {
teamSlug: string;
redirectToBillingPortal: BillingBillingPortalAction;
getBillingPortalUrl: GetBillingPortalUrlAction;
}) {
return (
<BillingAlertBanner
ctaLabel="View Invoices"
variant="warning"
title="Unpaid Invoices"
redirectToBillingPortal={props.redirectToBillingPortal}
getBillingPortalUrl={props.getBillingPortalUrl}
description={
<>
You have unpaid invoices. Service may be suspended if not paid
@@ -74,14 +87,14 @@ export function PastDueBannerUI(props: {
export function ServiceCutOffBannerUI(props: {
teamSlug: string;
redirectToBillingPortal: BillingBillingPortalAction;
getBillingPortalUrl: GetBillingPortalUrlAction;
}) {
return (
<BillingAlertBanner
ctaLabel="Pay Now"
variant="error"
title="Service Suspended"
redirectToBillingPortal={props.redirectToBillingPortal}
getBillingPortalUrl={props.getBillingPortalUrl}
description={
<>
Your service has been suspended due to unpaid invoices. Pay now to
@@ -28,7 +28,7 @@ export default async function Page(props: {
}
const [ecosystem, team] = await Promise.all([
fetchEcosystem(params.slug, authToken),
fetchEcosystem(params.slug, authToken, params.team_slug),
getTeamBySlug(params.team_slug),
]);
@@ -10,7 +10,7 @@ export async function EcosystemLayoutSlug({
ecosystemLayoutPath,
}: {
children: React.ReactNode;
params: { slug: string };
params: { slug: string; team_slug: string };
ecosystemLayoutPath: string;
}) {
const authToken = await getAuthToken();
@@ -19,7 +19,11 @@ export async function EcosystemLayoutSlug({
redirect(ecosystemLayoutPath);
}
const ecosystem = await fetchEcosystem(params.slug, authToken);
const ecosystem = await fetchEcosystem(
params.slug,
authToken,
params.team_slug,
);
if (!ecosystem) {
redirect(ecosystemLayoutPath);
@@ -30,6 +34,7 @@ export async function EcosystemLayoutSlug({
<EcosystemHeader
ecosystem={ecosystem}
ecosystemLayoutPath={ecosystemLayoutPath}
teamIdOrSlug={params.team_slug}
/>
<SidebarLayout
@@ -62,8 +62,11 @@ function EcosystemAlertBanner({ ecosystem }: { ecosystem: Ecosystem }) {
function EcosystemSelect(props: {
ecosystem: Ecosystem;
ecosystemLayoutPath: string;
teamIdOrSlug: string;
}) {
const { data: ecosystems, isPending } = useEcosystemList();
const { data: ecosystems, isPending } = useEcosystemList({
teamIdOrSlug: props.teamIdOrSlug,
});
return isPending ? (
<Skeleton className="h-10 w-full md:w-[160px]" />
@@ -109,8 +112,10 @@ function EcosystemSelect(props: {
export function EcosystemHeader(props: {
ecosystem: Ecosystem;
ecosystemLayoutPath: string;
teamIdOrSlug: string;
}) {
const { data: fetchedEcosystem } = useEcosystem({
teamIdOrSlug: props.teamIdOrSlug,
slug: props.ecosystem.slug,
refetchInterval:
props.ecosystem.status === "requested"
@@ -192,6 +197,7 @@ export function EcosystemHeader(props: {
<EcosystemSelect
ecosystem={ecosystem}
ecosystemLayoutPath={props.ecosystemLayoutPath}
teamIdOrSlug={props.teamIdOrSlug}
/>
</div>
</div>
@@ -7,8 +7,11 @@ import { IntegrationPermissionsSection } from "../server/integration-permissions
export function EcosystemPermissionsPage({
params,
authToken,
}: { params: { slug: string }; authToken: string }) {
const { data: ecosystem } = useEcosystem({ slug: params.slug });
}: { params: { slug: string; team_slug: string }; authToken: string }) {
const { data: ecosystem } = useEcosystem({
slug: params.slug,
teamIdOrSlug: params.team_slug,
});
return (
<div className="flex flex-col gap-8">
@@ -3,21 +3,23 @@ import { useQuery } from "@tanstack/react-query";
import type { Ecosystem } from "../../../types";
export function useEcosystem({
teamIdOrSlug,
slug,
refetchInterval,
refetchOnWindowFocus,
initialData,
}: {
teamIdOrSlug: string;
slug: string;
refetchInterval?: number;
refetchOnWindowFocus?: boolean;
initialData?: Ecosystem;
}) {
return useQuery({
queryKey: ["ecosystems", slug],
queryKey: ["ecosystems", teamIdOrSlug, slug],
queryFn: async () => {
const res = await apiServerProxy({
pathname: `/v1/ecosystem-wallet/${slug}`,
pathname: `/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
method: "GET",
});
@@ -3,13 +3,17 @@ import { useQuery } from "@tanstack/react-query";
import { useActiveAccount } from "thirdweb/react";
import type { Ecosystem } from "../types";
export function useEcosystemList() {
export function useEcosystemList({
teamIdOrSlug,
}: {
teamIdOrSlug: string;
}) {
const address = useActiveAccount()?.address;
return useQuery({
queryKey: ["ecosystems", address],
queryKey: ["ecosystems", teamIdOrSlug, address],
queryFn: async () => {
const res = await apiServerProxy({
pathname: "/v1/ecosystem-wallet/list",
pathname: `/v1/teams/${teamIdOrSlug}/ecosystem-wallet`,
method: "GET",
});
@@ -20,10 +20,12 @@ export default async function Page(props: {
loginRedirect(ecosystemLayoutPath);
}
const ecosystems = await fetchEcosystemList(authToken).catch((err) => {
console.error("failed to fetch ecosystems", err);
return [];
});
const ecosystems = await fetchEcosystemList(authToken, team_slug).catch(
(err) => {
console.error("failed to fetch ecosystems", err);
return [];
},
);
if (ecosystems[0]) {
redirect(`${ecosystemLayoutPath}/${ecosystems[0].slug}`);
}
@@ -1,12 +1,19 @@
import { API_SERVER_URL } from "@/constants/env";
import type { Ecosystem } from "../types";
export async function fetchEcosystem(slug: string, authToken: string) {
const res = await fetch(`${API_SERVER_URL}/v1/ecosystem-wallet/${slug}`, {
headers: {
Authorization: `Bearer ${authToken}`,
export async function fetchEcosystem(
slug: string,
authToken: string,
teamIdOrSlug: string,
) {
const res = await fetch(
`${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
},
});
);
if (!res.ok) {
const data = await res.json();
console.error(data);
@@ -1,12 +1,18 @@
import { API_SERVER_URL } from "@/constants/env";
import type { Ecosystem } from "../types";
export async function fetchEcosystemList(authToken: string) {
const res = await fetch(`${API_SERVER_URL}/v1/ecosystem-wallet/list`, {
headers: {
Authorization: `Bearer ${authToken}`,
export async function fetchEcosystemList(
authToken: string,
teamIdOrSlug: string,
) {
const res = await fetch(
`${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/ecosystem-wallet`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
},
});
);
if (!res.ok) {
const data = await res.json();
@@ -1,8 +1,7 @@
"use client";
import { redirectToCheckout } from "@/actions/billing";
import { getBillingCheckoutUrl } from "@/actions/billing";
import { CheckoutButton } from "@/components/billing";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import type { EngineTier } from "@3rdweb-sdk/react/hooks/useEngine";
import { Flex, Spacer } from "@chakra-ui/react";
@@ -10,7 +9,6 @@ import { useTrack } from "hooks/analytics/useTrack";
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useState } from "react";
interface EngineTierCardConfig {
name: string;
@@ -58,7 +56,6 @@ export const EngineTierCard = ({
isPrimaryCta?: boolean;
ctaText?: string;
}) => {
const [isRoutePending, startRouteTransition] = useState(false);
const trackEvent = useTrack();
const params = useParams<{ team_slug: string }>();
const { name, monthlyPriceUsd } = ENGINE_TIER_CARD_CONFIG[tier];
@@ -140,22 +137,20 @@ export const EngineTierCard = ({
? "product:engine_standard"
: "product:engine_premium"
}
className="gap-2"
redirectPath={`/team/${params?.team_slug}/~/engine`}
teamSlug={params?.team_slug || "~"}
onClick={() => {
startRouteTransition(true);
trackEvent({
category: "engine",
action: "click",
label: "clicked-cloud-hosted",
tier,
});
getBillingCheckoutUrl={getBillingCheckoutUrl}
buttonProps={{
onClick() {
trackEvent({
category: "engine",
action: "click",
label: "clicked-cloud-hosted",
tier,
});
},
variant: isPrimaryCta ? "default" : "outline",
}}
variant={isPrimaryCta ? "default" : "outline"}
redirectToCheckout={redirectToCheckout}
>
{isRoutePending && <Spinner className="size-4" />}
{ctaText ?? defaultCtaText}
</CheckoutButton>
)}
@@ -68,7 +68,10 @@ function Story() {
},
});
const redirectToBillingPortalStub = async () => ({ status: 200 });
const getBillingPortalUrlStub = async () => ({
status: 200,
url: "https://example.com",
});
return (
<div className="container flex max-w-[1130px] flex-col gap-12 lg:p-10">
@@ -76,7 +79,7 @@ function Story() {
<PlanInfoCard
team={team}
subscriptions={zeroUsageOnDemandSubs}
redirectToBillingPortal={redirectToBillingPortalStub}
getBillingPortalUrl={getBillingPortalUrlStub}
/>
</BadgeContainer>
@@ -84,7 +87,7 @@ function Story() {
<PlanInfoCard
team={team}
subscriptions={trialPlanZeroUsageOnDemandSubs}
redirectToBillingPortal={redirectToBillingPortalStub}
getBillingPortalUrl={getBillingPortalUrlStub}
/>
</BadgeContainer>
@@ -92,7 +95,7 @@ function Story() {
<PlanInfoCard
team={team}
subscriptions={subsWith1Usage}
redirectToBillingPortal={redirectToBillingPortalStub}
getBillingPortalUrl={getBillingPortalUrlStub}
/>
</BadgeContainer>
@@ -100,7 +103,7 @@ function Story() {
<PlanInfoCard
team={team}
subscriptions={subsWith4Usage}
redirectToBillingPortal={redirectToBillingPortalStub}
getBillingPortalUrl={getBillingPortalUrlStub}
/>
</BadgeContainer>
</div>
@@ -1,4 +1,4 @@
import type { BillingBillingPortalAction } from "@/actions/billing";
import type { GetBillingPortalUrlAction } from "@/actions/billing";
import type { Team } from "@/api/team";
import type { TeamSubscription } from "@/api/team-subscription";
import { BillingPortalButton } from "@/components/billing";
@@ -14,7 +14,7 @@ import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getVal
export function PlanInfoCard(props: {
subscriptions: TeamSubscription[];
team: Team;
redirectToBillingPortal: BillingBillingPortalAction;
getBillingPortalUrl: GetBillingPortalUrlAction;
}) {
const { subscriptions, team } = props;
const validPlan = getValidTeamPlan(team);
@@ -36,13 +36,13 @@ export function PlanInfoCard(props: {
<div className="rounded-lg border border-border bg-card">
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
<div>
<div className="flex flex-col items-start gap-2">
<div className="flex flex-col items-start gap-1">
<h3 className="font-semibold text-2xl capitalize tracking-tight">
{validPlan} Plan
</h3>
<p className="text-sm">
Go to "Manage Billing" to view your invoices, update your payment
method, or edit your billing details.
<p className="text-muted-foreground text-sm">
Click on "Manage Billing" to view your invoices, update your
payment method, or edit your billing details.
</p>
{trialEndsInFuture && <Badge>Trial</Badge>}
</div>
@@ -57,9 +57,11 @@ export function PlanInfoCard(props: {
{/* manage team billing */}
<BillingPortalButton
teamSlug={team.slug}
variant="primary"
redirectPath={`/team/${team.slug}/~/settings/billing`}
redirectToBillingPortal={props.redirectToBillingPortal}
buttonProps={{
variant: "default",
size: "sm",
}}
getBillingPortalUrl={props.getBillingPortalUrl}
>
Manage Billing
</BillingPortalButton>
@@ -143,9 +145,9 @@ function SubscriptionOverview(props: {
<div className="flex items-center justify-between gap-6">
<div>
{props.title && (
<h5 className="font-medium text-lg">{props.title}</h5>
<h5 className="font-medium text-base">{props.title}</h5>
)}
<p className="text-muted-foreground text-sm lg:text-base">
<p className="text-muted-foreground text-sm">
{format(
new Date(props.subscription.currentPeriodStart),
"MMMM dd yyyy",
@@ -20,11 +20,15 @@ export function TeamGeneralSettingsPage(props: {
team={props.team}
client={props.client}
updateTeamField={async (teamValue) => {
await updateTeam({
const res = await updateTeam({
teamId: props.team.id,
value: teamValue,
});
if (!res.ok) {
throw new Error(res.error);
}
// Current page's slug is updated
if (teamValue.slug) {
router.replace(`/team/${teamValue.slug}/~/settings`);
@@ -55,13 +59,17 @@ export function TeamGeneralSettingsPage(props: {
});
}
await updateTeam({
const res = await updateTeam({
teamId: props.team.id,
value: {
image: uri,
},
});
if (!res.ok) {
throw new Error(res.error);
}
router.refresh();
}}
/>
@@ -12,6 +12,7 @@ import { FileInput } from "components/shared/FileInput";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { teamSlugRegex } from "./common";
type UpdateTeamField = (team: Partial<Team>) => Promise<void>;
@@ -140,7 +141,7 @@ function TeamSlugFormControl(props: {
setTeamSlug(value);
if (value.trim().length === 0) {
setErrorMessage("Team URL can not be empty");
} else if (/[^a-zA-Z0-9-]/.test(value)) {
} else if (teamSlugRegex.test(value)) {
setErrorMessage(
"Invalid Team URL. Only letters, numbers and hyphens are allowed",
);
@@ -0,0 +1 @@
export const teamSlugRegex = /[^a-zA-Z0-9-]/;
@@ -24,6 +24,18 @@ export async function updateTeam(params: {
});
if (!res.ok) {
throw new Error("failed to update team");
return {
ok: false as const,
error: await res.text(),
};
}
const data = (await res.json()) as {
result: Team;
};
return {
ok: true as const,
data: data.result,
};
}
@@ -43,17 +43,25 @@ const inviteFormSchema = z.object({
type InviteFormValues = z.infer<typeof inviteFormSchema>;
// Note: This component is also used in team onboarding flow and not just in team settings page
export type InviteTeamMembersFn = (
params: Array<{
email: string;
role: TeamAccountRole;
}>,
) => Promise<{
results: Array<"fulfilled" | "rejected">;
}>;
export function InviteSection(props: {
team: Team;
userHasEditPermission: boolean;
inviteTeamMembers: (
params: Array<{
email: string;
role: TeamAccountRole;
}>,
) => Promise<{
results: Array<"fulfilled" | "rejected">;
}>;
inviteTeamMembers: InviteTeamMembersFn;
customCTASection?: React.ReactNode;
className?: string;
onInviteSuccess?: () => void;
shouldHideInviteButton?: boolean;
}) {
const teamPlan = getValidTeamPlan(props.team);
let bottomSection: React.ReactNode = null;
@@ -76,7 +84,6 @@ export function InviteSection(props: {
const sendInvites = useMutation({
mutationFn: async (data: InviteFormValues) => {
const res = await props.inviteTeamMembers(data.invites);
return {
inviteStatuses: res.results,
};
@@ -97,14 +104,18 @@ export function InviteSection(props: {
</Link>
</p>
<Button variant="outline" size="sm" asChild>
<Link
href={`/team/${props.team.slug}/~/settings/billing`}
className="gap-2"
>
Upgrade
</Link>
</Button>
{props.customCTASection ? (
props.customCTASection
) : (
<Button variant="outline" size="sm" asChild>
<Link
href={`/team/${props.team.slug}/~/settings/billing`}
className="gap-2"
>
Upgrade
</Link>
</Button>
)}
</div>
);
} else if (!props.userHasEditPermission) {
@@ -144,20 +155,28 @@ export function InviteSection(props: {
</p>
)}
<Button
variant="outline"
size="sm"
className="gap-2 max-sm:w-full"
type="submit"
disabled={sendInvites.isPending}
>
{sendInvites.isPending ? (
<Spinner className="size-4" />
) : (
<UserPlus className="size-4" />
<div className="flex gap-3">
{!props.shouldHideInviteButton && (
<Button
variant="default"
size="sm"
className="gap-2 max-sm:w-full"
type="submit"
disabled={sendInvites.isPending}
>
{sendInvites.isPending ? (
<Spinner className="size-4" />
) : (
<UserPlus className="size-4" />
)}
{form.watch("invites").length > 1
? "Send Invites"
: "Send Invite"}
</Button>
)}
{form.watch("invites").length > 1 ? "Send Invites" : "Send Invite"}
</Button>
{props.customCTASection}
</div>
</div>
);
}
@@ -190,6 +209,10 @@ export function InviteSection(props: {
toast.success(
`Successfully sent ${data.inviteStatuses.length === 1 ? "" : data.inviteStatuses.length} ${inviteOrInvites}`,
);
if (props.onInviteSuccess) {
props.onInviteSuccess();
}
}
},
});
@@ -209,7 +232,12 @@ export function InviteSection(props: {
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<section>
<div className="rounded-lg border border-border bg-card">
<div
className={cn(
"rounded-lg border border-border bg-card",
props.className,
)}
>
<div className="border-b px-4 py-4 lg:px-6">
<h2 className="font-semibold text-lg tracking-tight">Invite</h2>
@@ -0,0 +1,157 @@
"use client";
import { analyticsServerProxy } from "@/actions/proxies";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb/dist/types/client/client";
import {
type SponsoredTransaction,
SponsoredTransactionsTableUI,
} from "./SponsoredTransactionsTableUI";
type GetSponsoredTransactionsParams = {
teamId: string;
limit: number;
offset: number;
from: string;
to: string;
// optional
projectId?: string;
chainId?: string;
};
const getSponsoredTransactions = async (
params: GetSponsoredTransactionsParams,
) => {
const res = await analyticsServerProxy<{
data: SponsoredTransaction[];
meta: {
total: number;
};
}>({
pathname: "/v2/bundler/sponsored-transactions",
method: "GET",
searchParams: {
teamId: params.teamId,
limit: params.limit.toString(),
offset: params.offset.toString(),
projectId: params.projectId,
chainId: params.chainId,
from: params.from,
to: params.to,
},
});
if (!res.ok) {
throw new Error(res.error);
}
return res.data;
};
async function getSponsoredTransactionsCSV(params: {
teamId: string;
from: string;
to: string;
}) {
const res = await analyticsServerProxy<string>({
method: "GET",
pathname: "/v2/bundler/sponsored-transactions/export",
parseAsText: true,
searchParams: {
teamId: params.teamId,
from: params.from,
to: params.to,
},
});
if (!res.ok) {
throw new Error(res.error);
}
return res.data;
}
export function SponsoredTransactionsTable(
props: {
client: ThirdwebClient;
teamId: string;
teamSlug: string;
from: string;
to: string;
} & (
| {
variant: "team";
projects: {
id: string;
name: string;
image: string | null;
slug: string;
}[];
}
| {
variant: "project";
projectId: string;
}
),
) {
const pageSize = 10;
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<{
chainId?: string;
projectId?: string;
}>({
projectId: props.variant === "project" ? props.projectId : undefined,
});
const params = {
teamId: props.teamId,
limit: pageSize,
offset: (page - 1) * pageSize,
from: props.from,
to: props.to,
chainId: filters.chainId,
projectId: filters.projectId,
};
const sponsoredTransactionsQuery = useQuery({
queryKey: ["sponsored-transactions", params],
queryFn: () => {
return getSponsoredTransactions(params);
},
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
const totalPages = sponsoredTransactionsQuery.data
? Math.ceil(sponsoredTransactionsQuery.data.meta.total / pageSize)
: 0;
return (
<SponsoredTransactionsTableUI
filters={filters}
variant={props.variant}
projects={props.variant === "team" ? props.projects : []}
setFilters={(v) => {
setFilters(v);
setPage(1);
}}
client={props.client}
isError={sponsoredTransactionsQuery.isError}
getCSV={() => {
return getSponsoredTransactionsCSV({
teamId: props.teamId,
from: props.from,
to: props.to,
});
}}
isPending={sponsoredTransactionsQuery.isFetching}
sponsoredTransactions={sponsoredTransactionsQuery.data?.data ?? []}
pageNumber={page}
setPageNumber={setPage}
pageSize={pageSize}
teamSlug={props.teamSlug}
totalPages={totalPages}
/>
);
}
@@ -0,0 +1,175 @@
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { ThirdwebProvider } from "thirdweb/react";
import { projectStub } from "../../../../../../../../stories/stubs";
import {
type SponsoredTransaction,
SponsoredTransactionsTableUI,
} from "./SponsoredTransactionsTableUI";
const meta = {
title: "Usage/SponsoredTransactionsTable",
component: Variant,
parameters: {
layout: "centered",
},
decorators: [
(Story) => (
<div className="container max-w-[1154px] py-10">
<ThirdwebProvider>
<Story />
</ThirdwebProvider>
</div>
),
],
} satisfies Meta<typeof Variant>;
export default meta;
type Story = StoryObj<typeof meta>;
const mockProjects = [
projectStub("foo", "team-1"),
projectStub("bar", "team-1"),
projectStub("baz", "team-1"),
];
function createSponsoredTransactionStub() {
const chainIdsToPick = ["1", "137", "8453", "42161", "10", "100"];
const randomVal = Math.random();
const feeMode = randomVal < 0.2 ? "low" : randomVal < 0.4 ? "zero" : "high";
const feeUsd =
feeMode === "low"
? Math.random() / 100
: feeMode === "zero"
? 0
: Math.random();
return {
timestamp: new Date(
Date.now() - Math.random() * 1000 * 60 * 60 * 24 * 30,
).toISOString(),
teamId: crypto.randomUUID(),
projectId:
mockProjects[Math.floor(Math.random() * mockProjects.length)]?.id || "",
chainId: chainIdsToPick[
Math.floor(Math.random() * chainIdsToPick.length)
] as string,
transactionFee: randomVal,
transactionFeeUsd: feeUsd,
walletAddress: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
transactionHash:
"0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
userOpHash:
"0x0987654321fedcba0987654321fedcba0987654321fedcba0987654321fedcba",
} satisfies SponsoredTransaction;
}
const client = getThirdwebClient();
export const Default: Story = {
args: {
totalPages: 4,
sponsoredTransactions: new Array(10)
.fill(null)
.map(createSponsoredTransactionStub),
isPending: false,
projectLoadingFails: false,
isError: false,
},
};
export const Loading: Story = {
args: {
totalPages: 4,
sponsoredTransactions: [],
isPending: true,
projectLoadingFails: false,
isError: false,
},
};
export const NoTransactions: Story = {
args: {
totalPages: 1,
sponsoredTransactions: [],
isPending: false,
projectLoadingFails: false,
isError: false,
},
};
export const NoPagination: Story = {
args: {
totalPages: 1,
sponsoredTransactions: new Array(5)
.fill(null)
.map(createSponsoredTransactionStub),
isPending: false,
projectLoadingFails: false,
isError: false,
},
};
export const ErrorLoadingTransactions: Story = {
args: {
totalPages: 1,
sponsoredTransactions: [],
isPending: false,
projectLoadingFails: false,
isError: true,
},
};
function Variant(props: {
sponsoredTransactions: SponsoredTransaction[];
isPending: boolean;
projectLoadingFails: boolean;
totalPages: number;
isError: boolean;
}) {
const [variant, setVariant] = useState<"team" | "project">("team");
const [pageNumber, setPageNumber] = useState(1);
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<RadioGroup
value={variant}
onValueChange={(value) => setVariant(value as "team" | "project")}
className="flex items-center gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="team" id="team" />
<Label htmlFor="team">Team</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="project" id="project" />
<Label htmlFor="project">Project</Label>
</div>
</RadioGroup>
</div>
<SponsoredTransactionsTableUI
projects={mockProjects}
getCSV={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return "foo,bar\n1,2\n3,4";
}}
filters={{}}
setFilters={() => {}}
isError={props.isError}
client={client}
totalPages={props.totalPages}
isPending={props.isPending}
pageNumber={pageNumber}
pageSize={10}
setPageNumber={setPageNumber}
sponsoredTransactions={props.sponsoredTransactions}
teamSlug="foo"
variant={variant}
/>
</div>
);
}
@@ -0,0 +1,481 @@
"use client";
import { ProjectAvatar } from "@/components/blocks/Avatars/ProjectAvatar";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { SelectWithSearch } from "@/components/blocks/select-with-search";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { PaginationButtons } from "@/components/pagination-buttons";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { ChainIcon } from "components/icons/ChainIcon";
import { format, formatDistance } from "date-fns";
import { useAllChainsData } from "hooks/chains/allChains";
import { ExternalLinkIcon, XIcon } from "lucide-react";
import Link from "next/link";
import type { ThirdwebClient } from "thirdweb";
export type SponsoredTransaction = {
timestamp: string;
teamId: string;
projectId: string;
chainId: string;
transactionFee: number;
transactionFeeUsd: number;
walletAddress: string;
transactionHash: string;
userOpHash: string;
};
export function SponsoredTransactionsTableUI(
props: {
sponsoredTransactions: SponsoredTransaction[];
isPending: boolean;
isError: boolean;
teamSlug: string;
client: ThirdwebClient;
pageSize: number;
setPageNumber: (pageNumber: number) => void;
pageNumber: number;
totalPages: number;
filters: { chainId?: string; projectId?: string };
setFilters: (filters: { chainId?: string; projectId?: string }) => void;
} & (
| {
variant: "team";
projects: {
id: string;
name: string;
image: string | null;
slug: string;
}[];
getCSV: () => Promise<string>;
}
| {
variant: "project";
}
),
) {
const showPagination = props.totalPages > 1;
return (
<div className="overflow-hidden rounded-lg border bg-card">
{/* header */}
<div className="flex flex-col justify-between gap-3 border-b px-4 py-4 lg:flex-row lg:items-center lg:px-6">
<h2 className="font-semibold text-lg">Sponsored Transactions</h2>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
{/* Filters */}
<div className="flex gap-2">
{props.variant === "team" && (
<ProjectFilter
projectId={props.filters.projectId}
setProjectId={(projectId) =>
props.setFilters({ ...props.filters, projectId })
}
projects={props.projects}
client={props.client}
/>
)}
<ChainFilter
chainId={props.filters.chainId}
setChainId={(chainId) =>
props.setFilters({ ...props.filters, chainId })
}
/>
</div>
{props.variant === "team" && (
<>
<div className="hidden h-4 w-[1px] border bg-border lg:block" />
<ExportToCSVButton
className="bg-background"
getData={props.getCSV}
fileName="sponsored-transactions"
/>
</>
)}
</div>
</div>
<TableContainer className={"rounded-none border-none"}>
<Table>
<TableHeader>
<TableRow>
<TableHead>Transaction Hash</TableHead>
{props.variant === "team" && <TableHead>Project</TableHead>}
<TableHead>Chain</TableHead>
<TableHead>Wallet</TableHead>
<TableHead>Timestamp</TableHead>
<TableHead>Fee</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!props.isPending
? props.sponsoredTransactions.map((transaction) => (
<TableRow key={transaction.transactionHash}>
{/* Tx Hash */}
<TableCell>
<TransactionHashCell
hash={transaction.transactionHash}
chainId={transaction.chainId}
/>
</TableCell>
{/* Project */}
{props.variant === "team" && (
<TableCell>
<ProjectCell
teamSlug={props.teamSlug}
project={props.projects.find(
(p) => p.id === transaction.projectId,
)}
client={props.client}
/>
</TableCell>
)}
{/* Chain */}
<TableCell>
<ChainCell chainId={transaction.chainId} />
</TableCell>
{/* Wallet */}
<TableCell>
<WalletAddress address={transaction.walletAddress} />
</TableCell>
{/* Time */}
<TableCell>
<ToolTipLabel
hoverable
label={format(new Date(transaction.timestamp), "PPpp")}
>
<span>
{formatDistance(
new Date(transaction.timestamp),
new Date(),
{
addSuffix: true,
},
)}
</span>
</ToolTipLabel>
</TableCell>
{/* Fee */}
<TableCell>
<TransactionFeeCell
usdValue={transaction.transactionFeeUsd}
/>
</TableCell>
</TableRow>
))
: Array.from({ length: props.pageSize }).map((_, index) => (
<SkeletonRow
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={`skeleton-${index}`}
variant={props.variant}
/>
))}
</TableBody>
</Table>
</TableContainer>
{!props.isPending && (
// biome-ignore lint/complexity/noUselessFragments: better readability
<>
{props.isError ? (
<div className="px-6 py-24">
<div className="mb-4 flex justify-center">
<div className="rounded-full border p-2">
<XIcon className="size-5" />
</div>
</div>
<p className="text-center text-destructive-text text-sm">
Failed to load sponsored transactions
</p>
</div>
) : props.sponsoredTransactions.length === 0 ? (
<div className="px-6 py-24">
<div className="mb-4 flex justify-center">
<div className="rounded-full border p-2">
<XIcon className="size-5 text-muted-foreground" />
</div>
</div>
<p className="text-center text-muted-foreground text-sm">
No sponsored transactions
</p>
</div>
) : null}
</>
)}
{showPagination && (
<div className="flex justify-end gap-3 rounded-b-lg border-t p-4">
<PaginationButtons
activePage={props.pageNumber}
totalPages={props.totalPages}
onPageClick={(page) => props.setPageNumber(page)}
/>
</div>
)}
</div>
);
}
function SkeletonRow(props: { variant: "team" | "project" }) {
return (
<TableRow className="h-[72.5px]">
{/* Tx Hash */}
<TableCell>
<Skeleton className="h-7 w-[160px]" />
</TableCell>
{/* Project */}
{props.variant === "team" && (
<TableCell>
<Skeleton className="h-7 w-[145px]" />
</TableCell>
)}
{/* Chain */}
<TableCell>
<Skeleton className="h-7 w-[130px]" />
</TableCell>
{/* Wallet */}
<TableCell>
<Skeleton className="h-7 w-[130px]" />
</TableCell>
{/* Time */}
<TableCell>
<Skeleton className="h-7 w-[80px]" />
</TableCell>
{/* Fee */}
<TableCell>
<Skeleton className="h-7 w-[40px]" />
</TableCell>
</TableRow>
);
}
function TransactionHashCell(props: { hash: string; chainId: string }) {
const { idToChain } = useAllChainsData();
const chain = idToChain.get(Number(props.chainId));
const explorerUrl = chain?.explorers?.[0]?.url;
const txHashToShow = `${props.hash.slice(0, 6)}...${props.hash.slice(-4)}`;
if (explorerUrl) {
return (
<Button size="sm" variant="ghost" asChild>
<Link
href={`${explorerUrl}/tx/${props.hash}`}
target="_blank"
className="-translate-x-2 gap-2 font-mono"
>
{txHashToShow}
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
</Link>
</Button>
);
}
return (
<CopyTextButton
textToCopy={props.hash}
textToShow={txHashToShow}
tooltip="Transaction Hash"
copyIconPosition="right"
className="-translate-x-2 font-mono"
variant="ghost"
/>
);
}
function ChainCell(props: { chainId: string }) {
const { idToChain, allChains } = useAllChainsData();
const chain = idToChain.get(Number(props.chainId));
if (allChains.length === 0) {
return <Skeleton className="w-[100px]" />;
}
return (
<div className="relative flex w-max items-center gap-2">
<ChainIcon ipfsSrc={chain?.icon?.url} className="size-6" />
<Link
target="_blank"
href={`/${chain ? chain.slug : props.chainId}`}
className="before:absolute before:inset-0 hover:underline hover:underline-offset-4"
>
{chain ? chain.name : `Chain #${props.chainId}`}
</Link>
</div>
);
}
function ProjectCell(props: {
teamSlug: string;
project:
| {
id: string;
name: string;
image: string | null;
slug: string;
}
| undefined;
client: ThirdwebClient;
}) {
// just typeguard - never actually happens
if (!props.project) {
return <Skeleton className="h-6 w-[130px]" />;
}
return (
<div className="relative flex w-max items-center gap-2">
<ProjectAvatar
src={props.project.image || ""}
className="size-6 shrink-0"
client={props.client}
/>
<Link
href={`/team/${props.teamSlug}/${props.project.slug}`}
className="before:absolute before:inset-0 hover:underline hover:underline-offset-4"
target="_blank"
>
{props.project.name}
</Link>
</div>
);
}
function ChainFilter(props: {
chainId: string | undefined;
setChainId: (chainId: string | undefined) => void;
}) {
const isChainFilterActive = props.chainId !== undefined;
return (
<div className="flex bg-background">
{isChainFilterActive && (
<ToolTipLabel label="Remove chain filter">
<Button
variant="outline"
size="icon"
className="rounded-r-none border-r-0 px-3 text-muted-foreground"
onClick={() => props.setChainId(undefined)}
>
<XIcon className="size-4" />
</Button>
</ToolTipLabel>
)}
<SingleNetworkSelector
className={cn(isChainFilterActive && "rounded-l-none")}
chainId={props.chainId ? Number(props.chainId) : undefined}
onChange={(chainId) => props.setChainId(chainId.toString())}
popoverContentClassName="!w-[80vw] md:!w-[350px]"
align="end"
placeholder="All Chains"
disableChainId
/>
</div>
);
}
function ProjectFilter(props: {
projectId: string | undefined;
setProjectId: (projectId: string | undefined) => void;
projects: { id: string; name: string; image: string | null }[];
client: ThirdwebClient;
}) {
const isProjectFilterActive = props.projectId !== undefined;
return (
<div className="flex bg-background">
{isProjectFilterActive && (
<ToolTipLabel label="Remove project filter">
<Button
variant="outline"
size="icon"
className="rounded-r-none border-r-0 px-3 text-muted-foreground"
onClick={() => props.setProjectId(undefined)}
>
<XIcon className="size-4" />
</Button>
</ToolTipLabel>
)}
<SelectWithSearch
onValueChange={(value) => props.setProjectId(value)}
options={props.projects.map((project) => ({
label: project.name,
value: project.id,
}))}
value={props.projectId}
placeholder="All Projects"
popoverContentClassName="!w-[80vw] md:!w-[350px]"
align="end"
className={cn(
"min-w-[160px]",
isProjectFilterActive && "rounded-l-none",
)}
renderOption={(option) => {
const project = props.projects.find((p) => p.id === option.value);
if (!project) {
return <></>;
}
return (
<div className="flex items-center gap-2">
<ProjectAvatar
src={project.image || ""}
className="size-6 shrink-0"
client={props.client}
/>
<span>{project.name}</span>
</div>
);
}}
/>
</div>
);
}
// for values >= 0.01, show 2 decimal places
const normalValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
notation: "standard",
maximumFractionDigits: 2,
});
function formatTransactionFee(usdValue: number) {
if (usdValue >= 0.01 || usdValue === 0) {
return normalValueUSDFormatter.format(usdValue);
}
return "< $0.01";
}
function TransactionFeeCell(props: { usdValue: number }) {
return (
<ToolTipLabel label={`$${props.usdValue}`}>
<span>{formatTransactionFee(props.usdValue)}</span>
</ToolTipLabel>
);
}
@@ -10,8 +10,10 @@ import { InAppWalletUsersChartCardUI } from "components/embedded-wallets/Analyti
import { subMonths } from "date-fns";
import Link from "next/link";
import { Suspense, useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import { toPercent, toSize } from "utils/number";
import { TotalSponsoredChartCardUI } from "../../../../_components/TotalSponsoredCard";
import { SponsoredTransactionsTable } from "./SponsoredTransactionsTable";
import { UsageCard } from "./UsageCard";
type UsageProps = {
@@ -20,6 +22,13 @@ type UsageProps = {
subscriptions: TeamSubscription[];
account: Account;
team: Team;
client: ThirdwebClient;
projects: {
id: string;
name: string;
image: string | null;
slug: string;
}[];
};
export const Usage: React.FC<UsageProps> = ({
@@ -27,6 +36,8 @@ export const Usage: React.FC<UsageProps> = ({
subscriptions,
account,
team,
client,
projects,
}) => {
// TODO - get this from team instead of account
const storageMetrics = useMemo(() => {
@@ -105,6 +116,16 @@ export const Usage: React.FC<UsageProps> = ({
to={currentPeriodEnd}
/>
<SponsoredTransactionsTable
client={client}
teamSlug={team.slug}
teamId={team.id}
from={currentPeriodStart.toISOString()}
to={currentPeriodEnd.toISOString()}
projects={projects}
variant="team"
/>
<UsageCard
{...rpcMetrics}
name="RPC"
@@ -1,7 +1,10 @@
import { getProjects } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { getTeamSubscriptions } from "@/api/team-subscription";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { redirect } from "next/navigation";
import { getValidAccount } from "../../../../../account/settings/getAccount";
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
import { getAccountUsage } from "./getAccountUsage";
import { Usage } from "./overview/components/Usage";
@@ -11,19 +14,23 @@ export default async function Page(props: {
}>;
}) {
const params = await props.params;
const account = await getValidAccount(`/team/${params.team_slug}/~/usage`);
const team = await getTeamBySlug(params.team_slug);
const [account, team, authToken] = await Promise.all([
getValidAccount(`/team/${params.team_slug}/~/usage`),
getTeamBySlug(params.team_slug),
getAuthToken(),
]);
if (!team) {
redirect("/team");
}
const [accountUsage, subscriptions] = await Promise.all([
const [accountUsage, subscriptions, projects] = await Promise.all([
getAccountUsage(),
getTeamSubscriptions(team.slug),
getProjects(team.slug),
]);
if (!accountUsage || !subscriptions) {
if (!accountUsage || !subscriptions || !authToken) {
return (
<div className="flex min-h-[350px] items-center justify-center rounded-lg border p-4 text-destructive-text">
Something went wrong. Please try again later.
@@ -31,6 +38,8 @@ export default async function Page(props: {
);
}
const client = getThirdwebClient(authToken);
return (
<Usage
// TODO - remove accountUsage when we have all the data available from team
@@ -38,6 +47,13 @@ export default async function Page(props: {
subscriptions={subscriptions}
account={account}
team={team}
client={client}
projects={projects.map((project) => ({
id: project.id,
name: project.name,
image: project.image,
slug: project.slug,
}))}
/>
);
}
@@ -2,7 +2,7 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { TabPathLinks } from "@/components/ui/tabs";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
import { CircleAlertIcon } from "lucide-react";
import { useActiveWalletChain } from "thirdweb/react";
@@ -29,21 +29,20 @@ export function AccountAbstractionLayout(props: {
return (
<div>
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:text-3xl">
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:mb-2 lg:text-3xl">
Account Abstraction
</h1>
<p className="text-muted-foreground text-sm">
Easily integrate Account abstraction (ERC-4337) compliant smart accounts
into your apps.{" "}
<TrackedLinkTW
<TrackedUnderlineLink
target="_blank"
label="docs-wallets"
category={TRACKING_CATEGORY}
href="https://portal.thirdweb.com/wallets/smart-wallet"
className="text-link-foreground hover:text-foreground"
>
View Documentation
</TrackedLinkTW>
</TrackedUnderlineLink>
</p>
<div className="h-6" />
<div className="flex flex-col gap-6">
@@ -115,19 +115,23 @@ async function AsyncYourFactories(props: {
authToken: props.authToken,
});
const client = getThirdwebClient();
const client = getThirdwebClient(props.authToken);
const factories = (
await Promise.all(
deployedContracts.map(async (c) => {
const contract = getContract({
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(Number(c.chainId)),
address: c.contractAddress,
client,
});
const m = await getCompilerMetadata(contract);
return m.name.indexOf("AccountFactory") > -1 ? c : null;
try {
const contract = getContract({
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(Number(c.chainId)),
address: c.contractAddress,
client,
});
const m = await getCompilerMetadata(contract);
return m.name.indexOf("AccountFactory") > -1 ? c : null;
} catch {
return null;
}
}),
)
).filter((f) => f !== null);
@@ -1,12 +1,14 @@
import { getUserOpUsage } from "@/api/analytics";
import { getProject } from "@/api/projects";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import {
type Range,
getLastNDaysRange,
} from "components/analytics/date-range-selector";
import { AccountAbstractionAnalytics } from "components/smart-wallets/AccountAbstractionAnalytics";
import { redirect } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import type { SearchParams } from "nuqs/server";
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
import { searchParamLoader } from "./search-params";
interface PageParams {
@@ -19,11 +21,16 @@ export default async function Page(props: {
searchParams: Promise<SearchParams>;
children: React.ReactNode;
}) {
const [params, searchParams] = await Promise.all([
const [params, searchParams, authToken] = await Promise.all([
props.params,
searchParamLoader(props.searchParams),
getAuthToken(),
]);
if (!authToken) {
notFound();
}
const project = await getProject(params.team_slug, params.project_slug);
if (!project) {
@@ -53,5 +60,13 @@ export default async function Page(props: {
period: interval,
});
return <AccountAbstractionAnalytics userOpStats={userOpStats} />;
return (
<AccountAbstractionAnalytics
userOpStats={userOpStats}
client={getThirdwebClient(authToken)}
teamId={project.teamId}
projectId={project.id}
teamSlug={params.team_slug}
/>
);
}
@@ -1,5 +1,5 @@
import { getInAppWalletUsage } from "@/api/analytics";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
import { InAppWalletsSummary } from "components/embedded-wallets/Analytics/Summary";
import { subDays } from "date-fns";
import { TRACKING_CATEGORY } from "../_constants";
@@ -31,22 +31,21 @@ export async function InAppWalletsHeader({
return (
<div>
<h1 className="mb-3 font-semibold text-2xl tracking-tight md:text-3xl">
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:mb-2 lg:text-3xl">
In-App Wallets
</h1>
<p className="mt-3 mb-7 max-w-[700px] text-muted-foreground">
<p className="mb-7 max-w-[700px] text-muted-foreground text-sm leading-relaxed">
A wallet infrastructure that enables apps to create, manage, and control
their users wallets. Email login, social login, and bring-your-own auth
supported.{" "}
<TrackedLinkTW
<TrackedUnderlineLink
target="_blank"
href="https://portal.thirdweb.com/connect/in-app-wallet/overview"
label="learn-more"
category={TRACKING_CATEGORY}
className="text-link-foreground hover:text-foreground"
>
Learn more
</TrackedLinkTW>
</TrackedUnderlineLink>
</p>
<InAppWalletsSummary
allTimeStats={allTimeStats || []}
@@ -20,8 +20,8 @@ export default async function Layout(props: {
href: `/team/${team_slug}/${project_slug}/connect/account-abstraction`,
},
{
label: "Pay",
href: `/team/${team_slug}/${project_slug}/connect/pay`,
label: "Universal Bridge",
href: `/team/${team_slug}/${project_slug}/connect/universal-bridge`,
},
{
label: "Playground",
@@ -1,7 +1,7 @@
import { getProject } from "@/api/projects";
import { TabPathLinks } from "@/components/ui/tabs";
import Link from "next/link";
import { redirect } from "next/navigation";
import { UnderlineLink } from "../../../../../../@/components/ui/UnderlineLink";
export default async function Layout(props: {
params: Promise<{
@@ -17,26 +17,25 @@ export default async function Layout(props: {
redirect(`/team/${params.team_slug}`);
}
const payLayoutPath = `/team/${params.team_slug}/${params.project_slug}/connect/pay`;
const payLayoutPath = `/team/${params.team_slug}/${params.project_slug}/connect/universal-bridge`;
return (
<div className="flex w-full flex-col gap-6">
<div className="flex flex-col items-start justify-between gap-6 lg:flex-row">
<div className="max-w-[700px]">
<h1 className="mb-3 font-bold text-3xl tracking-tight md:text-4xl">
Pay
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:mb-2 lg:text-3xl">
Universal Bridge
</h1>
<p className="text-muted-foreground text-sm leading-relaxed md:text-base">
Pay allows your users to purchase cryptocurrencies and execute
transactions with their credit card or debit card, or with any token
via cross-chain routing.{" "}
<Link
<p className="text-muted-foreground text-sm leading-relaxed">
Universal Bridge allows your users to bridge, swap, and purchase
cryptocurrencies and execute transactions with any fiat options or
tokens via cross-chain routing.{" "}
<UnderlineLink
target="_blank"
href="https://portal.thirdweb.com/connect/pay/overview"
className="!text-link-foreground"
>
Learn more
</Link>
</UnderlineLink>
</p>
</div>
</div>
@@ -217,7 +217,7 @@ export function ProjectGeneralSettingsPageUI(props: {
const paths = {
aaConfig: `${projectLayout}/connect/account-abstraction/settings`,
inAppConfig: `${projectLayout}/connect/in-app-wallets/settings`,
payConfig: `${projectLayout}/connect/pay/settings`,
payConfig: `${projectLayout}/connect/universal-bridge/settings`,
afterDeleteRedirectTo: `/team/${props.teamSlug}`,
};
@@ -1,7 +1,7 @@
import { getTeamBySlug } from "@/api/team";
import { AppFooter } from "@/components/blocks/app-footer";
import { redirect } from "next/navigation";
import { TWAutoConnect } from "../../components/autoconnect";
import { isTeamOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
import { SaveLastVisitedTeamPage } from "../components/last-visited-page/SaveLastVisitedPage";
import {
PastDueBanner,
@@ -19,6 +19,10 @@ export default async function RootTeamLayout(props: {
redirect("/team");
}
if (!isTeamOnboardingComplete(team)) {
redirect(`/get-started/team/${team.slug}`);
}
return (
<div className="flex min-h-dvh flex-col">
<div className="flex grow flex-col">
@@ -32,7 +36,6 @@ export default async function RootTeamLayout(props: {
{props.children}
</div>
<TWAutoConnect />
<AppFooter />
<SaveLastVisitedTeamPage />
@@ -0,0 +1,5 @@
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
export default function Loading() {
return <GenericLoadingPage className="border-none" />;
}
@@ -62,6 +62,7 @@ import {
getModuleInstallParams,
showPrimarySaleFieldset,
showRoyaltyFieldset,
showSuperchainBridgeFieldset,
} from "./modular-contract-default-modules-fieldset";
import { Param } from "./param";
import { PlatformFeeFieldset } from "./platform-fee-fieldset";
@@ -294,6 +295,9 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
// set connected wallet address as default "primarySaleRecipient"
else if (showPrimarySaleFieldset(paramNames)) {
returnVal.primarySaleRecipient = activeAccount.address;
} else if (showSuperchainBridgeFieldset(paramNames)) {
returnVal.superchainBridge =
"0x4200000000000000000000000000000000000028";
}
acc[mod.name] = returnVal;
@@ -226,3 +226,7 @@ export function showPrimarySaleFieldset(paramNames: string[]) {
function showSequentialTokenIdFieldset(paramNames: string[]) {
return paramNames.length === 1 && paramNames.includes("startTokenId");
}
export function showSuperchainBridgeFieldset(paramNames: string[]) {
return paramNames.length === 1 && paramNames.includes("superchainBridge");
}
@@ -114,7 +114,7 @@ export async function ContractCard({
<div className="flex justify-between">
<div className="flex items-center gap-1.5">
{/* Audited */}
{auditLink && (
{auditLink && !isBeta && (
<>
<Link
target="_blank"
@@ -19,7 +19,8 @@ export function AnnouncementBanner(props: {
hasDismissedAnnouncement ||
layoutSegment === "login" ||
layoutSegment === "nebula-app" ||
layoutSegment === "join"
layoutSegment === "join" ||
layoutSegment === "get-started"
) {
return null;
}
@@ -28,7 +28,10 @@ export const Mobile: Story = {
};
function Story() {
const redirectToBillingPortalStub = async () => ({ status: 200 });
const getBillingPortalUrlStub = async () => ({
status: 200,
url: "https://example.com",
});
return (
<div className="container flex max-w-[1132px] flex-col gap-12 py-10">
@@ -36,7 +39,7 @@ function Story() {
<BillingPricing
team={teamStub("foo", "free")}
trialPeriodEndedAt={undefined}
redirectToCheckout={redirectToBillingPortalStub}
getBillingCheckoutUrl={getBillingPortalUrlStub}
/>
</BadgeContainer>
@@ -44,7 +47,7 @@ function Story() {
<BillingPricing
team={teamStub("foo", "starter")}
trialPeriodEndedAt={undefined}
redirectToCheckout={redirectToBillingPortalStub}
getBillingCheckoutUrl={getBillingPortalUrlStub}
/>
</BadgeContainer>
@@ -52,7 +55,7 @@ function Story() {
<BillingPricing
team={teamStub("foo", "growth")}
trialPeriodEndedAt={undefined}
redirectToCheckout={redirectToBillingPortalStub}
getBillingCheckoutUrl={getBillingPortalUrlStub}
/>
</BadgeContainer>
@@ -60,7 +63,7 @@ function Story() {
<BillingPricing
team={teamStub("foo", "pro")}
trialPeriodEndedAt={undefined}
redirectToCheckout={redirectToBillingPortalStub}
getBillingCheckoutUrl={getBillingPortalUrlStub}
/>
</BadgeContainer>
</div>
@@ -1,7 +1,12 @@
import type { RedirectBillingCheckoutAction } from "@/actions/billing";
"use client";
import type { GetBillingCheckoutUrlAction } from "@/actions/billing";
import type { Team } from "@/api/team";
import { PricingCard } from "@/components/blocks/pricing-card";
import { useMemo } from "react";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useMemo, useTransition } from "react";
import { useStripeRedirectEvent } from "../../../../app/stripe-redirect/stripeRedirectChannel";
import { getValidTeamPlan } from "../../../../app/team/components/TeamHeader/getValidTeamPlan";
// TODO - move this in app router folder in other pr
@@ -9,7 +14,7 @@ import { getValidTeamPlan } from "../../../../app/team/components/TeamHeader/get
interface BillingPricingProps {
team: Team;
trialPeriodEndedAt: string | undefined;
redirectToCheckout: RedirectBillingCheckoutAction;
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
}
type CtaLink = {
@@ -19,11 +24,19 @@ type CtaLink = {
export const BillingPricing: React.FC<BillingPricingProps> = ({
team,
trialPeriodEndedAt,
redirectToCheckout,
getBillingCheckoutUrl,
}) => {
const pagePath = `/team/${team.slug}/~/settings/billing`;
const validTeamPlan = getValidTeamPlan(team);
const [isPending, startTransition] = useTransition();
const contactUsHref = "/contact-us";
const router = useDashboardRouter();
useStripeRedirectEvent(() => {
setTimeout(() => {
startTransition(() => {
router.refresh();
});
}, 1000);
});
const starterCta: CtaLink | undefined = useMemo(() => {
switch (validTeamPlan) {
@@ -101,73 +114,79 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
}, [validTeamPlan]);
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
{/* Starter */}
<PricingCard
redirectPath={pagePath}
billingPlan="starter"
current={validTeamPlan === "starter"}
cta={
starterCta
? {
title: starterCta.label,
tracking: {
category: "account",
label: "starterPlan",
},
}
: undefined
}
teamSlug={team.slug}
redirectToCheckout={redirectToCheckout}
/>
<div>
<h2 className="mb-2 inline-flex items-center gap-3 font-semibold text-2xl tracking-tight">
{validTeamPlan === "free" ? "Select a Plan" : "Plans"}{" "}
{isPending && <Spinner className="size-5" />}
</h2>
<p className="text-muted-foreground">
Upgrade or downgrade your plan here to better fit your needs.
</p>
<div className="h-3" />
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
{/* Starter */}
<PricingCard
billingPlan="starter"
current={validTeamPlan === "starter"}
cta={
starterCta
? {
title: starterCta.label,
tracking: {
category: "account",
label: "starterPlan",
},
}
: undefined
}
teamSlug={team.slug}
getBillingCheckoutUrl={getBillingCheckoutUrl}
/>
{/* Growth */}
<PricingCard
redirectPath={pagePath}
billingPlan="growth"
activeTrialEndsAt={
validTeamPlan === "growth" ? trialPeriodEndedAt : undefined
}
current={validTeamPlan === "growth"}
cta={
growthCardCta
? {
title: growthCardCta.label,
tracking: {
category: "account",
label: "growthPlan",
},
variant: "default",
hint: undefined,
}
: undefined
}
canTrialGrowth={false}
// upsell growth plan if user is on free plan
highlighted={validTeamPlan === "free" || validTeamPlan === "starter"}
teamSlug={team.slug}
redirectToCheckout={redirectToCheckout}
/>
{/* Growth */}
<PricingCard
billingPlan="growth"
activeTrialEndsAt={
validTeamPlan === "growth" ? trialPeriodEndedAt : undefined
}
current={validTeamPlan === "growth"}
cta={
growthCardCta
? {
title: growthCardCta.label,
tracking: {
category: "account",
label: "growthPlan",
},
variant: "default",
hint: undefined,
}
: undefined
}
// upsell growth plan if user is on free plan
highlighted={validTeamPlan === "free" || validTeamPlan === "starter"}
teamSlug={team.slug}
getBillingCheckoutUrl={getBillingCheckoutUrl}
/>
<PricingCard
redirectPath={pagePath}
billingPlan="pro"
teamSlug={team.slug}
current={validTeamPlan === "pro"}
cta={
proCta
? {
title: proCta.label,
tracking: {
category: "account",
label: "proPlan",
},
}
: undefined
}
redirectToCheckout={redirectToCheckout}
/>
<PricingCard
billingPlan="pro"
teamSlug={team.slug}
current={validTeamPlan === "pro"}
cta={
proCta
? {
title: proCta.label,
tracking: {
category: "account",
label: "proPlan",
},
}
: undefined
}
getBillingCheckoutUrl={getBillingCheckoutUrl}
/>
</div>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More