Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7cb6ae7a4 | ||
|
|
7ecfcb95af | ||
|
|
4cf15a2475 | ||
|
|
62ce05ec35 | ||
|
|
563c4c7df1 | ||
|
|
32c91abc02 | ||
|
|
7a2dc25129 | ||
|
|
32568fd421 | ||
|
|
9091e813ab | ||
|
|
23d375778b | ||
|
|
e7b0110803 | ||
|
|
1825ac857e | ||
|
|
3ce2d0602e | ||
|
|
4bde2eb157 | ||
|
|
8d4d991e57 | ||
|
|
22090cc400 | ||
|
|
95a6c38703 | ||
|
|
b1060de7da | ||
|
|
0aca56317a | ||
|
|
5463baa7da | ||
|
|
64ffe199af | ||
|
|
76b522e7bc | ||
|
|
49786404b3 | ||
|
|
64bd603301 | ||
|
|
60a5b7c3e7 | ||
|
|
baf95b9077 | ||
|
|
e29f749342 | ||
|
|
2a7df337f7 | ||
|
|
348e3ab4ad | ||
|
|
e4590a7ada | ||
|
|
a45c558e06 | ||
|
|
a3e7300723 | ||
|
|
4c1f384a63 | ||
|
|
ec099a781a | ||
|
|
185d2f309c | ||
|
|
e8fb41752e | ||
|
|
3f42f522b7 | ||
|
|
bb29e1f705 | ||
|
|
76822982f3 | ||
|
|
52261a5740 | ||
|
|
cd30ba4f3a | ||
|
|
0ea898712b | ||
|
|
1b2caecfc4 | ||
|
|
e574114188 | ||
|
|
aa733b7b3e | ||
|
|
fa0d1141da | ||
|
|
f618e2bffb | ||
|
|
3ab31c8210 | ||
|
|
8eb5d8c01e | ||
|
|
d036b87ae1 | ||
|
|
c86cac4d61 | ||
|
|
af09576247 | ||
|
|
316cef1a58 | ||
|
|
1bfcd375e4 | ||
|
|
86070fa309 |
@@ -16,6 +16,7 @@ env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
TW_SECRET_KEY: ${{ secrets.TW_SECRET_KEY }}
|
||||
TW_CLIENT_ID: ${{ secrets.TW_CLIENT_ID }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
jobs:
|
||||
@@ -102,7 +103,7 @@ jobs:
|
||||
runs-on: ubuntu-latest-8
|
||||
strategy:
|
||||
matrix:
|
||||
package_manager: [npm, yarn, pnpm, bun]
|
||||
package_manager: [pnpm] # TODO, reenable [npm, yarn, pnpm, bun]
|
||||
bundler: [vite, webpack, esbuild]
|
||||
steps:
|
||||
- name: Check out the code
|
||||
@@ -123,7 +124,14 @@ jobs:
|
||||
mkdir test-project
|
||||
cd test-project
|
||||
npm init -y
|
||||
${{ matrix.package_manager }} add react react-dom ../packages/thirdweb
|
||||
|
||||
# Handle different package managers
|
||||
if [ "${{ matrix.package_manager }}" = "pnpm" ]; then
|
||||
# Create pnpm workspace
|
||||
echo '{"name": "test-project", "private": true, "workspaces": ["."]}' > package.json
|
||||
echo '{"packages": ["../packages/*"]}' > pnpm-workspace.yaml
|
||||
pnpm add react react-dom ../packages/thirdweb -w
|
||||
fi
|
||||
- name: Create test file
|
||||
run: |
|
||||
cd test-project
|
||||
@@ -133,7 +141,7 @@ jobs:
|
||||
if: matrix.bundler == 'vite'
|
||||
run: |
|
||||
cd test-project
|
||||
${{matrix.package_manager}} add vite
|
||||
${{matrix.package_manager}} add vite -w
|
||||
echo 'import { defineConfig } from "vite"; import {resolve} from "path"; export default defineConfig({ build: { lib: { entry: resolve(__dirname, "index.js"), name: "e2e_test" }, outDir: "dist" }});' > vite.config.js
|
||||
npx vite build
|
||||
|
||||
@@ -141,7 +149,7 @@ jobs:
|
||||
if: matrix.bundler == 'webpack'
|
||||
run: |
|
||||
cd test-project
|
||||
${{matrix.package_manager}} add webpack webpack-cli
|
||||
${{matrix.package_manager}} add webpack webpack-cli -w
|
||||
echo 'const path = require("path"); module.exports = { mode: "production", entry: "./index.js", output: { path: path.resolve(__dirname, "dist"), filename: "bundle.js" }};' > webpack.config.js
|
||||
npx webpack
|
||||
|
||||
@@ -149,7 +157,7 @@ jobs:
|
||||
if: matrix.bundler == 'esbuild'
|
||||
run: |
|
||||
cd test-project
|
||||
${{matrix.package_manager}} add esbuild
|
||||
${{matrix.package_manager}} add esbuild -w
|
||||
npx esbuild index.js --bundle --outdir=dist
|
||||
|
||||
- name: Verify bundle
|
||||
@@ -177,6 +185,9 @@ jobs:
|
||||
- name: Setup & Install
|
||||
uses: ./.github/composite-actions/install
|
||||
|
||||
- name: Build Packages
|
||||
run: pnpm build
|
||||
|
||||
- name: Report bundle size
|
||||
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
|
||||
with:
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
public-hoist-pattern[]=*import-in-the-middle*
|
||||
public-hoist-pattern[]=*require-in-the-middle*
|
||||
public-hoist-pattern[]=*require-in-the-middle*
|
||||
public-hoist-pattern[]=*pino-pretty*
|
||||
|
||||
@@ -12,7 +12,7 @@ const ContentSecurityPolicy = `
|
||||
style-src 'self' 'unsafe-inline' vercel.live;
|
||||
font-src 'self' vercel.live assets.vercel.com framerusercontent.com fonts.gstatic.com;
|
||||
frame-src * data:;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com static.reo.dev;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com;
|
||||
connect-src * data: blob:;
|
||||
worker-src 'self' blob:;
|
||||
block-all-mixed-content;
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.1",
|
||||
"@types/react-dom": "19.1.2",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/spdx-correct": "^3.1.3",
|
||||
"@types/swagger-ui-react": "^4.19.0",
|
||||
|
||||
@@ -81,6 +81,7 @@ export async function getWalletNFTs(params: {
|
||||
const result = await transformMoralisResponseToNFT(
|
||||
await parsedResponse,
|
||||
owner,
|
||||
chainId,
|
||||
);
|
||||
|
||||
return { result };
|
||||
@@ -194,6 +195,8 @@ async function getWalletNFTsFromInsight(params: {
|
||||
tokenURI: nft.metadata_url,
|
||||
type: nft.token_type === "erc721" ? "ERC721" : "ERC1155",
|
||||
supply: nft.balance,
|
||||
tokenAddress: nft.contract.address,
|
||||
chainId: nft.contract.chain_id,
|
||||
};
|
||||
|
||||
return walletNFT;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export async function joinTeamWaitlist(options: {
|
||||
teamSlug: string;
|
||||
// currently only 'nebula' is supported
|
||||
scope: "nebula";
|
||||
}) {
|
||||
const { teamSlug, scope } = options;
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/teams/${teamSlug}/waitlist`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
scope,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
errorMessage: "Failed to join waitlist",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,7 @@ function getStripe() {
|
||||
|
||||
export async function getTeamInvoices(
|
||||
team: Team,
|
||||
options?: { cursor?: string },
|
||||
options?: { cursor?: string; status?: "open" },
|
||||
) {
|
||||
try {
|
||||
const customerId = team.stripeCustomerId;
|
||||
@@ -37,6 +37,8 @@ export async function getTeamInvoices(
|
||||
customer: customerId,
|
||||
limit: 10,
|
||||
starting_after: options?.cursor,
|
||||
// Only return open invoices if the status is open
|
||||
status: options?.status,
|
||||
});
|
||||
|
||||
return invoices;
|
||||
|
||||
@@ -346,7 +346,7 @@ export async function getEcosystemWalletUsage(args: {
|
||||
searchParams.append("period", period);
|
||||
}
|
||||
const res = await fetchAnalytics(
|
||||
`v2/wallets/connects/${ecosystemSlug}?${searchParams.toString()}`,
|
||||
`v2/wallet/connects?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
@@ -356,7 +356,10 @@ export async function getEcosystemWalletUsage(args: {
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch ecosystem wallet stats");
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch ecosystem wallet stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,31 +74,3 @@ export async function getDefaultTeam() {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type TeamNebulaWaitList = {
|
||||
onWaitlist: boolean;
|
||||
createdAt: null | string;
|
||||
};
|
||||
|
||||
export async function getTeamNebulaWaitList(teamSlug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${teamSlug}/waitlist?scope=nebula`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
return (await res.json()).result as TeamNebulaWaitList;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import "server-only";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
export type RPCUsageDataItem = {
|
||||
date: string;
|
||||
usageType: "included" | "overage" | "rate-limit";
|
||||
count: string;
|
||||
};
|
||||
|
||||
export const fetchRPCUsage = unstable_cache(
|
||||
async (params: {
|
||||
teamId: string;
|
||||
projectId?: string;
|
||||
authToken: string;
|
||||
from: string;
|
||||
to: string;
|
||||
period: "day" | "week" | "month" | "year" | "all";
|
||||
}) => {
|
||||
const analyticsEndpoint = process.env.ANALYTICS_SERVICE_URL as string;
|
||||
const url = new URL(`${analyticsEndpoint}/v2/rpc/usage-types`);
|
||||
url.searchParams.set("teamId", params.teamId);
|
||||
if (params.projectId) {
|
||||
url.searchParams.set("projectId", params.projectId);
|
||||
}
|
||||
url.searchParams.set("from", params.from);
|
||||
url.searchParams.set("to", params.to);
|
||||
url.searchParams.set("period", params.period);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
return {
|
||||
ok: false as const,
|
||||
error: error,
|
||||
};
|
||||
}
|
||||
|
||||
const resData = await res.json();
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
data: resData.data as RPCUsageDataItem[],
|
||||
};
|
||||
},
|
||||
["rpc-usage"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
},
|
||||
);
|
||||
|
||||
type Last24HoursRPCUsageApiResponse = {
|
||||
peakRate: {
|
||||
date: string;
|
||||
peakRPS: number;
|
||||
};
|
||||
averageRate: {
|
||||
date: string;
|
||||
averageRate: number;
|
||||
includedCount: number;
|
||||
rateLimitedCount: number;
|
||||
overageCount: number;
|
||||
}[];
|
||||
totalCounts: {
|
||||
includedCount: number;
|
||||
rateLimitedCount: number;
|
||||
overageCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const getLast24HoursRPCUsage = unstable_cache(
|
||||
async (params: {
|
||||
teamId: string;
|
||||
projectId?: string;
|
||||
authToken: string;
|
||||
}) => {
|
||||
const analyticsEndpoint = process.env.ANALYTICS_SERVICE_URL as string;
|
||||
const url = new URL(`${analyticsEndpoint}/v2/rpc/24-hours`);
|
||||
url.searchParams.set("teamId", params.teamId);
|
||||
if (params.projectId) {
|
||||
url.searchParams.set("projectId", params.projectId);
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
return {
|
||||
ok: false as const,
|
||||
error: error,
|
||||
};
|
||||
}
|
||||
|
||||
const resData = await res.json();
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
data: resData.data as Last24HoursRPCUsageApiResponse,
|
||||
};
|
||||
},
|
||||
["rpc-usage-last-24-hours"],
|
||||
{
|
||||
revalidate: 60, // 1 minute
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
GetBillingCheckoutUrlAction,
|
||||
@@ -8,6 +10,7 @@ import type {
|
||||
GetBillingPortalUrlAction,
|
||||
GetBillingPortalUrlOptions,
|
||||
} from "../actions/billing";
|
||||
import type { Team } from "../api/team";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Spinner } from "./ui/Spinner/Spinner";
|
||||
import { Button, type ButtonProps } from "./ui/button";
|
||||
@@ -16,6 +19,7 @@ type CheckoutButtonProps = Omit<GetBillingCheckoutUrlOptions, "redirectUrl"> & {
|
||||
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
|
||||
buttonProps?: Omit<ButtonProps, "children">;
|
||||
children: React.ReactNode;
|
||||
billingStatus: Team["billingStatus"];
|
||||
};
|
||||
|
||||
export function CheckoutButton({
|
||||
@@ -25,6 +29,7 @@ export function CheckoutButton({
|
||||
getBillingCheckoutUrl,
|
||||
children,
|
||||
buttonProps,
|
||||
billingStatus,
|
||||
}: CheckoutButtonProps) {
|
||||
const getUrlMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -40,35 +45,65 @@ export function CheckoutButton({
|
||||
const errorMessage = "Failed to open checkout page";
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
className={cn(buttonProps?.className, "gap-2")}
|
||||
disabled={getUrlMutation.isPending || buttonProps?.disabled}
|
||||
onClick={async (e) => {
|
||||
buttonProps?.onClick?.(e);
|
||||
getUrlMutation.mutate(undefined, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.url) {
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
{/* show warning if the team has an invalid payment method */}
|
||||
{billingStatus === "invalidPayment" && (
|
||||
<BillingWarning teamSlug={teamSlug} />
|
||||
)}
|
||||
<Button
|
||||
{...buttonProps}
|
||||
className={cn(buttonProps?.className, "w-full gap-2")}
|
||||
disabled={
|
||||
// disable button if the team has an invalid payment method
|
||||
// api will return 402 error if the team has an invalid payment method
|
||||
billingStatus === "invalidPayment" ||
|
||||
getUrlMutation.isPending ||
|
||||
buttonProps?.disabled
|
||||
}
|
||||
onClick={async (e) => {
|
||||
buttonProps?.onClick?.(e);
|
||||
getUrlMutation.mutate(undefined, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.url) {
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = window.open(res.url, "_blank");
|
||||
const tab = window.open(res.url, "_blank");
|
||||
|
||||
if (!tab) {
|
||||
if (!tab) {
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(errorMessage);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{getUrlMutation.isPending && <Spinner className="size-4" />}
|
||||
{children}
|
||||
</Button>
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{getUrlMutation.isPending && <Spinner className="size-4" />}
|
||||
{children}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingWarning({ teamSlug }: { teamSlug: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
<AlertTriangleIcon className="h-5 w-5 flex-shrink-0" />
|
||||
<p className="text-sm">
|
||||
You have outstanding invoices. Please{" "}
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
|
||||
className="font-medium text-amber-700 underline transition-colors hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-300"
|
||||
>
|
||||
pay them
|
||||
</Link>{" "}
|
||||
to continue.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MultiSelect } from "@/components/blocks/multi-select";
|
||||
import { SelectWithSearch } from "@/components/blocks/select-with-search";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ChainIcon } from "../../../components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "../../../components/icons/ChainIcon";
|
||||
import { useAllChainsData } from "../../../hooks/chains/allChains";
|
||||
|
||||
function cleanChainName(chainName: string) {
|
||||
@@ -19,6 +19,7 @@ export function MultiNetworkSelector(props: {
|
||||
disableChainId?: boolean;
|
||||
className?: string;
|
||||
priorityChains?: number[];
|
||||
hideTestnets?: boolean;
|
||||
}) {
|
||||
const { allChains, idToChain } = useAllChainsData();
|
||||
|
||||
@@ -44,13 +45,17 @@ export function MultiNetworkSelector(props: {
|
||||
sortedChains = [...priorityChains, ...otherChains];
|
||||
}
|
||||
|
||||
if (props.hideTestnets) {
|
||||
sortedChains = sortedChains.filter((chain) => !chain.testnet);
|
||||
}
|
||||
|
||||
return sortedChains.map((chain) => {
|
||||
return {
|
||||
label: cleanChainName(chain.name),
|
||||
value: String(chain.chainId),
|
||||
};
|
||||
});
|
||||
}, [allChains, props.priorityChains, idToChain]);
|
||||
}, [allChains, props.priorityChains, idToChain, props.hideTestnets]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
(option: Option, searchValue: string) => {
|
||||
@@ -77,7 +82,7 @@ export function MultiNetworkSelector(props: {
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="flex grow gap-2 truncate text-left">
|
||||
<ChainIcon
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
ipfsSrc={chain.icon?.url}
|
||||
loading="lazy"
|
||||
@@ -172,7 +177,7 @@ export function SingleNetworkSelector(props: {
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="flex grow gap-2 truncate text-left">
|
||||
<ChainIcon
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
ipfsSrc={chain.icon?.url}
|
||||
loading="lazy"
|
||||
|
||||
@@ -15,6 +15,37 @@ type AppFooterProps = {
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
const footerLinks = [
|
||||
{
|
||||
href: "/home",
|
||||
label: "Home",
|
||||
},
|
||||
{
|
||||
href: "https://blog.thirdweb.com",
|
||||
label: "Blog",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/changelog",
|
||||
label: "Changelog",
|
||||
},
|
||||
{
|
||||
href: "https://feedback.thirdweb.com/",
|
||||
label: "Feedback",
|
||||
},
|
||||
{
|
||||
href: "https://thirdweb.com/privacy-policy",
|
||||
label: "Privacy Policy",
|
||||
},
|
||||
{
|
||||
href: "https://thirdweb.com/terms",
|
||||
label: "Terms of Service",
|
||||
},
|
||||
{
|
||||
href: "https://thirdweb.com/chainlist",
|
||||
label: "Chainlist",
|
||||
},
|
||||
];
|
||||
|
||||
export function AppFooter(props: AppFooterProps) {
|
||||
return (
|
||||
<footer
|
||||
@@ -80,57 +111,28 @@ export function AppFooter(props: AppFooterProps) {
|
||||
</div>
|
||||
{/* bottom row */}
|
||||
<div className="grid grid-flow-col grid-cols-2 grid-rows-5 gap-2 md:flex md:flex-row md:justify-between">
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="/home"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://blog.thirdweb.com"
|
||||
target="_blank"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://portal.thirdweb.com/changelog"
|
||||
target="_blank"
|
||||
>
|
||||
Changelog
|
||||
</Link>
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://feedback.thirdweb.com/"
|
||||
target="_blank"
|
||||
>
|
||||
Feedback
|
||||
</Link>
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://thirdweb.com/privacy-policy"
|
||||
target="_blank"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://thirdweb.com/terms"
|
||||
target="_blank"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://thirdweb.com/chainlist"
|
||||
target="_blank"
|
||||
>
|
||||
Chain List
|
||||
</Link>
|
||||
{footerLinks.map((link) => (
|
||||
<FooterLink key={link.href} {...link} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink(props: {
|
||||
href: string;
|
||||
label: string;
|
||||
prefetch?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={props.href}
|
||||
prefetch={false}
|
||||
className="px-0 py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
{props.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
} from "@/components/ui/chart";
|
||||
import { formatDate } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
@@ -30,11 +31,17 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
};
|
||||
footer?: React.ReactNode;
|
||||
customHeader?: React.ReactNode;
|
||||
// chart config
|
||||
config: TConfig;
|
||||
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
|
||||
showLegend?: boolean;
|
||||
maxLimit?: number;
|
||||
yAxis?: boolean;
|
||||
xAxis?: {
|
||||
sameDay?: boolean;
|
||||
};
|
||||
|
||||
// chart className
|
||||
chartClassName?: string;
|
||||
@@ -70,17 +77,33 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
) : props.data.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
) : (
|
||||
<AreaChart accessibilityLayer data={props.data}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={
|
||||
props.maxLimit
|
||||
? props.data.map((d) => ({
|
||||
...d,
|
||||
maxLimit: props.maxLimit,
|
||||
}))
|
||||
: props.data
|
||||
}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
{props.yAxis && <YAxis tickLine={false} axisLine={false} />}
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={20}
|
||||
tickFormatter={(value) => formatDate(new Date(value), "MMM dd")}
|
||||
tickFormatter={(value) =>
|
||||
formatDate(
|
||||
new Date(value),
|
||||
props.xAxis?.sameDay ? "MMM dd, HH:mm" : "MMM dd",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
cursor={true}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel={
|
||||
@@ -124,6 +147,16 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
stackId="a"
|
||||
/>
|
||||
))}
|
||||
{props.maxLimit && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="maxLimit"
|
||||
stroke="#ef4444"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
fill="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{props.showLegend && (
|
||||
<ChartLegend
|
||||
@@ -134,6 +167,9 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
)}
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
{props.footer && (
|
||||
<CardFooter className="w-full">{props.footer}</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type PricingCardCta = {
|
||||
|
||||
type PricingCardProps = {
|
||||
teamSlug: string;
|
||||
billingStatus: Team["billingStatus"];
|
||||
billingPlan: keyof typeof TEAM_PLANS;
|
||||
cta?: PricingCardCta;
|
||||
ctaHint?: string;
|
||||
@@ -41,6 +42,7 @@ type PricingCardProps = {
|
||||
|
||||
export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
teamSlug,
|
||||
billingStatus,
|
||||
billingPlan,
|
||||
cta,
|
||||
highlighted = false,
|
||||
@@ -131,6 +133,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
<div className="flex flex-col gap-3">
|
||||
{billingPlanToSkuMap[billingPlan] && cta.type === "checkout" && (
|
||||
<CheckoutButton
|
||||
billingStatus={billingStatus}
|
||||
buttonProps={{
|
||||
variant: highlighted ? "default" : "outline",
|
||||
className: highlighted ? undefined : "bg-background",
|
||||
|
||||
@@ -23,6 +23,7 @@ export function WalletAddress(props: {
|
||||
address: string | undefined;
|
||||
shortenAddress?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
const thirdwebClient = useThirdwebClient();
|
||||
// default back to zero address if no address provided
|
||||
@@ -78,6 +79,7 @@ export function WalletAddress(props: {
|
||||
address={address}
|
||||
profiles={profiles.data || []}
|
||||
thirdwebClient={thirdwebClient}
|
||||
iconClassName={props.iconClassName}
|
||||
/>
|
||||
)}
|
||||
<span className="cursor-pointer font-mono">
|
||||
@@ -168,6 +170,7 @@ function WalletAvatar(props: {
|
||||
address: string;
|
||||
profiles: SocialProfile[];
|
||||
thirdwebClient: ThirdwebClient;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
const avatar = useMemo(() => {
|
||||
return props.profiles.find(
|
||||
@@ -186,11 +189,20 @@ function WalletAvatar(props: {
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="size-6 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn("size-6 overflow-hidden rounded-full", props.iconClassName)}
|
||||
>
|
||||
{resolvedAvatarSrc ? (
|
||||
<Img src={resolvedAvatarSrc} className="size-6 object-cover" />
|
||||
<Img
|
||||
src={resolvedAvatarSrc}
|
||||
className={cn("size-6 object-cover", props.iconClassName)}
|
||||
/>
|
||||
) : (
|
||||
<Blobbie address={props.address} size={24} />
|
||||
<Blobbie
|
||||
address={props.address}
|
||||
size={24}
|
||||
className={props.iconClassName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -243,7 +243,9 @@ const ChartTooltipContent = React.forwardRef<
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
{item.name === "maxLimit"
|
||||
? "Upper Limit"
|
||||
: itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value !== undefined && (
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { getSDKTheme } from "app/components/sdk-component-theme";
|
||||
import { useV5DashboardChain } from "lib/v5-adapter";
|
||||
import { useTheme } from "next-themes";
|
||||
import { PayEmbed } from "thirdweb/react";
|
||||
|
||||
export function UniversalBridgeEmbed({ chainId }: { chainId?: number }) {
|
||||
const { theme } = useTheme();
|
||||
const chain = useV5DashboardChain(chainId || 1);
|
||||
|
||||
return (
|
||||
<PayEmbed
|
||||
client={getThirdwebClient()}
|
||||
payOptions={{
|
||||
mode: "fund_wallet",
|
||||
prefillBuy: {
|
||||
chain,
|
||||
amount: "0.01",
|
||||
},
|
||||
}}
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { UniversalBridgeEmbed } from "./components/client/UniversalBridgeEmbed";
|
||||
|
||||
const title = "Universal Bridge: Swap, Bridge, and On-Ramp";
|
||||
const description =
|
||||
"Swap, bridge, and on-ramp to any EVM chain with thirdweb's Universal Bridge.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RoutesPage({
|
||||
searchParams,
|
||||
}: { searchParams: Record<string, string | string[]> }) {
|
||||
const { chainId } = searchParams;
|
||||
return (
|
||||
<div className="relative mx-auto flex h-screen w-full flex-col items-center justify-center overflow-hidden border py-10">
|
||||
<main className="container z-10 flex justify-center">
|
||||
<UniversalBridgeEmbed chainId={chainId ? Number(chainId) : undefined} />
|
||||
</main>
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt=""
|
||||
src="/assets/login/background.svg"
|
||||
className="-bottom-12 -right-12 pointer-events-none absolute lg:right-0 lg:bottom-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { SectionTitle } from "../server/SectionTitle";
|
||||
|
||||
export default function NextSteps(props: { chain: ChainMetadata }) {
|
||||
const { chain } = props;
|
||||
const trackEvent = useTrack();
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle title="Next Steps" />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
<div className="relative flex gap-3 rounded-lg border bg-card p-4 pr-8 transition-colors hover:border-active-border">
|
||||
<FileText className="mt-0.5 size-5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="mb-1.5 font-medium">
|
||||
<Link
|
||||
href={
|
||||
"https://blog.thirdweb.com/supercharge-user-adoption-integrate-embedded-wallets-in-minutes/"
|
||||
}
|
||||
className="before:absolute before:inset-0"
|
||||
target="_blank"
|
||||
onClick={() =>
|
||||
trackEvent({
|
||||
category: "nextSteps",
|
||||
action: "click-inapp",
|
||||
label: "success",
|
||||
chain_id: chain.chainId,
|
||||
})
|
||||
}
|
||||
>
|
||||
Create a login for {chain.name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Supercharge User Adoption—Integrate In-App Wallets in Minutes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+5
-5
@@ -11,7 +11,7 @@ export function BuyFundsSection(props: { chain: ChainMetadata }) {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle title="Buy Funds" />
|
||||
<SectionTitle title="Bridge" />
|
||||
<div className="flex justify-center rounded-lg border border-border bg-card px-4 py-10">
|
||||
<div className="flex max-w-[520px] flex-col items-center ">
|
||||
<div className="flex items-center">
|
||||
@@ -28,21 +28,21 @@ export function BuyFundsSection(props: { chain: ChainMetadata }) {
|
||||
<div className="h-6" />
|
||||
|
||||
<h2 className="px-4 text-center font-semibold text-lg tracking-tight">
|
||||
Buy Funds on {sanitizedChainName} using Universal Bridge
|
||||
Bridge Funds to {sanitizedChainName}
|
||||
</h2>
|
||||
|
||||
<div className="h-2" />
|
||||
|
||||
<p className="max-w-[520px] px-4 text-center text-muted-foreground text-sm">
|
||||
Instantly onramp, bridge & swap into {sanitizedChainName} with any
|
||||
token.
|
||||
Get {props.chain.nativeCurrency.symbol} on {sanitizedChainName} with
|
||||
fiat or any token on another chain.
|
||||
</p>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
<PayModalButton
|
||||
chainId={props.chain.chainId}
|
||||
label={`Buy Funds on ${sanitizedChainName}`}
|
||||
label={`Bridge to ${sanitizedChainName}`}
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
@@ -13,12 +13,18 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { ChevronDownIcon, TicketCheckIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { mapV4ChainToV5Chain } from "../../../../../contexts/map-chains";
|
||||
import { getAuthToken } from "../../../../api/lib/getAuthToken";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import {
|
||||
getAuthToken,
|
||||
getAuthTokenWalletAddress,
|
||||
} from "../../../../api/lib/getAuthToken";
|
||||
import { NebulaFloatingChatButton } from "../../../../nebula-app/(app)/components/FloatingChat/FloatingChat";
|
||||
import { StarButton } from "../../components/client/star-button";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
import { AddChainToWallet } from "./components/client/add-chain-to-wallet";
|
||||
@@ -55,17 +61,58 @@ export default async function ChainPageLayout(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
const chain = await getChain(params.chain_id);
|
||||
const authToken = await getAuthToken();
|
||||
const [chain, authToken, account, accountAddress] = await Promise.all([
|
||||
getChain(params.chain_id),
|
||||
getAuthToken(),
|
||||
getRawAccount(),
|
||||
getAuthTokenWalletAddress(),
|
||||
]);
|
||||
|
||||
if (params.chain_id !== chain.slug) {
|
||||
redirect(chain.slug);
|
||||
}
|
||||
|
||||
const chainMetadata = await getChainMetadata(chain.chainId);
|
||||
const client = getThirdwebClient(authToken ?? undefined);
|
||||
|
||||
const chainPromptPrefix = `\
|
||||
You are assisting users exploring the chain ${chain.name} (Chain ID: ${chain.chainId}). Provide concise insights into the types of applications and activities prevalent on this chain, such as DeFi protocols, NFT marketplaces, or gaming platforms. Highlight notable projects or trends without delving into technical details like consensus mechanisms or gas fees.
|
||||
Users may seek comparisons between ${chain.name} and other chains. Provide objective, succinct comparisons focusing on performance, fees, and ecosystem support. Refrain from transaction-specific advice unless requested.
|
||||
Provide users with an understanding of the unique use cases and functionalities that ${chain.name} supports. Discuss how developers leverage this chain for specific applications, such as scalable dApps, low-cost transactions, or specialized token standards, focusing on practical implementations.
|
||||
Users may be interested in utilizing thirdweb tools on ${chain.name}. Offer clear guidance on how thirdweb's SDKs, smart contract templates, and deployment tools integrate with this chain. Emphasize the functionalities enabled by thirdweb without discussing transaction execution unless prompted.
|
||||
Avoid transaction-related actions to be executed by the user unless inquired about.
|
||||
|
||||
The following is the user's message:
|
||||
`;
|
||||
|
||||
const examplePrompts: string[] = [
|
||||
"What are users doing on this chain?",
|
||||
"What are the most active contracts?",
|
||||
"Why would I use this chain over others?",
|
||||
"Can I deploy thirdweb contracts to this chain?",
|
||||
];
|
||||
|
||||
if (chain.chainId !== 1) {
|
||||
examplePrompts.push("Can I bridge assets from Ethereum to this chain?");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NebulaFloatingChatButton
|
||||
authToken={authToken ?? undefined}
|
||||
account={account}
|
||||
label="Ask AI about this chain"
|
||||
client={client}
|
||||
nebulaParams={{
|
||||
messagePrefix: chainPromptPrefix,
|
||||
chainIds: [chain.chainId],
|
||||
wallet: accountAddress ?? undefined,
|
||||
}}
|
||||
examplePrompts={examplePrompts.map((prompt) => ({
|
||||
title: prompt,
|
||||
message: prompt,
|
||||
}))}
|
||||
/>
|
||||
<div className="flex h-14 border-border border-b pl-7">
|
||||
<Breadcrumb className="my-auto">
|
||||
<BreadcrumbList>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
import NextSteps from "./components/client/NextSteps";
|
||||
import { BuyFundsSection } from "./components/server/BuyFundsSection";
|
||||
import { ChainOverviewSection } from "./components/server/ChainOverviewSection";
|
||||
import { ClaimChainSection } from "./components/server/ClaimChainSection";
|
||||
@@ -57,6 +58,8 @@ export default async function Page(props: {
|
||||
{chain.services.filter((s) => s.enabled).length > 0 && (
|
||||
<SupportedProductsSection services={chain.services} />
|
||||
)}
|
||||
{/*Next Steps */}
|
||||
<NextSteps chain={chain} />
|
||||
|
||||
{/* Claim Chain */}
|
||||
{!chainMetadata && <ClaimChainSection />}
|
||||
|
||||
+4
@@ -205,6 +205,8 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
owner: nft.owner,
|
||||
type: "ERC721",
|
||||
tokenURI: nft.tokenURI,
|
||||
chainId: nft.chainId,
|
||||
tokenAddress: nft.tokenAddress,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -216,6 +218,8 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
owner: nft.owner,
|
||||
type: "ERC1155",
|
||||
tokenURI: nft.tokenURI,
|
||||
chainId: nft.chainId,
|
||||
tokenAddress: nft.tokenAddress,
|
||||
};
|
||||
}) as WalletNFT[];
|
||||
}, [ownedNFTs, form]);
|
||||
|
||||
+1
@@ -21,6 +21,7 @@ const dummyMetadata: (idx: number) => NFTWithContract = (idx) => ({
|
||||
owner: `0x_fake_${idx}`,
|
||||
type: "ERC721",
|
||||
supply: 1n,
|
||||
tokenAddress: ZERO_ADDRESS,
|
||||
});
|
||||
|
||||
interface NFTCardsProps {
|
||||
|
||||
+5
-2
@@ -55,7 +55,7 @@ type Options =
|
||||
};
|
||||
|
||||
export async function getClaimPhasesInLegacyFormat(
|
||||
options: BaseTransactionOptions<Options>,
|
||||
options: BaseTransactionOptions<Options> & { isMultiPhase: boolean },
|
||||
): Promise<CombinedClaimCondition[]> {
|
||||
const conditions = await (async () => {
|
||||
switch (options.type) {
|
||||
@@ -64,7 +64,10 @@ export async function getClaimPhasesInLegacyFormat(
|
||||
case "erc721":
|
||||
return ERC721Ext.getClaimConditions(options);
|
||||
case "erc1155":
|
||||
return ERC1155Ext.getClaimConditions(options);
|
||||
return ERC1155Ext.getClaimConditions({
|
||||
...options,
|
||||
singlePhaseDrop: !options.isMultiPhase,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
+1
@@ -222,6 +222,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
|
||||
const claimConditionsQuery = useReadContract(getClaimPhasesInLegacyFormat, {
|
||||
contract,
|
||||
isMultiPhase,
|
||||
...(isErc20
|
||||
? { type: "erc20", decimals: tokenDecimals.data }
|
||||
: isErc721
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
@@ -85,7 +85,7 @@ export const MetadataHeader: React.FC<MetadataHeaderProps> = ({
|
||||
href={`/${chain.slug}`}
|
||||
className="flex w-fit shrink-0 items-center gap-2 rounded-3xl border border-border bg-card px-2.5 py-1.5 hover:bg-accent"
|
||||
>
|
||||
<ChainIcon ipfsSrc={chain.icon?.url} className="size-4" />
|
||||
<ChainIconClient ipfsSrc={chain.icon?.url} className="size-4" />
|
||||
{cleanedChainName && (
|
||||
<span className="text-xs">{cleanedChainName}</span>
|
||||
)}
|
||||
|
||||
+1
@@ -33,6 +33,7 @@ export const NftsOwned: React.FC<NftsOwnedProps> = ({
|
||||
type: nft.type,
|
||||
contractAddress: nft.contractAddress,
|
||||
chainId: contract.chain.id,
|
||||
tokenAddress: nft.tokenAddress,
|
||||
}))}
|
||||
allNfts
|
||||
isPending={isWalletNFTsLoading}
|
||||
|
||||
+6
-12
@@ -2,7 +2,6 @@ import { fetchPublishedContractsFromDeploy } from "components/contract-component
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
type ContractOptions,
|
||||
eth_blockNumber,
|
||||
eth_getTransactionByHash,
|
||||
eth_getTransactionReceipt,
|
||||
getContractEvents,
|
||||
@@ -39,14 +38,17 @@ export default async function Page(props: {
|
||||
chain_id: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
const ProxyDeployedEvent = prepareEvent({
|
||||
signature:
|
||||
"event ProxyDeployedV2(address indexed implementation, address indexed proxy, address indexed deployer, bytes32 inputSalt, bytes data, bytes extraData)",
|
||||
filters: {
|
||||
proxy: params.contractAddress.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
}
|
||||
@@ -111,16 +113,9 @@ export default async function Page(props: {
|
||||
let creationBlockNumber: bigint | undefined;
|
||||
|
||||
if (twCloneFactoryContract) {
|
||||
const latestBlockNumber = await eth_blockNumber(
|
||||
getRpcClient({
|
||||
client: contract.client,
|
||||
chain: contract.chain,
|
||||
}),
|
||||
);
|
||||
const events = await getContractEvents({
|
||||
contract: twCloneFactoryContract,
|
||||
events: [ProxyDeployedEvent],
|
||||
blockRange: latestBlockNumber < 100000n ? latestBlockNumber : 100000n,
|
||||
});
|
||||
const event = events.find(
|
||||
(e) =>
|
||||
@@ -208,7 +203,6 @@ export default async function Page(props: {
|
||||
const events = await getContractEvents({
|
||||
contract: contract,
|
||||
events: [moduleEvent],
|
||||
blockRange: 123456n,
|
||||
});
|
||||
|
||||
const filteredEvents = events.filter(
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { SelectWithSearch } from "@/components/blocks/select-with-search";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
@@ -45,7 +45,7 @@ export function SingleNetworkSelector(props: {
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="flex grow gap-2 truncate text-left">
|
||||
<ChainIcon
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
ipfsSrc={chain.icon?.url}
|
||||
loading="lazy"
|
||||
|
||||
@@ -9,6 +9,12 @@ import { isAddress, isContractDeployed } from "thirdweb/utils";
|
||||
import type { MinimalTeamsAndProjects } from "../../../../../components/contract-components/contract-deploy-form/add-to-project-card";
|
||||
import { resolveFunctionSelectors } from "../../../../../lib/selectors";
|
||||
import { shortenIfAddress } from "../../../../../utils/usedapp-external";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import {
|
||||
getAuthToken,
|
||||
getAuthTokenWalletAddress,
|
||||
} from "../../../../api/lib/getAuthToken";
|
||||
import { NebulaFloatingChatButton } from "../../../../nebula-app/(app)/components/FloatingChat/FloatingChat";
|
||||
import { ConfigureCustomChain } from "./_layout/ConfigureCustomChain";
|
||||
import { getContractMetadataHeaderData } from "./_layout/contract-metadata";
|
||||
import { ContractPageLayout } from "./_layout/contract-page-layout";
|
||||
@@ -42,6 +48,12 @@ export default async function Layout(props: {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [authToken, account, accountAddress] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getRawAccount(),
|
||||
getAuthTokenWalletAddress(),
|
||||
]);
|
||||
|
||||
const client = getThirdwebClient();
|
||||
const teamsAndProjects = await getTeamsAndProjectsIfLoggedIn();
|
||||
|
||||
@@ -75,6 +87,25 @@ export default async function Layout(props: {
|
||||
const { contractMetadata, externalLinks } =
|
||||
await getContractMetadataHeaderData(contract);
|
||||
|
||||
const contractAddress = info.contract.address;
|
||||
const chainName = info.chainMetadata.name;
|
||||
const chainId = info.contract.chain.id;
|
||||
|
||||
const contractPromptPrefix = `A user is viewing the contract address ${contractAddress} on ${chainName} (Chain ID: ${chainId}). Provide a concise summary of this contract's functionalities, such as token minting, staking, or governance mechanisms. Focus on what the contract enables users to do, avoiding transaction execution details unless requested.
|
||||
Users may be interested in how to interact with the contract. Outline common interaction patterns, such as claiming rewards, participating in governance, or transferring assets. Emphasize the contract's capabilities without guiding through transaction processes unless asked.
|
||||
Provide insights into how the contract is being used. Share information on user engagement, transaction volumes, or integration with other dApps, focusing on the contract's role within the broader ecosystem.
|
||||
Users may be considering integrating the contract into their applications. Discuss how this contract's functionalities can be leveraged within different types of dApps, highlighting potential use cases and benefits.
|
||||
|
||||
The following is the user's message:`;
|
||||
|
||||
const examplePrompts: string[] = [
|
||||
"What does this contract do?",
|
||||
"What permissions or roles exist in this contract?",
|
||||
"Which functions are used the most?",
|
||||
"Has this contract been used recently?",
|
||||
"Who are the largest holders/users of this?",
|
||||
];
|
||||
|
||||
return (
|
||||
<ContractPageLayout
|
||||
chainMetadata={chainMetadata}
|
||||
@@ -85,6 +116,21 @@ export default async function Layout(props: {
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
client={client}
|
||||
>
|
||||
<NebulaFloatingChatButton
|
||||
authToken={authToken ?? undefined}
|
||||
account={account}
|
||||
label="Ask AI about this contract"
|
||||
client={client}
|
||||
nebulaParams={{
|
||||
messagePrefix: contractPromptPrefix,
|
||||
chainIds: [info.contract.chain.id],
|
||||
wallet: accountAddress ?? undefined,
|
||||
}}
|
||||
examplePrompts={examplePrompts.map((prompt) => ({
|
||||
title: prompt,
|
||||
message: prompt,
|
||||
}))}
|
||||
/>
|
||||
{props.children}
|
||||
</ContractPageLayout>
|
||||
);
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ export const ContractOverviewPage: React.FC<ContractOverviewPageProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-10 lg:flex-row lg:gap-8">
|
||||
<div className="flex grow flex-col gap-10">
|
||||
<div className="flex grow flex-col gap-10 overflow-hidden">
|
||||
<ContractChecklist
|
||||
isErc721={isErc721}
|
||||
isErc1155={isErc1155}
|
||||
|
||||
+44
-32
@@ -48,7 +48,6 @@ export function LatestEventsUI(props: {
|
||||
trackingCategory: string;
|
||||
}) {
|
||||
const { allEvents, autoUpdate, eventsHref } = props;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* header */}
|
||||
@@ -93,37 +92,50 @@ export function LatestEventsUI(props: {
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{allEvents.slice(0, 3).map((transaction) => (
|
||||
<TableRow key={transaction.transactionHash}>
|
||||
<TableCell>
|
||||
<CopyTextButton
|
||||
textToShow={shortenString(transaction.transactionHash)}
|
||||
textToCopy={transaction.transactionHash}
|
||||
tooltip="Copy transaction hash"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
className="-translate-x-2 font-mono"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex w-max flex-wrap gap-2">
|
||||
{transaction.events.map((e) => (
|
||||
<Button
|
||||
key={e.logIndex + e.address + e.eventName}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto rounded-full py-1"
|
||||
asChild
|
||||
>
|
||||
<Link href={`${eventsHref}?event=${e.eventName}`}>
|
||||
{e.eventName}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{allEvents.slice(0, 3).map((transaction) => {
|
||||
return (
|
||||
<TableRow key={transaction.transactionHash}>
|
||||
<TableCell>
|
||||
<CopyTextButton
|
||||
textToShow={shortenString(transaction.transactionHash)}
|
||||
textToCopy={transaction.transactionHash}
|
||||
tooltip="Copy transaction hash"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
className="-translate-x-2 font-mono"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex w-max flex-wrap gap-2">
|
||||
{transaction.events.slice(0, 3).map((e) => (
|
||||
<Button
|
||||
key={e.logIndex + e.address + e.eventName}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto rounded-full py-1"
|
||||
asChild
|
||||
>
|
||||
<Link href={`${eventsHref}?event=${e.eventName}`}>
|
||||
{e.eventName}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{transaction.events.length > 3 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto rounded-full py-1 hover:bg-transparent"
|
||||
asChild
|
||||
>
|
||||
<div>+ {transaction.events.length - 3}</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
+7
-1
@@ -7,7 +7,7 @@ import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
|
||||
import {
|
||||
type DirectListing,
|
||||
type EnglishAuction,
|
||||
@@ -237,6 +237,8 @@ const dummyMetadata: (idx: number) => ListingData = (idx) => ({
|
||||
supply: BigInt(1),
|
||||
tokenURI: "",
|
||||
type: "ERC721",
|
||||
tokenAddress: ZERO_ADDRESS,
|
||||
chainId: 1,
|
||||
},
|
||||
currencyValuePerToken: {
|
||||
decimals: 18,
|
||||
@@ -244,6 +246,8 @@ const dummyMetadata: (idx: number) => ListingData = (idx) => ({
|
||||
name: "Ether",
|
||||
symbol: "ETH",
|
||||
value: 0n,
|
||||
tokenAddress: ZERO_ADDRESS,
|
||||
chainId: 1,
|
||||
},
|
||||
creatorAddress: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
||||
type: "direct-listing",
|
||||
@@ -253,6 +257,8 @@ const dummyMetadata: (idx: number) => ListingData = (idx) => ({
|
||||
value: 0n,
|
||||
displayValue: "0.0",
|
||||
decimals: 18,
|
||||
tokenAddress: ZERO_ADDRESS,
|
||||
chainId: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+8
-1
@@ -46,8 +46,15 @@ export const TokenDetailsCard: React.FC<TokenBalancesProps> = ({
|
||||
tokenSupplyQuery.data,
|
||||
tokenMetadataQuery.data.decimals,
|
||||
),
|
||||
tokenAddress: contract.address,
|
||||
chainId: contract.chain.id,
|
||||
};
|
||||
}, [tokenMetadataQuery.data, tokenSupplyQuery.data]);
|
||||
}, [
|
||||
tokenMetadataQuery.data,
|
||||
tokenSupplyQuery.data,
|
||||
contract.address,
|
||||
contract.chain.id,
|
||||
]);
|
||||
|
||||
return (
|
||||
<TokenDetailsCardUI
|
||||
|
||||
+19
-3
@@ -15,7 +15,11 @@ const UNTHREAD_API_KEY = process.env.UNTHREAD_API_KEY || "";
|
||||
|
||||
const planToCustomerId = {
|
||||
free: process.env.UNTHREAD_FREE_TIER_ID as string,
|
||||
// treat starter as free
|
||||
starter: process.env.UNTHREAD_FREE_TIER_ID as string,
|
||||
growth: process.env.UNTHREAD_GROWTH_TIER_ID as string,
|
||||
accelerate: process.env.UNTHREAD_ACCELERATE_TIER_ID as string,
|
||||
scale: process.env.UNTHREAD_SCALE_TIER_ID as string,
|
||||
pro: process.env.UNTHREAD_PRO_TIER_ID as string,
|
||||
} as const;
|
||||
|
||||
@@ -43,9 +47,17 @@ function prepareEmailBody(props: {
|
||||
name: string;
|
||||
extraInfoInput: Record<string, string>;
|
||||
walletAddress: string;
|
||||
telegramHandle: string;
|
||||
}) {
|
||||
const { extraInfoInput, email, walletAddress, product, name, markdownInput } =
|
||||
props;
|
||||
const {
|
||||
extraInfoInput,
|
||||
email,
|
||||
walletAddress,
|
||||
product,
|
||||
name,
|
||||
markdownInput,
|
||||
telegramHandle,
|
||||
} = props;
|
||||
// Update `markdown` to include the infos from the form
|
||||
const extraInfo = Object.keys(extraInfoInput)
|
||||
.filter((key) => key.startsWith("extraInfo_"))
|
||||
@@ -58,6 +70,7 @@ function prepareEmailBody(props: {
|
||||
.join("");
|
||||
const markdown = `# Email: ${email}
|
||||
# Name: ${name}
|
||||
# Telegram: ${telegramHandle}
|
||||
# Wallet address: ${walletAddress}
|
||||
# Product: ${product}
|
||||
${extraInfo}
|
||||
@@ -99,12 +112,14 @@ export async function createTicketAction(
|
||||
}
|
||||
|
||||
const customerId = isValidPlan(team.supportPlan)
|
||||
? planToCustomerId[team.supportPlan]
|
||||
? // fall back to "free" tier
|
||||
planToCustomerId[team.supportPlan] || planToCustomerId.free
|
||||
: // fallback to "free" tier
|
||||
planToCustomerId.free;
|
||||
|
||||
const product = formData.get("product")?.toString() || "";
|
||||
const problemArea = formData.get("extraInfo_Problem_Area")?.toString() || "";
|
||||
const telegramHandle = formData.get("telegram")?.toString() || "";
|
||||
|
||||
const title = prepareEmailTitle(
|
||||
product,
|
||||
@@ -125,6 +140,7 @@ export async function createTicketAction(
|
||||
name: account.name || "",
|
||||
extraInfoInput: keyVal,
|
||||
walletAddress: walletAddress,
|
||||
telegramHandle: telegramHandle,
|
||||
});
|
||||
|
||||
const content = {
|
||||
|
||||
+3
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SupportForm_SelectInput } from "components/help/contact-forms/shared/SupportForm_SelectInput";
|
||||
import { SupportForm_TelegramInput } from "components/help/contact-forms/shared/SupportForm_TelegramInput";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
type ReactElement,
|
||||
@@ -155,6 +156,8 @@ export function CreateTicket(props: {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SupportForm_TelegramInput />
|
||||
|
||||
<ProductAreaSelection
|
||||
productLabel={productLabel}
|
||||
setProductLabel={setProductLabel}
|
||||
|
||||
@@ -77,6 +77,7 @@ export function InviteTeamMembersUI(props: {
|
||||
<Sheet open={showPlanModal} onOpenChange={setShowPlanModal}>
|
||||
<SheetContent className="!max-w-[1300px] w-full overflow-auto">
|
||||
<InviteModalContent
|
||||
billingStatus={props.team.billingStatus}
|
||||
teamSlug={props.team.slug}
|
||||
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
|
||||
trackEvent={props.trackEvent}
|
||||
@@ -144,6 +145,7 @@ export function InviteTeamMembersUI(props: {
|
||||
|
||||
function InviteModalContent(props: {
|
||||
teamSlug: string;
|
||||
billingStatus: Team["billingStatus"];
|
||||
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
|
||||
trackEvent: (params: TrackingParams) => void;
|
||||
}) {
|
||||
@@ -154,6 +156,7 @@ function InviteModalContent(props: {
|
||||
const starterPlan = (
|
||||
<PricingCard
|
||||
billingPlan="starter"
|
||||
billingStatus={props.billingStatus}
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get Started",
|
||||
@@ -174,6 +177,7 @@ function InviteModalContent(props: {
|
||||
const growthPlan = (
|
||||
<PricingCard
|
||||
billingPlan="growth"
|
||||
billingStatus={props.billingStatus}
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get Started",
|
||||
@@ -195,6 +199,7 @@ function InviteModalContent(props: {
|
||||
const acceleratePlan = (
|
||||
<PricingCard
|
||||
billingPlan="accelerate"
|
||||
billingStatus={props.billingStatus}
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get started",
|
||||
@@ -215,6 +220,7 @@ function InviteModalContent(props: {
|
||||
const scalePlan = (
|
||||
<PricingCard
|
||||
billingPlan="scale"
|
||||
billingStatus={props.billingStatus}
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get started",
|
||||
|
||||
@@ -34,7 +34,7 @@ export default async function Page(props: {
|
||||
session={session}
|
||||
type="new-chat"
|
||||
account={account}
|
||||
initialPrompt={undefined}
|
||||
initialParams={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function Page() {
|
||||
session={undefined}
|
||||
type="new-chat"
|
||||
account={account}
|
||||
initialPrompt={undefined}
|
||||
initialParams={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,18 @@ export function ChatBar(props: {
|
||||
sendMessage: (message: string) => void;
|
||||
isChatStreaming: boolean;
|
||||
abortChatStream: () => void;
|
||||
prefillMessage: string | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [message, setMessage] = useState(props.prefillMessage || "");
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border bg-card p-2",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
<AutoResizeTextarea
|
||||
placeholder={"Ask Nebula"}
|
||||
|
||||
@@ -29,7 +29,13 @@ export function ChatPageContent(props: {
|
||||
authToken: string;
|
||||
type: "landing" | "new-chat";
|
||||
account: Account;
|
||||
initialPrompt: string | undefined;
|
||||
initialParams:
|
||||
| {
|
||||
q: string | undefined;
|
||||
chainIds: number[];
|
||||
wallet: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}) {
|
||||
const address = useActiveAccount()?.address;
|
||||
const client = useThirdwebClient(props.authToken);
|
||||
@@ -85,8 +91,12 @@ export function ChatPageContent(props: {
|
||||
>(() => {
|
||||
const contextRes = props.session?.context;
|
||||
const value: NebulaContext = {
|
||||
chainIds: contextRes?.chain_ids || null,
|
||||
walletAddress: contextRes?.wallet_address || null,
|
||||
chainIds:
|
||||
contextRes?.chain_ids ||
|
||||
props.initialParams?.chainIds.map((x) => x.toString()) ||
|
||||
[],
|
||||
walletAddress:
|
||||
contextRes?.wallet_address || props.initialParams?.wallet || null,
|
||||
};
|
||||
|
||||
return value;
|
||||
@@ -118,18 +128,21 @@ export function ChatPageContent(props: {
|
||||
walletAddress: null,
|
||||
};
|
||||
|
||||
// Only set wallet address from connected wallet
|
||||
updatedContextFilters.walletAddress = address || null;
|
||||
if (!updatedContextFilters.walletAddress && address) {
|
||||
updatedContextFilters.walletAddress = address;
|
||||
}
|
||||
|
||||
// if we have last used chains in storage, continue using them
|
||||
try {
|
||||
const lastUsedChainIds = getLastUsedChainIds();
|
||||
if (lastUsedChainIds) {
|
||||
updatedContextFilters.chainIds = lastUsedChainIds;
|
||||
return updatedContextFilters;
|
||||
if (updatedContextFilters.chainIds?.length === 0) {
|
||||
// if we have last used chains in storage, continue using them
|
||||
try {
|
||||
const lastUsedChainIds = getLastUsedChainIds();
|
||||
if (lastUsedChainIds) {
|
||||
updatedContextFilters.chainIds = lastUsedChainIds;
|
||||
return updatedContextFilters;
|
||||
}
|
||||
} catch {
|
||||
// ignore local storage errors
|
||||
}
|
||||
} catch {
|
||||
// ignore local storage errors
|
||||
}
|
||||
|
||||
return updatedContextFilters;
|
||||
@@ -148,7 +161,9 @@ export function ChatPageContent(props: {
|
||||
(sessionId: string) => {
|
||||
_setSessionId(sessionId);
|
||||
// update page URL without reloading
|
||||
window.history.replaceState({}, "", `/chat/${sessionId}`);
|
||||
// THIS DOES NOT WORK ANYMORE!! - NEXT JS IS MONKEY PATCHING THIS TOO
|
||||
// Until we find a better solution, we are just not gonna update the URL
|
||||
// window.history.replaceState({}, "", `/chat/${sessionId}`);
|
||||
|
||||
// if the current page is landing page, link to /chat
|
||||
// if current page is new /chat page, link to landing page
|
||||
@@ -176,10 +191,6 @@ export function ChatPageContent(props: {
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (message: string) => {
|
||||
if (!address) {
|
||||
setShowConnectModal(true);
|
||||
return;
|
||||
}
|
||||
setUserHasSubmittedMessage(true);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
@@ -191,12 +202,11 @@ export function ChatPageContent(props: {
|
||||
},
|
||||
]);
|
||||
|
||||
const lowerCaseMessage = message.toLowerCase();
|
||||
// handle hardcoded replies first
|
||||
const lowerCaseMessage = message.toLowerCase();
|
||||
const interceptedReply = examplePrompts.find(
|
||||
(prompt) => prompt.message.toLowerCase() === lowerCaseMessage,
|
||||
)?.interceptedReply;
|
||||
|
||||
if (interceptedReply) {
|
||||
// slight delay to match other response times
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
@@ -220,8 +230,6 @@ export function ChatPageContent(props: {
|
||||
currentSessionId = session.id;
|
||||
}
|
||||
|
||||
let requestIdForMessage = "";
|
||||
|
||||
// add this session on sidebar
|
||||
if (messages.length === 0) {
|
||||
const prevValue = newSessionsStore.getValue();
|
||||
@@ -238,131 +246,29 @@ export function ChatPageContent(props: {
|
||||
|
||||
setChatAbortController(abortController);
|
||||
|
||||
await promptNebula({
|
||||
await handleNebulaPrompt({
|
||||
abortController,
|
||||
message: message,
|
||||
message,
|
||||
sessionId: currentSessionId,
|
||||
authToken: props.authToken,
|
||||
handleStream(res) {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.event === "init") {
|
||||
requestIdForMessage = res.data.request_id;
|
||||
}
|
||||
|
||||
if (res.event === "delta") {
|
||||
setMessages((prev) => {
|
||||
const lastMessage = prev[prev.length - 1];
|
||||
// if last message is presence, overwrite it
|
||||
if (lastMessage?.type === "presence") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
text: res.data.v,
|
||||
type: "assistant",
|
||||
request_id: requestIdForMessage,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// if last message is from chat, append to it
|
||||
if (lastMessage?.type === "assistant") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
text: lastMessage.text + res.data.v,
|
||||
type: "assistant",
|
||||
request_id: requestIdForMessage,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// otherwise, add a new message
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
text: res.data.v,
|
||||
type: "assistant",
|
||||
request_id: requestIdForMessage,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
if (res.event === "presence") {
|
||||
setMessages((prev) => {
|
||||
const lastMessage = prev[prev.length - 1];
|
||||
// if last message is presence, overwrite it
|
||||
if (lastMessage?.type === "presence") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ text: res.data.data, type: "presence" },
|
||||
];
|
||||
}
|
||||
// otherwise, add a new message
|
||||
return [...prev, { text: res.data.data, type: "presence" }];
|
||||
});
|
||||
}
|
||||
|
||||
if (res.event === "action") {
|
||||
if (res.type === "sign_transaction") {
|
||||
setMessages((prev) => {
|
||||
let prevMessages = prev;
|
||||
// if last message is presence, remove it
|
||||
if (
|
||||
prevMessages[prevMessages.length - 1]?.type === "presence"
|
||||
) {
|
||||
prevMessages = prevMessages.slice(0, -1);
|
||||
}
|
||||
|
||||
return [
|
||||
...prevMessages,
|
||||
{
|
||||
type: "send_transaction",
|
||||
data: res.data,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
context: contextFilters,
|
||||
setMessages,
|
||||
contextFilters: contextFilters,
|
||||
});
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.error(error);
|
||||
|
||||
setMessages((prev) => {
|
||||
const newMessages = prev.slice(
|
||||
0,
|
||||
prev[prev.length - 1]?.type === "presence" ? -1 : undefined,
|
||||
);
|
||||
|
||||
// add error message
|
||||
newMessages.push({
|
||||
text: `Error: ${error instanceof Error ? error.message : "Failed to execute command"}`,
|
||||
type: "error",
|
||||
});
|
||||
|
||||
return newMessages;
|
||||
handleNebulaPromptError({
|
||||
error,
|
||||
setMessages,
|
||||
});
|
||||
} finally {
|
||||
setIsChatStreaming(false);
|
||||
setEnableAutoScroll(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
sessionId,
|
||||
contextFilters,
|
||||
props.authToken,
|
||||
messages.length,
|
||||
initSession,
|
||||
address,
|
||||
],
|
||||
[sessionId, contextFilters, props.authToken, messages.length, initSession],
|
||||
);
|
||||
|
||||
const hasDoneAutoPrompt = useRef(false);
|
||||
@@ -370,16 +276,19 @@ export function ChatPageContent(props: {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (
|
||||
props.initialPrompt &&
|
||||
props.initialParams?.q &&
|
||||
messages.length === 0 &&
|
||||
!hasDoneAutoPrompt.current
|
||||
) {
|
||||
hasDoneAutoPrompt.current = true;
|
||||
handleSendMessage(props.initialPrompt);
|
||||
handleSendMessage(props.initialParams.q);
|
||||
}
|
||||
}, [props.initialPrompt, messages.length, handleSendMessage]);
|
||||
}, [props.initialParams?.q, messages.length, handleSendMessage]);
|
||||
|
||||
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
|
||||
const showEmptyState =
|
||||
!userHasSubmittedMessage &&
|
||||
messages.length === 0 &&
|
||||
!props.initialParams?.q;
|
||||
|
||||
const handleUpdateContextFilters = async (
|
||||
values: NebulaContext | undefined,
|
||||
@@ -412,7 +321,10 @@ export function ChatPageContent(props: {
|
||||
<div className="relative flex grow flex-col overflow-hidden rounded-lg pb-6">
|
||||
{showEmptyState ? (
|
||||
<div className="fade-in-0 container flex max-w-[800px] grow animate-in flex-col justify-center">
|
||||
<EmptyStateChatPageContent sendMessage={handleSendMessage} />
|
||||
<EmptyStateChatPageContent
|
||||
sendMessage={handleSendMessage}
|
||||
prefillMessage={props.initialParams?.q}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fade-in-0 relative z-[0] flex max-h-full flex-1 animate-in flex-col overflow-hidden">
|
||||
@@ -430,6 +342,7 @@ export function ChatPageContent(props: {
|
||||
|
||||
<div className="container max-w-[800px]">
|
||||
<ChatBar
|
||||
prefillMessage={undefined}
|
||||
sendMessage={handleSendMessage}
|
||||
isChatStreaming={isChatStreaming}
|
||||
abortChatStream={() => {
|
||||
@@ -452,9 +365,11 @@ export function ChatPageContent(props: {
|
||||
</div>
|
||||
<aside className="hidden w-[360px] flex-col border-l bg-card pt-4 xl:flex">
|
||||
<div className="px-4">
|
||||
<h3 className="font-semibold text-lg tracking-tight">Context</h3>
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
Settings
|
||||
</h3>
|
||||
<p className="mb-5 text-muted-foreground text-sm">
|
||||
Provide context to Nebula for your prompts
|
||||
Modify Nebula to suit your needs
|
||||
</p>
|
||||
</div>
|
||||
<ContextFiltersForm
|
||||
@@ -530,3 +445,136 @@ function getLastUsedChainIds(): string[] | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleNebulaPrompt(params: {
|
||||
abortController: AbortController;
|
||||
message: string;
|
||||
sessionId: string;
|
||||
authToken: string;
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
||||
contextFilters: NebulaContext | undefined;
|
||||
}) {
|
||||
const {
|
||||
abortController,
|
||||
message,
|
||||
sessionId,
|
||||
authToken,
|
||||
setMessages,
|
||||
contextFilters,
|
||||
} = params;
|
||||
let requestIdForMessage = "";
|
||||
|
||||
await promptNebula({
|
||||
abortController,
|
||||
message,
|
||||
sessionId,
|
||||
authToken,
|
||||
handleStream(res) {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.event === "init") {
|
||||
requestIdForMessage = res.data.request_id;
|
||||
}
|
||||
|
||||
if (res.event === "delta") {
|
||||
setMessages((prev) => {
|
||||
const lastMessage = prev[prev.length - 1];
|
||||
// if last message is presence, overwrite it
|
||||
if (lastMessage?.type === "presence") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
text: res.data.v,
|
||||
type: "assistant",
|
||||
request_id: requestIdForMessage,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// if last message is from chat, append to it
|
||||
if (lastMessage?.type === "assistant") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
text: lastMessage.text + res.data.v,
|
||||
type: "assistant",
|
||||
request_id: requestIdForMessage,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// otherwise, add a new message
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
text: res.data.v,
|
||||
type: "assistant",
|
||||
request_id: requestIdForMessage,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
if (res.event === "presence") {
|
||||
setMessages((prev) => {
|
||||
const lastMessage = prev[prev.length - 1];
|
||||
// if last message is presence, overwrite it
|
||||
if (lastMessage?.type === "presence") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ text: res.data.data, type: "presence" },
|
||||
];
|
||||
}
|
||||
// otherwise, add a new message
|
||||
return [...prev, { text: res.data.data, type: "presence" }];
|
||||
});
|
||||
}
|
||||
|
||||
if (res.event === "action") {
|
||||
if (res.type === "sign_transaction") {
|
||||
setMessages((prev) => {
|
||||
let prevMessages = prev;
|
||||
// if last message is presence, remove it
|
||||
if (prevMessages[prevMessages.length - 1]?.type === "presence") {
|
||||
prevMessages = prevMessages.slice(0, -1);
|
||||
}
|
||||
|
||||
return [
|
||||
...prevMessages,
|
||||
{
|
||||
type: "send_transaction",
|
||||
data: res.data,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
context: contextFilters,
|
||||
});
|
||||
}
|
||||
|
||||
export function handleNebulaPromptError(params: {
|
||||
error: unknown;
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
||||
}) {
|
||||
const { error, setMessages } = params;
|
||||
console.error(error);
|
||||
|
||||
setMessages((prev) => {
|
||||
const newMessages = prev.slice(
|
||||
0,
|
||||
prev[prev.length - 1]?.type === "presence" ? -1 : undefined,
|
||||
);
|
||||
|
||||
// add error message
|
||||
newMessages.push({
|
||||
text: `Error: ${error instanceof Error ? error.message : "Failed to execute command"}`,
|
||||
type: "error",
|
||||
});
|
||||
|
||||
return newMessages;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
"use client";
|
||||
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import {
|
||||
FileCode2Icon,
|
||||
MessageSquareShareIcon,
|
||||
MessagesSquareIcon,
|
||||
SquareDashedBottomCodeIcon,
|
||||
TextIcon,
|
||||
} from "lucide-react";
|
||||
import { ChevronRightIcon, FileCode2Icon, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { TruncatedSessionInfo } from "../api/types";
|
||||
import { useNewChatPageLink } from "../hooks/useNewChatPageLink";
|
||||
@@ -29,54 +22,47 @@ export function ChatSidebar(props: {
|
||||
const newChatPage = useNewChatPageLink();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-2">
|
||||
<div className="flex items-center justify-start gap-3 p-2 lg:justify-between">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-start gap-3 p-4 lg:justify-between">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<NebulaIcon className="size-8 text-foreground" aria-label="Nebula" />
|
||||
<span className="font-semibold text-lg tracking-tight">
|
||||
Playground
|
||||
</span>
|
||||
<span className="font-semibold text-lg tracking-tight">Nebula</span>
|
||||
</Link>
|
||||
|
||||
<Badge variant="secondary" className="gap-1 py-1">
|
||||
Alpha
|
||||
Beta
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
<div className="h-1" />
|
||||
|
||||
<div className="flex flex-col gap-2 px-2">
|
||||
<div className="flex flex-col gap-2 px-4">
|
||||
<Button asChild variant="outline" className="w-full gap-2 rounded-lg">
|
||||
<Link href={newChatPage}>New Chat</Link>
|
||||
<Link href={newChatPage}>
|
||||
<PlusIcon className="size-4" />
|
||||
New Chat
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="h-3" />
|
||||
|
||||
<SidebarIconLink
|
||||
href="https://portal.thirdweb.com/nebula/api-reference"
|
||||
icon={FileCode2Icon}
|
||||
label="API Reference"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<SidebarIconLink
|
||||
href="/chat/history"
|
||||
icon={MessagesSquareIcon}
|
||||
label="All Chats"
|
||||
/>
|
||||
<div className="h-5" />
|
||||
|
||||
{sessionsToShow.length > 0 && (
|
||||
<ScrollShadow
|
||||
className="my-3 flex-1 border-t border-dashed pt-2"
|
||||
scrollableClassName="max-h-full"
|
||||
shadowColor="transparent"
|
||||
shadowClassName="z-10"
|
||||
>
|
||||
<div className="flex-1 overflow-auto border-t border-dashed p-2 pt-2">
|
||||
<div className="flex items-center justify-between px-2 py-3">
|
||||
<h3 className="text-muted-foreground text-xs">Recent Chats</h3>
|
||||
{sessionsToShow.length < sessions.length && (
|
||||
<Link
|
||||
href="/chat/history"
|
||||
className="flex items-center gap-1 rounded-full text-foreground text-xs hover:underline"
|
||||
>
|
||||
View All
|
||||
<ChevronRightIcon className="size-3.5 text-muted-foreground" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="px-2 py-3 text-muted-foreground text-xs">
|
||||
Recent Chats
|
||||
</h3>
|
||||
{sessionsToShow.map((session) => {
|
||||
return (
|
||||
<ChatSidebarLink
|
||||
@@ -88,27 +74,13 @@ export function ChatSidebar(props: {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 border-y border-dashed py-3">
|
||||
<SidebarIconLink
|
||||
href="https://docs.google.com/forms/d/e/1FAIpQLSeM3fJRyywihRZUF1fiTNKEpJ_AzAcohRwXPpLr_3zxQ6W-tg/viewform?usp=sharing"
|
||||
icon={MessageSquareShareIcon}
|
||||
label="Take our quick survey!"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<SidebarIconLink
|
||||
href="https://portal.thirdweb.com/changelog"
|
||||
icon={TextIcon}
|
||||
label="Changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<div className="mb-3 border-y border-dashed px-2 py-3">
|
||||
<SidebarIconLink
|
||||
href="https://portal.thirdweb.com/nebula"
|
||||
icon={SquareDashedBottomCodeIcon}
|
||||
icon={FileCode2Icon}
|
||||
label="Documentation"
|
||||
target="_blank"
|
||||
/>
|
||||
@@ -130,13 +102,12 @@ function SidebarIconLink(props: {
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<Button asChild variant="ghost">
|
||||
<Link
|
||||
href={props.href}
|
||||
target={props.target}
|
||||
className="!justify-start !px-3 w-full gap-2.5 rounded-lg text-left"
|
||||
prefetch={false}
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-start gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm"
|
||||
>
|
||||
<Link href={props.href} target={props.target} prefetch={false}>
|
||||
<props.icon className="size-4" />
|
||||
{props.label}
|
||||
</Link>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ChatSidebarLink(props: {
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="!h-auto !w-full items-center justify-between px-2 text-left text-sm hover:bg-transparent"
|
||||
className="h-auto w-full items-center justify-between px-2 py-1.5 text-left text-sm hover:bg-transparent"
|
||||
>
|
||||
<NavLink href={`/chat/${props.sessionId}`} activeClassName="bg-muted">
|
||||
<span className="line-clamp-1 block truncate">{props.title}</span>
|
||||
@@ -61,7 +61,7 @@ export function ChatSidebarLink(props: {
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="-translate-y-1/2 !bg-background absolute top-1/2 right-1 z-10 h-auto w-auto p-1.5 opacity-0 group-hover:opacity-100"
|
||||
className="-translate-y-1/2 absolute top-1/2 right-1 z-10 h-auto w-auto bg-accent p-1 opacity-0 hover:bg-zinc-300 group-hover:opacity-100 dark:hover:bg-zinc-700"
|
||||
variant="ghost"
|
||||
>
|
||||
<EllipsisIcon className="size-4" />
|
||||
|
||||
@@ -27,6 +27,7 @@ function Story() {
|
||||
abortChatStream={() => {}}
|
||||
isChatStreaming={false}
|
||||
sendMessage={() => {}}
|
||||
prefillMessage={undefined}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -35,6 +36,16 @@ function Story() {
|
||||
abortChatStream={() => {}}
|
||||
isChatStreaming={true}
|
||||
sendMessage={() => {}}
|
||||
prefillMessage={undefined}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Prefilled Message">
|
||||
<ChatBar
|
||||
abortChatStream={() => {}}
|
||||
isChatStreaming={false}
|
||||
sendMessage={() => {}}
|
||||
prefillMessage="This is a prefilled message"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,7 @@ export function Chats(props: {
|
||||
client: ThirdwebClient;
|
||||
setEnableAutoScroll: (enable: boolean) => void;
|
||||
enableAutoScroll: boolean;
|
||||
useSmallText?: boolean;
|
||||
}) {
|
||||
const { messages, setEnableAutoScroll, enableAutoScroll } = props;
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
@@ -99,7 +100,7 @@ export function Chats(props: {
|
||||
>
|
||||
<ScrollShadow
|
||||
className="flex-1"
|
||||
scrollableClassName="max-h-full"
|
||||
scrollableClassName="max-h-full overscroll-contain"
|
||||
shadowColor="hsl(var(--background))"
|
||||
shadowClassName="z-[1]"
|
||||
>
|
||||
@@ -110,24 +111,20 @@ export function Chats(props: {
|
||||
props.isChatStreaming && index === props.messages.length - 1;
|
||||
return (
|
||||
<div
|
||||
className="fade-in-0 min-w-0 animate-in pt-1 text-sm duration-300 lg:text-base"
|
||||
className={cn(
|
||||
"fade-in-0 min-w-0 animate-in pt-1 text-sm duration-300 lg:text-base",
|
||||
props.useSmallText && "lg:text-sm",
|
||||
)}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is the unique key
|
||||
key={index}
|
||||
>
|
||||
{message.type === "user" ? (
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="max-w-[80%] overflow-auto rounded-xl border bg-card px-4 py-2">
|
||||
<MarkdownRenderer
|
||||
skipHtml
|
||||
markdownText={message.text}
|
||||
code={{
|
||||
ignoreFormattingErrors: true,
|
||||
className: "bg-transparent",
|
||||
}}
|
||||
className="text-foreground [&>*:last-child]:mb-0"
|
||||
p={{ className: "text-foreground leading-normal" }}
|
||||
li={{ className: "text-foreground" }}
|
||||
inlineCode={{ className: "border-none" }}
|
||||
<StyledMarkdownRenderer
|
||||
text={message.text}
|
||||
isMessagePending={isMessagePending}
|
||||
type="user"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,18 +158,10 @@ export function Chats(props: {
|
||||
<div className="min-w-0 grow">
|
||||
<ScrollShadow className="rounded-lg">
|
||||
{message.type === "assistant" ? (
|
||||
<MarkdownRenderer
|
||||
skipHtml
|
||||
markdownText={message.text}
|
||||
code={{
|
||||
disableCodeHighlight: isMessagePending,
|
||||
ignoreFormattingErrors: true,
|
||||
}}
|
||||
className="text-foreground [&>*:last-child]:mb-0"
|
||||
p={{
|
||||
className: "text-foreground",
|
||||
}}
|
||||
li={{ className: "text-foreground" }}
|
||||
<StyledMarkdownRenderer
|
||||
text={message.text}
|
||||
isMessagePending={isMessagePending}
|
||||
type="assistant"
|
||||
/>
|
||||
) : message.type === "error" ? (
|
||||
<div className="rounded-xl border bg-card px-4 py-2 text-destructive-text leading-normal">
|
||||
@@ -269,10 +258,14 @@ function MessageActions(props: {
|
||||
const sendBadRating = useMutation({
|
||||
mutationFn: () => sendRating("bad"),
|
||||
onSuccess() {
|
||||
toast.info("Thanks for the feedback!");
|
||||
toast.info("Thanks for the feedback!", {
|
||||
position: "top-right",
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
toast.error("Failed to send feedback");
|
||||
toast.error("Failed to send feedback", {
|
||||
position: "top-right",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -332,3 +325,29 @@ function MessageActions(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StyledMarkdownRenderer(props: {
|
||||
text: string;
|
||||
isMessagePending: boolean;
|
||||
type: "assistant" | "user";
|
||||
}) {
|
||||
return (
|
||||
<MarkdownRenderer
|
||||
skipHtml
|
||||
markdownText={props.text}
|
||||
className="text-foreground [&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
|
||||
code={{
|
||||
ignoreFormattingErrors: true,
|
||||
className: "bg-transparent",
|
||||
}}
|
||||
p={{
|
||||
className:
|
||||
props.type === "assistant"
|
||||
? "text-foreground leading-loose"
|
||||
: "text-foreground leading-normal",
|
||||
}}
|
||||
li={{ className: "text-foreground" }}
|
||||
inlineCode={{ className: "border-none" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,9 +66,9 @@ export default function ContextFiltersButton(props: {
|
||||
</DialogTrigger>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-1">
|
||||
<DialogTitle className="font-semibold text-xl">Context</DialogTitle>
|
||||
<DialogTitle className="font-semibold text-xl">Settings</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Provide context to Nebula for your prompts
|
||||
Modify Nebula to suit your needs
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -189,6 +189,7 @@ export function ContextFiltersForm(props: {
|
||||
<FormLabel>Chain IDs</FormLabel>
|
||||
<FormControl>
|
||||
<MultiNetworkSelector
|
||||
hideTestnets
|
||||
disableChainId
|
||||
className="bg-background"
|
||||
selectedChainIds={form
|
||||
@@ -197,7 +198,9 @@ export function ContextFiltersForm(props: {
|
||||
.filter(Boolean)
|
||||
.map(Number)}
|
||||
onChange={(values) => {
|
||||
form.setValue("chainIds", values.join(","));
|
||||
form.setValue("chainIds", values.join(","), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
priorityChains={[
|
||||
1, // ethereum
|
||||
@@ -250,13 +253,20 @@ export function ContextFiltersForm(props: {
|
||||
</DialogClose>
|
||||
)}
|
||||
<Button
|
||||
className="min-w-24 gap-2"
|
||||
className="min-w-24 gap-2 disabled:text-muted-foreground disabled:opacity-100"
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={updateMutation.isPending}
|
||||
variant={
|
||||
props.modal
|
||||
? "default"
|
||||
: form.formState.isDirty
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
disabled={updateMutation.isPending || !form.formState.isDirty}
|
||||
>
|
||||
{updateMutation.isPending && <Spinner className="size-4" />}
|
||||
Update Context
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+16
-3
@@ -15,13 +15,26 @@ export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
args: {
|
||||
prefillMessage: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
export const PrefilledMessage: Story = {
|
||||
args: {
|
||||
prefillMessage: "This is a prefilled message",
|
||||
},
|
||||
};
|
||||
|
||||
function Story(props: {
|
||||
prefillMessage: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="container flex max-w-[800px] grow flex-col justify-center overflow-hidden">
|
||||
<EmptyStateChatPageContent sendMessage={() => {}} />
|
||||
<EmptyStateChatPageContent
|
||||
sendMessage={() => {}}
|
||||
prefillMessage={props.prefillMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,22 +8,22 @@ import { ChatBar } from "./ChatBar";
|
||||
|
||||
export function EmptyStateChatPageContent(props: {
|
||||
sendMessage: (message: string) => void;
|
||||
prefillMessage: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-10 lg:py-16">
|
||||
<div className="relative py-10">
|
||||
<FancyBorders />
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-lg border p-1">
|
||||
<div className="rounded border bg-muted p-1.5">
|
||||
<div className="rounded-full border p-1">
|
||||
<div className="rounded-full border bg-card p-2">
|
||||
<NebulaIcon className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-5" />
|
||||
<h1 className="px-4 text-center font-semibold text-3xl tracking-tight md:text-4xl">
|
||||
How can I help you <br className="max-sm:hidden" />
|
||||
with the blockchain today?
|
||||
How can I help you <br /> onchain today?
|
||||
</h1>
|
||||
<div className="h-5" />
|
||||
<div className="mx-auto max-w-[600px]">
|
||||
@@ -33,6 +33,7 @@ export function EmptyStateChatPageContent(props: {
|
||||
abortChatStream={() => {
|
||||
// the page will switch so, no need to handle abort here
|
||||
}}
|
||||
prefillMessage={props.prefillMessage}
|
||||
/>
|
||||
<div className="h-5" />
|
||||
<div className="flex flex-wrap justify-center gap-2.5">
|
||||
|
||||
+2
-2
@@ -71,9 +71,9 @@ function Variant(props: {
|
||||
twAccount={accountStub()}
|
||||
txData={{
|
||||
chainId: 1,
|
||||
to: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
|
||||
to: "0xEb0effdFB4dC5b3d5d3aC6ce29F3ED213E95d675", // thirdweb.eth
|
||||
data: "0x",
|
||||
value: "0x16345785d8a0000",
|
||||
value: "0x16345785d8a0000", // 0.1 eth
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Account as TWAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import {
|
||||
ArrowRightLeftIcon,
|
||||
@@ -15,10 +16,12 @@ import { useState } from "react";
|
||||
import {
|
||||
type ThirdwebClient,
|
||||
prepareTransaction,
|
||||
toEther,
|
||||
waitForReceipt,
|
||||
} from "thirdweb";
|
||||
import { useSendTransaction } from "thirdweb/react";
|
||||
import { useActiveAccount, useSendTransaction } from "thirdweb/react";
|
||||
import { TransactionButton } from "../../../../components/buttons/TransactionButton";
|
||||
import { ChainIconClient } from "../../../../components/icons/ChainIcon";
|
||||
import { useTrack } from "../../../../hooks/analytics/useTrack";
|
||||
import { useV5DashboardChain } from "../../../../lib/v5-adapter";
|
||||
import { getSDKTheme } from "../../../components/sdk-component-theme";
|
||||
@@ -76,29 +79,157 @@ export function ExecuteTransactionCardLayout(props: {
|
||||
},
|
||||
});
|
||||
const chain = useV5DashboardChain(txData.chainId);
|
||||
const isTransactionSent =
|
||||
props.status.type === "confirming" || props.status.type === "confirmed";
|
||||
const trackEvent = useTrack();
|
||||
const account = useActiveAccount();
|
||||
|
||||
const explorer = chain.blockExplorers?.[0]?.url;
|
||||
|
||||
const isTransactionPending =
|
||||
props.status.type === "sending" || props.status.type === "confirming";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-xl border bg-card">
|
||||
<div className="flex flex-col gap-4 p-4 pb-6">
|
||||
<h3 className="font-semibold text-foreground text-lg tracking-tight">
|
||||
Transaction
|
||||
</h3>
|
||||
{/* header */}
|
||||
<h3 className="border-b p-4 py-4 font-semibold text-foreground text-lg tracking-tight lg:px-6 lg:text-xl">
|
||||
Transaction
|
||||
</h3>
|
||||
|
||||
<CodeClient code={JSON.stringify(txData, null, 2)} lang="json" />
|
||||
{/* content */}
|
||||
<div className="px-4 text-sm lg:px-6 [&>*]:h-12 [&>*]:border-b lg:[&>*]:h-14">
|
||||
{/* From */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">From</span>
|
||||
{account ? (
|
||||
<WalletAddress
|
||||
address={account.address}
|
||||
className="h-auto py-0"
|
||||
iconClassName="size-5"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Your Wallet</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* To */}
|
||||
{txData.to && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">To</span>
|
||||
|
||||
<WalletAddress
|
||||
address={txData.to}
|
||||
className="h-auto py-0"
|
||||
iconClassName="size-5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Value */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Value</span>
|
||||
{toEther(BigInt(txData.value))} {chain.nativeCurrency?.symbol}
|
||||
</div>
|
||||
|
||||
{/* Network */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Network</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChainIconClient
|
||||
className="size-5 rounded-full"
|
||||
src={chain.icon?.url}
|
||||
/>
|
||||
<span className="text-foreground">
|
||||
{chain.name || `Chain ID: ${txData.chainId}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
{props.status.type !== "idle" && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 font-medium",
|
||||
props.status.type === "sending" && "text-blue-500",
|
||||
props.status.type === "confirming" && "text-yellow-500",
|
||||
props.status.type === "confirmed" && "text-green-500",
|
||||
props.status.type === "failed" && "text-red-500",
|
||||
)}
|
||||
>
|
||||
{/* icon */}
|
||||
{(props.status.type === "sending" ||
|
||||
props.status.type === "confirming") && (
|
||||
<Spinner className="size-4" />
|
||||
)}
|
||||
|
||||
{props.status.type === "confirmed" && (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
)}
|
||||
|
||||
{props.status.type === "failed" && (
|
||||
<CircleXIcon className="size-4" />
|
||||
)}
|
||||
|
||||
{/* text */}
|
||||
<span>
|
||||
{props.status.type === "sending" && "Sending Transaction"}
|
||||
{props.status.type === "confirming" &&
|
||||
"Waiting for Confirmation"}
|
||||
{props.status.type === "confirmed" &&
|
||||
"Transaction Confirmed"}
|
||||
{props.status.type === "failed" && "Transaction Failed"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction Hash */}
|
||||
{"txHash" in props.status && props.status.txHash && (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-muted-foreground">Transaction Hash</span>
|
||||
<div className="flex justify-end gap-2.5">
|
||||
{explorer ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 font-mono"
|
||||
>
|
||||
<Link
|
||||
href={`${explorer}/tx/${props.status.txHash}`}
|
||||
target="_blank"
|
||||
>
|
||||
{`${props.status.txHash.slice(0, 6)}...${props.status.txHash.slice(-4)}`}
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<CopyTextButton
|
||||
textToCopy={props.status.txHash}
|
||||
textToShow={`${props.status.txHash.slice(0, 6)}...${props.status.txHash.slice(-4)}`}
|
||||
variant="ghost"
|
||||
className="font-mono"
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Transaction Hash"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end border-t p-4">
|
||||
{/* footer */}
|
||||
<div className="flex items-center justify-end px-4 py-6 lg:px-6">
|
||||
<TransactionButton
|
||||
isPending={sendTransaction.isPending}
|
||||
transactionCount={undefined}
|
||||
txChainID={txData.chainId}
|
||||
variant="default"
|
||||
disabled={isTransactionPending}
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
trackEvent({
|
||||
category: "nebula",
|
||||
@@ -164,72 +295,6 @@ export function ExecuteTransactionCardLayout(props: {
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tx Status */}
|
||||
{props.status.type !== "idle" && (
|
||||
<div className="mt-5 rounded-lg border bg-card">
|
||||
<div className="flex flex-col gap-1.5 p-4">
|
||||
{props.status.type === "sending" && (
|
||||
<div className="flex items-center gap-2 text-link-foreground">
|
||||
<Spinner className="size-4" />
|
||||
<p> Sending Transaction </p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTransactionSent && (
|
||||
<div className="flex items-center gap-2 text-success-text">
|
||||
<CircleCheckIcon className="size-4" />
|
||||
<p> Transaction Sent </p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.status.type === "confirming" && (
|
||||
<div className="flex items-center gap-2 text-link-foreground">
|
||||
<Spinner className="size-4" />
|
||||
<p> Confirming Transaction </p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.status.type === "confirmed" && (
|
||||
<div className="flex items-center gap-2 text-success-text">
|
||||
<CircleCheckIcon className="size-4" />
|
||||
<p> Transaction Confirmed </p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.status.type === "failed" && (
|
||||
<div className="flex items-center gap-2 text-destructive-text">
|
||||
<CircleXIcon className="size-4" />
|
||||
<p> Transaction Failed </p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{"txHash" in props.status && props.status.txHash && (
|
||||
<div className="flex justify-end gap-2.5 border-t p-4">
|
||||
{explorer && (
|
||||
<Button asChild variant="outline" size="sm" className="gap-2 ">
|
||||
<Link
|
||||
href={`${explorer}/tx/${props.status.txHash}`}
|
||||
target="_blank"
|
||||
>
|
||||
View on Explorer
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<CopyTextButton
|
||||
textToCopy={props.status.txHash}
|
||||
className=""
|
||||
textToShow="Transaction Hash"
|
||||
variant="outline"
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Transaction Hash"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { accountStub } from "../../../../../stories/stubs";
|
||||
import { examplePrompts } from "../../data/examplePrompts";
|
||||
import { NebulaFloatingChatButton } from "./FloatingChat";
|
||||
|
||||
const meta = {
|
||||
title: "Nebula/FloatingChat",
|
||||
component: NebulaFloatingChatButton,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ThirdwebProvider>
|
||||
<Story />
|
||||
</ThirdwebProvider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof NebulaFloatingChatButton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NebulaFloatingChatButton>;
|
||||
|
||||
const client = getThirdwebClient();
|
||||
|
||||
export const LoggedIn: Story = {
|
||||
args: {
|
||||
account: accountStub(),
|
||||
authToken: "foo",
|
||||
nebulaParams: undefined,
|
||||
label: "Ask AI about this contract",
|
||||
examplePrompts: examplePrompts,
|
||||
client,
|
||||
},
|
||||
};
|
||||
|
||||
export const LoggedOut: Story = {
|
||||
args: {
|
||||
account: undefined,
|
||||
authToken: undefined,
|
||||
nebulaParams: undefined,
|
||||
label: "Ask AI about this contract",
|
||||
examplePrompts: examplePrompts,
|
||||
client,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { ExternalLinkIcon, RefreshCcwIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Suspense,
|
||||
lazy,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { ExamplePrompt } from "../../data/examplePrompts";
|
||||
import { NebulaIcon } from "../../icons/NebulaIcon";
|
||||
|
||||
const LazyFloatingChatContent = lazy(() => import("./FloatingChatContent"));
|
||||
|
||||
export function NebulaFloatingChatButton(props: {
|
||||
authToken: string | undefined;
|
||||
examplePrompts: ExamplePrompt[];
|
||||
account: Account | undefined;
|
||||
label: string;
|
||||
client: ThirdwebClient;
|
||||
nebulaParams:
|
||||
| {
|
||||
messagePrefix: string;
|
||||
chainIds: number[];
|
||||
wallet: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasBeenOpened, setHasBeenOpened] = useState(false);
|
||||
const closeModal = useCallback(() => setIsOpen(false), []);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isOpen && (
|
||||
<div className="fixed right-6 bottom-6 z-50 flex items-center gap-1.5">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
setHasBeenOpened(true);
|
||||
}}
|
||||
variant="default"
|
||||
className="gap-2 rounded-full"
|
||||
>
|
||||
<NebulaIcon className="size-4" />
|
||||
{props.label}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-10 rounded-full bg-card p-0"
|
||||
onClick={() => setIsDismissed(true)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NebulaChatUIContainer
|
||||
onClose={closeModal}
|
||||
isOpen={isOpen}
|
||||
hasBeenOpened={hasBeenOpened}
|
||||
authToken={props.authToken}
|
||||
account={props.account}
|
||||
client={props.client}
|
||||
nebulaParams={props.nebulaParams}
|
||||
examplePrompts={props.examplePrompts}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NebulaChatUIContainer(props: {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
hasBeenOpened: boolean;
|
||||
authToken: string | undefined;
|
||||
account: Account | undefined;
|
||||
examplePrompts: ExamplePrompt[];
|
||||
client: ThirdwebClient;
|
||||
nebulaParams:
|
||||
| {
|
||||
messagePrefix: string;
|
||||
chainIds: number[];
|
||||
wallet: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}) {
|
||||
const ref = useOutsideClick(props.onClose);
|
||||
const shouldRenderChat = props.isOpen || props.hasBeenOpened;
|
||||
const [nebulaSessionKey, setNebulaSessionKey] = useState(0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"slide-in-from-bottom-20 zoom-in-95 fade-in-0 fixed bottom-0 left-0 z-50 flex h-[80vh] w-[100vw] animate-in flex-col overflow-hidden rounded-t-2xl border bg-background shadow-2xl duration-200 lg:right-6 lg:bottom-6 lg:left-auto lg:h-[80vh] lg:max-w-xl lg:rounded-xl",
|
||||
!props.isOpen && "hidden",
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<Link
|
||||
className="group flex items-center gap-2"
|
||||
target="_blank"
|
||||
href="https://thirdweb.com/nebula"
|
||||
>
|
||||
<h2 className="font-semibold text-lg tracking-tight">Nebula</h2>
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground/70 group-hover:text-foreground" />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolTipLabel label="Reset chat">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setNebulaSessionKey((prev) => prev + 1)}
|
||||
className="h-auto w-auto p-1 text-muted-foreground"
|
||||
>
|
||||
<RefreshCcwIcon className="size-5" />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
|
||||
<ToolTipLabel label="Close chat">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={props.onClose}
|
||||
className="h-auto w-auto p-1 text-muted-foreground"
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* once opened keep the component mounted to preserve the states */}
|
||||
<div className="relative flex grow flex-col overflow-hidden">
|
||||
{shouldRenderChat && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyFloatingChatContent
|
||||
authToken={props.authToken}
|
||||
account={props.account}
|
||||
client={props.client}
|
||||
nebulaParams={props.nebulaParams}
|
||||
key={nebulaSessionKey}
|
||||
examplePrompts={props.examplePrompts}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useOutsideClick(onOutsideClick: () => void) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// clicking outside the chat window should close it
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
// if clicked on a dialog or popover - ignore
|
||||
if (
|
||||
(event.target as HTMLElement).closest(
|
||||
"[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onOutsideClick();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [onOutsideClick]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { ArrowRightIcon, ArrowUpRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { Button } from "../../../../../@/components/ui/button";
|
||||
import type { NebulaContext } from "../../api/chat";
|
||||
import { createSession } from "../../api/session";
|
||||
import type { ExamplePrompt } from "../../data/examplePrompts";
|
||||
import { NebulaIcon } from "../../icons/NebulaIcon";
|
||||
import { ChatBar } from "../ChatBar";
|
||||
import {
|
||||
handleNebulaPrompt,
|
||||
handleNebulaPromptError,
|
||||
} from "../ChatPageContent";
|
||||
import { Chats } from "../Chats";
|
||||
import type { ChatMessage } from "../Chats";
|
||||
|
||||
export default function FloatingChatContent(props: {
|
||||
authToken: string | undefined;
|
||||
account: Account | undefined;
|
||||
client: ThirdwebClient;
|
||||
examplePrompts: ExamplePrompt[];
|
||||
nebulaParams:
|
||||
| {
|
||||
messagePrefix: string;
|
||||
chainIds: number[];
|
||||
wallet: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}) {
|
||||
if (!props.account || !props.authToken) {
|
||||
return <LoggedOutStateChatContent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingChatContentLoggedIn
|
||||
authToken={props.authToken}
|
||||
account={props.account}
|
||||
client={props.client}
|
||||
nebulaParams={props.nebulaParams}
|
||||
examplePrompts={props.examplePrompts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FloatingChatContentLoggedIn(props: {
|
||||
authToken: string;
|
||||
account: Account;
|
||||
client: ThirdwebClient;
|
||||
examplePrompts: ExamplePrompt[];
|
||||
nebulaParams:
|
||||
| {
|
||||
messagePrefix: string;
|
||||
chainIds: number[];
|
||||
wallet: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}) {
|
||||
const [userHasSubmittedMessage, setUserHasSubmittedMessage] = useState(false);
|
||||
const [messages, setMessages] = useState<Array<ChatMessage>>([]);
|
||||
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
|
||||
const [chatAbortController, setChatAbortController] = useState<
|
||||
AbortController | undefined
|
||||
>();
|
||||
const [isChatStreaming, setIsChatStreaming] = useState(false);
|
||||
const [enableAutoScroll, setEnableAutoScroll] = useState(false);
|
||||
|
||||
const contextFilters: NebulaContext = useMemo(() => {
|
||||
return {
|
||||
chainIds:
|
||||
props.nebulaParams?.chainIds.map((chainId) => chainId.toString()) ||
|
||||
null,
|
||||
walletAddress: props.nebulaParams?.wallet || null,
|
||||
};
|
||||
}, [props.nebulaParams]);
|
||||
|
||||
const initSession = useCallback(async () => {
|
||||
const session = await createSession({
|
||||
authToken: props.authToken,
|
||||
context: contextFilters,
|
||||
});
|
||||
setSessionId(session.id);
|
||||
return session;
|
||||
}, [props.authToken, contextFilters]);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (userMessage: string) => {
|
||||
const abortController = new AbortController();
|
||||
setUserHasSubmittedMessage(true);
|
||||
setIsChatStreaming(true);
|
||||
setEnableAutoScroll(true);
|
||||
|
||||
// if this is first message, set the message prefix
|
||||
const messageToSend =
|
||||
props.nebulaParams?.messagePrefix && !userHasSubmittedMessage
|
||||
? `${props.nebulaParams.messagePrefix}\n\n${userMessage}`
|
||||
: userMessage;
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ text: userMessage, type: "user" },
|
||||
// instant loading indicator feedback to user
|
||||
{
|
||||
type: "presence",
|
||||
text: "Thinking...",
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
// Ensure we have a session ID
|
||||
let currentSessionId = sessionId;
|
||||
if (!currentSessionId) {
|
||||
const session = await initSession();
|
||||
currentSessionId = session.id;
|
||||
}
|
||||
|
||||
setChatAbortController(abortController);
|
||||
await handleNebulaPrompt({
|
||||
abortController,
|
||||
message: messageToSend,
|
||||
sessionId: currentSessionId,
|
||||
authToken: props.authToken,
|
||||
setMessages,
|
||||
contextFilters: contextFilters,
|
||||
});
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleNebulaPromptError({
|
||||
error,
|
||||
setMessages,
|
||||
});
|
||||
} finally {
|
||||
setIsChatStreaming(false);
|
||||
setEnableAutoScroll(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
props.authToken,
|
||||
contextFilters,
|
||||
initSession,
|
||||
sessionId,
|
||||
props.nebulaParams?.messagePrefix,
|
||||
userHasSubmittedMessage,
|
||||
],
|
||||
);
|
||||
|
||||
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col overflow-hidden">
|
||||
{showEmptyState ? (
|
||||
<EmptyStateChatPageContent
|
||||
sendMessage={handleSendMessage}
|
||||
examplePrompts={props.examplePrompts}
|
||||
/>
|
||||
) : (
|
||||
<Chats
|
||||
messages={messages}
|
||||
isChatStreaming={isChatStreaming}
|
||||
authToken={props.authToken}
|
||||
sessionId={sessionId}
|
||||
className="min-w-0 pb-10"
|
||||
twAccount={props.account}
|
||||
client={props.client}
|
||||
enableAutoScroll={enableAutoScroll}
|
||||
setEnableAutoScroll={setEnableAutoScroll}
|
||||
useSmallText
|
||||
/>
|
||||
)}
|
||||
<ChatBar
|
||||
abortChatStream={() => {
|
||||
chatAbortController?.abort();
|
||||
setChatAbortController(undefined);
|
||||
setIsChatStreaming(false);
|
||||
// if last message is presence, remove it
|
||||
if (messages[messages.length - 1]?.type === "presence") {
|
||||
setMessages((prev) => prev.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
isChatStreaming={isChatStreaming}
|
||||
prefillMessage=""
|
||||
sendMessage={handleSendMessage}
|
||||
className="rounded-none border-x-0 border-b-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoggedOutStateChatContent() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div className="flex grow flex-col items-center justify-center p-4">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full border p-1">
|
||||
<div className="rounded-full border bg-card p-2">
|
||||
<NebulaIcon className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="px-4 text-center font-semibold text-3xl tracking-tight md:text-4xl">
|
||||
How can I help you <br className="max-sm:hidden" />
|
||||
onchain today?
|
||||
</h1>
|
||||
|
||||
<div className="h-3" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
Sign in to use Nebula AI
|
||||
</p>
|
||||
<div className="h-5" />
|
||||
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/login?next=${encodeURIComponent(pathname)}`}
|
||||
className="w-full max-w-96 gap-2"
|
||||
>
|
||||
Sign in
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyStateChatPageContent(props: {
|
||||
sendMessage: (message: string) => void;
|
||||
examplePrompts: ExamplePrompt[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center overflow-auto p-4 ">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full border p-1">
|
||||
<div className="rounded-full border bg-card p-2">
|
||||
<NebulaIcon className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="px-4 text-center font-semibold text-3xl tracking-tight md:text-4xl">
|
||||
How can I help you <br className="max-sm:hidden" />
|
||||
onchain today?
|
||||
</h1>
|
||||
|
||||
<div className="h-6" />
|
||||
<div className="flex max-w-lg flex-col flex-wrap justify-center gap-2.5">
|
||||
{props.examplePrompts.map((prompt) => {
|
||||
return (
|
||||
<ExamplePromptButton
|
||||
key={prompt.title}
|
||||
label={prompt.title}
|
||||
onClick={() => props.sendMessage(prompt.message)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExamplePromptButton(props: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto gap-1.5 rounded-full bg-card px-3 py-2 text-muted-foreground text-xs"
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.label} <ArrowUpRightIcon className="size-3" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
type ExamplePrompt = {
|
||||
export type ExamplePrompt = {
|
||||
title: string;
|
||||
message: string;
|
||||
interceptedReply?: string;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { type Team, getTeams } from "@/api/team";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import Link from "next/link";
|
||||
import { getTeams } from "@/api/team";
|
||||
import type React from "react";
|
||||
import { getValidAccount } from "../../account/settings/getAccount";
|
||||
import {
|
||||
@@ -8,11 +6,8 @@ import {
|
||||
getAuthTokenWalletAddress,
|
||||
} from "../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../login/loginRedirect";
|
||||
import { NebulaWaitListPage } from "../../team/[team_slug]/[project_slug]/nebula/components/nebula-waitlist-page";
|
||||
import { getSessions } from "./api/session";
|
||||
import { ChatPageLayout } from "./components/ChatPageLayout";
|
||||
import { NebulaAccountButton } from "./components/NebulaAccountButton";
|
||||
import { NebulaIcon } from "./icons/NebulaIcon";
|
||||
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
@@ -37,15 +32,6 @@ export default async function Layout(props: {
|
||||
loginRedirect();
|
||||
}
|
||||
|
||||
const teamWithNebulaAccess = teams.find((team) =>
|
||||
team.enabledScopes.includes("nebula"),
|
||||
);
|
||||
|
||||
// if none of them teams have nebula access, request access on first team, and show waitlist page
|
||||
if (!teamWithNebulaAccess) {
|
||||
return <NebulaWaitlistPage account={account} team={firstTeam} />;
|
||||
}
|
||||
|
||||
const sessions = await getSessions({
|
||||
authToken,
|
||||
}).catch(() => []);
|
||||
@@ -61,43 +47,3 @@ export default async function Layout(props: {
|
||||
</ChatPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function NebulaWaitlistPage(props: {
|
||||
account: Account;
|
||||
team: Team;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col overflow-hidden">
|
||||
<header className="border-b">
|
||||
<div className="container flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-2 font-medium text-xl tracking-tight">
|
||||
<NebulaIcon className="size-8 text-foreground" />
|
||||
Nebula
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<Link
|
||||
href="https://thirdweb.com/support"
|
||||
className="text-muted-foreground text-sm hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/"
|
||||
className="text-muted-foreground text-sm hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
|
||||
<NebulaAccountButton account={props.account} type="compact" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* page */}
|
||||
<NebulaWaitListPage team={props.team} hideHeader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { isAddress } from "thirdweb";
|
||||
import { fetchChain } from "../../../utils/fetchChain";
|
||||
import { getValidAccount } from "../../account/settings/getAccount";
|
||||
import { getAuthToken } from "../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../login/loginRedirect";
|
||||
@@ -5,27 +8,67 @@ import { ChatPageContent } from "./components/ChatPageContent";
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<{
|
||||
prompt?: string;
|
||||
q?: string | string[];
|
||||
chain?: string | string[];
|
||||
wallet?: string | string[];
|
||||
}>;
|
||||
}) {
|
||||
const [searchParams, authToken] = await Promise.all([
|
||||
props.searchParams,
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const [chainIds, authToken, account] = await Promise.all([
|
||||
getChainIds(searchParams.chain),
|
||||
getAuthToken(),
|
||||
getValidAccount(),
|
||||
]);
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect();
|
||||
}
|
||||
|
||||
const account = await getValidAccount();
|
||||
|
||||
return (
|
||||
<ChatPageContent
|
||||
authToken={authToken}
|
||||
session={undefined}
|
||||
type="landing"
|
||||
account={account}
|
||||
initialPrompt={searchParams.prompt}
|
||||
initialParams={{
|
||||
q: typeof searchParams.q === "string" ? searchParams.q : undefined,
|
||||
chainIds: chainIds,
|
||||
wallet:
|
||||
typeof searchParams.wallet === "string" &&
|
||||
isAddress(searchParams.wallet)
|
||||
? searchParams.wallet
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getChainIds = unstable_cache(
|
||||
async (_chainNames: string[] | string | undefined) => {
|
||||
if (!_chainNames) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chainIds: number[] = [];
|
||||
|
||||
const chainNames =
|
||||
typeof _chainNames === "string" ? [_chainNames] : _chainNames;
|
||||
|
||||
const chainResults = await Promise.allSettled(
|
||||
chainNames.map((x) => fetchChain(x)),
|
||||
);
|
||||
|
||||
for (const chainResult of chainResults) {
|
||||
if (chainResult.status === "fulfilled" && chainResult.value) {
|
||||
chainIds.push(chainResult.value.chainId);
|
||||
}
|
||||
}
|
||||
|
||||
return chainIds;
|
||||
},
|
||||
["nebula_getChainIds"],
|
||||
{
|
||||
revalidate: 60 * 60 * 24, // 24 hours
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,11 +11,38 @@ import { LoginAndOnboardingPageContent } from "../../login/LoginPage";
|
||||
|
||||
export function NebulaLoginPage(props: {
|
||||
account: Account | undefined;
|
||||
params: {
|
||||
chain: string | string[] | undefined;
|
||||
q: string | undefined;
|
||||
wallet: string | undefined;
|
||||
};
|
||||
}) {
|
||||
const [message, setMessage] = useState<string | undefined>(undefined);
|
||||
const [message, setMessage] = useState<string | undefined>(props.params.q);
|
||||
const [showPage, setShowPage] = useState<"connect" | "welcome">(
|
||||
props.account ? "connect" : "welcome",
|
||||
);
|
||||
|
||||
const redirectPathObj = {
|
||||
chain: props.params.chain,
|
||||
q: message, // don't use props.params.q, because message may be updated by user
|
||||
wallet: props.params.wallet,
|
||||
};
|
||||
|
||||
const redirectPathParams = Object.entries(redirectPathObj)
|
||||
.map(([key, value]) => {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => `${key}=${encodeURIComponent(v)}`).join("&");
|
||||
}
|
||||
|
||||
return `${key}=${encodeURIComponent(value)}`;
|
||||
})
|
||||
.filter((v) => v !== "")
|
||||
.join("&");
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col overflow-hidden bg-background">
|
||||
{/* nav */}
|
||||
@@ -55,15 +82,14 @@ export function NebulaLoginPage(props: {
|
||||
<LoginAndOnboardingPageContent
|
||||
loginWithInAppWallet={false}
|
||||
account={props.account}
|
||||
redirectPath={
|
||||
message ? `/?prompt=${encodeURIComponent(message)}` : "/"
|
||||
}
|
||||
redirectPath={`/?${redirectPathParams}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPage === "welcome" && (
|
||||
<div className="container relative flex max-w-[800px] grow flex-col justify-center overflow-hidden rounded-lg pb-6">
|
||||
<EmptyStateChatPageContent
|
||||
prefillMessage={message}
|
||||
sendMessage={(msg) => {
|
||||
setMessage(msg);
|
||||
setShowPage("connect");
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { getRawAccount } from "../../account/settings/getAccount";
|
||||
import { NebulaLoginPage } from "./NebulaLoginPage";
|
||||
|
||||
export default async function NebulaLogin() {
|
||||
export default async function NebulaLogin(props: {
|
||||
searchParams: Promise<{
|
||||
chain?: string | string[];
|
||||
q?: string | string[];
|
||||
wallet?: string | string[];
|
||||
}>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const account = await getRawAccount();
|
||||
|
||||
return <NebulaLoginPage account={account} />;
|
||||
return (
|
||||
<NebulaLoginPage
|
||||
account={account}
|
||||
params={{
|
||||
chain: searchParams.chain,
|
||||
q: typeof searchParams.q === "string" ? searchParams.q : undefined,
|
||||
wallet:
|
||||
typeof searchParams.wallet === "string"
|
||||
? searchParams.wallet
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { UserPlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function InviteTeamMembersButton(props: { teamSlug: string }) {
|
||||
const trackEvent = useTrack();
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() =>
|
||||
trackEvent({
|
||||
category: "inviteTeam",
|
||||
action: "click",
|
||||
label: "invite-team",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Link href={`/team/${props.teamSlug}/~/settings/members`}>
|
||||
<UserPlusIcon className="size-4" />
|
||||
<span>Invite Team Members</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { subDays } from "date-fns";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import { InviteTeamMembersButton } from "./_components/invite-team-members-button";
|
||||
import {
|
||||
type ProjectWithAnalytics,
|
||||
TeamProjectsPage,
|
||||
@@ -33,11 +34,14 @@ export default async function Page(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b py-10">
|
||||
<div className="container">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Team Overview
|
||||
</h1>
|
||||
<div className="border-border border-b">
|
||||
<div className="container flex flex-col items-start gap-3 py-10 md:flex-row md:items-center">
|
||||
<div className="flex-1">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Team Overview
|
||||
</h1>
|
||||
</div>
|
||||
<InviteTeamMembersButton teamSlug={params.team_slug} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+11
@@ -4,6 +4,7 @@ import {
|
||||
getLastNDaysRange,
|
||||
} from "components/analytics/date-range-selector";
|
||||
import { RangeSelector } from "components/analytics/range-selector";
|
||||
import type { Partner } from "../../../../types";
|
||||
import { EcosystemWalletUsersChartCard } from "./EcosystemWalletUsersChartCard";
|
||||
import { EcosystemWalletsSummary } from "./Summary";
|
||||
|
||||
@@ -12,11 +13,13 @@ export async function EcosystemAnalyticsPage({
|
||||
teamId,
|
||||
interval,
|
||||
range,
|
||||
partners,
|
||||
}: {
|
||||
ecosystemSlug: string;
|
||||
teamId: string;
|
||||
interval: "day" | "week";
|
||||
range?: Range;
|
||||
partners: Partner[];
|
||||
}) {
|
||||
if (!range) {
|
||||
range = getLastNDaysRange("last-120");
|
||||
@@ -69,6 +72,14 @@ export async function EcosystemAnalyticsPage({
|
||||
<EcosystemWalletUsersChartCard
|
||||
ecosystemWalletStats={stats || []}
|
||||
isPending={false}
|
||||
groupBy="authenticationMethod"
|
||||
partners={partners}
|
||||
/>
|
||||
<EcosystemWalletUsersChartCard
|
||||
ecosystemWalletStats={stats || []}
|
||||
isPending={false}
|
||||
groupBy="ecosystemPartnerId"
|
||||
partners={partners}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+8
@@ -53,6 +53,7 @@ function ecosystemWalletStatsStub(
|
||||
date: formattedDate,
|
||||
authenticationMethod: authMethod || "MetaMask",
|
||||
uniqueWalletsConnected: Math.floor(Math.random() * 1000) + 1,
|
||||
ecosystemPartnerId: "123",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -65,6 +66,7 @@ export const EmptyData: Story = {
|
||||
args: {
|
||||
ecosystemWalletStats: [],
|
||||
isPending: false,
|
||||
groupBy: "authenticationMethod",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -73,6 +75,7 @@ export const Loading: Story = {
|
||||
args: {
|
||||
ecosystemWalletStats: [],
|
||||
isPending: true,
|
||||
groupBy: "authenticationMethod",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -81,6 +84,7 @@ export const ThirtyDaysData: Story = {
|
||||
args: {
|
||||
ecosystemWalletStats: ecosystemWalletStatsStub(30),
|
||||
isPending: false,
|
||||
groupBy: "authenticationMethod",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -89,6 +93,7 @@ export const SixtyDaysData: Story = {
|
||||
args: {
|
||||
ecosystemWalletStats: ecosystemWalletStatsStub(60),
|
||||
isPending: false,
|
||||
groupBy: "authenticationMethod",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -97,6 +102,7 @@ export const OneHundredTwentyDaysData: Story = {
|
||||
args: {
|
||||
ecosystemWalletStats: ecosystemWalletStatsStub(120),
|
||||
isPending: false,
|
||||
groupBy: "authenticationMethod",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -114,6 +120,7 @@ export const ManyAuthMethods: Story = {
|
||||
}));
|
||||
})(),
|
||||
isPending: false,
|
||||
groupBy: "authenticationMethod",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -125,5 +132,6 @@ export const ZeroValues: Story = {
|
||||
uniqueWalletsConnected: 0,
|
||||
})),
|
||||
isPending: false,
|
||||
groupBy: "authenticationMethod",
|
||||
},
|
||||
};
|
||||
|
||||
+36
-12
@@ -10,6 +10,7 @@ import { formatDate } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import type { EcosystemWalletStats } from "types/analytics";
|
||||
import { formatTickerNumber } from "../../../../../../../../../../lib/format-utils";
|
||||
import type { Partner } from "../../../../types";
|
||||
|
||||
type ChartData = Record<string, number> & {
|
||||
time: string; // human readable date
|
||||
@@ -19,8 +20,10 @@ const defaultLabel = "Unknown Auth";
|
||||
export function EcosystemWalletUsersChartCard(props: {
|
||||
ecosystemWalletStats: EcosystemWalletStats[];
|
||||
isPending: boolean;
|
||||
groupBy: "ecosystemPartnerId" | "authenticationMethod";
|
||||
partners?: Partner[];
|
||||
}) {
|
||||
const { ecosystemWalletStats } = props;
|
||||
const { ecosystemWalletStats, groupBy, partners } = props;
|
||||
|
||||
const topChainsToShow = 10;
|
||||
|
||||
@@ -31,25 +34,40 @@ export function EcosystemWalletUsersChartCard(props: {
|
||||
// for each stat, add it in _chartDataMap
|
||||
for (const stat of ecosystemWalletStats) {
|
||||
const chartData = _chartDataMap.get(stat.date);
|
||||
const { authenticationMethod } = stat;
|
||||
const { authenticationMethod, ecosystemPartnerId } = stat;
|
||||
|
||||
// if no data for current day - create new entry
|
||||
if (!chartData) {
|
||||
_chartDataMap.set(stat.date, {
|
||||
time: stat.date,
|
||||
[authenticationMethod || defaultLabel]: stat.uniqueWalletsConnected,
|
||||
[groupBy === "ecosystemPartnerId"
|
||||
? ecosystemPartnerId
|
||||
: authenticationMethod || defaultLabel]:
|
||||
stat.uniqueWalletsConnected,
|
||||
} as ChartData);
|
||||
} else if (chartData) {
|
||||
chartData[authenticationMethod || defaultLabel] =
|
||||
(chartData[authenticationMethod || defaultLabel] || 0) +
|
||||
stat.uniqueWalletsConnected;
|
||||
chartData[
|
||||
groupBy === "ecosystemPartnerId"
|
||||
? ecosystemPartnerId
|
||||
: authenticationMethod || defaultLabel
|
||||
] =
|
||||
(chartData[
|
||||
groupBy === "ecosystemPartnerId"
|
||||
? ecosystemPartnerId
|
||||
: authenticationMethod || defaultLabel
|
||||
] || 0) + stat.uniqueWalletsConnected;
|
||||
}
|
||||
|
||||
authMethodToVolumeMap.set(
|
||||
authenticationMethod || defaultLabel,
|
||||
groupBy === "ecosystemPartnerId"
|
||||
? ecosystemPartnerId
|
||||
: authenticationMethod || defaultLabel,
|
||||
stat.uniqueWalletsConnected +
|
||||
(authMethodToVolumeMap.get(authenticationMethod || defaultLabel) ||
|
||||
0),
|
||||
(authMethodToVolumeMap.get(
|
||||
groupBy === "ecosystemPartnerId"
|
||||
? ecosystemPartnerId
|
||||
: authenticationMethod || defaultLabel,
|
||||
) || 0),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +90,12 @@ export function EcosystemWalletUsersChartCard(props: {
|
||||
|
||||
authMethodsToShow.forEach((walletType, i) => {
|
||||
_chartConfig[walletType] = {
|
||||
label: authMethodsToShow[i],
|
||||
label:
|
||||
groupBy === "ecosystemPartnerId"
|
||||
? partners?.find((p) => p.id === walletType)?.name ||
|
||||
authMethodsToShow[i] ||
|
||||
"none"
|
||||
: authMethodsToShow[i],
|
||||
color: `hsl(var(--chart-${(i % 10) + 1}))`,
|
||||
};
|
||||
});
|
||||
@@ -92,7 +115,7 @@ export function EcosystemWalletUsersChartCard(props: {
|
||||
),
|
||||
chartConfig: _chartConfig,
|
||||
};
|
||||
}, [ecosystemWalletStats]);
|
||||
}, [ecosystemWalletStats, groupBy, partners]);
|
||||
|
||||
const uniqueAuthMethods = Object.keys(chartConfig);
|
||||
const disableActions =
|
||||
@@ -108,7 +131,8 @@ export function EcosystemWalletUsersChartCard(props: {
|
||||
customHeader={
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
|
||||
Unique Users
|
||||
Unique Users by{" "}
|
||||
{groupBy === "ecosystemPartnerId" ? "Partner" : "Auth Method"}
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
The total number of active users in your ecosystem for each period.
|
||||
|
||||
+8
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { getTeamBySlug } from "../../../../../../../../../@/api/team";
|
||||
import { getAuthToken } from "../../../../../../../../api/lib/getAuthToken";
|
||||
import { fetchEcosystem } from "../../../utils/fetchEcosystem";
|
||||
import { fetchPartners } from "../configuration/hooks/fetchPartners";
|
||||
import { EcosystemAnalyticsPage } from "./components/EcosystemAnalyticsPage";
|
||||
|
||||
export default async function Page(props: {
|
||||
@@ -40,11 +41,18 @@ export default async function Page(props: {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
const partners = await fetchPartners({
|
||||
ecosystem,
|
||||
authToken,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<EcosystemAnalyticsPage
|
||||
ecosystemSlug={ecosystem.slug}
|
||||
teamId={team.id}
|
||||
interval={searchParams.interval || "week"}
|
||||
partners={partners}
|
||||
range={
|
||||
searchParams.from && searchParams.to
|
||||
? {
|
||||
|
||||
+6
-3
@@ -23,7 +23,7 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { TWTable } from "components/shared/TWTable";
|
||||
import { format } from "date-fns";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
@@ -81,7 +81,7 @@ export const ContractSubscriptionTable: React.FC<
|
||||
const chain = idToChain.get(cell.getValue());
|
||||
return (
|
||||
<Flex align="center" gap={2}>
|
||||
<ChainIcon className="size-3" ipfsSrc={chain?.icon?.url} />
|
||||
<ChainIconClient className="size-3" ipfsSrc={chain?.icon?.url} />
|
||||
<Text>{chain?.name ?? "N/A"}</Text>
|
||||
</Flex>
|
||||
);
|
||||
@@ -399,7 +399,10 @@ const RemoveModal = ({
|
||||
<FormControl>
|
||||
<FormLabel>Chain</FormLabel>
|
||||
<Flex align="center" gap={2}>
|
||||
<ChainIcon className="size-3" ipfsSrc={chain?.icon?.url} />
|
||||
<ChainIconClient
|
||||
className="size-3"
|
||||
ipfsSrc={chain?.icon?.url}
|
||||
/>
|
||||
<Text>{chain?.name ?? "N/A"}</Text>
|
||||
</Flex>
|
||||
</FormControl>
|
||||
|
||||
+5
-2
@@ -34,7 +34,7 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { type ColumnDef, createColumnHelper } from "@tanstack/react-table";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { TWTable } from "components/shared/TWTable";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
@@ -582,7 +582,10 @@ const SendFundsModal = ({
|
||||
<FormControl>
|
||||
<FormLabel>Chain</FormLabel>
|
||||
<Flex align="center" gap={2}>
|
||||
<ChainIcon className="size-3" ipfsSrc={chain?.icon?.url} />
|
||||
<ChainIconClient
|
||||
className="size-3"
|
||||
ipfsSrc={chain?.icon?.url}
|
||||
/>
|
||||
<Text>{chain?.name}</Text>
|
||||
</Flex>
|
||||
</FormControl>
|
||||
|
||||
+3
-3
@@ -38,7 +38,7 @@ import {
|
||||
useEngineTransactions,
|
||||
} from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { Collapse, Divider, useDisclosure } from "@chakra-ui/react";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { format, formatDate } from "date-fns/format";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
@@ -373,7 +373,7 @@ function TxChainCell(props: { chainId: string | undefined }) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChainIcon className="size-5" ipfsSrc={chain.icon?.url} />
|
||||
<ChainIconClient className="size-5" ipfsSrc={chain.icon?.url} />
|
||||
<div className="max-w-[150px] truncate">
|
||||
{chain.name ?? `Chain ID: ${chainId}`}
|
||||
</div>
|
||||
@@ -622,7 +622,7 @@ const TransactionDetailsDrawer = ({
|
||||
<div>
|
||||
<FormLabel>Chain</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChainIcon className="size-5" ipfsSrc={chain?.icon?.url} />
|
||||
<ChainIconClient className="size-5" ipfsSrc={chain?.icon?.url} />
|
||||
<span>{chain?.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -23,7 +23,7 @@ import {
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { TWTable } from "components/shared/TWTable";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
@@ -71,7 +71,7 @@ export const RelayersTable: React.FC<RelayersTableProps> = ({
|
||||
const chain = idToChain.get(Number.parseInt(cell.getValue()));
|
||||
return (
|
||||
<Flex align="center" gap={2}>
|
||||
<ChainIcon className="size-3" ipfsSrc={chain?.icon?.url} />
|
||||
<ChainIconClient className="size-3" ipfsSrc={chain?.icon?.url} />
|
||||
<Text>{chain?.name ?? "N/A"}</Text>
|
||||
</Flex>
|
||||
);
|
||||
@@ -404,7 +404,7 @@ const RemoveModal = ({
|
||||
<FormControl>
|
||||
<FormLabel>Chain</FormLabel>
|
||||
<Flex align="center" gap={2}>
|
||||
<ChainIcon
|
||||
<ChainIconClient
|
||||
className="size-3"
|
||||
ipfsSrc={
|
||||
idToChain.get(Number.parseInt(relayer.chainId))?.icon?.url
|
||||
|
||||
+2
@@ -119,6 +119,8 @@ export function PlanInfoCardUI(props: {
|
||||
</Button>
|
||||
|
||||
<CancelPlanButton
|
||||
teamSlug={props.team.slug}
|
||||
billingStatus={props.team.billingStatus}
|
||||
cancelPlan={props.cancelPlan}
|
||||
currentPlan={props.team.billingPlan}
|
||||
getTeam={props.getTeam}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useQueryStates } from "nuqs";
|
||||
import { startTransition } from "react";
|
||||
import { searchParams } from "../search-params";
|
||||
|
||||
export function BillingFilter() {
|
||||
const [{ status }, setStates] = useQueryStates(
|
||||
{
|
||||
cursor: searchParams.cursor,
|
||||
status: searchParams.status,
|
||||
},
|
||||
{
|
||||
history: "push",
|
||||
shallow: false,
|
||||
startTransition,
|
||||
},
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
value={status ?? "all"}
|
||||
onValueChange={(v) => {
|
||||
setStates({
|
||||
cursor: null,
|
||||
// only set the status if it's "open", otherwise clear it
|
||||
status: v === "open" ? "open" : null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Invoices</SelectItem>
|
||||
<SelectItem value="open">Open Invoices</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
+9
@@ -25,6 +25,7 @@ import { searchParams } from "../search-params";
|
||||
|
||||
export function BillingHistory(props: {
|
||||
invoices: Stripe.Invoice[];
|
||||
status: "all" | "past_due" | "open";
|
||||
hasMore: boolean;
|
||||
}) {
|
||||
const [isLoading, startTransition] = useTransition();
|
||||
@@ -74,6 +75,14 @@ export function BillingHistory(props: {
|
||||
};
|
||||
|
||||
if (props.invoices.length === 0) {
|
||||
if (props.status === "open") {
|
||||
return (
|
||||
<div className="py-6 text-center">
|
||||
<Receipt className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-2 font-medium text-lg">No open invoices</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="py-6 text-center">
|
||||
<Receipt className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getTeamBySlug } from "@/api/team";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { SearchParams } from "nuqs/server";
|
||||
import { getValidAccount } from "../../../../../../account/settings/getAccount";
|
||||
import { BillingFilter } from "./components/billing-filter";
|
||||
import { BillingHistory } from "./components/billing-history";
|
||||
import { searchParamLoader } from "./search-params";
|
||||
|
||||
@@ -31,19 +32,28 @@ export default async function Page(props: {
|
||||
|
||||
const invoices = await getTeamInvoices(team, {
|
||||
cursor: searchParams.cursor ?? undefined,
|
||||
status: searchParams.status ?? undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="p-6">
|
||||
<h2 className="font-semibold text-2xl leading-none tracking-tight">
|
||||
Invoice History
|
||||
</h2>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
View your past invoices and payment history
|
||||
</p>
|
||||
<div className="flex items-center justify-between p-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold text-2xl leading-none tracking-tight">
|
||||
Invoice History
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
View your past invoices and payment history
|
||||
</p>
|
||||
</div>
|
||||
<BillingFilter />
|
||||
</div>
|
||||
<BillingHistory invoices={invoices.data} hasMore={invoices.has_more} />
|
||||
<BillingHistory
|
||||
invoices={invoices.data}
|
||||
hasMore={invoices.has_more}
|
||||
// fall back to "all" if the status is not set
|
||||
status={searchParams.status ?? "all"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createLoader, parseAsString } from "nuqs/server";
|
||||
import { createLoader, parseAsString, parseAsStringEnum } from "nuqs/server";
|
||||
|
||||
export const searchParams = {
|
||||
cursor: parseAsString,
|
||||
status: parseAsStringEnum(["open"]),
|
||||
};
|
||||
|
||||
export const searchParamLoader = createLoader(searchParams);
|
||||
|
||||
@@ -23,6 +23,11 @@ export default async function Layout(props: {
|
||||
exactMatch: true,
|
||||
label: "Overview",
|
||||
},
|
||||
{
|
||||
href: `/team/${params.team_slug}/~/usage/rpc`,
|
||||
exactMatch: true,
|
||||
label: "RPC",
|
||||
},
|
||||
{
|
||||
href: `/team/${params.team_slug}/~/usage/storage`,
|
||||
exactMatch: true,
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { ExternalLinkIcon, XIcon } from "lucide-react";
|
||||
@@ -317,7 +317,7 @@ function ChainCell(props: { chainId: string }) {
|
||||
|
||||
return (
|
||||
<div className="relative flex w-max items-center gap-2">
|
||||
<ChainIcon ipfsSrc={chain?.icon?.url} className="size-6" />
|
||||
<ChainIconClient ipfsSrc={chain?.icon?.url} className="size-6" />
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`/${chain ? chain.slug : props.chainId}`}
|
||||
|
||||
+99
-40
@@ -1,6 +1,8 @@
|
||||
import { getInAppWalletUsage, getUserOpUsage } from "@/api/analytics";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { TeamSubscription } from "@/api/team-subscription";
|
||||
import type { RPCUsageDataItem } from "@/api/usage/rpc";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type {
|
||||
Account,
|
||||
@@ -11,7 +13,6 @@ import { subMonths } from "date-fns";
|
||||
import Link from "next/link";
|
||||
import { Suspense, useMemo } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { toPercent, toSize } from "utils/number";
|
||||
import { TotalSponsoredChartCardUI } from "../../../../_components/TotalSponsoredCard";
|
||||
import { SponsoredTransactionsTable } from "./SponsoredTransactionsTable";
|
||||
import { UsageCard } from "./UsageCard";
|
||||
@@ -29,8 +30,14 @@ type UsageProps = {
|
||||
image: string | null;
|
||||
slug: string;
|
||||
}[];
|
||||
rpcUsage: RPCUsageDataItem[];
|
||||
};
|
||||
|
||||
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: 0,
|
||||
notation: "compact",
|
||||
});
|
||||
|
||||
export const Usage: React.FC<UsageProps> = ({
|
||||
usage: usageData,
|
||||
subscriptions,
|
||||
@@ -38,40 +45,8 @@ export const Usage: React.FC<UsageProps> = ({
|
||||
team,
|
||||
client,
|
||||
projects,
|
||||
rpcUsage,
|
||||
}) => {
|
||||
// TODO - get this from team instead of account
|
||||
const storageMetrics = useMemo(() => {
|
||||
if (!usageData) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const consumedBytes = usageData.usage.storage.sumFileSizeBytes;
|
||||
const limitBytes = usageData.limits.storage;
|
||||
const percent = toPercent(consumedBytes, limitBytes);
|
||||
|
||||
return {
|
||||
total: `${toSize(Math.min(consumedBytes, limitBytes), "MB")} of ${toSize(limitBytes)} included storage used`,
|
||||
progress: percent,
|
||||
totalUsage: `Total Usage: ${toSize(consumedBytes, "MB")}`,
|
||||
};
|
||||
}, [usageData]);
|
||||
|
||||
// TODO - get this from team instead of account
|
||||
const rpcMetrics = useMemo(() => {
|
||||
if (!usageData) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Unlimited Requests",
|
||||
total: (
|
||||
<span className="text-muted-foreground">
|
||||
{usageData.rateLimits.rpc} Requests Per Second
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}, [usageData]);
|
||||
|
||||
const gatewayMetrics = useMemo(() => {
|
||||
if (!usageData) {
|
||||
return {};
|
||||
@@ -81,7 +56,8 @@ export const Usage: React.FC<UsageProps> = ({
|
||||
title: "Unlimited Requests",
|
||||
total: (
|
||||
<span className="text-muted-foreground">
|
||||
{usageData.rateLimits.storage} Requests Per Second
|
||||
{compactNumberFormatter.format(usageData.rateLimits.storage)} Requests
|
||||
Per Second
|
||||
</span>
|
||||
),
|
||||
};
|
||||
@@ -100,6 +76,39 @@ export const Usage: React.FC<UsageProps> = ({
|
||||
? new Date(usageSub.currentPeriodEnd)
|
||||
: new Date();
|
||||
|
||||
const storageUsage = team.capabilities.storage.upload;
|
||||
|
||||
const rpcUsageData = useMemo(() => {
|
||||
const mappedRPCUsage = rpcUsage.reduce(
|
||||
(acc, curr) => {
|
||||
switch (curr.usageType) {
|
||||
case "rate-limit":
|
||||
acc[curr.date] = {
|
||||
...(acc[curr.date] || {}),
|
||||
"rate-limit":
|
||||
(acc[curr.date]?.["rate-limit"] || 0) + Number(curr.count),
|
||||
included: acc[curr.date]?.included || 0,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
acc[curr.date] = {
|
||||
...(acc[curr.date] || {}),
|
||||
"rate-limit": acc[curr.date]?.["rate-limit"] || 0,
|
||||
included: (acc[curr.date]?.included || 0) + Number(curr.count),
|
||||
};
|
||||
break;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { included: number; "rate-limit": number }>,
|
||||
);
|
||||
|
||||
return Object.entries(mappedRPCUsage).map(([date, usage]) => ({
|
||||
time: new Date(date).getTime(),
|
||||
...usage,
|
||||
}));
|
||||
}, [rpcUsage]);
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-8">
|
||||
<InAppWalletUsersChartCard
|
||||
@@ -126,10 +135,26 @@ export const Usage: React.FC<UsageProps> = ({
|
||||
variant="team"
|
||||
/>
|
||||
|
||||
<UsageCard
|
||||
{...rpcMetrics}
|
||||
name="RPC"
|
||||
description="Amount of RPC requests allowed per second in your plan"
|
||||
<ThirdwebBarChart
|
||||
header={{
|
||||
title: "RPC Requests",
|
||||
description: `Your plan allows for ${usageData.rateLimits.rpc} requests per second`,
|
||||
titleClassName: "text-xl mb-0.5",
|
||||
}}
|
||||
data={rpcUsageData}
|
||||
isPending={false}
|
||||
variant="stacked"
|
||||
config={{
|
||||
included: {
|
||||
label: "RPC Requests",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
"rate-limit": {
|
||||
label: "Rate Limited Requests",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
}}
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4]"
|
||||
/>
|
||||
|
||||
<UsageCard
|
||||
@@ -139,7 +164,15 @@ export const Usage: React.FC<UsageProps> = ({
|
||||
/>
|
||||
|
||||
<UsageCard
|
||||
{...storageMetrics}
|
||||
title={`${formatStorageBytes(storageUsage.totalFileSizeBytesLimit)} Storage`}
|
||||
total={
|
||||
storageUsage.rateLimit > 10_000 ? undefined : (
|
||||
<p className="text-muted-foreground">
|
||||
{compactNumberFormatter.format(storageUsage.rateLimit)} Requests
|
||||
Per Second
|
||||
</p>
|
||||
)
|
||||
}
|
||||
name="Storage Pinning"
|
||||
description="Amount of IPFS Storage pinning allowed in your plan"
|
||||
>
|
||||
@@ -274,3 +307,29 @@ async function AsyncTotalSponsoredChartCard(
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatStorageBytes(bytes: number) {
|
||||
const ONE_KB = 1024;
|
||||
const ONE_MB = ONE_KB * 1024;
|
||||
const ONE_GB = ONE_MB * 1024;
|
||||
|
||||
if (bytes < ONE_KB) {
|
||||
return `${bytes} bytes`;
|
||||
}
|
||||
|
||||
if (bytes < ONE_MB) {
|
||||
return `${compactNumberFormatter.format(bytes / ONE_KB)} KB`;
|
||||
}
|
||||
|
||||
if (bytes < ONE_GB) {
|
||||
return `${compactNumberFormatter.format(bytes / ONE_MB)} MB`;
|
||||
}
|
||||
|
||||
const gbs = bytes / ONE_GB;
|
||||
|
||||
if (gbs > 1000) {
|
||||
return "Unlimited";
|
||||
}
|
||||
|
||||
return `${compactNumberFormatter.format(gbs)} GB`;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getTeamSubscriptions } from "@/api/team-subscription";
|
||||
import { fetchRPCUsage } from "@/api/usage/rpc";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { normalizeTimeISOString } from "lib/time";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getValidAccount } from "../../../../../account/settings/getAccount";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../login/loginRedirect";
|
||||
import { getAccountUsage } from "./getAccountUsage";
|
||||
import { Usage } from "./overview/components/Usage";
|
||||
|
||||
@@ -24,13 +27,28 @@ export default async function Page(props: {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
const [accountUsage, subscriptions, projects] = await Promise.all([
|
||||
if (!authToken) {
|
||||
loginRedirect(`/team/${params.team_slug}/~/usage`);
|
||||
}
|
||||
|
||||
const [accountUsage, subscriptions, projects, rpcUsage] = await Promise.all([
|
||||
getAccountUsage(),
|
||||
getTeamSubscriptions(team.slug),
|
||||
getProjects(team.slug),
|
||||
fetchRPCUsage({
|
||||
authToken,
|
||||
period: "day",
|
||||
// 7 days ago
|
||||
from: normalizeTimeISOString(
|
||||
new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
|
||||
),
|
||||
// now
|
||||
to: normalizeTimeISOString(new Date()),
|
||||
teamId: team.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!accountUsage || !subscriptions || !authToken) {
|
||||
if (!accountUsage || !subscriptions) {
|
||||
return (
|
||||
<div className="flex min-h-[350px] items-center justify-center rounded-lg border p-4 text-destructive-text">
|
||||
Something went wrong. Please try again later.
|
||||
@@ -48,6 +66,7 @@ export default async function Page(props: {
|
||||
account={account}
|
||||
team={team}
|
||||
client={client}
|
||||
rpcUsage={rpcUsage.ok ? rpcUsage.data : []}
|
||||
projects={projects.map((project) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
|
||||
import { formatDate } from "date-fns";
|
||||
|
||||
export function CountGraph(props: {
|
||||
peakPercentage: number;
|
||||
currentRateLimit: number;
|
||||
data: {
|
||||
date: string;
|
||||
includedCount: number;
|
||||
overageCount: number;
|
||||
rateLimitedCount: number;
|
||||
}[];
|
||||
}) {
|
||||
const hasAnyRateLimited = props.data.some((v) => v.rateLimitedCount > 0);
|
||||
return (
|
||||
<ThirdwebAreaChart
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4]"
|
||||
header={{
|
||||
title: "Requests Over Time",
|
||||
description: "Requests over the last 24 hours. All times in UTC.",
|
||||
}}
|
||||
config={
|
||||
hasAnyRateLimited
|
||||
? {
|
||||
includedCount: {
|
||||
label: "Successful Requests",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
rateLimitedCount: {
|
||||
label: "Rate Limited Requests",
|
||||
color: "hsl(var(--chart-4))",
|
||||
},
|
||||
}
|
||||
: {
|
||||
includedCount: {
|
||||
label: "Successful Requests",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
}
|
||||
}
|
||||
showLegend={hasAnyRateLimited}
|
||||
yAxis
|
||||
xAxis={{
|
||||
sameDay: true,
|
||||
}}
|
||||
hideLabel={false}
|
||||
toolTipLabelFormatter={(label) => {
|
||||
return formatDate(new Date(label), "MMM dd, HH:mm");
|
||||
}}
|
||||
// @ts-expect-error - sending MORE data than expected is ok
|
||||
data={props.data
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
.map((v) => ({
|
||||
time: v.date,
|
||||
includedCount: Number(v.includedCount) + Number(v.overageCount),
|
||||
rateLimitedCount: Number(v.rateLimitedCount),
|
||||
}))}
|
||||
isPending={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
|
||||
import { formatDate } from "date-fns";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
|
||||
export function RateGraph(props: {
|
||||
peakPercentage: number;
|
||||
currentRateLimit: number;
|
||||
data: { date: string; averageRate: number }[];
|
||||
}) {
|
||||
return (
|
||||
<ThirdwebAreaChart
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4]"
|
||||
header={{
|
||||
title: "Request Rate Over Time",
|
||||
description: "Request rate over the last 24 hours. All times in UTC.",
|
||||
}}
|
||||
// only show the footer if the peak usage is greater than 80%
|
||||
footer={
|
||||
props.peakPercentage > 80 ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
The red dashed line represents your current plan rate limit (
|
||||
{props.currentRateLimit} RPS)
|
||||
</p>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
config={{
|
||||
averageRate: {
|
||||
label: "Average RPS",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
}}
|
||||
data={props.data
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
.map((v) => ({
|
||||
time: v.date,
|
||||
averageRate: Number(v.averageRate.toFixed(2)),
|
||||
}))}
|
||||
yAxis
|
||||
xAxis={{
|
||||
sameDay: true,
|
||||
}}
|
||||
hideLabel={false}
|
||||
toolTipLabelFormatter={(label) => {
|
||||
return formatDate(new Date(label), "MMM dd, HH:mm");
|
||||
}}
|
||||
// only show the upper limit if the peak usage is greater than 80%
|
||||
maxLimit={props.peakPercentage > 80 ? props.currentRateLimit : undefined}
|
||||
isPending={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getLast24HoursRPCUsage } from "@/api/usage/rpc";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../../../../api/lib/getAuthToken";
|
||||
import { TeamPlanBadge } from "../../../../../../components/TeamPlanBadge";
|
||||
import { loginRedirect } from "../../../../../../login/loginRedirect";
|
||||
import { CountGraph } from "./components/count-graph";
|
||||
import { RateGraph } from "./components/rate-graph";
|
||||
|
||||
export default async function RPCUsage(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(`/team/${params.team_slug}/~/usage/storage`);
|
||||
}
|
||||
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
const currentPlan = team.billingPlan;
|
||||
const currentRateLimit = team.capabilities.rpc.rateLimit;
|
||||
|
||||
const apiData = await getLast24HoursRPCUsage({
|
||||
teamId: team.id,
|
||||
authToken,
|
||||
});
|
||||
|
||||
if (!apiData.ok) {
|
||||
return <div>Error fetching data, please try again later</div>;
|
||||
}
|
||||
|
||||
const { peakRate, totalCounts, averageRate } = apiData.data;
|
||||
|
||||
// Calculate percentage of limit for the peak
|
||||
const peakPercentage = (peakRate.peakRPS / currentRateLimit) * 100;
|
||||
|
||||
// Determine status based on peak percentage
|
||||
const getStatusColor = (percentage: number) => {
|
||||
if (percentage < 70) return "bg-green-500";
|
||||
if (percentage < 90) return "bg-yellow-500";
|
||||
return "bg-red-500";
|
||||
};
|
||||
|
||||
// Calculate total requests
|
||||
const totalRequests =
|
||||
Number(totalCounts.includedCount) +
|
||||
Number(totalCounts.rateLimitedCount) +
|
||||
Number(totalCounts.overageCount);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-8 py-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-bold text-3xl tracking-tight">
|
||||
RPC Usage Dashboard
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Monitor your RPC usage and rate limits for the last 24 hours
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="font-medium text-sm">
|
||||
Plan Rate Limit
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-bold text-2xl capitalize">
|
||||
{currentRateLimit.toLocaleString()} RPS
|
||||
</div>
|
||||
<TeamPlanBadge plan={currentPlan} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="font-medium text-sm">Peak Usage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-bold text-2xl">
|
||||
{peakRate.peakRPS.toFixed(1)} RPS
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full ${getStatusColor(peakPercentage)}`}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{peakPercentage > 100
|
||||
? `${(peakPercentage - 100).toFixed(0)}% over limit`
|
||||
: `${peakPercentage.toFixed(0)}% of limit`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
<ClockIcon className="mr-1 inline h-3 w-3" />
|
||||
{peakRate.date
|
||||
? format(new Date(peakRate.date), "MMM d, HH:mm")
|
||||
: "No Requests in last 24 hours"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="font-medium text-sm">
|
||||
Total Requests
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="font-bold text-2xl">
|
||||
{totalRequests.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center text-muted-foreground text-xs">
|
||||
<CheckCircleIcon className="mr-1 h-3 w-3 text-green-500" />
|
||||
<span>
|
||||
{/* we count both included and overage as included */}
|
||||
{(
|
||||
((Number(totalCounts.includedCount) +
|
||||
Number(totalCounts.overageCount)) /
|
||||
Number(totalRequests)) *
|
||||
100
|
||||
).toFixed(1)}
|
||||
% successful requests
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="font-medium text-sm">Rate Limited</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="font-bold text-2xl">
|
||||
{totalCounts.rateLimitedCount.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center text-muted-foreground text-xs">
|
||||
<XCircleIcon className="mr-1 h-3 w-3 text-red-500" />
|
||||
<span>
|
||||
{/* if there are no requests, we show 100% success rate */}
|
||||
{totalRequests === 0
|
||||
? "100"
|
||||
: (
|
||||
(Number(totalCounts.rateLimitedCount) /
|
||||
Number(totalRequests)) *
|
||||
100
|
||||
).toFixed(1)}
|
||||
% of requests
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{peakPercentage > 100 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertTitle>Rate Limit Exceeded</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your peak usage of {peakRate.peakRPS.toFixed(1)} RPS has exceeded
|
||||
your plan limit of {currentRateLimit} RPS. Consider upgrading your
|
||||
plan to avoid rate limiting.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{peakPercentage > 80 && peakPercentage <= 100 && (
|
||||
<Alert variant="warning" className="border-yellow-500 text-yellow-700">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertTitle>Approaching Rate Limit</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your peak usage is at {peakPercentage.toFixed(0)}% of your plan
|
||||
limit. You may experience rate limiting during high traffic periods.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<RateGraph
|
||||
peakPercentage={peakPercentage}
|
||||
currentRateLimit={currentRateLimit}
|
||||
data={averageRate}
|
||||
/>
|
||||
|
||||
<CountGraph
|
||||
peakPercentage={peakPercentage}
|
||||
currentRateLimit={currentRateLimit}
|
||||
data={averageRate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+7
-12
@@ -16,9 +16,8 @@ import { NebulaIcon } from "../../../../nebula-app/(app)/icons/NebulaIcon";
|
||||
export function ProjectSidebarLayout(props: {
|
||||
layoutPath: string;
|
||||
children: React.ReactNode;
|
||||
showNebula: boolean;
|
||||
}) {
|
||||
const { layoutPath, children, showNebula } = props;
|
||||
const { layoutPath, children } = props;
|
||||
|
||||
const tracking = (label: string) => ({
|
||||
category: "project-sidebar",
|
||||
@@ -60,16 +59,12 @@ export function ProjectSidebarLayout(props: {
|
||||
icon: ContractIcon,
|
||||
tracking: tracking("contracts"),
|
||||
},
|
||||
...(showNebula
|
||||
? [
|
||||
{
|
||||
href: `${layoutPath}/nebula`,
|
||||
label: "Nebula",
|
||||
icon: NebulaIcon,
|
||||
tracking: tracking("nebula"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
href: `${layoutPath}/nebula`,
|
||||
label: "Nebula",
|
||||
icon: NebulaIcon,
|
||||
tracking: tracking("nebula"),
|
||||
},
|
||||
{
|
||||
href: `${layoutPath}/insight`,
|
||||
label: "Insight",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AnnouncementBanner } from "../../../../components/notices/AnnouncementBanner";
|
||||
@@ -49,9 +49,6 @@ export default async function ProjectLayout(props: {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const isOnNebulaWaitList = (await getTeamNebulaWaitList(team.slug))
|
||||
?.onWaitlist;
|
||||
|
||||
const layoutPath = `/team/${params.team_slug}/${params.project_slug}`;
|
||||
|
||||
return (
|
||||
@@ -67,10 +64,7 @@ export default async function ProjectLayout(props: {
|
||||
accountAddress={accountAddress}
|
||||
/>
|
||||
</div>
|
||||
<ProjectSidebarLayout
|
||||
layoutPath={layoutPath}
|
||||
showNebula={!!isOnNebulaWaitList}
|
||||
>
|
||||
<ProjectSidebarLayout layoutPath={layoutPath}>
|
||||
{props.children}
|
||||
</ProjectSidebarLayout>
|
||||
</div>
|
||||
|
||||
-127
@@ -1,127 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { NebulaIcon } from "../../../../../nebula-app/(app)/icons/NebulaIcon";
|
||||
import { ShareButton } from "./share-button.client";
|
||||
|
||||
export function NebulaWaitListPageUI(props: {
|
||||
teamId: string;
|
||||
className?: string;
|
||||
hideHeader?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex grow flex-col", props.className)}>
|
||||
{/* Header */}
|
||||
{!props.hideHeader && (
|
||||
<div className="border-b py-10">
|
||||
<div className="container flex max-w-7xl items-center justify-between">
|
||||
<h1 className="font-semibold text-3xl tracking-tight"> Nebula </h1>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/contact-us" target="_blank">
|
||||
Contact Us
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="container flex max-w-7xl grow flex-col overflow-hidden py-32">
|
||||
<CenteredCard
|
||||
title="You're on the waitlist"
|
||||
description="You should receive access to Nebula soon!"
|
||||
footer={
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<ShareButton teamId={props.teamId} />
|
||||
<p className="w-full max-w-[340px] text-center text-muted-foreground text-sm">
|
||||
Share this invite link and get moved up the list when your
|
||||
friends sign up!
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CenteredCard(props: {
|
||||
footer: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex grow flex-col items-center justify-center max-sm:px-4">
|
||||
<div className="relative flex min-h-[480px] w-full flex-col rounded-xl border bg-card p-2 lg:w-[480px]">
|
||||
{/* fancy borders */}
|
||||
<div className="">
|
||||
{/* top */}
|
||||
<DashedBgDiv
|
||||
className="-translate-x-1/2 -translate-y-5 absolute top-0 right-0 left-1/2 h-[1px] w-[calc(100%+200px)]"
|
||||
type="horizontal"
|
||||
/>
|
||||
{/* bottom */}
|
||||
<DashedBgDiv
|
||||
className="-translate-x-1/2 absolute right-0 bottom-0 left-1/2 h-[1px] w-[calc(100%+200px)] translate-y-5"
|
||||
type="horizontal"
|
||||
/>
|
||||
{/* left */}
|
||||
<DashedBgDiv
|
||||
className="-translate-x-5 -translate-y-1/2 absolute top-1/2 left-0 h-[calc(100%+200px)] w-[1px]"
|
||||
type="vertical"
|
||||
/>
|
||||
{/* right */}
|
||||
<DashedBgDiv
|
||||
className="-translate-y-1/2 absolute top-1/2 right-0 h-[calc(100%+200px)] w-[1px] translate-x-5"
|
||||
type="vertical"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex grow items-center justify-center rounded-lg border p-4">
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Icon */}
|
||||
<div className="rounded-xl border p-1">
|
||||
<div className="rounded-lg border bg-card p-2">
|
||||
<NebulaIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<h2 className="text-balance text-center font-semibold text-2xl tracking-tight md:text-3xl">
|
||||
{props.title}
|
||||
</h2>
|
||||
|
||||
<div className="h-2" />
|
||||
|
||||
<p className="text-center text-muted-foreground lg:px-8">
|
||||
{props.description}
|
||||
</p>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{props.footer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashedBgDiv(props: {
|
||||
className?: string;
|
||||
type: "horizontal" | "vertical";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={props.className}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${props.type === "horizontal" ? "90deg" : "180deg"}, hsl(var(--foreground)/20%) 0 30%, transparent 0 100%)`,
|
||||
backgroundRepeat: "repeat",
|
||||
backgroundSize: "10px 10px",
|
||||
maskImage: `linear-gradient(${
|
||||
props.type === "horizontal" ? "to right" : "to bottom"
|
||||
}, rgba(0,0,0,0.1), black 20%, black 80%, rgba(0,0,0,0.1))`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
import { joinTeamWaitlist } from "@/actions/joinWaitlist";
|
||||
import { type Team, getTeamNebulaWaitList } from "@/api/team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { NebulaWaitListPageUI } from "./nebula-waitlist-page-ui.client";
|
||||
|
||||
export async function NebulaWaitListPage(props: {
|
||||
team: Team;
|
||||
className?: string;
|
||||
hideHeader?: boolean;
|
||||
}) {
|
||||
const nebulaWaitList = await getTeamNebulaWaitList(props.team.slug);
|
||||
|
||||
// this should never happen
|
||||
if (!nebulaWaitList) {
|
||||
return (
|
||||
<UnexpectedErrorPage message="Failed to get Nebula waitlist status for your team" />
|
||||
);
|
||||
}
|
||||
|
||||
// if not already on the waitlist, join the waitlist
|
||||
if (!nebulaWaitList.onWaitlist) {
|
||||
const res = await joinTeamWaitlist({
|
||||
scope: "nebula",
|
||||
teamSlug: props.team.slug,
|
||||
}).catch(() => null);
|
||||
|
||||
// this should never happen
|
||||
if (!res?.success) {
|
||||
return (
|
||||
<UnexpectedErrorPage message="Failed to join Nebula waitlist. Please try again later" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NebulaWaitListPageUI
|
||||
teamId={props.team.id}
|
||||
className={props.className}
|
||||
hideHeader={props.hideHeader}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UnexpectedErrorPage(props: {
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex grow flex-col py-10">
|
||||
<div className="flex min-h-[300px] grow flex-col items-center justify-center rounded-lg border">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span className="text-destructive-text">{props.message}</span>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/support">Get Support</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { NebulaWaitListPageUI } from "./nebula-waitlist-page-ui.client";
|
||||
|
||||
const meta = {
|
||||
title: "nebula/waitlist",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Variants: Story = {
|
||||
args: {
|
||||
inWaitlist: false,
|
||||
},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
return <NebulaWaitListPageUI teamId="foo" />;
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { CheckIcon, ShareIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ShareButton(props: {
|
||||
teamId: string;
|
||||
}) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<ToolTipLabel label="Copy Invite Link">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
const url = new URL("https://thirdweb.com/nebula");
|
||||
url.searchParams.append("utm_content", props.teamId);
|
||||
url.searchParams.append("utm_campaign", "nebula");
|
||||
navigator.clipboard.writeText(url.href);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 1000);
|
||||
}}
|
||||
>
|
||||
Share
|
||||
{isCopied ? (
|
||||
<CheckIcon className="size-4 text-green-500" />
|
||||
) : (
|
||||
<ShareIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../login/loginRedirect";
|
||||
import { NebulaAnalyticsPage } from "./components/analytics/nebula-analytics-page";
|
||||
import { NebulaWaitListPage } from "./components/nebula-waitlist-page";
|
||||
import { NebulaFTUX } from "./nebula-ftux";
|
||||
|
||||
export default async function Page(props: {
|
||||
@@ -42,8 +41,6 @@ export default async function Page(props: {
|
||||
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/nebula`);
|
||||
}
|
||||
|
||||
const hasNebulaAccess = team.enabledScopes.includes("nebula");
|
||||
|
||||
const activeResponse = await isProjectActive({
|
||||
teamId: team.id,
|
||||
projectId: project.id,
|
||||
@@ -51,35 +48,31 @@ export default async function Page(props: {
|
||||
|
||||
const showFTUX = !activeResponse.nebula;
|
||||
|
||||
if (hasNebulaAccess) {
|
||||
if (showFTUX) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-b py-10">
|
||||
<div className="container max-w-7xl">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Nebula</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mt-6 max-w-7xl">
|
||||
<NebulaFTUX
|
||||
secretKeyMasked={project.secretKeys[0]?.masked || ""}
|
||||
projectId={project.id}
|
||||
/>
|
||||
if (showFTUX) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-b py-10">
|
||||
<div className="container max-w-7xl">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Nebula</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NebulaAnalyticsPage
|
||||
teamId={team.id}
|
||||
authToken={authToken}
|
||||
searchParams={searchParams}
|
||||
projectId={project.id}
|
||||
/>
|
||||
<div className="container mt-6 max-w-7xl">
|
||||
<NebulaFTUX
|
||||
secretKeyMasked={project.secretKeys[0]?.masked || ""}
|
||||
projectId={project.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <NebulaWaitListPage team={team} />;
|
||||
return (
|
||||
<NebulaAnalyticsPage
|
||||
teamId={team.id}
|
||||
authToken={authToken}
|
||||
searchParams={searchParams}
|
||||
projectId={project.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { type TrendingContract, fetchTopContracts } from "lib/search";
|
||||
import { ArrowRightIcon, CommandIcon, SearchIcon, XIcon } from "lucide-react";
|
||||
@@ -322,7 +322,7 @@ const SearchResult: React.FC<SearchResultProps> = ({
|
||||
isActive && "bg-muted",
|
||||
)}
|
||||
>
|
||||
<ChainIcon
|
||||
<ChainIconClient
|
||||
className="size-6 shrink-0"
|
||||
ipfsSrc={result.chainMetadata?.icon?.url}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { useStore } from "@/lib/reactive";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { getDashboardChainRpc } from "lib/rpc";
|
||||
import { CircleAlertIcon, Trash2Icon } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -369,7 +369,10 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
label="Icon"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<ChainIcon className="size-5" ipfsSrc={form.watch("icon")} />
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
ipfsSrc={form.watch("icon")}
|
||||
/>
|
||||
<IconUpload
|
||||
onUpload={(uri) => {
|
||||
form.setValue("icon", uri, { shouldDirty: true });
|
||||
|
||||
+7
-6
@@ -49,7 +49,7 @@ export const MarkdownRenderer: React.FC<{
|
||||
<h2
|
||||
className={cn(
|
||||
commonHeadingClassName,
|
||||
"mb-4 border-border border-b text-3xl",
|
||||
"mb-3 border-border border-b text-xl md:text-2xl",
|
||||
)}
|
||||
{...cleanedProps(props)}
|
||||
/>
|
||||
@@ -60,7 +60,7 @@ export const MarkdownRenderer: React.FC<{
|
||||
{...cleanedProps(props)}
|
||||
className={cn(
|
||||
commonHeadingClassName,
|
||||
"mt-8 mb-4 border-border border-b text-2xl",
|
||||
"mt-8 mb-3 border-border border-b text-lg md:text-xl",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
@@ -68,7 +68,10 @@ export const MarkdownRenderer: React.FC<{
|
||||
h3: (props) => (
|
||||
<h4
|
||||
{...cleanedProps(props)}
|
||||
className={cn(commonHeadingClassName, "mt-4 text-xl")}
|
||||
className={cn(
|
||||
commonHeadingClassName,
|
||||
"mt-4 text-base md:text-lg",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -198,9 +201,7 @@ export const MarkdownRenderer: React.FC<{
|
||||
</li>
|
||||
),
|
||||
strong(props) {
|
||||
return (
|
||||
<strong className="font-semibold" {...cleanedProps(props)} />
|
||||
);
|
||||
return <strong className="font-medium" {...cleanedProps(props)} />;
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { NetworkSelectDropdown } from "components/selects/NetworkSelectDropdown";
|
||||
import { EllipsisVerticalIcon, ExternalLinkIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -263,7 +263,7 @@ const ChainNameCell = React.memo(function ChainNameCell(props: {
|
||||
`Unknown Network (#${props.chainId})`;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChainIcon className="size-5" ipfsSrc={data?.icon?.url} />
|
||||
<ChainIconClient className="size-5" ipfsSrc={data?.icon?.url} />
|
||||
<SkeletonContainer
|
||||
loadedData={data ? cleanedChainName : undefined}
|
||||
skeletonData={`Chain ID ${props.chainId}`}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type Props = {
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const defaultDescription = "@YourHandle";
|
||||
|
||||
export const SupportForm_TelegramInput = (props: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 bg-[#0a0a0a]">
|
||||
<Label htmlFor="telegram" className="relative">
|
||||
Telegram
|
||||
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
|
||||
</Label>
|
||||
|
||||
<input
|
||||
className="flex h-10 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1"
|
||||
type="text"
|
||||
name="telegram"
|
||||
placeholder={props.placeholder ?? defaultDescription}
|
||||
autoComplete="off"
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,7 @@ type ChainIconProps = ImageProps & {
|
||||
ipfsSrc?: string;
|
||||
};
|
||||
|
||||
export const ChainIcon = ({ ipfsSrc, ...restProps }: ChainIconProps) => {
|
||||
export const ChainIconClient = ({ ipfsSrc, ...restProps }: ChainIconProps) => {
|
||||
const src = ipfsSrc ? replaceIpfsUrl(ipfsSrc) : fallbackChainIcon;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { OPSponsoredChains } from "constants/chains";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import type { UseNetworkSwitcherModalOptions } from "thirdweb/react";
|
||||
@@ -49,7 +49,7 @@ export const CustomChainRenderer = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChainIcon ipfsSrc={chain.icon?.url} className="size-8" />
|
||||
<ChainIconClient ipfsSrc={chain.icon?.url} className="size-8" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { useMemo } from "react";
|
||||
import { useAllChainsData } from "../../hooks/chains/allChains";
|
||||
|
||||
@@ -67,7 +67,7 @@ export const NetworkSelectDropdown: React.FC<NetworkSelectDropdownProps> = ({
|
||||
>
|
||||
<SelectItem value="all-chains">
|
||||
<div className="flex items-center gap-2 py-1" data-all-chains>
|
||||
<ChainIcon ipfsSrc={undefined} className="size-5" />
|
||||
<ChainIconClient ipfsSrc={undefined} className="size-5" />
|
||||
All Networks
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -75,7 +75,7 @@ export const NetworkSelectDropdown: React.FC<NetworkSelectDropdownProps> = ({
|
||||
{chains.map((chain) => (
|
||||
<SelectItem key={chain.chainId} value={String(chain.chainId)}>
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<ChainIcon ipfsSrc={chain.icon?.url} className="size-5" />
|
||||
<ChainIconClient ipfsSrc={chain.icon?.url} className="size-5" />
|
||||
{useCleanChainName ? cleanChainName(chain.name) : chain.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useStore } from "@/lib/reactive";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { popularChains } from "@3rdweb-sdk/react/components/popularChains";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { useActiveChainAsDashboardChain } from "lib/v5-adapter";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
@@ -199,7 +199,7 @@ export const NetworkSelectorButton: React.FC<NetworkSelectorButtonProps> = ({
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChainIcon ipfsSrc={chain?.icon?.url} className="size-5" />
|
||||
<ChainIconClient ipfsSrc={chain?.icon?.url} className="size-5" />
|
||||
{chain?.name || "Select Network"}
|
||||
|
||||
<ChevronDownIcon className="ml-auto size-4" />
|
||||
|
||||
+27
-1
@@ -83,8 +83,10 @@ const cancelReasons: Array<{
|
||||
];
|
||||
|
||||
export function CancelPlanButton(props: {
|
||||
teamSlug: string;
|
||||
cancelPlan: CancelPlan;
|
||||
currentPlan: Team["billingPlan"];
|
||||
billingStatus: Team["billingStatus"];
|
||||
getTeam: () => Promise<Team>;
|
||||
}) {
|
||||
return (
|
||||
@@ -102,7 +104,9 @@ export function CancelPlanButton(props: {
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{props.currentPlan === "pro" ? (
|
||||
{props.billingStatus === "invalidPayment" ? (
|
||||
<UnpaidInvoicesWarning teamSlug={props.teamSlug} />
|
||||
) : props.currentPlan === "pro" ? (
|
||||
<ProPlanCancelPlanSheetContent />
|
||||
) : (
|
||||
<CancelPlanSheetContent
|
||||
@@ -115,6 +119,28 @@ export function CancelPlanButton(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function UnpaidInvoicesWarning({ teamSlug }: { teamSlug: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-1 font-semibold text-2xl tracking-tight">
|
||||
Cancel Plan
|
||||
</h2>
|
||||
<p className="mb-5 text-muted-foreground text-sm">
|
||||
You have unpaid invoices. Please pay them before cancelling your plan.
|
||||
</p>
|
||||
|
||||
<Button variant="outline" asChild className="w-full gap-2 bg-card">
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
|
||||
target="_blank"
|
||||
>
|
||||
See Invoices
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProPlanCancelPlanSheetContent() {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Account, BillingCredit } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||
@@ -49,7 +49,7 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 right-0">
|
||||
{isOpCredit ? (
|
||||
<ChainIcon
|
||||
<ChainIconClient
|
||||
ipfsSrc=// Hard-coded here to remove @thirdweb dev/chains
|
||||
"ipfs://QmcxZHpyJa8T4i63xqjPYrZ6tKrt55tZJpbXcjSDKuKaf9/optimism/512.png"
|
||||
className="size-6"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user