Compare commits

...
Author SHA1 Message Date
Jonas DanielsGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
60629e3cf3 Version Packages (#5215)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-10-31 15:43:37 +13:00
Joaquim VergesandGitHub 178d407e6f feat: add support for custom singlePhase drops (#5244) 2024-10-31 15:29:15 +13:00
MananTank 7e1cb6d8d8 Fix in-app wallet analytics chart on legacy dashboard (#5243)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the parameters used in the `Page` component within the `analytics` directory to reflect a change in the expected data structure, specifically changing from `project_slug` to `clientId`.

### Detailed summary
- Updated the `params` type in the `Page` function from `{ team_slug: string; project_slug: string }` to `{ clientId: string }`.
- Changed the prop passed to `<InAppWalletAnalytics />` from `clientId={params.project_slug}` to `clientId={params.clientId}`.

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

<!-- end pr-codex -->
2024-10-30 23:58:20 +00:00
MananTank c2a90542d7 Add loading.tsx for connect pages (#5240)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on standardizing loading pages across various components in the dashboard by introducing a `GenericLoadingPage`. It also refines data fetching error handling and enhances the `InAppWalletsHeader` and `InAppWallets` pages with improved stats handling.

### Detailed summary
- Deleted multiple `layout.tsx` files.
- Added `"use client"` directive to various loading components.
- Implemented `GenericLoadingPage` for consistent loading UI.
- Improved error handling in `getInAppWalletUsage` function.
- Enhanced `InAppWalletsHeader` with Promise.all for stats fetching.
- Updated `InAppWallets` pages to handle empty stats gracefully.
- Added footer section to several pages for better UX.

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

<!-- end pr-codex -->
2024-10-30 23:40:45 +00:00
Joaquim VergesandGitHub 802d3bfd4b fix: sanitize block explorer URLs (#5237) 2024-10-31 12:36:59 +13:00
MananTank 09b1912eb9 Shorten publisher address in OG image on published contract page (#5242)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `publishedContractOGImageTemplate` function to conditionally shorten the display of the `publisher` address if it is a valid blockchain address.

### Detailed summary
- Added import for `isAddress` and `shortenAddress` from `thirdweb`.
- Updated the rendering of the `publisher` in the `<h2>` element to check if it is a valid address using `isAddress`.
- If valid, it displays the shortened address using `shortenAddress`; otherwise, it displays the full `publisher` name.

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

<!-- end pr-codex -->
2024-10-30 22:38:24 +00:00
MananTank 975cd0b384 Add BurnMintERC677 in popular contracts section (#5239)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `apps/dashboard/src/data/explore.ts` file by adding a new entry to a list of addresses or identifiers related to third-party contracts.

### Detailed summary
- Added the entry `0xaF3202F6bAEbA50d37e0d4B0b870455EDF198D7c/BurnMintERC677` to the existing list of third-party contracts.

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

<!-- end pr-codex -->
2024-10-30 22:03:59 +00:00
MananTank 436f6b79b5 Fix MaskedAvatar and PublisherAvatar sizing issues, improved loading & fallback states (#5238)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on updating the `PublisherAvatar` component usage across several files, replacing the `boxSize` prop with a `className` prop for styling. Additionally, it enhances the `Img` component with improved loading behavior.

### Detailed summary
- Replaced `boxSize` with `className` in `PublisherAvatar` components in:
  - `apps/dashboard/src/components/explore/publisher/index.tsx`
  - `apps/dashboard/src/pages/profile/[profileAddress].tsx`
  - `apps/dashboard/src/components/contract-components/publisher/publisher-header.tsx`
  - `apps/dashboard/src/pages/community/ambassadors.tsx`
- Updated `MaskedAvatar` component to use `className` instead of `boxSize`.
- Modified `MaskedAvatar` component's rendering logic to use a new `Img` component.
- Enhanced `Img` component with a loading state and error handling using a `ref`.

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

<!-- end pr-codex -->
2024-10-30 22:03:58 +00:00
MananTank df8b2f6f90 Fix enum lint warning in dashboard (#5234)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR refactors the code to replace the usage of `AccountStatus` and `AccountPlan` enums with `accountStatus` and `accountPlan` constants, improving consistency and clarity throughout the codebase.

### Detailed summary
- Replaced `AccountStatus` enum with `accountStatus` constant object.
- Replaced `AccountPlan` enum with `accountPlan` constant object.
- Updated all references to use `accountStatus` and `accountPlan` instead of enums.
- Adjusted conditions and logic to align with the new structure.

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

<!-- end pr-codex -->
2024-10-30 18:48:10 +00:00
950ce60ec2 feat(dashboard): Adds analytics to in-app-wallets page (#5232)
Co-authored-by: gregfromstl <[email protected]>
2024-10-31 07:38:00 +13:00
MananTank 3658b3fece Fix theming class hydration warning on html element (#5233)
This is the correct way to fix this
https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app

` suppressHydrationWarning` property only applies one level deep, so it won't block hydration warnings on other child elements of `<html>`
2024-10-30 16:35:51 +00:00
Joaquim VergesandGitHub 686d0c3c05 feat: respect custom bundler URL for gas fees (#5231) 2024-10-30 23:14:17 +13:00
ElasticBottle 9425e9ef42 Winston/don't use iframe for jwt or custom auth endpoint validation (#5229)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR consolidates authentication methods in the `thirdweb` package by introducing a common endpoint for custom JWT and custom auth, enhancing code organization and reducing redundancy.

### Detailed summary
- Changed `getSessionHeaders` to no longer export it.
- Introduced `ecosystem` parameter in various functions.
- Replaced direct `fetch` calls with `getClientFetch` for consistency.
- Simplified the `loginWithAuthToken` method to accept an optional `recoveryCode`.
- Unified handling of authentication strategies in `InAppWebConnector`.

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

<!-- end pr-codex -->
2024-10-30 05:48:20 +00:00
Joaquim VergesandGitHub 556e11373c Revert "[Dashboard] Feature: Adds analytics to in-app-wallets page (#5186) (#5228) 2024-10-30 14:19:12 +13:00
6e87a866b2 [Dashboard] Feature: Adds analytics to in-app-wallets page (#5186) (#5227)
Co-authored-by: gregfromstl <[email protected]>
2024-10-30 14:02:15 +13:00
iuwqyir 043ee92598 unhide insight service (#5190)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR removes an entry from the `HIDDEN_SERVICES` array in the `validations.ts` file, indicating a potential update in the handling of API keys.

### Detailed summary
- Removed `"insight"` from the `HIDDEN_SERVICES` array in the `validations.ts` file.

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

<!-- end pr-codex -->
2024-10-29 22:41:11 +00:00
DustinandGitHub 89df3b5fa9 Soneium manito op grant update (#5222) 2024-10-30 10:18:32 +13:00
kien-ngo c9f52e9239 [Dashboard] chore: Update OS Page (#5223)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR adds new contributors to the list in the `open-source.tsx` file for the dashboard page.

### Detailed summary
- Added new contributors to the list:
  - `iuwqyir`
  - `GWSzeto`
  - `Joe-Thirdweb`
  - `AmineAfia`
  - `catalyst17`
  - `dirtycajunrice`

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

<!-- end pr-codex -->
2024-10-29 20:01:54 +00:00
kien-ngo 519349b5cf [Dashboard] chore: add spacing for TUrnstile captcha (#5182)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the styling of the `Turnstile` component within the `FaucetButton.tsx` file by adding a margin-top class.

### Detailed summary
- Added the `className="mt-4"` to the `Turnstile` component in `FaucetButton.tsx` to apply a top margin for improved spacing.

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

<!-- end pr-codex -->
2024-10-29 18:22:54 +00:00
MananTank 71a21e185e Fix page crash caused by BillingAlert component (#5220)
Fixes: DASH-356
2024-10-29 18:12:20 +00:00
kien-ngo c8b5780627 [Dashboard] Fix contract source code page (#5218)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the error handling for resolving the contract ABI in the `page.tsx` file by changing the promise handling from `then` to `catch`, ensuring that any errors in resolving the ABI will result in `undefined` being assigned.

### Detailed summary
- Changed the promise handling for `resolveContractAbi(contract)`:
  - From using `.then(() => undefined)` to `.catch(() => undefined)` to properly handle errors.

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

<!-- end pr-codex -->
2024-10-29 15:01:29 +00:00
ElasticBottle 68ce724ff6 [Dashboard]: Update in app wallet user api (#5187)
## Problem solved

Updates the dashboard so that list of in app wallet will pull from thirdweb RDS.
NOTE: NEED TO UPDATE ENV VAR AFTER DEPLOYEMENT

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `thirdweb` wallet integration by exposing the `WalletUser` type, updating API endpoints, and improving the handling of embedded wallets in the dashboard. It also refines wallet user data display and pagination in the user interface.

### Detailed summary
- Exposed `WalletUser` type in `packages/thirdweb/src/exports/wallets.ts`.
- Updated API host URL in `apps/dashboard/src/constants/urls.ts`.
- Modified `embeddedWallets` function to include `page` parameter.
- Refactored `useEmbeddedWallets` to support pagination and improved error handling.
- Changed data structure in `useEmbeddedWallets` to return `users` and `totalPages`.
- Updated wallet user display logic in `apps/dashboard/src/components/embedded-wallets/Users/index.tsx`.
- Removed deprecated `withinDays` function from `apps/dashboard/src/utils/date-utils.ts`.
- Enhanced CSV download functionality to include all users and relevant details.

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

<!-- end pr-codex -->
2024-10-29 02:36:37 +00:00
Joaquim VergesandGitHub ad4af68d1d perf: optimize passkey authentication for iOS 15 (#5214) 2024-10-29 14:18:43 +13:00
163 changed files with 3702 additions and 821 deletions
+26 -4
View File
@@ -1,6 +1,7 @@
/* eslint-disable @next/next/no-img-element */
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { useIsomorphicLayoutEffect } from "../../lib/useIsomorphicLayoutEffect";
import { cn } from "../../lib/utils";
type imgElementProps = React.DetailedHTMLProps<
@@ -25,19 +26,39 @@ export function Img(props: imgElementProps) {
const { className, fallback, skeleton, ...restProps } = props;
const defaultSkeleton = <div className="animate-pulse bg-accent" />;
const defaultFallback = <div className="bg-muted" />;
const imgRef = useRef<HTMLImageElement>(null);
useIsomorphicLayoutEffect(() => {
const imgEl = imgRef.current;
if (!imgEl) {
return;
}
if (imgEl.complete) {
setStatus("loaded");
} else {
function handleLoad() {
setStatus("loaded");
}
imgEl.addEventListener("load", handleLoad);
return () => {
imgEl.removeEventListener("load", handleLoad);
};
}
}, []);
return (
<div className="relative">
<img
{...restProps}
onLoad={() => {
setStatus("loaded");
}}
// avoid setting empty src string to prevent request to the entire page
src={restProps.src || undefined}
ref={imgRef}
onError={() => {
setStatus("fallback");
}}
style={{
opacity: status === "loaded" ? 1 : 0,
...restProps.style,
}}
alt={restProps.alt || ""}
className={cn(
@@ -48,6 +69,7 @@ export function Img(props: imgElementProps) {
{status !== "loaded" && (
<div
style={restProps.style}
className={cn(
"fade-in-0 absolute inset-0 overflow-hidden transition-opacity duration-300 [&>*]:h-full [&>*]:w-full",
className,
@@ -0,0 +1,11 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
export function GenericLoadingPage() {
return (
<div className="flex min-h-[500px] grow items-center justify-center rounded-lg border border-border">
<Spinner className="size-10" />
</div>
);
}
@@ -63,8 +63,11 @@ export const embeddedWalletsKeys = {
all: ["embeddedWallets"] as const,
wallet: (walletAddress: string) =>
[...embeddedWalletsKeys.all, walletAddress] as const,
embeddedWallets: (walletAddress: string, clientId: string | undefined) =>
[...embeddedWalletsKeys.wallet(walletAddress), clientId] as const,
embeddedWallets: (
walletAddress: string,
clientId: string | undefined,
page: number,
) => [...embeddedWalletsKeys.wallet(walletAddress), clientId, page] as const,
};
export const engineKeys = {
@@ -12,21 +12,24 @@ import { useLoggedInUser } from "./useLoggedInUser";
// FIXME: We keep repeating types, API server should provide them
export enum AccountStatus {
NoCustomer = "noCustomer",
NoPayment = "noPayment",
PaymentVerification = "paymentVerification",
ValidPayment = "validPayment",
InvalidPayment = "invalidPayment",
InvalidPaymentMethod = "invalidPaymentMethod",
}
export const accountStatus = {
noCustomer: "noCustomer",
noPayment: "noPayment",
paymentVerification: "paymentVerification",
validPayment: "validPayment",
invalidPayment: "invalidPayment",
invalidPaymentMethod: "invalidPaymentMethod",
} as const;
export enum AccountPlan {
Free = "free",
Growth = "growth",
Pro = "pro",
Enterprise = "enterprise",
}
export const accountPlan = {
free: "free",
growth: "growth",
pro: "pro",
enterprise: "enterprise",
} as const;
export type AccountStatus = (typeof accountStatus)[keyof typeof accountStatus];
export type AccountPlan = (typeof accountPlan)[keyof typeof accountPlan];
export type AuthorizedWallet = {
id: string;
@@ -235,6 +238,12 @@ export interface WalletStats {
walletType: string;
}
export interface InAppWalletStats {
date: string;
authenticationMethod: string;
uniqueWalletsConnected: number;
}
export interface UserOpStats {
date: string;
successful: number;
@@ -436,6 +445,43 @@ async function getUserOpUsage(args: {
return json.data;
}
export async function getInAppWalletUsage(args: {
clientId: string;
from?: Date;
to?: Date;
period?: "day" | "week" | "month" | "year" | "all";
}) {
const { clientId, from, to, period } = args;
const searchParams = new URLSearchParams();
searchParams.append("clientId", clientId);
if (from) {
searchParams.append("from", from.toISOString());
}
if (to) {
searchParams.append("to", to.toISOString());
}
if (period) {
searchParams.append("period", period);
}
const res = await fetch(
`${THIRDWEB_ANALYTICS_API_HOST}/v1/wallets/in-app?${searchParams.toString()}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
);
const json = await res?.json();
if (!res || res.status !== 200) {
throw new Error(json.message);
}
return json.data;
}
export function useUserOpUsageAggregate(args: {
clientId: string;
from?: Date;
@@ -1,51 +1,43 @@
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { THIRDWEB_EWS_API_HOST } from "constants/urls";
import type { WalletUser } from "thirdweb/wallets";
import { embeddedWalletsKeys } from "../cache-keys";
import { useLoggedInUser } from "./useLoggedInUser";
// FIXME: Make API to return camelCase or transform
export type EmbeddedWalletUser = {
id: string;
client_id: string;
created_at: string;
last_accessed_at: string;
embedded_wallet?: {
id: string;
address: string;
chain: string;
wallet_user_id: string;
}[];
ews_authed_user: {
id: string;
authed_user_id: string;
email: string;
}[];
};
export function useEmbeddedWallets(clientId: string) {
export function useEmbeddedWallets(clientId: string, page: number) {
const { user, isLoggedIn } = useLoggedInUser();
return useQuery({
queryKey: embeddedWalletsKeys.embeddedWallets(
user?.address as string,
clientId as string,
clientId,
page,
),
queryFn: async () => {
const res = await fetch(
`${THIRDWEB_EWS_API_HOST}/api/thirdweb/embedded-wallet?clientId=${clientId}&lastAccessedAt=0`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${user?.jwt}`,
},
},
const url = new URL(
`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`,
);
url.searchParams.append("clientId", clientId);
url.searchParams.append("page", page.toString());
const res = await fetch(url.href, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${user?.jwt}`,
},
});
if (!res.ok) {
throw new Error(`Failed to fetch wallets: ${await res.text()}`);
}
const json = await res.json();
return json.walletUsers as EmbeddedWalletUser[];
return json as {
users: WalletUser[];
totalPages: number;
};
},
placeholderData: keepPreviousData,
enabled: !!user?.address && isLoggedIn && !!clientId,
});
}
@@ -208,6 +208,7 @@ export function FaucetButton({
<FormItem>
<FormControl>
<Turnstile
className="mt-4"
siteKey={TURNSTILE_SITE_KEY}
onSuccess={(token) => field.onChange(token)}
/>
@@ -28,7 +28,7 @@ export default async function Page(props: {
);
}
const abi = await resolveContractAbi(contract).then(() => undefined);
const abi = await resolveContractAbi(contract).catch(() => undefined);
return (
<ContractCodePage
@@ -23,7 +23,6 @@ import plumeCTA from "./temp-assets/plumeCTA.png";
import rootStockBanner from "./temp-assets/rootstock-banner.png";
import rootStockCTABG from "./temp-assets/rootstock-cta.png";
import soneiumBanner from "./temp-assets/soneiumBanner.png";
import soneiumCTA from "./temp-assets/soneiumCTA.png";
import superpositionBanner from "./temp-assets/superpositionBanner.png";
import superpositionCTA from "./temp-assets/superpositionCTA.png";
import thirdwebBanner from "./temp-assets/thirdweb-banner.png";
@@ -404,13 +403,7 @@ const chainMetaRecord = {
headerImgUrl: soneiumBanner.src,
about:
"Soneium, an Ethereum layer-2 developed by Sony Block Solutions Labs. This versatile chain is a general-purpose blockchain platform that aims to evoke emotion, empower creativity, and meet diverse needs to go mainstream. Soneium will be simplifying blockchain experiences while empowering developers, creators, and communities. Built on accessibility, scalability, and efficiency, it aims to solve real-world problems across industries globally. Soneium will change the way we interact with the internet, opening up a world of innovative applications and unlimited potential for users worldwide.",
cta: {
backgroundImageUrl: soneiumCTA.src,
title: "Begin your journey with Soneium Spark",
buttonLink:
"https://soneium.org/en/blog/soneium-minato-testnet-is-live-along-with-soneium-spark-incubation-program/",
buttonText: "Learn more",
},
cta: OP_CTA,
},
} satisfies Record<number, ExtraChainMetadata>;
// END TEMPORARY
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -1,4 +1,4 @@
import { AccountStatus } from "@3rdweb-sdk/react/hooks/useApi";
import { accountStatus } from "@3rdweb-sdk/react/hooks/useApi";
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
import { ConnectSDKCard } from "components/shared/ConnectSDKCard";
import { SmartWallets } from "components/smart-wallets";
@@ -38,7 +38,7 @@ export default async function Page(props: {
const hasSmartWalletsWithoutBilling = apiKeys.find((k) =>
k.services?.find(
(s) =>
dashboardAccount.status !== AccountStatus.ValidPayment &&
dashboardAccount.status !== accountStatus.validPayment &&
s.name === "bundler",
),
);
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -16,7 +16,7 @@ import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItemButton } from "@/components/ui/radio-group";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { AccountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { accountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import Link from "next/link";
@@ -164,7 +164,7 @@ export function CreateEcosystemForm(props: {
)}
/>
</div>
{billingAccountInfo?.status !== AccountStatus.ValidPayment ? (
{billingAccountInfo?.status !== accountStatus.validPayment ? (
<ToolTipLabel label="Please update your payment method to create an ecosystem">
{/* Allows the button to be disabled but the tooltip still works */}
<div className="w-full">
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,32 @@
"use client";
import { TabLinks } from "@/components/ui/tabs";
import { usePathname } from "next/navigation";
export function Tabs({ clientId }: { clientId: string }) {
const path = usePathname();
return (
<TabLinks
links={[
{
name: "Analytics",
href: `/dashboard/connect/in-app-wallets/${clientId}/analytics`,
isActive: path?.endsWith("/analytics") || false,
isDisabled: false,
},
{
name: "Users",
href: `/dashboard/connect/in-app-wallets/${clientId}/users`,
isActive: path?.endsWith("/users") || false,
isDisabled: false,
},
{
name: "Configuration",
href: `/dashboard/connect/in-app-wallets/${clientId}/config`,
isActive: path?.endsWith("/config") || false,
isDisabled: false,
},
]}
/>
);
}
@@ -0,0 +1 @@
export const TRACKING_CATEGORY = "embedded-wallet";
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,38 @@
import type { Range } from "components/analytics/date-range-selector";
import { InAppWalletAnalytics } from "components/embedded-wallets/Analytics";
export default function Page({
params,
searchParams,
}: {
params: { clientId: string };
searchParams: {
from?: string;
to?: string;
type?: string;
interval?: string;
};
}) {
const range =
searchParams.from && searchParams.to
? {
type: searchParams.type ?? "last-120",
from: new Date(searchParams.from),
to: new Date(searchParams.to),
}
: undefined;
const interval: "day" | "week" = ["day", "week"].includes(
searchParams.interval ?? "",
)
? (searchParams.interval as "day" | "week")
: "week";
return (
<InAppWalletAnalytics
clientId={params.clientId}
interval={interval}
range={range as Range}
/>
);
}
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,21 @@
import { InAppWalletSettingsPage } from "components/embedded-wallets/Configure";
import { redirect } from "next/navigation";
import { getInAppWalletSupportedAPIKeys } from "../../getInAppWalletSupportedAPIKeys";
import { TRACKING_CATEGORY } from "../_constants";
export default async function Page({
params: { clientId },
}: { params: { clientId: string } }) {
const apiKeys = await getInAppWalletSupportedAPIKeys();
const apiKey = apiKeys.find((key) => key.key === clientId);
if (!apiKey) {
redirect("/dashboard/connect/in-app-wallets");
}
return (
<InAppWalletSettingsPage
apiKey={apiKey}
trackingCategory={TRACKING_CATEGORY}
/>
);
}
@@ -0,0 +1,86 @@
import { InAppWalletsSummary } from "components/embedded-wallets/Analytics/Summary";
import { getInAppWalletUsage } from "data/analytics/wallets/in-app";
import { redirect } from "next/navigation";
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
import { InAppWaletFooterSection } from "../../../../../team/[team_slug]/[project_slug]/connect/in-app-wallets/_components/footer";
import { PageHeader } from "../PageHeader";
import { getInAppWalletSupportedAPIKeys } from "../getInAppWalletSupportedAPIKeys";
import { InAppWalletsAPIKeysMenu } from "../inAppWalletsAPIKeysMenu";
import { Tabs } from "./_components/tabs";
export default async function Page(props: {
params: {
clientId: string;
};
searchParams: {
tab?: string;
};
children: React.ReactNode;
}) {
const authToken = getAuthToken();
const { clientId } = props.params;
if (!authToken) {
redirect(
`/login?next=${encodeURIComponent(`/dashboard/connect/in-app-wallets/${clientId}`)}`,
);
}
const apiKeys = await getInAppWalletSupportedAPIKeys();
const apiKey = apiKeys.find((key) => key.key === clientId);
if (!apiKey) {
redirect("/dashboard/connect/in-app-wallets");
}
const allTimeStatsPromise = getInAppWalletUsage({
clientId,
from: new Date(2022, 0, 1),
to: new Date(),
period: "all",
});
const monthlyStatsPromise = getInAppWalletUsage({
clientId,
from: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
to: new Date(),
period: "month",
});
const [allTimeStats, monthlyStats] = await Promise.all([
allTimeStatsPromise,
monthlyStatsPromise,
]).catch(() => [null, null]);
return (
<div>
{/* header */}
<div className="flex flex-col gap-4 lg:flex-row lg:justify-between">
<PageHeader />
<div>
<InAppWalletsAPIKeysMenu
apiKeys={apiKeys.map((x) => ({
name: x.name,
key: x.key,
}))}
selectedAPIKey={apiKey}
/>
</div>
</div>
<div className="h-8" />
<InAppWalletsSummary
allTimeStats={allTimeStats || []}
monthlyStats={monthlyStats || []}
/>
<div className="h-8" />
<Tabs clientId={clientId} />
<div className="h-8" />
{props.children}
<div className="h-16" />
<InAppWaletFooterSection trackingCategory="embedded-wallet" />
</div>
);
}
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -1,9 +1,4 @@
import { redirect } from "next/navigation";
import { EmbeddedWallets } from "../../../../../../components/embedded-wallets";
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
import { PageHeader } from "../PageHeader";
import { getInAppWalletSupportedAPIKeys } from "../getInAppWalletSupportedAPIKeys";
import { InAppWalletsAPIKeysMenu } from "../inAppWalletsAPIKeysMenu";
export default async function Page(props: {
params: {
@@ -13,44 +8,7 @@ export default async function Page(props: {
tab?: string;
};
}) {
const authToken = getAuthToken();
const { clientId } = props.params;
if (!authToken) {
redirect(
`/login?next=${encodeURIComponent(`/dashboard/connect/in-app-wallets/${clientId}`)}`,
);
}
const apiKeys = await getInAppWalletSupportedAPIKeys();
const apiKey = apiKeys.find((key) => key.key === clientId);
if (!apiKey) {
redirect("/dashboard/connect/in-app-wallets");
}
return (
<div>
{/* header */}
<div className="flex flex-col gap-4 lg:flex-row lg:justify-between">
<PageHeader />
<div>
<InAppWalletsAPIKeysMenu
apiKeys={apiKeys.map((x) => ({
name: x.name,
key: x.key,
}))}
selectedAPIKey={apiKey}
/>
</div>
</div>
<div className="h-8" />
<EmbeddedWallets
apiKey={apiKey}
trackingCategory="embedded-wallet"
defaultTab={props.searchParams.tab === "1" ? 1 : 0}
/>
</div>
);
redirect(`/dashboard/connect/in-app-wallets/${clientId}/analytics`);
}
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,24 @@
import { InAppWalletUsersPageContent } from "components/embedded-wallets/Users";
import { redirect } from "next/navigation";
import { getInAppWalletSupportedAPIKeys } from "../../getInAppWalletSupportedAPIKeys";
import { TRACKING_CATEGORY } from "../_constants";
export default async function Page(props: {
params: {
clientId: string;
};
}) {
const { clientId } = props.params;
const apiKeys = await getInAppWalletSupportedAPIKeys();
const apiKey = apiKeys.find((key) => key.key === clientId);
if (!apiKey) {
redirect("/dashboard/connect/in-app-wallets");
}
return (
<InAppWalletUsersPageContent
clientId={apiKey.key}
trackingCategory={TRACKING_CATEGORY}
/>
);
}
@@ -1,17 +0,0 @@
import { AnalyticsCallout } from "../../../../team/[team_slug]/[project_slug]/connect/in-app-wallets/_components/AnalyticsCallout";
import { InAppWaletFooterSection } from "../../../../team/[team_slug]/[project_slug]/connect/in-app-wallets/_components/footer";
export default function Layout(props: {
children: React.ReactNode;
}) {
return (
<div>
{props.children}
<div className="h-16" />
{/* Footer */}
<AnalyticsCallout trackingCategory="embedded-wallet" />
<div className="h-5" />
<InAppWaletFooterSection trackingCategory="embedded-wallet" />
</div>
);
}
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation";
import { NoApiKeys } from "../../../../../components/settings/ApiKeys/NoApiKeys";
import { getAuthToken } from "../../../../api/lib/getAuthToken";
import { InAppWaletFooterSection } from "../../../../team/[team_slug]/[project_slug]/connect/in-app-wallets/_components/footer";
import { PageHeader } from "./PageHeader";
import { getInAppWalletSupportedAPIKeys } from "./getInAppWalletSupportedAPIKeys";
@@ -25,6 +26,8 @@ export default async function Page() {
<PageHeader />
<div className="h-8" />
<NoApiKeys service="in-app wallets" />
<div className="h-16" />
<InAppWaletFooterSection trackingCategory="embedded-wallet" />
</div>
);
}
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -1,7 +1,6 @@
"use client";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -47,6 +46,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { shortenString } from "utils/usedapp-external";
import { z } from "zod";
import GenericLoadingPage from "../loading";
type Webhook = {
url: string;
@@ -79,7 +79,7 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
});
if (webhooksQuery.isPending) {
return <Spinner className="mx-auto size-8" />;
return <GenericLoadingPage />;
}
if (!webhooksQuery.data?.length) {
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -1,7 +1,9 @@
/* eslint-disable @next/next/no-img-element */
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { ImageResponse } from "next/og";
import { isAddress } from "thirdweb";
import { download } from "thirdweb/storage";
import { shortenAddress } from "thirdweb/utils";
const OgBrandIcon: React.FC = () => (
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
@@ -255,7 +257,9 @@ export async function publishedContractOGImageTemplate(params: {
)}
<h2 tw="text-2xl text-white font-medium max-w-full">
{params.publisher}
{isAddress(params.publisher)
? shortenAddress(params.publisher)
: params.publisher}
</h2>
</div>
</div>
+1 -1
View File
@@ -27,7 +27,7 @@ export default function GlobalError({
}, [error]);
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body>
<h2>Something went wrong!</h2>
<button type="button" onClick={() => reset()}>
+1 -1
View File
@@ -55,7 +55,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
<PlausibleProvider
domain="thirdweb.com"
@@ -1,20 +1,21 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { AccountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { accountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { Billing } from "components/settings/Account/Billing";
export const SettingsBillingPage = (props: {
teamId: string | undefined;
}) => {
const meQuery = useAccount({
refetchInterval: (query) =>
[
AccountStatus.InvalidPayment,
AccountStatus.InvalidPaymentMethod,
].includes(query.state?.status as AccountStatus)
? 1000
: false,
refetchInterval: (query) => {
const status = query.state?.status as string;
const isInvalidPayment =
status === accountStatus.invalidPayment ||
status === accountStatus.invalidPaymentMethod;
return isInvalidPayment ? 1000 : false;
},
});
const { data: account } = meQuery;
@@ -4,8 +4,8 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import {
AccountStatus,
type ApiKeyService,
accountStatus,
useAccount,
} from "@3rdweb-sdk/react/hooks/useApi";
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
@@ -40,7 +40,7 @@ export function AccountAbstractionPage(props: {
return apiKeyServices.find(
(s) =>
accountQuery.data.status !== AccountStatus.ValidPayment &&
accountQuery.data.status !== accountStatus.validPayment &&
s.name === "bundler",
);
}, [apiKeyServices, accountQuery.data]);
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -1,15 +1,15 @@
"use client";
import {
getInAppWalletUsage,
useUserOpUsageAggregate,
useWalletUsageAggregate,
useWalletUsagePeriod,
} from "@3rdweb-sdk/react/hooks/useApi";
import { useQuery } from "@tanstack/react-query";
import {
DateRangeSelector,
type Range,
getLastNDaysRange,
} from "components/analytics/date-range-selector";
import { IntervalSelector } from "components/analytics/interval-selector";
import { differenceInDays } from "date-fns";
import { useState } from "react";
import { ConnectAnalyticsDashboardUI } from "./ConnectAnalyticsDashboardUI";
@@ -45,31 +45,42 @@ export function ConnectAnalyticsDashboard(props: {
clientId: props.clientId,
});
const inAppAggregateQuery = useQuery({
queryKey: ["in-app-usage-aggregate", props.clientId],
queryFn: async () => {
const [allTimeStats, monthlyStats] = await Promise.all([
getInAppWalletUsage({
clientId: props.clientId,
from: new Date(2022, 0, 1),
to: new Date(),
period: "all",
}),
getInAppWalletUsage({
clientId: props.clientId,
from: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
to: new Date(),
period: "month",
}),
]);
return { allTimeStats, monthlyStats };
},
});
return (
<div>
<div className="flex gap-3">
<DateRangeSelector
range={range}
setRange={(newRange) => {
setRange(newRange);
const days = differenceInDays(newRange.to, newRange.from);
setIntervalType(days > 30 ? "week" : "day");
}}
/>
<IntervalSelector
intervalType={intervalType}
setIntervalType={setIntervalType}
/>
</div>
<div className="h-4" />
<ConnectAnalyticsDashboardUI
walletUsage={walletUsageQuery.data || []}
aggregateWalletUsage={walletUsageAggregateQuery.data || []}
aggregateUserOpUsageQuery={userOpAggregateQuery.data}
connectLayoutSlug={props.connectLayoutSlug}
inAppAggregateQuery={inAppAggregateQuery.data}
isPending={
walletUsageQuery.isPending || walletUsageAggregateQuery.isPending
}
range={range}
setRange={setRange}
intervalType={intervalType}
setIntervalType={setIntervalType}
/>
</div>
);
@@ -1,10 +1,19 @@
import { Button } from "@/components/ui/button";
import type { UserOpStats, WalletStats } from "@3rdweb-sdk/react/hooks/useApi";
import type {
InAppWalletStats,
UserOpStats,
WalletStats,
} from "@3rdweb-sdk/react/hooks/useApi";
import type { Range } from "components/analytics/date-range-selector";
import { Stat } from "components/analytics/stat";
import { InAppWalletsSummary } from "components/embedded-wallets/Analytics/Summary";
import { AccountAbstractionSummary } from "components/smart-wallets/AccountAbstractionAnalytics/AccountAbstractionSummary";
import { CableIcon, WalletCardsIcon } from "lucide-react";
import { differenceInDays } from "date-fns";
import { ArrowRightIcon, CableIcon, WalletCardsIcon } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { DateRangeSelector } from "../../../../../../components/analytics/date-range-selector";
import { IntervalSelector } from "../../../../../../components/analytics/interval-selector";
import { DailyConnectionsChartCard } from "./_components/DailyConnectionsChartCard";
import { WalletConnectorsChartCard } from "./_components/WalletConnectorsChartCard";
import { WalletDistributionChartCard } from "./_components/WalletDistributionChartCard";
@@ -15,6 +24,14 @@ export function ConnectAnalyticsDashboardUI(props: {
aggregateUserOpUsageQuery?: UserOpStats;
connectLayoutSlug: string;
isPending: boolean;
inAppAggregateQuery?: {
allTimeStats: InAppWalletStats[];
monthlyStats: InAppWalletStats[];
};
setRange: (range: Range) => void;
range: Range;
intervalType: "day" | "week";
setIntervalType: (intervalType: "day" | "week") => void;
}) {
const { totalWallets, uniqueWallets } = useMemo(() => {
return props.aggregateWalletUsage.reduce(
@@ -29,35 +46,77 @@ export function ConnectAnalyticsDashboardUI(props: {
return (
<div className="flex flex-col gap-4 lg:gap-6">
{/* Connections */}
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<Stat label="Connections" value={totalWallets} icon={CableIcon} />
<Stat
label="Unique Wallets"
value={uniqueWallets}
icon={WalletCardsIcon}
/>
<div>
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Connections
</h3>
<p className="text-muted-foreground">
View how many users are connecting to your app.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<Stat label="Connections" value={totalWallets} icon={CableIcon} />
<Stat
label="Unique Wallets"
value={uniqueWallets}
icon={WalletCardsIcon}
/>
</div>
</div>
<div className="h-4">
<div className="w-full border-muted border-b" />
<div className="w-full border-separate border-b" />
</div>
<div>
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="font-medium text-xl tracking-tight">
Account Abstraction
</h1>
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
In-App Wallets
</h3>
<p className="text-muted-foreground">
Sponsor and batch transactions for your users.
View metrics for your in-app wallets.
</p>
</div>
<div>
<Button asChild variant="primary" size="sm" className="w-full">
<Link href={`${props.connectLayoutSlug}/in-app-wallets`}>
In-App Wallet Metrics
<ArrowRightIcon className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
<InAppWalletsSummary
allTimeStats={props.inAppAggregateQuery?.allTimeStats}
monthlyStats={props.inAppAggregateQuery?.monthlyStats}
/>
</div>
<div className="h-4">
<div className="w-full border-separate border-b" />
</div>
<div>
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Account Abstraction
</h3>
<p className="text-muted-foreground">
View how smart wallets are used in your app.
</p>
</div>
<div>
<Button asChild variant="primary" size="sm" className="w-full">
<Link href={`${props.connectLayoutSlug}/account-abstraction`}>
View More Stats
Account Abstraction Metrics
<ArrowRightIcon className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
@@ -68,7 +127,22 @@ export function ConnectAnalyticsDashboardUI(props: {
</div>
<div className="h-4">
<div className="w-full border-muted border-b" />
<div className="w-full border-separate border-b" />
</div>
<div className="flex justify-end gap-3">
<DateRangeSelector
range={props.range}
setRange={(newRange) => {
props.setRange(newRange);
const days = differenceInDays(newRange.to, newRange.from);
props.setIntervalType(days > 30 ? "week" : "day");
}}
/>
<IntervalSelector
intervalType={props.intervalType}
setIntervalType={props.setIntervalType}
/>
</div>
<DailyConnectionsChartCard
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createUserOpStatsStub } from "components/smart-wallets/AccountAbstractionAnalytics/storyUtils";
import { getLastNDaysRange } from "../../../../../../../components/analytics/date-range-selector";
import { mobileViewport } from "../../../../../../../stories/utils";
import { ConnectAnalyticsDashboardUI } from "../ConnectAnalyticsDashboardUI";
import { createWalletStatsStub } from "./storyUtils";
@@ -30,6 +31,10 @@ function Component() {
return (
<div className="container max-w-[1150px] py-8">
<ConnectAnalyticsDashboardUI
setRange={() => {}}
range={getLastNDaysRange("last-120")}
intervalType="day"
setIntervalType={() => {}}
walletUsage={createWalletStatsStub(30)}
aggregateWalletUsage={createWalletStatsStub(30)}
aggregateUserOpUsageQuery={createUserOpStatsStub(1)?.[0]}
@@ -27,7 +27,15 @@ import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, LabelList, XAxis } from "recharts";
import {
Bar,
BarChart,
CartesianGrid,
LabelList,
XAxis,
YAxis,
} from "recharts";
import { formatTickerNumber } from "../../../../../../../lib/format-utils";
type ChartToShow = "uniqueWallets" | "totalWallets";
@@ -194,7 +202,21 @@ export function DailyConnectionsChartCard(props: {
axisLine={false}
/>
<ChartTooltip cursor={true} content={<ChartTooltipContent />} />
<YAxis
dataKey={(data) => data[chartToShow]}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatTickerNumber(value)}
/>
<ChartTooltip
cursor={true}
content={
<ChartTooltipContent
valueFormatter={(value) => formatTickerNumber(Number(value))}
/>
}
/>
<Bar
dataKey={chartToShow}
@@ -207,6 +229,7 @@ export function DailyConnectionsChartCard(props: {
offset={12}
className="invisible fill-foreground sm:visible"
fontSize={12}
formatter={(value: number) => formatTickerNumber(value)}
/>
)}
</Bar>
@@ -26,7 +26,11 @@ import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import {
formatTickerNumber,
formatWalletType,
} from "../../../../../../../lib/format-utils";
type ChartToShow = "uniqueWalletsConnected" | "totalConnections";
@@ -60,7 +64,8 @@ export function WalletConnectorsChartCard(props: {
// for each stat, add it in _chartDataMap
for (const stat of walletStats) {
const chartData = _chartDataMap.get(stat.date);
const { walletType } = stat;
const { walletType: rawWalletType } = stat;
const walletType = formatWalletType(rawWalletType);
// if no data for current day - create new entry
if (!chartData) {
@@ -213,7 +218,26 @@ export function WalletConnectorsChartCard(props: {
axisLine={false}
/>
<ChartTooltip cursor={true} content={<ChartTooltipContent />} />
<YAxis
dataKey={(data) =>
Object.entries(data)
.filter(([key]) => key !== "time")
.map(([, value]) => value)
.reduce((acc, current) => Number(acc) + Number(current), 0)
}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatTickerNumber(value)}
/>
<ChartTooltip
cursor={true}
content={
<ChartTooltipContent
valueFormatter={(value) => formatTickerNumber(Number(value))}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{uniqueWalletTypes.map((walletType) => {
return (
@@ -26,6 +26,10 @@ import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { DocLink } from "components/shared/DocLink";
import { useMemo, useState } from "react";
import { Pie, PieChart } from "recharts";
import {
formatTickerNumber,
formatWalletType,
} from "../../../../../../../lib/format-utils";
type ChartToShow = "totalConnections" | "uniqueWalletsConnected";
@@ -63,7 +67,8 @@ export function WalletDistributionChartCard(props: {
let _uniqueConnections = 0;
for (const stat of walletStats) {
const { walletType } = stat;
const { walletType: rawWalletType } = stat;
const walletType = formatWalletType(rawWalletType);
const chartData = _chartDataMap.get(walletType);
_totalConnections += stat.totalConnections;
@@ -235,7 +240,7 @@ export function WalletDistributionChartCard(props: {
? uniqueConnections
: totalConnections;
const percentageValue = ((v / sumValue) * 100).toFixed(2);
return `${percentageValue}% - ${v}`;
return `${percentageValue}% - ${formatTickerNumber(v)}`;
}
}}
/>
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -1,79 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { useAccountUsage } from "@3rdweb-sdk/react/hooks/useApi";
import { UsageCard } from "components/settings/Account/UsageCard";
import { ArrowRightIcon } from "lucide-react";
import { useMemo } from "react";
import { toNumber, toPercent } from "utils/number";
type AnalyticsCalloutProps = {
trackingCategory: string;
};
export const AnalyticsCallout: React.FC<AnalyticsCalloutProps> = ({
trackingCategory,
}) => {
const usageQuery = useAccountUsage();
const walletsMetrics = useMemo(() => {
if (!usageQuery?.data) {
return undefined;
}
const usageData = usageQuery.data;
const numOfWallets = usageData.usage.embeddedWallets.countWalletAddresses;
const limitWallets = usageData.limits.embeddedWallets;
const percent = toPercent(numOfWallets, limitWallets);
return {
total: `${toNumber(numOfWallets)} / ${toNumber(
limitWallets,
)} (${percent}%)`,
progress: percent,
...(usageData.billableUsd.embeddedWallets > 0
? {
overage: usageData.billableUsd.embeddedWallets,
}
: {}),
};
}, [usageQuery]);
return (
<div className="flex flex-col gap-6 rounded-lg border border-border bg-muted/50 p-4 md:px-10 md:py-12 lg:flex-row lg:justify-between">
{/* Left */}
<div>
<p className="mb-3 text-muted-foreground text-xs">Analytics</p>
<h2 className="mb-5 max-w-[500px] font-semibold text-2xl tracking-tight">
View more insights about how users are interacting with your
application
</h2>
<Button asChild variant="outline" size="sm">
<TrackedLinkTW
href="/dashboard/connect/analytics"
category={trackingCategory}
label="view-analytics"
className="mt-auto min-w-[150px] gap-2"
>
View Analytics
<ArrowRightIcon className="size-4" />
</TrackedLinkTW>
</Button>
</div>
{/* Right */}
<div className="min-w-[280px]">
{walletsMetrics && (
<UsageCard
{...walletsMetrics}
name="Monthly Active Users"
tooltip="Email wallet (with managed recovery code) usage is calculated by monthly active wallets (i.e. active as defined by at least 1 user log-in via email or social within the billing period month)."
/>
)}
</div>
</div>
);
};
@@ -0,0 +1,51 @@
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { InAppWalletsSummary } from "components/embedded-wallets/Analytics/Summary";
import { getInAppWalletUsage } from "data/analytics/wallets/in-app";
import { TRACKING_CATEGORY } from "../_constants";
export async function InAppWalletsHeader({ clientId }: { clientId: string }) {
const allTimeStatsPromise = getInAppWalletUsage({
clientId,
from: new Date(2022, 0, 1),
to: new Date(),
period: "all",
});
const monthlyStatsPromise = getInAppWalletUsage({
clientId,
from: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
to: new Date(),
period: "month",
});
const [allTimeStats, monthlyStats] = await Promise.all([
allTimeStatsPromise,
monthlyStatsPromise,
]).catch(() => [null, null]);
return (
<div>
<h1 className="mb-3 font-semibold text-2xl tracking-tight md:text-3xl">
In-App Wallets
</h1>
<p className="mt-3 mb-7 max-w-[700px] text-muted-foreground">
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
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>
</p>
<InAppWalletsSummary
allTimeStats={allTimeStats || []}
monthlyStats={monthlyStats || []}
/>
</div>
);
}
@@ -0,0 +1,35 @@
"use client";
import { TabLinks } from "@/components/ui/tabs";
import { usePathname } from "next/navigation";
export function Tabs({
team_slug,
project_slug,
}: { team_slug: string; project_slug: string }) {
const path = usePathname();
return (
<TabLinks
links={[
{
name: "Analytics",
href: `/team/${team_slug}/${project_slug}/connect/in-app-wallets/analytics`,
isActive: path?.endsWith("/analytics") || false,
isDisabled: false,
},
{
name: "Users",
href: `/team/${team_slug}/${project_slug}/connect/in-app-wallets/users`,
isActive: path?.endsWith("/users") || false,
isDisabled: false,
},
{
name: "Configuration",
href: `/team/${team_slug}/${project_slug}/connect/in-app-wallets/config`,
isActive: path?.endsWith("/config") || false,
isDisabled: false,
},
]}
/>
);
}
@@ -0,0 +1 @@
export const TRACKING_CATEGORY = "team/in-app-wallets";
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,38 @@
import type { Range } from "components/analytics/date-range-selector";
import { InAppWalletAnalytics } from "components/embedded-wallets/Analytics";
export default function Page({
params,
searchParams,
}: {
params: { team_slug: string; project_slug: string };
searchParams: {
from?: string;
to?: string;
type?: string;
interval?: string;
};
}) {
const range =
searchParams.from && searchParams.to
? {
type: searchParams.type ?? "last-120",
from: new Date(searchParams.from),
to: new Date(searchParams.to),
}
: undefined;
const interval: "day" | "week" = ["day", "week"].includes(
searchParams.interval ?? "",
)
? (searchParams.interval as "day" | "week")
: "week";
return (
<InAppWalletAnalytics
clientId={params.project_slug}
interval={interval}
range={range as Range}
/>
);
}
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,30 @@
import { getProject } from "@/api/projects";
import { getAPIKeyForProjectId } from "app/api/lib/getAPIKeys";
import { notFound } from "next/navigation";
import { InAppWalletSettingsPage } from "../../../../../../../components/embedded-wallets/Configure";
import { TRACKING_CATEGORY } from "../_constants";
export default async function Page({
params,
}: { params: { team_slug: string; project_slug: string } }) {
const project = await getProject(params.team_slug, params.project_slug);
if (!project) {
notFound();
}
const apiKey = await getAPIKeyForProjectId(project.id);
if (!apiKey) {
notFound();
}
return (
<>
<InAppWalletSettingsPage
apiKey={apiKey}
trackingCategory={TRACKING_CATEGORY}
/>
</>
);
}
@@ -0,0 +1,49 @@
import { getProject } from "@/api/projects";
import { getAPIKeyForProjectId } from "app/api/lib/getAPIKeys";
import { notFound } from "next/navigation";
import { InAppWaletFooterSection } from "./_components/footer";
import { InAppWalletsHeader } from "./_components/header";
import { Tabs } from "./_components/tabs";
import { TRACKING_CATEGORY } from "./_constants";
export default async function Layout(props: {
params: {
team_slug: string;
project_slug: string;
};
children: React.ReactNode;
}) {
const project = await getProject(
props.params.team_slug,
props.params.project_slug,
);
if (!project) {
notFound();
}
const apiKey = await getAPIKeyForProjectId(project.id);
if (!apiKey) {
notFound();
}
return (
<div>
<InAppWalletsHeader clientId={apiKey.key} />
<div className="h-8" />
<Tabs
team_slug={props.params.team_slug}
project_slug={props.params.project_slug}
/>
<div className="h-8" />
{props.children}
<div className="h-8" />
<InAppWaletFooterSection trackingCategory={TRACKING_CATEGORY} />
</div>
);
}
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -1,9 +1,4 @@
import { getProject } from "@/api/projects";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { notFound } from "next/navigation";
import { InAppWalletUsersPageContent } from "../../../../../../components/embedded-wallets/Users";
import { AnalyticsCallout } from "./_components/AnalyticsCallout";
import { InAppWaletFooterSection } from "./_components/footer";
import { redirect } from "next/navigation";
export default async function Page(props: {
params: {
@@ -11,48 +6,8 @@ export default async function Page(props: {
project_slug: string;
};
}) {
const project = await getProject(
props.params.team_slug,
props.params.project_slug,
);
if (!project) {
notFound();
}
const TRACKING_CATEGORY = "team/in-app-wallets";
return (
<div>
<h1 className="mb-3 font-semibold text-2xl tracking-tight md:text-3xl">
In-App Wallets
</h1>
<p className="mt-3 mb-7 max-w-[700px] text-muted-foreground">
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
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>
</p>
<InAppWalletUsersPageContent
clientId={project.publishableKey}
trackingCategory={TRACKING_CATEGORY}
/>
<div className="h-16" />
<AnalyticsCallout trackingCategory={TRACKING_CATEGORY} />
<div className="h-5" />
<InAppWaletFooterSection trackingCategory={TRACKING_CATEGORY} />
</div>
// Default to the users tab
redirect(
`/team/${props.params.team_slug}/${props.params.project_slug}/connect/in-app-wallets/analytics`,
);
}
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,15 @@
import { InAppWalletUsersPageContent } from "components/embedded-wallets/Users";
import { TRACKING_CATEGORY } from "../_constants";
export default function Page({
params,
}: { params: { team_slug: string; project_slug: string } }) {
return (
<>
<InAppWalletUsersPageContent
clientId={params.project_slug}
trackingCategory={TRACKING_CATEGORY}
/>
</>
);
}
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -0,0 +1,3 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -54,7 +54,7 @@ export const PublisherHeader: React.FC<PublisherHeaderProps> = ({
>
<PublisherAvatar
alt="Publisher avatar"
boxSize={14}
className="size-14"
address={ensQuery.data?.ensName || wallet}
/>
</Link>
@@ -0,0 +1,238 @@
"use client";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { InAppWalletStats } from "@3rdweb-sdk/react/hooks/useApi";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { formatTickerNumber } from "../../../lib/format-utils";
type ChartData = Record<string, number> & {
time: string; // human readable date
};
export function InAppWalletUsersChartCard(props: {
inAppWalletStats: InAppWalletStats[];
isPending: boolean;
}) {
const { inAppWalletStats } = props;
const topChainsToShow = 10;
const { chartConfig, chartData } = useMemo(() => {
const _chartConfig: ChartConfig = {};
const _chartDataMap: Map<string, ChartData> = new Map();
const authMethodToVolumeMap: Map<string, number> = new Map();
// for each stat, add it in _chartDataMap
for (const stat of inAppWalletStats) {
const chartData = _chartDataMap.get(stat.date);
const { authenticationMethod } = stat;
// if no data for current day - create new entry
if (!chartData && stat.uniqueWalletsConnected > 0) {
_chartDataMap.set(stat.date, {
time: format(new Date(stat.date), "MMM dd"),
[authenticationMethod || "Unknown"]: stat.uniqueWalletsConnected,
} as ChartData);
} else if (chartData) {
chartData[authenticationMethod || "Unknown"] =
(chartData[authenticationMethod || "Unknown"] || 0) +
stat.uniqueWalletsConnected;
}
authMethodToVolumeMap.set(
authenticationMethod || "Unknown",
stat.uniqueWalletsConnected +
(authMethodToVolumeMap.get(authenticationMethod || "Unknown") || 0),
);
}
const authMethodsSorted = Array.from(authMethodToVolumeMap.entries())
.sort((a, b) => b[1] - a[1])
.map((w) => w[0]);
const authMethodsToShow = authMethodsSorted.slice(0, topChainsToShow);
const authMethodsAsOther = authMethodsSorted.slice(topChainsToShow);
// replace chainIdsToTagAsOther chainId with "other"
for (const data of _chartDataMap.values()) {
for (const authMethod in data) {
if (authMethodsAsOther.includes(authMethod)) {
data.others = (data.others || 0) + (data[authMethod] || 0);
delete data[authMethod];
}
}
}
authMethodsToShow.forEach((walletType, i) => {
_chartConfig[walletType] = {
label: authMethodsToShow[i],
color: `hsl(var(--chart-${(i % 10) + 1}))`,
};
});
// Add Other
authMethodsToShow.push("others");
_chartConfig.others = {
label: "Others",
color: "hsl(var(--muted-foreground))",
};
return {
chartData: Array.from(_chartDataMap.values()).sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
),
chartConfig: _chartConfig,
};
}, [inAppWalletStats]);
const uniqueAuthMethods = Object.keys(chartConfig);
const disableActions =
props.isPending ||
chartData.length === 0 ||
chartData.every((data) => data.sponsoredUsd === 0);
return (
<div className="relative w-full rounded-lg border border-border bg-muted/50 p-4 md:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Unique Users
</h3>
<p className="mb-3 text-muted-foreground text-sm">
The total number of active in-app wallet users on your project.
</p>
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<ExportToCSVButton
className="bg-background"
fileName="Connect Wallets"
disabled={disableActions}
getData={async () => {
// Shows the number of each type of wallet connected on all dates
const header = ["Date", ...uniqueAuthMethods];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
/>
</div>
{/* Chart */}
<ChartContainer
config={chartConfig}
className="h-[250px] w-full md:h-[350px]"
>
{props.isPending ? (
<LoadingChartState />
) : chartData.length === 0 ||
chartData.every((data) => data.sponsoredUsd === 0) ? (
<EmptyChartState>
<div className="flex flex-col items-center justify-center">
<span className="mb-6 text-lg">
Connect users to your app with social logins
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
label="React Native"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unity/v5/wallets/in-app-wallet"
label="Unity"
icon={UnityIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unreal-engine/getting-started"
label="Unreal Engine"
icon={UnrealIcon}
/>
</div>
</div>
</EmptyChartState>
) : (
<BarChart
accessibilityLayer
data={chartData}
margin={{
top: 20,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<YAxis
dataKey={(data) =>
Object.entries(data)
.filter(([key]) => key !== "time")
.map(([, value]) => value)
.reduce((acc, current) => Number(acc) + Number(current), 0)
}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatTickerNumber(value)}
/>
<ChartTooltip
cursor={true}
content={
<ChartTooltipContent
valueFormatter={(value) => formatTickerNumber(Number(value))}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{uniqueAuthMethods.map((authMethod) => {
return (
<Bar
key={authMethod}
dataKey={authMethod}
fill={chartConfig[authMethod]?.color}
radius={4}
stackId="a"
strokeWidth={1.5}
className="stroke-muted"
/>
);
})}
</BarChart>
)}
</ChartContainer>
</div>
);
}
@@ -0,0 +1,42 @@
"use client";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { DateRangeSelector } from "components/analytics/date-range-selector";
import type { Range } from "components/analytics/date-range-selector";
import { IntervalSelector } from "components/analytics/interval-selector";
import { differenceInDays } from "date-fns";
import { usePathname, useSearchParams } from "next/navigation";
export function RangeSelector({
range,
interval,
}: { range: Range; interval: "day" | "week" }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useDashboardRouter();
return (
<div className="flex justify-end gap-3">
<DateRangeSelector
range={range}
setRange={(newRange) => {
const days = differenceInDays(newRange.to, newRange.from);
const interval = days > 30 ? "week" : "day";
const newSearchParams = new URLSearchParams(searchParams || {});
newSearchParams.set("from", newRange.from.toISOString());
newSearchParams.set("to", newRange.to.toISOString());
newSearchParams.set("type", newRange.type);
newSearchParams.set("interval", interval);
router.push(`${pathname}?${newSearchParams.toString()}`);
}}
/>
<IntervalSelector
intervalType={interval}
setIntervalType={(newInterval) => {
const newSearchParams = new URLSearchParams(searchParams || {});
newSearchParams.set("interval", newInterval);
router.push(`${pathname}?${newSearchParams.toString()}`);
}}
/>
</div>
);
}
@@ -0,0 +1,43 @@
import type { InAppWalletStats } from "@3rdweb-sdk/react/hooks/useApi";
import { Stat } from "components/analytics/stat";
import { ActivityIcon, UserIcon } from "lucide-react";
export function InAppWalletsSummary(props: {
allTimeStats: InAppWalletStats[] | undefined;
monthlyStats: InAppWalletStats[] | undefined;
}) {
const allTimeStats = props.allTimeStats?.reduce(
(acc, curr) => {
acc.uniqueWalletsConnected += curr.uniqueWalletsConnected;
return acc;
},
{
uniqueWalletsConnected: 0,
},
);
const monthlyStats = props.monthlyStats?.reduce(
(acc, curr) => {
acc.uniqueWalletsConnected += curr.uniqueWalletsConnected;
return acc;
},
{
uniqueWalletsConnected: 0,
},
);
return (
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<Stat
label="Total Users"
value={allTimeStats?.uniqueWalletsConnected || 0}
icon={ActivityIcon}
/>
<Stat
label="Monthly Active Users"
value={monthlyStats?.uniqueWalletsConnected || 0}
icon={UserIcon}
/>
</div>
);
}
@@ -0,0 +1,39 @@
import {
type Range,
getLastNDaysRange,
} from "components/analytics/date-range-selector";
import { getInAppWalletUsage } from "data/analytics/wallets/in-app";
import { InAppWalletUsersChartCard } from "./InAppWalletUsersChartCard";
import { RangeSelector } from "./RangeSelector";
export async function InAppWalletAnalytics({
clientId,
interval,
range,
}: { clientId: string; interval: "day" | "week"; range?: Range }) {
if (!range) {
range = getLastNDaysRange("last-120");
}
const stats = await getInAppWalletUsage({
clientId,
from: range.from,
to: range.to,
period: interval,
}).catch(() => null);
return (
<div>
<RangeSelector range={range} interval={interval} />
<div className="h-6" />
<div className="flex flex-col gap-4 lg:gap-6">
<InAppWalletUsersChartCard
inAppWalletStats={stats || []}
isPending={false}
/>
</div>
</div>
);
}
@@ -14,7 +14,6 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { cn } from "@/lib/utils";
@@ -230,7 +229,7 @@ export const InAppWalletSettingsUI: React.FC<
canEditAdvancedFeatures={canEditAdvancedFeatures}
/>
<Separator className="my-5" />
<div className="h-5" />
<AuthEndpointFields
form={form}
@@ -3,41 +3,54 @@
import { WalletAddress } from "@/components/blocks/wallet-address";
import { PaginationButtons } from "@/components/pagination-buttons";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
type EmbeddedWalletUser,
useEmbeddedWallets,
} from "@3rdweb-sdk/react/hooks/useEmbeddedWallets";
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useEmbeddedWallets } from "@3rdweb-sdk/react/hooks/useEmbeddedWallets";
import { Spinner } from "@chakra-ui/react";
import { createColumnHelper } from "@tanstack/react-table";
import { TWTable } from "components/shared/TWTable";
import { format } from "date-fns/format";
import Papa from "papaparse";
import { useCallback, useMemo, useState } from "react";
import { withinDays } from "utils/date-utils";
import { useCallback, useState } from "react";
import type { WalletUser } from "thirdweb/wallets";
const ACTIVE_THRESHOLD_DAYS = 30;
const columnHelper = createColumnHelper<EmbeddedWalletUser>();
const getUserIdentifier = (accounts: WalletUser["linkedAccounts"]) => {
const mainDetail = accounts[0]?.details;
return (
mainDetail?.email ??
mainDetail?.phone ??
mainDetail?.address ??
mainDetail?.id
);
};
const columnHelper = createColumnHelper<WalletUser>();
const columns = [
columnHelper.accessor("ews_authed_user", {
header: "Email",
columnHelper.accessor("linkedAccounts", {
header: "User Identifier",
enableColumnFilter: true,
cell: (cell) => (
<span className="text-sm">{cell.getValue()?.[0]?.email}</span>
),
cell: (cell) => {
const identifier = getUserIdentifier(cell.getValue());
return <span className="text-sm">{identifier}</span>;
},
id: "user_identifier",
}),
columnHelper.accessor("embedded_wallet", {
columnHelper.accessor("wallets", {
header: "Address",
cell: (cell) => {
const address = cell.getValue()?.[0]?.address;
const address = cell.getValue()[0]?.address;
return address ? <WalletAddress address={address} /> : null;
},
id: "address",
}),
columnHelper.accessor("created_at", {
columnHelper.accessor("wallets", {
header: "Created",
cell: (cell) => {
const value = cell.getValue();
const value = cell.getValue()[0]?.createdAt;
if (!value) {
return;
@@ -48,21 +61,36 @@ const columns = [
</span>
);
},
id: "created_at",
}),
columnHelper.accessor("last_accessed_at", {
header: "Last login",
columnHelper.accessor("linkedAccounts", {
header: "Login Methods",
cell: (cell) => {
const value = cell.getValue();
if (!value) {
return;
}
const loginMethodsDisplay = value.reduce((acc, curr) => {
if (acc.length === 2) {
acc.push("...");
}
if (acc.length < 2) {
acc.push(curr.type);
}
return acc;
}, [] as string[]);
const loginMethods = value.map((v) => v.type).join(", ");
return (
<span className="text-sm">
{format(new Date(value), "MMM dd, yyyy")}
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="text-sm">
{loginMethodsDisplay.join(", ")}
</TooltipTrigger>
<TooltipContent>
<span className="text-sm">{loginMethods}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
id: "login_methods",
}),
];
@@ -70,37 +98,27 @@ export const InAppWalletUsersPageContent = (props: {
clientId: string;
trackingCategory: string;
}) => {
const [onlyActive, setOnlyActive] = useState(true);
const walletsQuery = useEmbeddedWallets(props.clientId);
const wallets = walletsQuery?.data || [];
const activeWallets = useMemo(() => {
if (!wallets) {
return [];
}
return wallets.filter((w) => {
const lastAccessedAt = w.last_accessed_at;
return (
lastAccessedAt && withinDays(lastAccessedAt, ACTIVE_THRESHOLD_DAYS)
);
});
}, [wallets]);
const theWalletsWeWant = useMemo(() => {
return (onlyActive ? activeWallets : wallets) ?? [];
}, [activeWallets, onlyActive, wallets]);
const [activePage, setActivePage] = useState(1);
const walletsQuery = useEmbeddedWallets(props.clientId, activePage);
const { users: wallets, totalPages } = walletsQuery?.data || {
users: [],
totalPages: 1,
};
// TODO: Make the download CSV grab all data instead of merely what's on the current page
const downloadCSV = useCallback(() => {
if (theWalletsWeWant.length === 0) {
if (wallets.length === 0) {
return;
}
const csv = Papa.unparse(
theWalletsWeWant.map((row) => ({
email: row.ews_authed_user[0]?.email,
address: row.embedded_wallet?.[0]?.address || "",
created: format(new Date(row.created_at), "MMM dd, yyyy"),
last_login: format(new Date(row.last_accessed_at), "MMM dd, yyyy"),
wallets.map((row) => ({
user_identifier: getUserIdentifier(row.linkedAccounts),
address: row.wallets[0]?.address || "",
created: format(
new Date(row.wallets[0]?.createdAt ?? ""),
"MMM dd, yyyy",
),
login_methods: row.linkedAccounts.map((acc) => acc.type).join(", "),
})),
);
const csvUrl = URL.createObjectURL(
@@ -110,20 +128,7 @@ export const InAppWalletUsersPageContent = (props: {
tempLink.href = csvUrl;
tempLink.setAttribute("download", "download.csv");
tempLink.click();
}, [theWalletsWeWant]);
const [activePage, setActivePage] = useState(1);
const itemsPerPage = 20;
const totalPages =
theWalletsWeWant.length <= itemsPerPage
? 1
: Math.ceil(theWalletsWeWant.length / itemsPerPage);
const itemsToShow = useMemo(() => {
const startIndex = (activePage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return theWalletsWeWant.slice(startIndex, endIndex);
}, [activePage, theWalletsWeWant]);
}, [wallets]);
return (
<div>
@@ -131,7 +136,7 @@ export const InAppWalletUsersPageContent = (props: {
{/* Top section */}
<div className="flex items-center justify-between">
<Button
disabled={theWalletsWeWant.length === 0}
disabled={wallets.length === 0}
variant="outline"
onClick={downloadCSV}
size="sm"
@@ -140,20 +145,20 @@ export const InAppWalletUsersPageContent = (props: {
</Button>
<div className="flex items-center justify-end gap-2">
<p className="text-muted-foreground text-sm">
Active last {ACTIVE_THRESHOLD_DAYS} days
</p>
<Switch
checked={onlyActive}
onCheckedChange={(v) => setOnlyActive(v)}
disabled={wallets.length === 0}
/>
{walletsQuery.isPlaceholderData && (
<>
<Spinner className="size-4" />
<p className="text-muted-foreground text-sm">
Loading page {activePage} of {totalPages}
</p>
</>
)}
</div>
</div>
<TWTable
title="active in-app wallets"
data={itemsToShow}
title="in-app wallets"
data={wallets}
columns={columns}
isPending={walletsQuery.isPending}
isFetched={walletsQuery.isFetched}
@@ -1,81 +0,0 @@
"use client";
import { TabButtons } from "@/components/ui/tabs";
import type { ApiKey } from "@3rdweb-sdk/react/hooks/useApi";
import { useState } from "react";
import { InAppWalletSettingsPage } from "./Configure";
import { InAppWalletUsersPageContent } from "./Users";
interface EmbeddedWalletsProps {
apiKey: Pick<
ApiKey,
| "id"
| "name"
| "domains"
| "bundleIds"
| "services"
| "redirectUrls"
| "key"
>;
trackingCategory: string;
defaultTab: 0 | 1;
}
export const EmbeddedWallets: React.FC<EmbeddedWalletsProps> = ({
apiKey,
trackingCategory,
defaultTab,
}) => {
const [selectedTab, setSelectedTab] = useState<"users" | "config">(
defaultTab === 0 ? "users" : "config",
);
function updateSearchParams(value: string) {
const url = new URL(window.location.href);
url.searchParams.set("tab", value);
window.history.pushState(null, "", url.toString());
}
return (
<div>
<TabButtons
tabs={[
{
name: "Users",
onClick: () => {
setSelectedTab("users");
updateSearchParams("0");
},
isActive: selectedTab === "users",
isEnabled: true,
},
{
name: "Configuration",
onClick: () => {
setSelectedTab("config");
updateSearchParams("1");
},
isActive: selectedTab === "config",
isEnabled: true,
},
]}
/>
<div className="h-6" />
{selectedTab === "users" && (
<InAppWalletUsersPageContent
clientId={apiKey.key}
trackingCategory={trackingCategory}
/>
)}
{selectedTab === "config" && (
<InAppWalletSettingsPage
apiKey={apiKey}
trackingCategory={trackingCategory}
/>
)}
</div>
);
};
@@ -1,7 +1,7 @@
"use client";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { AccountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { accountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useState } from "react";
import { toast } from "sonner";
import { ConfirmEngineTierDialog } from "../../../components/engine/create/ConfirmEngineTierDialog";
@@ -79,7 +79,7 @@ export const CreateEnginePage = () => {
setIsConfirmationDialogOpen(false);
// If Payment is already setup, deploy the Engine
if (accountQuery.data?.status === AccountStatus.ValidPayment) {
if (accountQuery.data?.status === accountStatus.validPayment) {
await createEngineInstance(selectedTier);
} else {
trackEvent({
@@ -30,7 +30,7 @@ export const ContractPublisher: React.FC<ContractPublisherProps> = async ({
>
<PublisherAvatar
isPending={false}
boxSize={5}
className="size-5"
address={addressOrEns || ""}
/>
@@ -35,7 +35,7 @@ export const Judges = ({ TRACKING_CATEGORY }: JudgesProps) => {
];
return (
<div className="relative flex flex-col gap-8">
<div className="container relative flex max-w-[1200px] flex-col gap-8">
<Heading size="title.2xl">Judges</Heading>
<SimpleGrid
columns={{ base: 1, lg: 4 }}
@@ -46,10 +46,9 @@ export const Judges = ({ TRACKING_CATEGORY }: JudgesProps) => {
{judges.map((judge) => (
<Flex key={judge.name} flexDir="column" gap={2} alignItems="center">
<MaskedAvatar
boxSize={40}
src={judge.image}
alt={judge.name}
className="rounded-full"
className="size-52"
/>
<Heading size="title.sm" mt={4} textAlign="center">
{judge.name}
@@ -27,7 +27,7 @@ export const JudgesEarn = () => {
];
return (
<div className="relative flex flex-col gap-8">
<div className="container relative flex max-w-[1200px] flex-col gap-8">
<Heading size="title.2xl">Judges</Heading>
<SimpleGrid
columns={{ base: 1, lg: 4 }}
@@ -38,10 +38,9 @@ export const JudgesEarn = () => {
{judges.map((judge) => (
<Flex key={judge.name} flexDir="column" gap={2} alignItems="center">
<MaskedAvatar
boxSize={40}
src={judge.image}
alt={judge.name}
className="rounded-full"
className="size-52"
/>
<Heading size="title.sm" mt={4} textAlign="center">
{judge.name}
@@ -1,4 +1,4 @@
import { AccountPlan } from "@3rdweb-sdk/react/hooks/useApi";
import { type AccountPlan, accountPlan } from "@3rdweb-sdk/react/hooks/useApi";
import { Box, type CardProps, Flex } from "@chakra-ui/react";
import {
Badge,
@@ -133,7 +133,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
<FeatureItem key={Array.isArray(f) ? f[0] : f} text={f} />
))}
</Flex>
{name === AccountPlan.Growth && onDashboard ? (
{name === accountPlan.growth && onDashboard ? (
<UpgradeModal
name={name}
ctaProps={ctaProps}
@@ -1,4 +1,4 @@
import { AccountPlan } from "@3rdweb-sdk/react/hooks/useApi";
import { accountPlan } from "@3rdweb-sdk/react/hooks/useApi";
import { Box, Container, Flex, SimpleGrid } from "@chakra-ui/react";
import { Heading, Text, TrackedLink } from "tw-components";
import { CONTACT_US_URL } from "utils/pricing";
@@ -48,7 +48,7 @@ export const PricingSection: React.FC<PricingSectionProps> = ({
<SimpleGrid columns={{ base: 1, xl: 3 }} gap={{ base: 6, xl: 8 }}>
<PricingCard
name={AccountPlan.Free}
name={accountPlan.free}
ctaTitle="Get started for free"
ctaProps={{
category: trackingCategory,
@@ -61,7 +61,7 @@ export const PricingSection: React.FC<PricingSectionProps> = ({
ctaTitle={
canTrialGrowth ? "Claim your 1-month free" : "Get started"
}
name={AccountPlan.Growth}
name={accountPlan.growth}
ctaHint={
canTrialGrowth
? "Your free trial will end after 30 days."
@@ -82,7 +82,7 @@ export const PricingSection: React.FC<PricingSectionProps> = ({
/>
<PricingCard
name={AccountPlan.Pro}
name={accountPlan.pro}
ctaTitle="Contact us"
ctaProps={{
category: trackingCategory,
@@ -1,4 +1,8 @@
import { AccountPlan, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import {
type AccountPlan,
accountPlan,
useAccount,
} from "@3rdweb-sdk/react/hooks/useApi";
import {
Flex,
Modal,
@@ -44,7 +48,7 @@ export const UpgradeModal: React.FC<UpgradeModalProps> = ({
// FIXME: this needs to be re-worked
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (account.data?.plan === AccountPlan.Growth) {
if (account.data?.plan === accountPlan.growth) {
onClose();
}
}, [account.data?.plan, onClose]);
@@ -1,4 +1,4 @@
import { AccountPlan, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { accountPlan, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { Flex, FormControl, Input, Textarea } from "@chakra-ui/react";
import { Select as ChakraSelect } from "chakra-react-select";
import { ChakraNextImage } from "components/Image";
@@ -41,7 +41,7 @@ export const ApplyForOpCreditsForm: React.FC<ApplyForOpCreditsFormProps> = ({
firstname: "",
lastname: "",
thirdweb_account_id: account?.id || "",
plan_type: PlanToCreditsRecord[account?.plan || AccountPlan.Free].title,
plan_type: PlanToCreditsRecord[account?.plan || accountPlan.free].title,
email: account?.email || "",
company: "",
website: "",
@@ -208,6 +208,7 @@ export const ApplyForOpCreditsForm: React.FC<ApplyForOpCreditsFormProps> = ({
"Ancient8",
"Donatuz",
"Mantle",
"Soneium",
].map((chain) => ({
label: chain === "Optimism" ? "OP Mainnet" : chain,
value: chain,
@@ -1,6 +1,7 @@
import {
AccountPlan,
AccountStatus,
type AccountPlan,
accountPlan,
accountStatus,
useAccount,
} from "@3rdweb-sdk/react/hooks/useApi";
import {
@@ -32,13 +33,13 @@ export type CreditsRecord = {
};
export const PlanToCreditsRecord: Record<AccountPlan, CreditsRecord> = {
[AccountPlan.Free]: {
[accountPlan.free]: {
title: "Starter",
upTo: true,
credits: "$250",
color: "#3b394b",
},
[AccountPlan.Growth]: {
[accountPlan.growth]: {
title: "Growth",
upTo: true,
credits: "$2,500",
@@ -52,7 +53,7 @@ export const PlanToCreditsRecord: Record<AccountPlan, CreditsRecord> = {
ctaTitle: "Upgrade for $99",
ctaHref: "/dashboard/settings/billing",
},
[AccountPlan.Pro]: {
[accountPlan.pro]: {
title: "Pro",
credits: "$3,000+",
color: "#282B6F",
@@ -64,7 +65,7 @@ export const PlanToCreditsRecord: Record<AccountPlan, CreditsRecord> = {
ctaTitle: "Contact Us",
ctaHref: "https://meetings.hubspot.com/sales-thirdweb/thirdweb-pro",
},
[AccountPlan.Enterprise]: {
[accountPlan.enterprise]: {
title: "Enterprise",
credits: "Custom",
color: "#000000",
@@ -94,16 +95,16 @@ export const ApplyForOpCreditsModal: React.FC = () => {
const hasValidPayment = useMemo(() => {
return (
!!(account?.data?.status === AccountStatus.ValidPayment) ||
!!(account?.data?.status === accountStatus.validPayment) ||
hasAddedPaymentMethod
);
}, [account?.data?.status, hasAddedPaymentMethod]);
const isFreePlan = account.data?.plan === AccountPlan.Free;
const isProPlan = account.data?.plan === AccountPlan.Pro;
const isFreePlan = account.data?.plan === accountPlan.free;
const isProPlan = account.data?.plan === accountPlan.pro;
const creditsRecord =
PlanToCreditsRecord[account.data?.plan || AccountPlan.Free];
PlanToCreditsRecord[account.data?.plan || accountPlan.free];
return (
<>
@@ -193,11 +194,11 @@ export const ApplyForOpCreditsModal: React.FC = () => {
>
{isFreePlan && (
<PlanCard
creditsRecord={PlanToCreditsRecord[AccountPlan.Growth]}
creditsRecord={PlanToCreditsRecord[accountPlan.growth]}
/>
)}
<PlanCard
creditsRecord={PlanToCreditsRecord[AccountPlan.Pro]}
creditsRecord={PlanToCreditsRecord[accountPlan.pro]}
/>
</SimpleGrid>
</>
@@ -1,5 +1,6 @@
import {
AccountPlan,
type AccountPlan,
accountPlan,
useUpdateAccountPlan,
} from "@3rdweb-sdk/react/hooks/useApi";
import { PricingCard } from "components/homepage/sections/PricingCard";
@@ -24,7 +25,7 @@ const OnboardingChoosePlan: React.FC<OnboardingChoosePlanProps> = ({
});
// free is default, so no need to update account
if (plan === AccountPlan.Free) {
if (plan === accountPlan.free) {
trackEvent({
category: "account",
action: "choosePlan",
@@ -76,13 +77,13 @@ const OnboardingChoosePlan: React.FC<OnboardingChoosePlanProps> = ({
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
<PricingCard
size="sm"
name={AccountPlan.Free}
name={accountPlan.free}
ctaTitle="Get started for free"
ctaProps={{
category: "account",
onClick: (e) => {
e.preventDefault();
handleSave(AccountPlan.Free);
handleSave(accountPlan.free);
},
label: "freePlan",
href: "/",
@@ -93,7 +94,7 @@ const OnboardingChoosePlan: React.FC<OnboardingChoosePlanProps> = ({
<PricingCard
size="sm"
ctaTitle="Claim your 1-month free"
name={AccountPlan.Growth}
name={accountPlan.growth}
ctaHint="Your free trial will end after 30 days."
canTrialGrowth={true}
ctaProps={{
@@ -101,7 +102,7 @@ const OnboardingChoosePlan: React.FC<OnboardingChoosePlanProps> = ({
label: "growthPlan",
onClick: (e) => {
e.preventDefault();
handleSave(AccountPlan.Growth);
handleSave(accountPlan.growth);
},
href: "/",
variant: "solid",
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
import {
AccountStatus,
accountStatus,
useAccount,
useAccountCredits,
useApiKeys,
@@ -76,7 +76,7 @@ export const OnboardingSteps: React.FC<OnboardingStepsProps> = ({
);
const hasValidPayment = useMemo(() => {
return meQuery?.data?.status === AccountStatus.ValidPayment;
return meQuery?.data?.status === accountStatus.validPayment;
}, [meQuery?.data?.status]);
const hasApiKeys = useMemo(() => {
@@ -1,7 +1,7 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import {
type Account,
AccountStatus,
accountStatus,
useAccount,
} from "@3rdweb-sdk/react/hooks/useApi";
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
@@ -26,9 +26,9 @@ function Loading() {
const skipBilling = (account: Account) => {
return (
[AccountStatus.ValidPayment, AccountStatus.PaymentVerification].includes(
account.status,
) || account.onboardSkipped
account.status === accountStatus.validPayment ||
account.status === accountStatus.paymentVerification ||
account.onboardSkipped
);
};
@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import {
type Account,
AccountStatus,
accountStatus,
useCreateBillingSession,
} from "@3rdweb-sdk/react/hooks/useApi";
import { useMemo } from "react";
@@ -26,14 +26,14 @@ export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
const [buttonLabel, buttonText] = useMemo(() => {
switch (account.status) {
case AccountStatus.InvalidPayment:
case AccountStatus.ValidPayment: {
case accountStatus.invalidPayment:
case accountStatus.validPayment: {
return ["manage", "Manage billing"];
}
case AccountStatus.PaymentVerification: {
case accountStatus.paymentVerification: {
return ["verifyPaymentMethod", "Verify your payment method →"];
}
case AccountStatus.InvalidPaymentMethod: {
case accountStatus.invalidPaymentMethod: {
return ["addAnotherPayment", "Add another payment method →"];
}
default: {
@@ -1,4 +1,4 @@
import { AccountPlan } from "@3rdweb-sdk/react/hooks/useApi";
import { type AccountPlan, accountPlan } from "@3rdweb-sdk/react/hooks/useApi";
import { SimpleGrid } from "@chakra-ui/react";
import { PricingCard } from "components/homepage/sections/PricingCard";
import { useMemo } from "react";
@@ -25,15 +25,13 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
loading,
onSelect,
}) => {
const isPro = [AccountPlan.Pro, AccountPlan.Enterprise].includes(
plan as AccountPlan,
);
const isPro = plan === accountPlan.pro || plan === accountPlan.enterprise;
const freeCtaTitle = useMemo(() => {
if (!validPayment) {
return "Get started for free";
}
if (plan !== AccountPlan.Free) {
if (plan !== accountPlan.free) {
return "Downgrade";
}
@@ -51,7 +49,7 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
return "Contact us";
}
if (plan === AccountPlan.Free) {
if (plan === accountPlan.free) {
return canTrialGrowth ? trialTitle : "Upgrade";
}
@@ -65,14 +63,14 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
return (
<SimpleGrid columns={{ base: 1, xl: 3 }} gap={{ base: 6, xl: 8 }}>
<PricingCard
current={plan === AccountPlan.Free}
current={plan === accountPlan.free}
size="sm"
name={AccountPlan.Free}
name={accountPlan.free}
ctaTitle={freeCtaTitle}
ctaProps={{
onClick: (e) => {
e.preventDefault();
handleSelect(AccountPlan.Free);
handleSelect(accountPlan.free);
},
isLoading: loading,
isDisabled: loading || invalidPayment || paymentVerification,
@@ -85,18 +83,18 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
<PricingCard
activeTrialEndsAt={
plan === AccountPlan.Growth ? trialPeriodEndedAt : undefined
plan === accountPlan.growth ? trialPeriodEndedAt : undefined
}
current={plan === AccountPlan.Growth}
current={plan === accountPlan.growth}
size="sm"
name={AccountPlan.Growth}
name={accountPlan.growth}
ctaTitle={growthCtaTitle}
ctaHint="Your free trial will end after 30 days."
canTrialGrowth={canTrialGrowth}
ctaProps={{
onClick: (e) => {
e.preventDefault();
handleSelect(AccountPlan.Growth);
handleSelect(accountPlan.growth);
},
isLoading: loading,
isDisabled: loading || invalidPayment || paymentVerification,
@@ -112,7 +110,7 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
<PricingCard
current={isPro}
size="sm"
name={AccountPlan.Pro}
name={accountPlan.pro}
ctaTitle="Contact us"
ctaProps={{
category: "account",
@@ -2,7 +2,7 @@
import { NavLink } from "@/components/ui/NavLink";
import { Button } from "@/components/ui/button";
import { AccountPlan, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { accountPlan, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
import { usePathname } from "next/navigation";
@@ -16,7 +16,7 @@ export const UpgradeButton = () => {
meQuery.isPending ||
!meQuery.data ||
pathname?.startsWith("/dashboard/settings/billing") ||
meQuery.data?.plan !== AccountPlan.Free
meQuery.data?.plan !== accountPlan.free
) {
return null;
}
@@ -1,25 +1,25 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { cn } from "@/lib/utils";
import {
type Account,
AccountStatus,
type UsageBillableByService,
accountStatus,
useAccount,
useAccountUsage,
} from "@3rdweb-sdk/react/hooks/useApi";
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
import { useDisclosure } from "@chakra-ui/react";
import * as Sentry from "@sentry/nextjs";
import { OnboardingModal } from "components/onboarding/Modal";
import { format } from "date-fns";
import { useTrack } from "hooks/analytics/useTrack";
import { useLocalStorage } from "hooks/useLocalStorage";
import { ExternalLinkIcon, XIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
import { LazyOnboardingBilling } from "../../../../onboarding/LazyOnboardingBilling";
import { ManageBillingButton } from "../ManageButton";
import { RecurringPaymentFailureAlert } from "./RecurringPaymentFailureAlert";
@@ -45,11 +45,9 @@ export const BillingAlerts = (props: {
const usageQuery = useAccountUsage();
const meQuery = useAccount({
refetchInterval: (account) =>
[
AccountStatus.InvalidPayment,
AccountStatus.InvalidPaymentMethod,
AccountStatus.PaymentVerification,
].includes(account.state.data?.status as AccountStatus)
account.state.data?.status === accountStatus.invalidPayment ||
account.state.data?.status === accountStatus.invalidPaymentMethod ||
account.state.data?.status === accountStatus.paymentVerification
? 1000
: false,
});
@@ -112,7 +110,7 @@ export function BillingAlertsUI(props: {
const paymentFailureAlerts: AlertConditionType[] = [
{
shouldShowAlert:
dashboardAccount.status === AccountStatus.PaymentVerification,
dashboardAccount.status === accountStatus.paymentVerification,
key: "verifyPaymentAlert",
title: "Your payment method requires verification",
description:
@@ -122,7 +120,7 @@ export function BillingAlertsUI(props: {
},
{
shouldShowAlert:
dashboardAccount.status === AccountStatus.InvalidPaymentMethod,
dashboardAccount.status === accountStatus.invalidPaymentMethod,
key: "invalidPaymentMethodAlert",
title: "Your payment method is invalid",
description:
@@ -162,7 +160,7 @@ export function BillingAlertsUI(props: {
usage.storage.sumFileSizeBytes >= limits.storage;
const hasHardLimits =
dashboardAccount.status !== AccountStatus.ValidPayment;
dashboardAccount.status !== accountStatus.validPayment;
const isFreePlan = dashboardAccount.plan === "free";
const isGrowthPlan = dashboardAccount.plan === "growth";
const usageAlerts: AlertConditionType[] = [
@@ -301,7 +299,9 @@ export function BillingAlertsUI(props: {
}
return (
<div className={cn("flex flex-col gap-4", props.className)}>{alerts}</div>
<ErrorBoundary FallbackComponent={BillingAlertsErrorBoundary}>
<div className={cn("flex flex-col gap-4", props.className)}>{alerts}</div>
</ErrorBoundary>
);
}
@@ -331,16 +331,11 @@ const AddPaymentNotification: React.FC<AddPaymentNotificationProps> = ({
// TODO: We should find a way to move this deeper into the
// TODO: ManageBillingButton component and set an optional field to override
const [paymentMethodSaving, setPaymentMethodSaving] = useState(false);
const {
onOpen: onPaymentMethodOpen,
onClose: onPaymentMethodClose,
isOpen: isPaymentMethodOpen,
} = useDisclosure();
const [isPaymentMethodOpen, setIsPaymentMethodOpen] = useState(false);
const handlePaymentAdded = () => {
setPaymentMethodSaving(true);
onPaymentMethodClose();
setIsPaymentMethodOpen(false);
};
const isBilling = ctaHref === "/dashboard/settings/billing";
@@ -353,7 +348,7 @@ const AddPaymentNotification: React.FC<AddPaymentNotificationProps> = ({
<OnboardingModal isOpen={isPaymentMethodOpen}>
<LazyOnboardingBilling
onSave={handlePaymentAdded}
onCancel={onPaymentMethodClose}
onCancel={() => setIsPaymentMethodOpen(false)}
/>
</OnboardingModal>
@@ -361,13 +356,13 @@ const AddPaymentNotification: React.FC<AddPaymentNotificationProps> = ({
<AlertDescription>{description}</AlertDescription>
{showCTAs && (
<div className="mt-4 flex gap-2">
<div className="mt-4 flex flex-col gap-3 md:flex-row">
{isBilling ? (
<ManageBillingButton
account={dashboardAccount}
loading={paymentMethodSaving}
loadingText="Verifying payment method"
onClick={onPaymentMethodOpen}
onClick={() => setIsPaymentMethodOpen(true)}
/>
) : (
<Button variant="outline" asChild>
@@ -412,3 +407,17 @@ const AddPaymentNotification: React.FC<AddPaymentNotificationProps> = ({
</Alert>
);
};
function BillingAlertsErrorBoundary(errorProps: FallbackProps) {
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
Sentry.withScope((scope) => {
scope.setTag("component-crashed", "true");
scope.setTag("component-crashed-boundary", "BillingAlertsErrorBoundary");
scope.setLevel("fatal");
Sentry.captureException(errorProps.error);
});
}, [errorProps.error]);
return null;
}
@@ -1,12 +1,11 @@
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import { accountStatus } from "@3rdweb-sdk/react/hooks/useApi";
import type { Meta, StoryObj } from "@storybook/react";
import { BadgeContainer, mobileViewport } from "stories/utils";
import { ThirdwebProvider } from "thirdweb/react";
import { AccountStatus } from "../../../../../@3rdweb-sdk/react/hooks/useApi";
import {
createBillableServiceUsageDataStub,
createDashboardAccountStub,
} from "../../../../../stories/stubs";
import { BadgeContainer, mobileViewport } from "../../../../../stories/utils";
import { BillingAlertsUI } from "./Alert";
const meta = {
@@ -43,92 +42,88 @@ function tomorrow(d: Date) {
function Story() {
return (
<div className="py-4">
<ChakraProviderSetup>
<ThirdwebProvider>
<div className="container flex max-w-[1100px] flex-col gap-10 py-10">
<BadgeContainer label="#usage">
<BillingAlertsUI
dashboardAccount={createDashboardAccountStub("foo")}
usageData={createBillableServiceUsageDataStub()}
/>
</BadgeContainer>
<ThirdwebProvider>
<div className="container flex max-w-[1100px] flex-col gap-10 py-10">
<BadgeContainer label="#usage">
<BillingAlertsUI
dashboardAccount={createDashboardAccountStub("foo")}
usageData={createBillableServiceUsageDataStub()}
/>
</BadgeContainer>
<BadgeContainer label="#paymentVerification">
<BillingAlertsUI
dashboardAccount={createDashboardAccountStub("foo", {
status: AccountStatus.PaymentVerification,
stripePaymentActionUrl: "https://example.com",
})}
usageData={createBillableServiceUsageDataStub({
limits: {
embeddedWallets: 100,
storage: 100,
},
})}
/>
<BadgeContainer label="#paymentVerification">
<BillingAlertsUI
dashboardAccount={createDashboardAccountStub("foo", {
status: accountStatus.paymentVerification,
stripePaymentActionUrl: "https://example.com",
})}
usageData={createBillableServiceUsageDataStub({
limits: {
embeddedWallets: 100,
storage: 100,
},
})}
/>
</BadgeContainer>
<BadgeContainer label="#serviceCutoff">
<BillingAlertsUI
dashboardAccount={createDashboardAccountStub("foo", {
recurringPaymentFailures: [
{
subscriptionId: "s1",
subscriptionDescription: "s1 desc",
paymentFailureCode: "400",
serviceCutoffDate: yesterday(new Date()).toISOString(),
},
],
})}
usageData={createBillableServiceUsageDataStub({
limits: {
embeddedWallets: 100,
storage: 100,
},
})}
/>
</BadgeContainer>
<BadgeContainer label="#serviceCutoff">
<BillingAlertsUI
dashboardAccount={createDashboardAccountStub("foo", {
recurringPaymentFailures: [
{
subscriptionId: "s1",
subscriptionDescription: "s1 desc",
paymentFailureCode: "400",
serviceCutoffDate: yesterday(new Date()).toISOString(),
},
],
})}
usageData={createBillableServiceUsageDataStub({
limits: {
embeddedWallets: 100,
storage: 100,
},
})}
/>
</BadgeContainer>
<BadgeContainer label="#recurringPayment">
<BillingAlertsUI
dashboardAccount={createDashboardAccountStub("foo", {
recurringPaymentFailures: [
{
subscriptionId: "s1",
subscriptionDescription: "s1 desc",
paymentFailureCode: "400",
serviceCutoffDate: tomorrow(new Date()).toISOString(),
},
],
})}
usageData={createBillableServiceUsageDataStub({
limits: {
embeddedWallets: 100,
storage: 100,
},
})}
/>
</BadgeContainer>
<BadgeContainer label="#recurringPayment">
<BillingAlertsUI
dashboardAccount={createDashboardAccountStub("foo", {
recurringPaymentFailures: [
{
subscriptionId: "s1",
subscriptionDescription: "s1 desc",
paymentFailureCode: "400",
serviceCutoffDate: tomorrow(new Date()).toISOString(),
},
],
})}
usageData={createBillableServiceUsageDataStub({
limits: {
embeddedWallets: 100,
storage: 100,
},
})}
/>
</BadgeContainer>
<BadgeContainer label="usage + serviceCutoff">
<BillingAlertsUI
dashboardAccount={createDashboardAccountStub("foo", {
recurringPaymentFailures: [
{
subscriptionId: "s1",
subscriptionDescription: "s1 desc",
paymentFailureCode: "400",
serviceCutoffDate: yesterday(new Date()).toISOString(),
},
],
})}
usageData={createBillableServiceUsageDataStub()}
/>
</BadgeContainer>
</BadgeContainer>
</div>
</ThirdwebProvider>
</ChakraProviderSetup>
</div>
<BadgeContainer label="usage + serviceCutoff">
<BillingAlertsUI
dashboardAccount={createDashboardAccountStub("foo", {
recurringPaymentFailures: [
{
subscriptionId: "s1",
subscriptionDescription: "s1 desc",
paymentFailureCode: "400",
serviceCutoffDate: yesterday(new Date()).toISOString(),
},
],
})}
usageData={createBillableServiceUsageDataStub()}
/>
</BadgeContainer>
</div>
</ThirdwebProvider>
);
}
@@ -1,12 +1,11 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { type Account, AccountStatus } from "@3rdweb-sdk/react/hooks/useApi";
import { type Account, accountStatus } from "@3rdweb-sdk/react/hooks/useApi";
import { OnboardingModal } from "components/onboarding/Modal";
import { getRecurringPaymentFailureResponse } from "lib/billing";
import { ExternalLinkIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { Text } from "tw-components";
import { LazyOnboardingBilling } from "../../../../onboarding/LazyOnboardingBilling";
import { ManageBillingButton } from "../ManageButton";
@@ -70,23 +69,21 @@ export const RecurringPaymentFailureAlert: React.FC<
<ul className="list-disc pl-3.5">
{affectedServices.map((service) => (
<li key={service}>
<Text>{service}</Text>
<span>{service}</span>
</li>
))}
</ul>
</div>
)}
<div className="flex gap-2">
<div className="flex flex-col gap-3 md:flex-row">
<ManageBillingButton
account={dashboardAccount}
loading={paymentMethodSaving}
loadingText="Verifying payment method"
onClick={
[
AccountStatus.ValidPayment,
AccountStatus.InvalidPayment,
].includes(dashboardAccount.status)
dashboardAccount.status === accountStatus.validPayment ||
dashboardAccount.status === accountStatus.invalidPayment
? undefined
: () => setIsPaymentMethodOpen(true)
}
@@ -1,8 +1,9 @@
"use client";
import {
type Account,
AccountPlan,
AccountStatus,
type AccountPlan,
accountPlan,
accountStatus,
useUpdateAccountPlan,
} from "@3rdweb-sdk/react/hooks/useApi";
import { Flex } from "@chakra-ui/react";
@@ -30,7 +31,7 @@ interface BillingProps {
export const Billing: React.FC<BillingProps> = ({ account, teamId }) => {
const updatePlanMutation = useUpdateAccountPlan(
account?.plan === AccountPlan.Free,
account?.plan === accountPlan.free,
);
const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
const [paymentMethodSaving, setPaymentMethodSaving] = useState(false);
@@ -52,10 +53,10 @@ export const Billing: React.FC<BillingProps> = ({ account, teamId }) => {
"Failed to change your billing plan.",
);
const validPayment = account.status === AccountStatus.ValidPayment;
const validPayment = account.status === accountStatus.validPayment;
const paymentVerification =
account.status === AccountStatus.PaymentVerification;
const invalidPayment = account.status === AccountStatus.InvalidPayment;
account.status === accountStatus.paymentVerification;
const invalidPayment = account.status === accountStatus.invalidPayment;
const handleUpdatePlan = useCallback(
(plan: AccountPlan, feedback?: string) => {
@@ -114,7 +115,7 @@ export const Billing: React.FC<BillingProps> = ({ account, teamId }) => {
return;
}
// downgrade from Growth to Free
if (plan === AccountPlan.Free || account.plan === AccountPlan.Growth) {
if (plan === accountPlan.free || account.plan === accountPlan.growth) {
setDowngradePlan(plan);
} else {
handleUpdatePlan(plan);
@@ -211,8 +212,8 @@ export const Billing: React.FC<BillingProps> = ({ account, teamId }) => {
// and didn't have it already set, so update it here when payment
// method is available.
if (
account.plan !== AccountPlan.Growth &&
selectedPlan === AccountPlan.Growth
account.plan !== accountPlan.growth &&
selectedPlan === accountPlan.growth
) {
handleUpdatePlan(selectedPlan);
setSelectedPlan(undefined);
@@ -232,12 +233,11 @@ export const Billing: React.FC<BillingProps> = ({ account, teamId }) => {
handleUpdatePlan,
]);
const showSteps = [
AccountStatus.NoCustomer,
AccountStatus.NoPayment,
AccountStatus.InvalidPayment,
AccountStatus.InvalidPaymentMethod,
].includes(account.status);
const showSteps =
account.status === accountStatus.noCustomer ||
account.status === accountStatus.noPayment ||
account.status === accountStatus.invalidPayment ||
account.status === accountStatus.invalidPaymentMethod;
return (
<Flex flexDir="column" gap={8}>
@@ -149,4 +149,4 @@ export type ApiKeyPayConfigValidationSchema = z.infer<
>;
// FIXME: Remove
export const HIDDEN_SERVICES = ["relayer", "chainsaw", "insight"];
export const HIDDEN_SERVICES = ["relayer", "chainsaw"];
@@ -8,12 +8,12 @@ export function AccountAbstractionSummary(props: {
return (
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<Stat
label="Sponsored Transactions"
label="Mainnet Sponsored Transactions"
value={props.aggregateUserOpUsageQuery?.successful || 0}
icon={ActivityIcon}
/>
<Stat
label="Total Sponsored"
label="Mainnet Gas Sponsored"
value={props.aggregateUserOpUsageQuery?.sponsoredUsd || 0}
formatter={(value: number) =>
new Intl.NumberFormat("en-US", {
@@ -21,7 +21,8 @@ import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { formatTickerNumber } from "../../../lib/format-utils";
type ChartData = {
time: string; // human readable date
@@ -69,9 +70,12 @@ export function SponsoredTransactionsChartCard(props: {
return (
<div className="relative w-full rounded-lg border border-border bg-muted/50 p-4 md:p-6">
<h3 className="mb-4 font-semibold text-xl tracking-tight md:text-2xl">
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Sponsored Transactions
</h3>
<p className="mb-3 text-muted-foreground text-sm">
Total number of sponsored transactions.
</p>
<div className="top-6 right-6 mb-8 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<ExportToCSVButton
@@ -153,7 +157,21 @@ export function SponsoredTransactionsChartCard(props: {
axisLine={false}
/>
<ChartTooltip cursor={true} content={<ChartTooltipContent />} />
<YAxis
dataKey={(data) => data.successful + data.failed}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatTickerNumber(value)}
/>
<ChartTooltip
cursor={true}
content={
<ChartTooltipContent
valueFormatter={(value) => formatTickerNumber(Number(value))}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{(["failed", "successful"] as const).map((result) => {
return (
@@ -22,8 +22,9 @@ import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { useAllChainsData } from "../../../hooks/chains/allChains";
import { formatTickerNumber } from "../../../lib/format-utils";
type ChartData = Record<string, number> & {
time: string; // human readable date
@@ -51,7 +52,8 @@ export function TotalSponsoredChartCard(props: {
if (!chartData) {
_chartDataMap.set(stat.date, {
time: format(new Date(stat.date), "MMM dd"),
[chainId || "Unknown"]: Math.round(stat.sponsoredUsd * 100) / 100,
[chain?.name || chainId || "Unknown"]:
Math.round(stat.sponsoredUsd * 100) / 100,
} as ChartData);
} else {
chartData[chain?.name || chainId || "Unknown"] =
@@ -120,7 +122,7 @@ export function TotalSponsoredChartCard(props: {
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<ExportToCSVButton
className="bg-background"
fileName="Connect Wallets"
fileName="Gas Sponsored"
disabled={disableActions}
getData={async () => {
// Shows the number of each type of wallet connected on all dates
@@ -200,7 +202,25 @@ export function TotalSponsoredChartCard(props: {
axisLine={false}
/>
<ChartTooltip cursor={true} content={<ChartTooltipContent />} />
<YAxis
dataKey={(data) => {
console.log(data);
return Object.entries(data)
.filter(([key]) => key !== "time")
.map(([, value]) => value)
.reduce((acc, current) => Number(acc) + Number(current), 0);
}}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `$${formatTickerNumber(value)}`}
/>
<ChartTooltip
cursor={true}
content={
<ChartTooltipContent valueFormatter={(value) => `$${value}`} />
}
/>
<ChartLegend content={<ChartLegendContent />} />
{uniqueChainIds.map((chainId) => {
return (
+2 -1
View File
@@ -6,7 +6,8 @@ export const THIRDWEB_API_HOST = "/api/server-proxy/api";
export const THIRDWEB_ANALYTICS_API_HOST = "/api/server-proxy/analytics";
export const THIRDWEB_EWS_API_HOST =
process.env.NEXT_PUBLIC_THIRDWEB_EWS_API_HOST || "https://ews.thirdweb.com";
process.env.NEXT_PUBLIC_THIRDWEB_EWS_API_HOST ||
"https://in-app-wallet.thirdweb.com";
export const THIRDWEB_PAY_DOMAIN =
process.env.NEXT_PUBLIC_PAY_URL || "pay.thirdweb-dev.com";
@@ -0,0 +1,33 @@
import "server-only";
export async function fetchAnalytics(input: string | URL, init?: RequestInit) {
const [pathname, searchParams] = input.toString().split("?");
if (!pathname) {
throw new Error("Invalid input, no pathname provided");
}
// create a new URL object for the analytics server
const API_SERVER_URL = new URL(
process.env.ANALYTICS_SERVICE_URL || "https://analytics.thirdweb.com",
);
API_SERVER_URL.pathname = pathname;
for (const param of searchParams?.split("&") || []) {
const [key, value] = param.split("=");
if (!key || !value) {
return;
}
API_SERVER_URL.searchParams.append(
decodeURIComponent(key),
decodeURIComponent(value),
);
}
return await fetch(API_SERVER_URL, {
...init,
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.ANALYTICS_SERVICE_API_KEY}`,
...init?.headers,
},
});
}

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