Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
cbc0efff31 Version Packages (#6383)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-03 00:45:13 -08:00
gregfromstl 9799b0aae1 [SDK] Fix: add gpt protocol to legacy tx chains (#6390)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on adding support for the GPT Protocol to chains that only support legacy transactions.

### Detailed summary
- Updated the `.changeset/every-bees-exist.md` file to document the addition of the GPT Protocol.
- Added `1511670449` as the fee data for `GPT Mainnet` in the `packages/thirdweb/src/gas/fee-data.ts` file.

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

<!-- end pr-codex -->
2025-03-03 07:59:11 +00:00
Joaquim VergesandGitHub c3b90a6f1b [SDK] fix: Handle comma decimal input in PayEmbed (#6386) 2025-03-02 16:20:59 -08:00
MananTank 323e9d7a37 [TOOL-3572] Dashboard:Fix theming issue in engine pages (#6389) 2025-03-01 17:58:51 +00:00
Phillip HoandGitHub 80ae08bd5a [service-utils] fix: build error on kafka disconnect() (#6388) 2025-03-01 20:13:20 +08:00
Phillip HoandGitHub 0c0d837f23 [service-utils] fix: crash if Kafka connects more than once (#6387) 2025-03-01 20:08:42 +08:00
Phillip HoandGitHub 879148f37e fix: relative path import for usageV2 (#6385) 2025-03-01 13:47:53 +08:00
Phillip HoandGitHub 25b149d3a9 chore: forward origin header to usage tracker (#6384) 2025-03-01 13:21:30 +08:00
Phillip HoandGitHub 47c9c43a87 [service-utils] trim project ID prefix (#6382) 2025-03-01 10:53:56 +08:00
saminacodes 31dce55025 Portal Changes (#6381)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating descriptions and FAQ content related to `Nebula` and `EVM networks` across various markdown files, enhancing clarity and accuracy.

### Detailed summary
- Updated `FeatureCard` description for `Nebula`.
- Revised contract deployment description to specify "any EVM network".
- Changed RPC endpoints description to "every EVM chain".
- Added new FAQ entries and details about `Nebula`'s model and capabilities.
- Reformatted existing FAQs with `<Details>` components for better presentation.

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

<!-- end pr-codex -->
2025-03-01 02:48:06 +00:00
MananTank b535cc816f [TOOL-3571] Dashboard: Remove custom high z-indices (#6380) 2025-03-01 00:11:46 +00:00
catalyst17 c3ded8a37c docs: multichain queries (#6375)
[Portal] Feature: Add multichain queries documentation and examples

## Notes for the reviewer
This PR introduces comprehensive documentation for multichain query functionality in Insight, including detailed examples, response formats, and best practices.

## How to test
- Review the new multichain queries documentation page
- Verify the code examples work with the described response formats
- Check that the sidebar navigation correctly links to the new documentation
- Confirm the authentication methods and chain ID formats are accurately described

---

## PR-Codex overview
This PR enhances the `Insight` docs by documenting multichain queries. It updates various documentation files to reflect new features, examples, and response formats related to multichain functionality.

### Detailed summary
- Added `Multichain Queries` section to the sidebar in `sidebar.tsx`.
- Introduced examples for querying multiple chains in `page.mdx` files.
- Updated chain ID handling examples in `agents-and-llms/page.mdx` and `llmstxt/page.mdx`.
- Detailed multichain response structure added to documentation.
- Explained authentication methods for multichain queries.
- Provided best practices and use cases for multichain queries in `multichain-queries/page.mdx`.
- Updated response format details for multichain queries, including metadata and data array structure.
- Included examples for cross-chain portfolio tracking and activity monitoring.

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

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces support for multichain queries in the `thirdweb Insight` API, enhancing the ability to retrieve data across multiple blockchain networks in a single API call.

### Detailed summary
- Added a new entry for "Multichain Queries" in the `sidebar`.
- Updated examples to illustrate querying multiple chains using query parameters.
- Enhanced documentation for multichain queries with examples and best practices.
- Modified response formats to include `chain_ids` and `limit_per_chain` for multichain queries.
- Updated existing examples to reflect multichain capabilities.

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

<!-- end pr-codex -->
2025-02-28 23:32:19 +00:00
MananTank 257d0bddbf [NEB-85] Portal: Update Nebula sidebar grouping (#6378) 2025-02-28 23:28:11 +00:00
MananTank 5a9a76b7a2 [TOOL-3392] Dashboard: Add project Image field in project settings page (#6379) 2025-02-28 23:19:43 +00:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
e772581044 Version Packages (#6367)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-28 13:51:45 -08:00
Joaquim VergesandGitHub 5625ff1f80 [SDK] expose estimateUserOpGasCost (#6370) 2025-02-28 13:46:39 -08:00
MananTank 062504f463 [TOOL-3569] Dashboard: Add acount.id to posthog identify calls (#6377) 2025-02-28 21:13:58 +00:00
MananTank 853ed473c6 [TOOL-3525] Dashboard: Add delete account in account settings page (#6339)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the management of teams and accounts in the application. It introduces a new method to fetch the default team and updates various components to utilize this information, improving user experience and functionality.

### Detailed summary
- Added `getDefaultTeam` function to fetch the default team using an API call.
- Updated `TeamRootPage` to use `getDefaultTeam` instead of fetching all teams.
- Modified middleware to redirect using the default team.
- Refactored account settings page to include `defaultTeamSlug` and `defaultTeamName`.
- Enhanced `DangerSettingCard` to manage confirmation dialog state.
- Updated `AccountSettingsPageUI` to handle account deletion with relevant alerts for errors.
- Added UI components for handling account deletion status responses in the storybook.

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

<!-- end pr-codex -->
2025-02-28 21:05:11 +00:00
MananTank f5765819d7 [TOOL-3566] Dashboard: Add static.reo.dev in Next.js CSP settings (#6374)
<!-- start pr-codex -->

## PR-Codex overview
This PR updates the Content Security Policy (CSP) in the `next.config.ts` file to include an additional source for `script-src`.

### Detailed summary
- Added `static.reo.dev` to the `script-src` directive in the CSP.

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

<!-- end pr-codex -->
2025-02-28 20:57:16 +00:00
jnsdls f4ca73c4bf add /storage to framer rewrites (#6373)
closes: CORE-758

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `framer-rewrites.js` file to include a new route related to storage within the application's routing configuration.

### Detailed summary
- Added a new route `"/storage"` to the existing routes in `framer-rewrites.js`.

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

<!-- end pr-codex -->
2025-02-28 20:23:53 +00:00
MananTank 1f517fa10d [NEB-110] Dashboard: Add Playground and API references links (#6376) 2025-02-28 20:16:25 +00:00
MananTank 8887e46a00 [TOOL-3562] Portal: Add llms.txt and llms-full.txt generation script (#6372)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the layout components and search data extraction in the application. It introduces new files for LLM content and modifies existing components to include additional properties for better indexing and rendering.

### Detailed summary
- Added `public/llms.txt` and `public/llms-full.txt` files.
- Removed `data-noindex` from `<div>` in multiple layout files.
- Added `noLLM` property to `DocLayout` in various components.
- Updated `extractSearchData` to return LLM content.
- Modified `extractContent` to handle new LLM data.
- Adjusted `ArticleCard` and `Code` components to include `data-noindex`.
- Updated dependencies in `package.json` and `pnpm-lock.yaml`.

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

<!-- end pr-codex -->
2025-02-28 19:13:59 +00:00
joaquim-verges 56301bc50e [thirdweb] deps: Upgrade viem to 2.23.5 (#6336)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates several dependencies, particularly the `viem` package, and makes adjustments to the `package.json` and lock files to reflect these changes. It also increases the size limit in the `.size-limit.json` file.

### Detailed summary
- Updated `viem` version from `2.22.17` to `2.23.5` in `package.json` and `pnpm-lock.yaml`.
- Increased size limit in `packages/thirdweb/.size-limit.json` from `130 kB` to `131 kB`.
- Adjusted various dependencies in `pnpm-lock.yaml` to include `@swc/[email protected]`.

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

<!-- end pr-codex -->
2025-02-28 10:31:01 +00:00
MananTank 36b4ea5260 [TOOL-3556] Dashboard: Add Notifications, Revamp Team Overview page (#6368)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the notification system in the dashboard by integrating changelog and inbox notifications, improving UI components, and updating dependencies.

### Detailed summary
- Added `getChangelogNotifications`, `getInboxNotifications`, and `markNotificationAsRead` functions.
- Integrated notifications in `AccountHeaderUI`, `TeamHeader`, and `SecondaryNav`.
- Updated styles for various components.
- Replaced `Changelog` component with a new layout in `TeamProjectsPage`.
- Enhanced notification handling using `idb-keyval` for local storage.

> The following files were skipped due to too many changes: `pnpm-lock.yaml`

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

<!-- end pr-codex -->
2025-02-28 01:19:00 +00:00
joaquim-verges e1f73c8286 [SDK] chore: Re-enable flaky smart wallet tests (#6369)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on addressing flaky tests in the `thirdweb` codebase by enabling previously skipped tests related to `SmartWallet` functionality, ensuring they are executed as part of the test suite.

### Detailed summary
- Removed `.github/workflows/issue.yml`.
- Enabled `describe` and `test` blocks previously marked as skipped in:
  - `smart-wallet-modular.test.ts`
  - `verify-typed-data.test.ts`
  - `smart-wallet-integration-v07.test.ts`
- Adjusted test configurations by removing `FIMXE` comments and retry limits.

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

<!-- end pr-codex -->
2025-02-28 01:05:01 +00:00
Joaquim VergesandGitHub c4e7f3b344 [SDK] chore: fix all await expect tests (#6359) 2025-02-27 16:45:52 -08:00
Joaquim VergesandGitHub 6235fe76d3 [SDK] fix: Initialize wallet connection status as unknown (#6360) 2025-02-27 16:19:23 -08:00
Jonas DanielsandGitHub d8e9245ecf upgrade Next.js to 15.2.0 (#6365) 2025-02-27 15:56:46 -08:00
161 changed files with 2752 additions and 1287 deletions
-49
View File
@@ -1,49 +0,0 @@
name: Linked Issue
on:
pull_request:
types: [opened, edited, ready_for_review]
env:
VALID_ISSUE_PREFIXES: "CORE|TOOL|NEB|INFRA"
jobs:
linear:
name: Linear
runs-on: ubuntu-latest
steps:
- name: Check for linked issue
uses: actions/github-script@v7
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
// Check if contributor is external
const isInternalContributor = ['MEMBER', 'OWNER', 'COLLABORATOR'].includes(
context.payload.pull_request.author_association
);
// Automatically pass for external contributors
if (!isInternalContributor) {
console.log('External contributor detected - automatically passing check');
return;
}
const body = pr.data.body || '';
const branchName = pr.data.head.ref;
const issueRegex = new RegExp(`(${process.env.VALID_ISSUE_PREFIXES})-\\d+`, 'i');
const branchIssueRegex = new RegExp(`(${process.env.VALID_ISSUE_PREFIXES.toLowerCase()})-\\d+`, 'i');
if (!issueRegex.test(body) && !branchIssueRegex.test(branchName)) {
core.setFailed(
`No valid issue reference found. PR body or branch name must contain an issue ID with one of these prefixes: ${process.env.VALID_ISSUE_PREFIXES}`
);
return;
}
const matches = body.match(issueRegex) || branchName.match(branchIssueRegex);
console.log(`Found issue reference: ${matches[0]}`);
+2
View File
@@ -13,6 +13,8 @@ module.exports = [
"/connect/universal-bridge",
"/connect/auth",
"/connect/in-app-wallets",
// -- storage
"/storage",
// -- nebula
"/nebula",
// --insight
+1 -1
View File
@@ -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;
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;
connect-src * data: blob:;
worker-src 'self' blob:;
block-all-mixed-content;
+5 -4
View File
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "next dev --turbo",
"dev": "next dev --turbopack",
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
"start": "next start",
"format": "biome format ./src --write",
@@ -63,11 +63,12 @@
"flat": "^6.0.1",
"framer-motion": "12.4.7",
"fuse.js": "7.1.0",
"idb-keyval": "^6.2.1",
"input-otp": "^1.4.1",
"ioredis": "^5.5.0",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.476.0",
"next": "15.1.7",
"next": "15.2.0",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.4",
"nextjs-toploader": "^1.6.12",
@@ -104,8 +105,8 @@
"devDependencies": {
"@chakra-ui/cli": "^2.4.1",
"@chromatic-com/storybook": "3.2.4",
"@next/bundle-analyzer": "15.1.7",
"@next/eslint-plugin-next": "15.1.7",
"@next/bundle-analyzer": "15.2.0",
"@next/eslint-plugin-next": "15.2.0",
"@playwright/test": "1.50.1",
"@storybook/addon-essentials": "8.6.0",
"@storybook/addon-interactions": "8.6.0",
+17
View File
@@ -57,6 +57,23 @@ export async function getTeams() {
return null;
}
export async function getDefaultTeam() {
const token = await getAuthToken();
if (!token) {
return null;
}
const res = await fetch(`${API_SERVER_URL}/v1/teams/~`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (res.ok) {
return (await res.json())?.result as Team;
}
return null;
}
type TeamNebulaWaitList = {
onWaitlist: boolean;
createdAt: null | string;
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { BadgeContainer } from "../../../../stories/utils";
import { getThirdwebClient } from "../../../constants/thirdweb.server";
import { Button } from "../../ui/button";
import { ProjectAvatar } from "./ProjectAvatar";
@@ -17,17 +18,19 @@ export const Desktop: Story = {
args: {},
};
const client = getThirdwebClient();
function Story() {
return (
<div className="flex flex-col gap-10 p-10">
<p> All images below are set with size-6 className </p>
<BadgeContainer label="No Src - Skeleton">
<ProjectAvatar src={undefined} className="size-6" />
<ProjectAvatar src={undefined} className="size-6" client={client} />
</BadgeContainer>
<BadgeContainer label="Invalid/Empty Src - BoxIcon Fallback">
<ProjectAvatar src={""} className="size-6" />
<ProjectAvatar src={""} className="size-6" client={client} />
</BadgeContainer>
<ToggleTest />
@@ -62,13 +65,14 @@ function ToggleTest() {
<p> Src+Name is: {data ? "set" : "not set"} </p>
<BadgeContainer label="Valid Src">
<ProjectAvatar src={data?.src} className="size-6" />
<ProjectAvatar src={data?.src} className="size-6" client={client} />
</BadgeContainer>
<BadgeContainer label="invalid Src">
<ProjectAvatar
src={data ? "invalid-src" : undefined}
className="size-6"
client={client}
/>
</BadgeContainer>
</div>
@@ -1,15 +1,23 @@
import { Img } from "@/components/blocks/Img";
import { BoxIcon } from "lucide-react";
import type { ThirdwebClient } from "thirdweb";
import { resolveSchemeWithErrorHandler } from "../../../lib/resolveSchemeWithErrorHandler";
import { cn } from "../../../lib/utils";
export function ProjectAvatar(props: {
src: string | undefined;
className: string | undefined;
client: ThirdwebClient;
}) {
return (
<Img
src={props.src}
className={cn("rounded-lg border border-border", props.className)}
src={
resolveSchemeWithErrorHandler({
uri: props.src,
client: props.client,
}) || ""
}
className={cn("rounded-full border border-border", props.className)}
alt={""}
fallback={
<div className="flex items-center justify-center bg-card">
@@ -10,7 +10,9 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useState } from "react";
import { cn } from "../../lib/utils";
import { DynamicHeight } from "../ui/DynamicHeight";
export function DangerSettingCard(props: {
title: string;
@@ -24,9 +26,14 @@ export function DangerSettingCard(props: {
confirmationDialog: {
title: string;
description: React.ReactNode;
children?: React.ReactNode;
onClose?: () => void;
};
children?: React.ReactNode;
}) {
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] =
useState(false);
return (
<div
className={cn(
@@ -50,7 +57,15 @@ export function DangerSettingCard(props: {
props.footerClassName,
)}
>
<Dialog>
<Dialog
open={isConfirmationDialogOpen}
onOpenChange={(v) => {
setIsConfirmationDialogOpen(v);
if (!v) {
props.confirmationDialog.onClose?.();
}
}}
>
<DialogTrigger asChild>
<Button
variant="destructive"
@@ -62,21 +77,21 @@ export function DangerSettingCard(props: {
</Button>
</DialogTrigger>
<DialogContent
className="z-[10001] overflow-hidden p-0"
dialogOverlayClassName="z-[10000]"
>
<div className="p-6">
<DialogHeader className="pr-10">
<DialogTitle className="leading-snug">
{props.confirmationDialog.title}
</DialogTitle>
<DialogContent className="overflow-hidden p-0">
<DynamicHeight>
<div className="p-6">
<DialogHeader className="pr-10">
<DialogTitle className="leading-snug">
{props.confirmationDialog.title}
</DialogTitle>
<DialogDescription>
{props.confirmationDialog.description}
</DialogDescription>
</DialogHeader>
</div>
<DialogDescription>
{props.confirmationDialog.description}
</DialogDescription>
</DialogHeader>
{props.confirmationDialog.children}
</div>
</DynamicHeight>
<div className="flex justify-end gap-4 border-t bg-card p-6 lg:gap-2">
<DialogClose asChild>
@@ -225,7 +225,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
</Button>
</PopoverTrigger>
<PopoverContent
className="z-[10001] p-0"
className="p-0"
align="center"
sideOffset={10}
onEscapeKeyDown={() => setIsPopoverOpen(false)}
@@ -137,7 +137,7 @@ export const SelectWithSearch = React.forwardRef<
</PopoverTrigger>
<PopoverContent
className={cn("z-[10001] p-0", popoverContentClassName)}
className={cn("p-0", popoverContentClassName)}
align={props.align || "center"}
side={props.side}
sideOffset={10}
@@ -47,13 +47,16 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const btnOnlyProps =
Comp === "button" ? { type: "button" as const } : undefined;
Comp === "button"
? { type: props.type || ("button" as const) }
: undefined;
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...btnOnlyProps}
{...props}
{...btnOnlyProps}
/>
);
},
+1 -1
View File
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
+4 -3
View File
@@ -76,7 +76,7 @@ export function TabLinks(props: {
export function TabButtons(props: {
tabs: {
name: string;
name: React.ReactNode;
onClick: () => void;
isActive: boolean;
isEnabled?: boolean;
@@ -108,10 +108,11 @@ export function TabButtons(props: {
className={cn("flex", props.tabContainerClassName)}
ref={containerRef}
>
{props.tabs.map((tab) => {
{props.tabs.map((tab, index) => {
return (
<Button
key={tab.name}
// biome-ignore lint/suspicious/noArrayIndexKey: tabs don't change order, so index is stable
key={index}
variant="ghost"
ref={tab.isActive ? activeTabRef : undefined}
className={cn(
@@ -74,6 +74,7 @@ export async function RouteListCard({
<CardHeader className="flex flex-row items-center justify-between p-4">
<div className="flex flex-row items-center gap-2">
{resolvedOriginTokenIconUri ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolvedOriginTokenIconUri}
alt={originTokenAddress}
@@ -83,6 +84,7 @@ export async function RouteListCard({
<div className="size-8 rounded-full bg-white/10" />
)}
{resolvedDestinationTokenIconUri ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolvedDestinationTokenIconUri}
alt={destinationTokenAddress}
@@ -76,6 +76,7 @@ export async function RouteListRow({
<div className="flex items-center gap-1">
{resolvedOriginTokenIconUri ? (
// For now we're using a normal img tag because the domain for these images is unknown
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolvedOriginTokenIconUri}
alt={originTokenAddress}
@@ -110,6 +111,7 @@ export async function RouteListRow({
<div className="flex flex-row items-center gap-4">
<div className="flex items-center gap-1">
{resolvedDestinationTokenIconUri ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolvedDestinationTokenIconUri}
alt={destinationTokenAddress}
@@ -109,6 +109,7 @@ async function getTeamsAndProjectsIfLoggedIn() {
projects: (await getProjects(team.slug)).map((x) => ({
id: x.id,
name: x.name,
image: x.image,
})),
})),
);
@@ -133,7 +133,7 @@ export function ModuleCard(props: ModuleCardProps) {
open={isUninstallModalOpen}
onOpenChange={setIsUninstallModalOpen}
>
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
<DialogContent>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -49,7 +49,7 @@ export const ProposalButton: React.FC<VoteButtonProps> = ({
Create Proposal
</Button>
</SheetTrigger>
<SheetContent className="z-[10000] w-full sm:w-[540px] sm:max-w-[90%] lg:w-[700px]">
<SheetContent className="w-full sm:w-[540px] sm:max-w-[90%] lg:w-[700px]">
<SheetHeader>
<SheetTitle>Create new proposal</SheetTitle>
</SheetHeader>
@@ -70,7 +70,7 @@ export const TokenBurnButton: React.FC<TokenBurnButtonProps> = ({
<Flame size={16} /> Burn
</Button>
</SheetTrigger>
<SheetContent className="z-[10000]">
<SheetContent>
<SheetHeader>
<SheetTitle className="text-left">Burn tokens</SheetTitle>
</SheetHeader>
@@ -60,7 +60,7 @@ export const TokenClaimButton: React.FC<TokenClaimButtonProps> = ({
<GemIcon size={16} /> Claim
</Button>
</SheetTrigger>
<SheetContent className="z-[10000]">
<SheetContent>
<SheetHeader>
<SheetTitle className="text-left">Claim tokens</SheetTitle>
</SheetHeader>
@@ -58,7 +58,7 @@ export const TokenMintButton: React.FC<TokenMintButtonProps> = ({
<PlusIcon size={16} /> Mint
</Button>
</SheetTrigger>
<SheetContent className="z-[10000]">
<SheetContent>
<SheetHeader>
<SheetTitle className="text-left">
Mint additional tokens
@@ -64,7 +64,7 @@ export const TokenTransferButton: React.FC<TokenTransferButtonProps> = ({
<SendIcon size={16} /> Transfer
</Button>
</SheetTrigger>
<SheetContent className="z-[10000]">
<SheetContent>
<SheetHeader>
<SheetTitle className="text-left">Transfer tokens</SheetTitle>
</SheetHeader>
@@ -36,6 +36,7 @@ export async function DeployFormForUri(props: DeployFormForUriProps) {
projects: (await getProjects(team.slug)).map((x) => ({
id: x.id,
name: x.name,
image: x.image,
})),
})),
);
@@ -10,6 +10,11 @@ import { useCallback, useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { LazyCreateProjectDialog } from "../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { doLogout } from "../../login/auth-actions";
import {
getChangelogNotifications,
getInboxNotifications,
markNotificationAsRead,
} from "../../team/components/NotificationButton/fetch-notifications";
import {
type AccountHeaderCompProps,
AccountHeaderDesktopUI,
@@ -54,6 +59,9 @@ export function AccountHeader(props: {
account: props.account,
client,
accountAddress: props.accountAddress,
getChangelogNotifications: getChangelogNotifications,
getInboxNotifications: getInboxNotifications,
markNotificationAsRead: markNotificationAsRead,
};
return (
@@ -62,6 +62,9 @@ function Variants(props: {
email: "[email protected]",
}}
client={client}
getChangelogNotifications={() => Promise.resolve([])}
getInboxNotifications={() => Promise.resolve([])}
markNotificationAsRead={() => Promise.resolve()}
/>
</div>
</BadgeContainer>
@@ -8,6 +8,10 @@ import type { ThirdwebClient } from "thirdweb";
import { SecondaryNav } from "../../components/Header/SecondaryNav/SecondaryNav";
import { MobileBurgerMenuButton } from "../../components/MobileBurgerMenuButton";
import { ThirdwebMiniLogo } from "../../components/ThirdwebMiniLogo";
import {
NotificationButtonUI,
type NotificationMetadata,
} from "../../team/components/NotificationButton/NotificationButton";
import { TeamAndProjectSelectorPopoverButton } from "../../team/components/TeamHeader/TeamAndProjectSelectorPopoverButton";
import { TeamSelectorMobileMenuButton } from "../../team/components/TeamHeader/TeamSelectorMobileMenuButton";
@@ -20,6 +24,9 @@ export type AccountHeaderCompProps = {
account: Pick<Account, "email" | "id" | "image">;
client: ThirdwebClient;
accountAddress: string;
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
};
export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
@@ -59,6 +66,7 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
focus="team-selection"
createProject={props.createProject}
account={props.account}
client={props.client}
/>
)}
</div>
@@ -70,6 +78,9 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
connectButton={props.connectButton}
client={props.client}
accountAddress={props.accountAddress}
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
</header>
);
@@ -111,13 +122,21 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) {
</div>
</div>
<MobileBurgerMenuButton
type="loggedIn"
email={props.account?.email}
logout={props.logout}
connectButton={props.connectButton}
accountAddress={props.accountAddress}
/>
<div className="flex items-center gap-3">
<NotificationButtonUI
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
<MobileBurgerMenuButton
type="loggedIn"
email={props.account?.email}
logout={props.logout}
connectButton={props.connectButton}
accountAddress={props.accountAddress}
/>
</div>
</header>
);
}
@@ -1,18 +1,26 @@
"use client";
import { redirectToBillingPortal } from "@/actions/billing";
import { confirmEmailWithOTP } from "@/actions/confirmEmail";
import { apiServerProxy } from "@/actions/proxies";
import { updateAccount } from "@/actions/updateAccount";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import type { ThirdwebClient } from "thirdweb";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { upload } from "thirdweb/storage";
import { doLogout } from "../../login/auth-actions";
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
export function AccountSettingsPage(props: {
account: Account;
client: ThirdwebClient;
defaultTeamSlug: string;
defaultTeamName: string;
}) {
const router = useDashboardRouter();
const activeWallet = useActiveWallet();
const { disconnect } = useDisconnect();
return (
<div>
<header className="border-border border-b py-10">
@@ -25,8 +33,32 @@ export function AccountSettingsPage(props: {
<div className="container max-w-[950px] grow pt-8 pb-20">
<AccountSettingsPageUI
hideDeleteAccount
client={props.client}
defaultTeamSlug={props.defaultTeamSlug}
defaultTeamName={props.defaultTeamName}
onAccountDeleted={async () => {
await doLogout();
if (activeWallet) {
disconnect(activeWallet);
}
router.replace("/login");
}}
deleteAccount={async () => {
try {
const res = await apiServerProxy({
method: "DELETE",
pathname: "/v1/account",
});
return {
status: res.status,
};
} catch (error) {
console.error(error);
return { status: 500 };
}
}}
redirectToBillingPortal={redirectToBillingPortal}
updateAccountAvatar={async (file) => {
let uri: string | undefined = undefined;
@@ -1,4 +1,12 @@
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
@@ -40,6 +48,13 @@ function Variants() {
const [isVerifiedEmail, setIsVerifiedEmail] = useState(true);
const [sendEmailFails, setSendEmailFails] = useState(false);
const [emailConfirmationFails, setEmailConfirmationFails] = useState(false);
const [deleteAccountStatusResponse, setDeleteAccountStatusResponse] =
useState<400 | 402 | 500 | 200>(200);
const deleteAccountStub = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { status: deleteAccountStatusResponse };
};
return (
<div className="container flex max-w-[1132px] flex-col gap-10 py-10">
@@ -67,9 +82,37 @@ function Variants() {
/>
Email Confirmation Fails
</CheckboxWithLabel>
<div className="mt-3 flex flex-col gap-2">
<Label>Delete Account Response</Label>
<Select
value={String(deleteAccountStatusResponse)}
onValueChange={(value) =>
setDeleteAccountStatusResponse(
Number(value) as 400 | 402 | 500 | 200,
)
}
>
<SelectTrigger className="min-w-[320px]">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="200">200 - Success</SelectItem>
<SelectItem value="400">400 - Active Subscriptions</SelectItem>
<SelectItem value="402">402 - Unpaid Invoices</SelectItem>
<SelectItem value="500">500 - Unknown Error</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<AccountSettingsPageUI
defaultTeamSlug="foo"
defaultTeamName="Foo"
redirectToBillingPortal={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { status: 200 };
}}
account={{
name: "John Doe",
email: "[email protected]",
@@ -96,6 +139,10 @@ function Variants() {
throw new Error("Email already exists");
}
}}
deleteAccount={deleteAccountStub}
onAccountDeleted={() => {
console.log("Account deleted");
}}
/>
<Toaster richColors />
</div>
@@ -1,8 +1,11 @@
"use client";
import type { BillingBillingPortalAction } from "@/actions/billing";
import { BillingPortalButton } from "@/components/billing";
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
import { SettingsCard } from "@/components/blocks/SettingsCard";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -29,14 +32,14 @@ import {
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { cn } from "@/lib/utils";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { EllipsisIcon } from "lucide-react";
import { EllipsisIcon, ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -51,12 +54,16 @@ type MinimalAccount = Pick<
export function AccountSettingsPageUI(props: {
account: MinimalAccount;
hideDeleteAccount?: boolean;
sendEmail: (email: string) => Promise<void>;
updateName: (name: string) => Promise<void>;
updateEmailWithOTP: (otp: string) => Promise<void>;
updateAccountAvatar: (avatar: File | undefined) => Promise<void>;
client: ThirdwebClient;
deleteAccount: DeleteAccount;
onAccountDeleted: () => void;
defaultTeamSlug: string;
defaultTeamName: string;
redirectToBillingPortal: BillingBillingPortalAction;
}) {
return (
<div className="flex flex-col gap-8">
@@ -76,7 +83,13 @@ export function AccountSettingsPageUI(props: {
updateEmailWithOTP={props.updateEmailWithOTP}
/>
{!props.hideDeleteAccount && <DeleteAccountCard />}
<DeleteAccountCard
deleteAccount={props.deleteAccount}
onAccountDeleted={props.onAccountDeleted}
defaultTeamSlug={props.defaultTeamSlug}
defaultTeamName={props.defaultTeamName}
redirectToBillingPortal={props.redirectToBillingPortal}
/>
</div>
);
}
@@ -182,30 +195,43 @@ function AccountNameFormControl(props: {
);
}
function DeleteAccountCard() {
const router = useDashboardRouter();
const title = "Delete Account";
const description =
"Permanently remove your Personal Account and all of its contents from the thirdweb platform. This action is not reversible, please continue with caution.";
type DeleteAccount = () => Promise<{
status: number;
}>;
// TODO: when user can create multiple teams - the error status messaging needs to be updated
function DeleteAccountCard(props: {
deleteAccount: DeleteAccount;
onAccountDeleted: () => void;
defaultTeamSlug: string;
defaultTeamName: string;
redirectToBillingPortal: BillingBillingPortalAction;
}) {
const title = "Delete Account";
const description = (
<>
Permanently delete your thirdweb account, the default team "
{props.defaultTeamName}" and all associated data
<br />
This action is not reversible
</>
);
// TODO
const deleteAccount = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Deleting account");
throw new Error("Not implemented");
},
onSuccess: () => {
router.push("/team");
mutationFn: props.deleteAccount,
onSuccess: (data) => {
if (data.status === 200) {
props.onAccountDeleted();
toast.success("Account deleted successfully");
} else {
toast.error("Failed to delete account");
}
},
});
function handleDelete() {
const promises = deleteAccount.mutateAsync();
toast.promise(promises, {
success: "Account deleted successfully",
error: "Failed to delete account",
});
deleteAccount.mutate();
}
return (
@@ -216,9 +242,77 @@ function DeleteAccountCard() {
buttonOnClick={handleDelete}
isPending={deleteAccount.isPending}
confirmationDialog={{
title: "Are you sure you want to delete your account?",
description:
"This action is not reversible and will delete all of your data.",
title: "Delete Account",
description: (
<span className="block space-y-2">
<span className="block">
Permanently delete your thirdweb account, the default team "
{props.defaultTeamName}" and all associated data
</span>
<span className="block font-medium">
This action is not reversible
</span>
</span>
),
onClose: () => {
deleteAccount.reset();
},
children: (
<>
{deleteAccount.data?.status === 400 && (
<div className="mt-4">
<Alert variant="destructive">
<AlertTitle>Failed to delete account</AlertTitle>
<AlertDescription>
<span className="mb-1 block">
Your default team "{props.defaultTeamName}" has active
subscriptions. These subscriptions have to be cancelled
first before deleting account
</span>
<span className="mb-4 block">
Reach out to support to help you cancel them
</span>
<Button
variant="default"
asChild
size="sm"
className="gap-2"
>
<Link href="/support/create-ticket">
Contact Support
<ExternalLinkIcon className="size-4" />
</Link>
</Button>
</AlertDescription>
</Alert>
</div>
)}
{deleteAccount.data?.status === 402 && (
<div className="mt-4">
<Alert variant="destructive">
<AlertTitle>Failed to delete account</AlertTitle>
<AlertDescription>
<span className="mb-4 block">
Your default team "{props.defaultTeamName}" has unpaid
invoices. These invoices have to be paid before deleting
account
</span>
<BillingPortalButton
size="sm"
teamSlug={props.defaultTeamSlug}
variant="default"
redirectPath="/team"
redirectToBillingPortal={props.redirectToBillingPortal}
>
Manage Billing
</BillingPortalButton>
</AlertDescription>
</Alert>
</div>
)}
</>
),
}}
/>
);
@@ -1,3 +1,4 @@
import { getDefaultTeam } from "@/api/team";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { getAuthToken } from "../../api/lib/getAuthToken";
import { loginRedirect } from "../../login/loginRedirect";
@@ -6,14 +7,23 @@ import { getValidAccount } from "./getAccount";
export default async function Page() {
const pagePath = "/account";
const account = await getValidAccount(pagePath);
const token = await getAuthToken();
if (!token) {
const [defaultTeam, account, token] = await Promise.all([
getDefaultTeam(),
getValidAccount(pagePath),
getAuthToken(),
]);
if (!token || !defaultTeam) {
loginRedirect(pagePath);
}
return (
<AccountSettingsPage account={account} client={getThirdwebClient(token)} />
<AccountSettingsPage
account={account}
client={getThirdwebClient(token)}
defaultTeamSlug={defaultTeam.slug}
defaultTeamName={defaultTeam.name}
/>
);
}
@@ -2,6 +2,10 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import Link from "next/link";
import type React from "react";
import type { ThirdwebClient } from "thirdweb";
import {
NotificationButtonUI,
type NotificationMetadata,
} from "../../../team/components/NotificationButton/NotificationButton";
import { ResourcesDropdownButton } from "./ResourcesDropdownButton";
import { AccountButton } from "./account-button.client";
@@ -11,17 +15,27 @@ export function SecondaryNav(props: {
connectButton: React.ReactNode;
client: ThirdwebClient;
accountAddress: string;
getChangelogs: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
return (
<div className="flex items-center gap-6">
<SecondaryNavLinks />
<AccountButton
logout={props.logout}
connectButton={props.connectButton}
account={props.account}
client={props.client}
accountAddress={props.accountAddress}
/>
<div className="flex items-center gap-3">
<NotificationButtonUI
getChangelogs={props.getChangelogs}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
<AccountButton
logout={props.logout}
connectButton={props.connectButton}
account={props.account}
client={props.client}
accountAddress={props.accountAddress}
/>
</div>
</div>
);
}
@@ -33,7 +33,7 @@ export function AccountButton(props: {
<Button
size="icon"
asChild
className="size-10 cursor-pointer rounded-full hover:ring-2 hover:ring-offset-2"
className="size-10 cursor-pointer rounded-full hover:ring-2 hover:ring-ring hover:ring-offset-1"
variant="ghost"
>
{/* Don't remove the div */}
@@ -59,10 +59,10 @@ export function MobileBurgerMenuButton(
/> */}
<Button
variant="outline"
className="!h-auto p-1"
className="flex size-10 items-center justify-center rounded-full bg-background p-0"
onClick={() => setIsMenuOpen(true)}
>
<MenuIcon className="size-6 text-muted-foreground" />
<MenuIcon className="size-4 text-muted-foreground" />
</Button>
{isMenuOpen && (
+4
View File
@@ -8,6 +8,7 @@ import NextTopLoader from "nextjs-toploader";
import { Suspense } from "react";
import { OrganizeContractsToProjectsBanner } from "../components/notices/AnnouncementBanner";
import { OpCreditsGrantedModalWrapperServer } from "../components/onboarding/OpCreditsGrantedModalWrapperServer";
import { PosthogIdentifierServer } from "../components/wallets/PosthogIdentifierServer";
import { EnsureValidConnectedWalletLoginServer } from "./components/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer";
import { PostHogProvider } from "./components/root-providers";
import { AppRouterProviders } from "./providers";
@@ -80,6 +81,9 @@ export default function RootLayout({
<Suspense fallback={null}>
<EnsureValidConnectedWalletLoginServer />
</Suspense>
<Suspense fallback={null}>
<PosthogIdentifierServer />
</Suspense>
</AppRouterProviders>
<DashboardRouterTopProgressBar />
<NextTopLoader
-2
View File
@@ -11,7 +11,6 @@ import {
useConnectionManager,
} from "thirdweb/react";
import { CustomConnectWallet } from "../@3rdweb-sdk/react/components/connect-wallet";
import { PosthogIdentifier } from "../components/wallets/PosthogIdentifier";
import { isSanctionedAddress } from "../data/eth-sanctioned-addresses";
import { useAllChainsData } from "../hooks/chains/allChains";
import { SyncChainStores } from "../stores/chainStores";
@@ -27,7 +26,6 @@ export function AppRouterProviders(props: { children: React.ReactNode }) {
<ThirdwebProvider>
<SyncChainDefinitionsToConnectionManager />
<TWAutoConnect />
<PosthogIdentifier />
<ThemeProvider
attribute="class"
disableTransitionOnChange
@@ -1,9 +1,11 @@
import { getWalletConnections } from "@/api/analytics";
import { type Project, getProjects } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { Changelog } from "components/dashboard/Changelog";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { subDays } from "date-fns";
import { redirect } from "next/navigation";
import { getAuthToken } from "../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../login/loginRedirect";
import {
type ProjectWithAnalytics,
TeamProjectsPage,
@@ -13,7 +15,14 @@ export default async function Page(props: {
params: Promise<{ team_slug: string }>;
}) {
const params = await props.params;
const team = await getTeamBySlug(params.team_slug);
const [team, authToken] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
]);
if (!authToken) {
loginRedirect(`/team/${params.team_slug}`);
}
if (!team) {
redirect("/team");
@@ -23,16 +32,21 @@ export default async function Page(props: {
const projectsWithTotalWallets = await getProjectsWithAnalytics(projects);
return (
<div className="container flex grow flex-col gap-12 py-8 lg:flex-row">
<div className="flex grow flex-col">
<h1 className="mb-4 font-semibold text-2xl tracking-tight">Projects</h1>
<TeamProjectsPage projects={projectsWithTotalWallets} team={team} />
<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>
</div>
<div className="shrink-0 lg:w-[320px]">
<h2 className="mb-4 font-semibold text-2xl tracking-tight">
Changelog
</h2>
<Changelog />
<div className="container flex grow flex-col pt-8 pb-20">
<TeamProjectsPage
projects={projectsWithTotalWallets}
team={team}
client={getThirdwebClient(authToken)}
/>
</div>
</div>
);
@@ -26,7 +26,7 @@ export function UpdatePartnerModal({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
<DialogContent>
<DialogHeader className="mb-2">
<DialogTitle>Update {partner.name}</DialogTitle>
</DialogHeader>
@@ -132,10 +132,7 @@ const ChangeVersionModal = (props: {
// Self-hosted modal: prompt to update manually.
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="z-[10001] max-w-[400px]"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="max-w-[400px]">
<DialogHeader>
<DialogTitle className="mb-6 pr-4 font-semibold text-2xl tracking-tight">
Update your self-hosted Engine
@@ -182,10 +179,7 @@ const ChangeVersionModal = (props: {
// For cloud-hosted, prompt the user to select a version to update to.
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="z-[10001] max-w-[400px]"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="max-w-[400px]">
<DialogHeader>
<DialogTitle>Update Engine version</DialogTitle>
</DialogHeader>
@@ -198,7 +192,7 @@ const ChangeVersionModal = (props: {
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectContent>
<SelectGroup>
{serverVersions.map(({ name, createdAt }, idx) => {
const isCurrentVersion = name === currentVersion;
@@ -91,10 +91,7 @@ export function EngineAlertDialogForm(props: {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent
className="z-[10001] p-0"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="p-0">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-6">
@@ -120,7 +117,7 @@ export function EngineAlertDialogForm(props: {
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectContent>
<SelectGroup>
{alertRuleOptions.map((option) => (
<SelectItem
@@ -161,7 +158,7 @@ export function EngineAlertDialogForm(props: {
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select Notification Type" />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectContent>
<SelectGroup>
{Object.entries(
EngineNotificationChannelTypeConfig,
@@ -7,7 +7,7 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
import { ChakraProvider } from "@chakra-ui/react";
import { ChakraProviderSetup } from "../../../../../../../../@/components/ChakraProviderSetup";
import { getValidAccount } from "../../../../../../../account/settings/getAccount";
import { getAuthToken } from "../../../../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../../../../login/loginRedirect";
@@ -54,7 +54,7 @@ export default async function Layout(props: {
}
return (
<ChakraProvider>
<ChakraProviderSetup>
<div className="flex grow flex-col">
<EngineInstanceHeader
instance={instance}
@@ -77,7 +77,7 @@ export default async function Layout(props: {
</EnsureEnginePermission>
</EngineSidebarLayout>
</div>
</ChakraProvider>
</ChakraProviderSetup>
);
}
@@ -92,7 +92,7 @@ function EngineInstanceHeader(props: {
: instance.url;
return (
<div className="border-b">
<div className="border-border border-b">
<div className="container py-6">
<Breadcrumb>
<BreadcrumbList>
@@ -129,10 +129,7 @@ export const CreateBackendWalletButton: React.FC<
Create
</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent
className="z-[10001] overflow-hidden p-0"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="overflow-hidden p-0">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-6">
@@ -162,7 +159,7 @@ export const CreateBackendWalletButton: React.FC<
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectContent>
<SelectGroup>
{walletTypeOptions.map((option) => (
<SelectItem key={option.key} value={option.key}>
@@ -147,10 +147,7 @@ export const ImportBackendWalletButton: React.FC<
</Button>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent
className="z-[10001] p-0"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="p-0">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-6">
@@ -175,7 +172,7 @@ export const ImportBackendWalletButton: React.FC<
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectContent>
<SelectGroup>
{walletTypeOptions.map((option) => (
<SelectItem key={option.key} value={option.key}>
@@ -67,10 +67,7 @@ export const CredentialForm = ({
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
className="z-[10001] overflow-hidden p-0"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="overflow-hidden p-0">
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => {
@@ -114,7 +111,7 @@ export const CredentialForm = ({
<SelectTrigger className="bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectContent>
<SelectGroup>
{CREDENTIAL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
@@ -6,33 +6,30 @@ import { ProjectAvatar } from "@/components/blocks/Avatars/ProjectAvatar";
import { PaginationButtons } from "@/components/pagination-buttons";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import {
ChevronDownIcon,
EllipsisVerticalIcon,
PlusIcon,
SearchIcon,
} from "lucide-react";
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { formatDate } from "date-fns";
import { PlusIcon, SearchIcon } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
type SortById = "name" | "createdAt" | "monthlyActiveUsers";
@@ -43,6 +40,7 @@ export type ProjectWithAnalytics = Project & {
export function TeamProjectsPage(props: {
projects: ProjectWithAnalytics[];
team: Team;
client: ThirdwebClient;
}) {
const { projects } = props;
const [searchTerm, setSearchTerm] = useState("");
@@ -80,7 +78,7 @@ export function TeamProjectsPage(props: {
return _projectsToShow;
}, [searchTerm, sortBy, projects]);
const pageSize = 8;
const pageSize = 10;
const [page, setPage] = useState(1);
const paginatedProjects = sortedProjects.slice(
(page - 1) * pageSize,
@@ -92,78 +90,135 @@ export function TeamProjectsPage(props: {
return (
<div className="flex grow flex-col">
{/* Filters + Add New */}
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<SearchInput
value={searchTerm}
onValueChange={(v) => {
setSearchTerm(v);
setPage(1);
}}
/>
<div className="flex gap-4">
<SelectBy
value={sortBy}
onChange={(v) => {
setSortBy(v);
<div className="relative flex flex-col gap-5 rounded-t-lg pb-4 lg:flex-row lg:items-center lg:justify-between lg:border lg:border-b-0 lg:bg-card lg:px-6 lg:py-6">
<h2 className="font-semibold text-2xl tracking-tight">Projects</h2>
{/* Filters + Add New */}
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<SearchInput
value={searchTerm}
onValueChange={(v) => {
setSearchTerm(v);
setPage(1);
}}
/>
<AddNewButton
createProject={() => setIsCreateProjectDialogOpen(true)}
teamMembersSettingsPath={`/team/${props.team.slug}/~/settings/members`}
/>
<div className="flex gap-3">
<SelectBy
value={sortBy}
onChange={(v) => {
setSortBy(v);
setPage(1);
}}
/>
<AddNewButton
createProject={() => setIsCreateProjectDialogOpen(true)}
teamMembersSettingsPath={`/team/${props.team.slug}/~/settings/members`}
/>
</div>
</div>
</div>
<div className="h-6" />
{/* Projects */}
{/* Projects Table */}
{paginatedProjects.length === 0 ? (
<>
{searchTerm !== "" ? (
<div className="flex min-h-[450px] grow items-center justify-center rounded-lg border border-border">
<div className="flex min-h-[450px] grow items-center justify-center border border-border bg-card">
<div className="flex flex-col items-center">
<p className="mb-5 text-center">No projects found</p>
</div>
</div>
) : (
<div className="flex min-h-[450px] grow items-center justify-center rounded-lg border border-border">
<div className="flex min-h-[450px] grow items-center justify-center rounded-b-lg border border-border bg-card">
<div className="flex flex-col items-center">
<p className="mb-5 text-center">No projects created</p>
<p className="mb-5 text-center font-semibold text-lg">
No projects created
</p>
<Button
className="gap-2"
className="gap-2 bg-background"
onClick={() => setIsCreateProjectDialogOpen(true)}
variant="outline"
>
<PlusIcon className="size-4" />
Create a Project
Create Project
</Button>
</div>
</div>
)}
</>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
{paginatedProjects.map((project) => {
return (
<ProjectCard
key={project.id}
project={project}
team_slug={props.team.slug}
/>
);
})}
</div>
)}
<div>
<TableContainer
className={cn(
"lg:rounded-t-none",
showPagination && "rounded-b-none",
)}
>
<Table>
<TableHeader>
<TableRow>
<TableHead className="max-w-[300px]">Project</TableHead>
<TableHead>Monthly Active Users</TableHead>
<TableHead className="w-[250px]">Client ID</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedProjects.map((project) => (
<TableRow
key={project.id}
linkBox
className="hover:bg-accent/50"
>
<TableCell>
<Link
className="flex items-center gap-3 before:absolute before:inset-0"
href={`/team/${props.team.slug}/${project.slug}`}
>
<ProjectAvatar
className="size-8 rounded-full"
src={project.image || ""}
client={props.client}
/>
<span className="font-medium text-sm">
{project.name}
</span>
</Link>
</TableCell>
<TableCell>
{project.monthlyActiveUsers === 0 ? (
<span className="text-muted-foreground">No Users</span>
) : (
project.monthlyActiveUsers
)}
</TableCell>
<TableCell>
<CopyTextButton
textToCopy={project.publishableKey}
textToShow={`${project.publishableKey.slice(0, 4)}...${project.publishableKey.slice(-4)}`}
copyIconPosition="right"
tooltip={undefined}
variant="ghost"
className="-translate-x-2 z-10 font-mono text-muted-foreground"
/>
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(new Date(project.createdAt), "MMM d, yyyy")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{showPagination && (
<div className="py-6">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
{showPagination && (
<div className="rounded-b-lg border border-t-0 bg-card p-4">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</div>
)}
@@ -184,64 +239,6 @@ export function TeamProjectsPage(props: {
);
}
function ProjectCard(props: {
project: ProjectWithAnalytics;
team_slug: string;
}) {
const { project, team_slug } = props;
return (
<div
key={project.id}
className="relative flex items-center gap-4 rounded-lg border border-border bg-card p-5 transition-colors hover:border-active-border"
>
{/* TODO - set image */}
<ProjectAvatar className="size-10 rounded-full" src="" />
<div className="flex flex-grow flex-col gap-1">
<Link
className="group static before:absolute before:top-0 before:right-0 before:bottom-0 before:left-0 before:z-0"
// remove /connect when we have overview page
href={`/team/${team_slug}/${project.slug}`}
>
<h2 className="font-medium text-base">{project.name}</h2>
</Link>
<p className="flex items-center gap-1 text-muted-foreground text-sm">
<span>{project.monthlyActiveUsers}</span>
Monthly Active Users
</p>
</div>
<Popover>
<PopoverTrigger asChild>
<Button className="z-10 h-auto w-auto p-2" variant="ghost">
<EllipsisVerticalIcon className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-1">
<CopyTextButton
textToCopy={project.publishableKey}
textToShow="Copy Client ID"
copyIconPosition="right"
tooltip={undefined}
variant="ghost"
className="flex h-10 w-full justify-between gap-3 rounded-md px-4 py-2"
/>
<Button
variant="ghost"
className="w-full justify-start gap-3"
asChild
>
<Link href={`/team/${team_slug}/${project.slug}/settings`}>
Settings
</Link>
</Button>
</PopoverContent>
</Popover>
</div>
);
}
function SearchInput(props: {
value: string;
onValueChange: (value: string) => void;
@@ -252,7 +249,7 @@ function SearchInput(props: {
placeholder="Search Projects by name or Client ID"
value={props.value}
onChange={(e) => props.onValueChange(e.target.value)}
className="bg-card pl-9"
className="bg-background pl-9 lg:w-[400px]"
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
</div>
@@ -264,28 +261,16 @@ function AddNewButton(props: {
teamMembersSettingsPath: string;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" className="gap-2">
Add New
<ChevronDownIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[220px] p-2" sideOffset={12}>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
className="justify-between px-2"
onClick={props.createProject}
>
Project
</Button>
<Button variant="ghost" className="justify-between px-2" asChild>
<Link href={props.teamMembersSettingsPath}>Team Member</Link>
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="default"
className="absolute top-0 right-0 gap-2 lg:static"
onClick={props.createProject}
>
<PlusIcon className="size-4" />
<span>
<span className="hidden lg:inline">Create</span> Project
</span>
</Button>
);
}
@@ -307,11 +292,9 @@ function SelectBy(props: {
props.onChange(v as SortById);
}}
>
<SelectTrigger className="min-w-[200px] bg-card capitalize">
<SelectTrigger className="min-w-[200px] bg-background capitalize">
<div className="flex items-center gap-1.5">
<span className="!hidden lg:!inline text-muted-foreground">
Sort by
</span>
<span className="text-muted-foreground">Sort by</span>
{valueToLabel[props.value]}
</div>
</SelectTrigger>
@@ -238,7 +238,7 @@ function CreateWebhookButton(props: PropsWithChildren<PayWebhooksPageProps>) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
<DialogContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) =>
@@ -1,3 +1,6 @@
import { Button } from "@/components/ui/button";
import { FileCode2Icon, MessageSquareQuoteIcon } from "lucide-react";
import Link from "next/link";
import {
ResponsiveSearchParamsProvider,
ResponsiveSuspense,
@@ -20,17 +23,32 @@ export function NebulaAnalyticsPage(props: {
return (
<ResponsiveSearchParamsProvider value={props.searchParams}>
<header className="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-2xl tracking-tight md:text-3xl">
Nebula
</h1>
<div className="container flex flex-col items-start gap-4 py-10 md:flex-row md:items-center md:justify-between">
<h1 className="font-semibold text-3xl tracking-tight">Nebula</h1>
<div className="flex gap-3">
<Button variant="outline" className="gap-2 bg-card" asChild>
<Link href="https://nebula.thirdweb.com" target="_blank">
<MessageSquareQuoteIcon className="size-4 text-muted-foreground" />
Playground
</Link>
</Button>
<Button variant="outline" className="gap-2 bg-card" asChild>
<Link href="https://portal.thirdweb.com/nebula" target="_blank">
<FileCode2Icon className="size-4 text-muted-foreground" />
API Reference
</Link>
</Button>
</div>
<NebulaAnalyticsFilter />
</div>
</header>
<div className="container pt-8 pb-20">
<div className="mb-4 flex flex-col justify-between gap-3 md:flex-row md:items-end">
<h2 className="font-semibold text-2xl tracking-tight">Analytics</h2>
<NebulaAnalyticsFilter />
</div>
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={<NebulaAnalyticsDashboardUI data={[]} isPending={true} />}
@@ -36,6 +36,10 @@ function Story(props: {
return (
<div className="mx-auto w-full max-w-[1100px] px-4 py-6">
<ProjectGeneralSettingsPageUI
updateProjectImage={async (file) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("updateProjectImage", file);
}}
isOwnerAccount={props.isOwnerAccount}
transferProject={async (newTeam) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -1,4 +1,5 @@
"use client";
import { apiServerProxy } from "@/actions/proxies";
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar";
@@ -31,6 +32,7 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { cn } from "@/lib/utils";
import type { RotateSecretKeyAPIReturnType } from "@3rdweb-sdk/react/hooks/useApi";
import {
@@ -46,6 +48,7 @@ import {
type ServiceName,
getServiceByName,
} from "@thirdweb-dev/service-utils";
import { FileInput } from "components/shared/FileInput";
import { format } from "date-fns";
import { useTrack } from "hooks/analytics/useTrack";
import {
@@ -60,11 +63,11 @@ import { type UseFormReturn, useForm } from "react-hook-form";
import { type FieldArrayWithId, useFieldArray } from "react-hook-form";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { upload } from "thirdweb/storage";
import { RE_BUNDLE_ID } from "utils/regex";
import { joinWithComma, toArrFromList } from "utils/string";
import { validStrList } from "utils/validations";
import { z } from "zod";
import { apiServerProxy } from "../../../../../@/actions/proxies";
import {
HIDDEN_SERVICES,
projectDomainsSchema,
@@ -119,6 +122,29 @@ export function ProjectGeneralSettingsPage(props: {
client={props.client}
teamSlug={props.teamSlug}
project={props.project}
updateProjectImage={async (file) => {
let uri: string | undefined = undefined;
if (file) {
// upload to IPFS
uri = await upload({
client: props.client,
files: [file],
});
}
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.project.teamId,
},
{
image: uri,
},
);
router.refresh();
}}
updateProject={async (projectValues) => {
return updateProjectClient(
{
@@ -184,6 +210,7 @@ export function ProjectGeneralSettingsPageUI(props: {
client: ThirdwebClient;
transferProject: (newTeam: Team) => Promise<void>;
isOwnerAccount: boolean;
updateProjectImage: (file: File | undefined) => Promise<void>;
}) {
const projectLayout = `/team/${props.teamSlug}/${props.project.slug}`;
@@ -320,6 +347,12 @@ export function ProjectGeneralSettingsPageUI(props: {
handleSubmit={handleSubmit}
/>
<ProjectImageSetting
updateProjectImage={props.updateProjectImage}
avatar={project.image || null}
client={props.client}
/>
<ProjectKeyDetails
project={project}
rotateSecretKey={props.rotateSecretKey}
@@ -402,6 +435,66 @@ function ProjectNameSetting(props: {
);
}
function ProjectImageSetting(props: {
updateProjectImage: (file: File | undefined) => Promise<void>;
avatar: string | null;
client: ThirdwebClient;
}) {
const projectAvatarUrl = resolveSchemeWithErrorHandler({
client: props.client,
uri: props.avatar || undefined,
});
const [projectAvatar, setProjectAvatar] = useState<File | undefined>();
const updateProjectAvatarMutation = useMutation({
mutationFn: async (_avatar: File | undefined) => {
await props.updateProjectImage(_avatar);
},
});
function handleSave() {
const promise = updateProjectAvatarMutation.mutateAsync(projectAvatar);
toast.promise(promise, {
success: "Project avatar updated successfully",
error: "Failed to update project avatar",
});
}
return (
<SettingsCard
bottomText="An avatar is optional but strongly recommended."
saveButton={{
onClick: handleSave,
disabled: false,
isPending: updateProjectAvatarMutation.isPending,
}}
noPermissionText={undefined}
errorText={undefined}
>
<div className="flex flex-row gap-4 md:justify-between">
<div>
<h3 className="font-semibold text-xl tracking-tight">
Project Avatar
</h3>
<p className="mt-1.5 mb-4 text-foreground text-sm leading-relaxed">
This is your project's avatar. <br /> Click on the avatar to upload
a custom one
</p>
</div>
<FileInput
accept={{ "image/*": [] }}
value={projectAvatar}
setValue={setProjectAvatar}
className="w-20 rounded-full lg:w-28"
disableHelperText
fileUrl={projectAvatarUrl}
/>
</div>
</SettingsCard>
);
}
function AllowedDomainsSetting(props: {
form: UpdateAPIForm;
isUpdatingProject: boolean;
@@ -0,0 +1,183 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
NotificationButtonInner,
type NotificationMetadata,
} from "./NotificationButton";
const meta = {
title: "headers/components/NotificationButton",
component: NotificationButtonInner,
parameters: {
layout: "centered",
},
decorators: [
(Story) => (
<div className="flex justify-end px-6 py-4">
<Story />
</div>
),
],
} satisfies Meta<typeof NotificationButtonInner>;
export default meta;
type Story = StoryObj<typeof meta>;
const titlesToPickFrom = [
"Insight API Supports Block Queries",
"Insight Adds Search Capability",
"RPC Edge: Faster, Leaner, and Smarter",
".NET/Unity - Insight Indexer and Engine Wallet Integration",
"Invite and Manage Team Members on Dashboard",
"Insight API ENS Support",
"Insight Token Queries Just Got 10X Faster",
"Insight indexer major version upgrade",
"Introducing Universal Bridge",
"Unreal Engine Plugin v2",
"Cross-chain deterministic contract deployments",
"Organize Contracts into Projects on Dashboard",
".NET/Unity - Auth token login, timeout improvements, rotating secret keys.",
"Mint fees for contract deployments update",
"Engine v2.1.32: Circle Wallet and Secure Credential Management",
"Nebula Update v0.0.7: Support for swapping and bridging, Offchain",
"Insight - Automatic Event & Transaction Resolution",
"Nebula Update v0.0.6: Upgraded Model, Speed & Accuracy Improvements",
"Enhancing RPC Edge Support with Support IDs",
"Blocks data retrieval with Insight [v0.2.2-beta]",
"Insight reorg handling improvement",
"Resilient Infrastructure & Smarter Insights",
"Nebula Update v0.0.5: Advanced configurations, new endpoints, and simplified context filters",
"Universal Smart Wallet Addresses",
".NET/Unity - Nebula Integration for native apps and games!",
];
function generateRandomNotification(isRead: boolean): NotificationMetadata {
const index = Math.floor(Math.random() * titlesToPickFrom.length);
const safeIndex = Math.min(index, titlesToPickFrom.length - 1);
const title = titlesToPickFrom[safeIndex] || "";
const randomTimeAgo = Math.floor(Math.random() * 7 * 24 * 60 * 60 * 1000); // Random time within last 7 days
return {
title,
href: "https://blog.thirdweb.com/changelog",
isRead,
createdAt: new Date(Date.now() - randomTimeAgo).toISOString(),
id: crypto.randomUUID(),
};
}
function randomNotifications(count: number): NotificationMetadata[] {
return Array.from({ length: count }, () => generateRandomNotification(false));
}
export const AllUnread: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(10),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30),
isPending: false,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
export const Loading: Story = {
args: {
notificationTabs: {
inbox: {
notifications: [],
isPending: true,
},
changelogs: {
notifications: [],
isPending: true,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
export const NoNotifications: Story = {
args: {
notificationTabs: {
inbox: {
notifications: [],
isPending: false,
},
changelogs: {
notifications: [],
isPending: false,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
export const AllRead: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
export const MixedNotifications: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(10).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
export const NoUnreadInboxNotifications: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(5).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
@@ -0,0 +1,307 @@
"use client";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { TabButtons } from "@/components/ui/tabs";
import { useQuery } from "@tanstack/react-query";
import { formatDistance } from "date-fns";
import { ActivityIcon, BellIcon, InboxIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { cleanupReadNotifications } from "./fetch-notifications";
export type NotificationMetadata = {
title: string;
id: string;
href: string;
isRead: boolean;
createdAt: string;
};
type NotificationTabs = {
changelogs: {
notifications: NotificationMetadata[];
isPending: boolean;
};
inbox: {
notifications: NotificationMetadata[];
isPending: boolean;
};
};
export function NotificationButtonUI(props: {
getChangelogs: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
const inboxNotificationsQuery = useQuery({
queryKey: ["inbox-notifications"],
queryFn: props.getInboxNotifications,
});
const changelogsQuery = useQuery({
queryKey: ["changelogs"],
queryFn: props.getChangelogs,
});
const [readNotifications, setReadNotifications] = useState<Set<string>>(
new Set(),
);
const changelogNotifications = useMemo(() => {
return (changelogsQuery.data || []).map((notification) => ({
...notification,
isRead: notification.isRead || readNotifications.has(notification.id),
}));
}, [changelogsQuery.data, readNotifications]);
const inboxNotifications = useMemo(() => {
return (inboxNotificationsQuery.data || []).map((notification) => ({
...notification,
isRead: notification.isRead || readNotifications.has(notification.id),
}));
}, [inboxNotificationsQuery.data, readNotifications]);
// Ensure we don't keep adding things to the IndexedDB storage by removing notification ids that are no longer being displayed
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (changelogNotifications.length > 0 && inboxNotifications.length > 0) {
cleanupReadNotifications({
changelogs: changelogNotifications,
updates: inboxNotifications,
});
}
}, [changelogNotifications, inboxNotifications]);
const markAsRead = useCallback(
async (id: string) => {
setReadNotifications((prev) => new Set([...prev, id]));
await props.markNotificationAsRead(id);
},
[props.markNotificationAsRead],
);
const notificationTabs: NotificationTabs = {
inbox: {
notifications: inboxNotifications,
isPending: inboxNotificationsQuery.isPending,
},
changelogs: {
notifications: changelogNotifications,
isPending: changelogsQuery.isPending,
},
};
return (
<NotificationButtonInner
notificationTabs={notificationTabs}
markNotificationAsRead={markAsRead}
/>
);
}
export function NotificationButtonInner(props: {
notificationTabs: NotificationTabs;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
const hasUnreadInboxNotifications =
props.notificationTabs.inbox.notifications.some((x) => !x.isRead);
const [tab, setTab] = useState<keyof NotificationTabs>("inbox");
const unreadNotifications = {
inbox: props.notificationTabs.inbox.notifications.filter((x) => !x.isRead),
changelogs: props.notificationTabs.changelogs.notifications.filter(
(x) => !x.isRead,
),
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
className="relative size-10 rounded-full bg-background p-0"
variant="outline"
>
<BellIcon className="size-4 text-muted-foreground" />
{hasUnreadInboxNotifications && (
<div className="absolute top-0 right-0 flex size-2.5 items-center justify-center rounded-full bg-red-500" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="!z-50 ml-4 w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-border p-0 shadow-xl lg:ml-0 lg:w-[500px]"
align="end"
sideOffset={10}
side="bottom"
>
<TabButtons
tabClassName="!text-sm"
tabContainerClassName="px-2 pt-1.5"
tabs={[
{
isActive: tab === "inbox",
name: (
<BadgeTab
name="Inbox"
unreadCount={unreadNotifications.inbox.length}
/>
),
onClick: () => setTab("inbox"),
isEnabled: true,
},
{
isActive: tab === "changelogs",
name: (
<BadgeTab
name="Changelog"
unreadCount={unreadNotifications.changelogs.length}
/>
),
onClick: () => setTab("changelogs"),
isEnabled: true,
},
]}
/>
{tab === "inbox" && (
<NotificationContent
notifications={props.notificationTabs.inbox.notifications}
isPending={props.notificationTabs.inbox.isPending}
icon={InboxIcon}
markNotificationAsRead={props.markNotificationAsRead}
/>
)}
{tab === "changelogs" && (
<NotificationContent
notifications={props.notificationTabs.changelogs.notifications}
isPending={props.notificationTabs.changelogs.isPending}
icon={ActivityIcon}
markNotificationAsRead={props.markNotificationAsRead}
/>
)}
</PopoverContent>
</Popover>
);
}
function BadgeTab(props: {
name: string;
unreadCount: number;
}) {
return (
<span className="flex items-center gap-2">
{props.name}
{props.unreadCount > 0 && (
<span className="rounded-lg bg-accent px-1.5 py-1 text-xs">
{props.unreadCount}
</span>
)}
</span>
);
}
function NotificationContent(props: {
notifications: NotificationMetadata[];
isPending: boolean;
icon: React.FC<{ className?: string }>;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
return (
<div>
<ScrollShadow
scrollableClassName="h-[70vh] overscroll-contain"
shadowClassName="z-20"
shadowColor="hsl(var(--background))"
>
{props.notifications?.map((notification) => (
<NotificationLink
notification={notification}
key={notification.id}
icon={props.icon}
markNotificationAsRead={props.markNotificationAsRead}
/>
))}
{props.isPending && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-10" />
</div>
)}
{!props.isPending && props.notifications.length === 0 && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div>
<div className="mb-3 flex justify-center">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border bg-card text-muted-foreground">
<props.icon className="size-4" />
</div>
</div>
<p className="text-center text-muted-foreground text-sm">
No Notifications
</p>
</div>
</div>
)}
</ScrollShadow>
</div>
);
}
function NotificationLink(props: {
notification: NotificationMetadata;
icon: React.FC<{ className?: string }>;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
const { notification } = props;
const link = new URL(notification.href);
link.searchParams.append("utm_source", "notification");
const handleClick = useCallback(async () => {
if (!notification.isRead) {
await props.markNotificationAsRead(notification.id);
}
}, [notification.id, notification.isRead, props.markNotificationAsRead]);
return (
<Link
href={link.href}
target="_blank"
className="relative flex items-start gap-4 border-b px-4 py-4 hover:bg-accent/50"
onClick={handleClick}
>
{/* Icon */}
<div className="relative flex size-10 shrink-0 items-center justify-center rounded-full border bg-card text-muted-foreground">
<props.icon className="size-4" />
</div>
{/* Content */}
<div className="grow">
<h3 className="font-medium text-foreground text-sm">
{notification.title}
</h3>
<p className="mt-1 text-muted-foreground text-xs">
{formatDistance(new Date(notification.createdAt), new Date(), {
addSuffix: true,
})}
</p>
</div>
<div className="flex w-6 items-center justify-center self-stretch">
{!notification.isRead && (
<span className="relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
</span>
)}
</div>
</Link>
);
}
@@ -0,0 +1,92 @@
import { get, set } from "idb-keyval";
import type { NotificationMetadata } from "./NotificationButton";
type ChangelogItem = {
published_at: string;
title: string;
id: string;
url: string;
};
// Note: This saving read notifications in IndexedDB is temporary implementation - This will soon be replaced by a proper API setup
const READ_NOTIFICATIONS_KEY = "thirdweb:read-notifications";
// get set of notification ids marked as read
async function getReadNotificationIds(): Promise<Set<string>> {
const readIds = (await get<string[]>(READ_NOTIFICATIONS_KEY)) || [];
return new Set(readIds);
}
// fetches last 20 posts with tag "changelog" notifications
async function fetchGhostPosts(tag: string) {
const res = await fetch(
`https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=id,title,url,published_at&filter=tag:${tag}&visibility:public&limit=20`,
);
const json = await res.json();
return json.posts as ChangelogItem[];
}
// Clean up the indexedDB storage of read notification IDs
// This is to prevent the storage from growing indefinitely
// Remove notification IDs from storage that are no longer shown to the user
export async function cleanupReadNotifications(params: {
changelogs: NotificationMetadata[];
updates: NotificationMetadata[];
}) {
const { changelogs, updates } = params;
const readIds = await getReadNotificationIds();
// Get all current notification IDs
const currentIds = new Set([
...changelogs.map((item) => item.id),
...updates.map((item) => item.id),
]);
// remove ids that are no longer being displayed
const usedIds = new Set(
Array.from(readIds).filter((id) => currentIds.has(id)),
);
await set(READ_NOTIFICATIONS_KEY, Array.from(usedIds));
}
export async function markNotificationAsRead(id: string) {
const readIds = await getReadNotificationIds();
readIds.add(id);
// Clean up old notification IDs periodically (every 10th notification marked as read)
await set(READ_NOTIFICATIONS_KEY, Array.from(readIds));
}
export async function getChangelogNotifications(): Promise<
NotificationMetadata[]
> {
const [changelogs, readIds] = await Promise.all([
fetchGhostPosts("changelog"),
getReadNotificationIds(),
]);
return changelogs.map((item) => ({
id: item.id,
title: item.title,
href: item.url,
isRead: readIds.has(item.id),
createdAt: item.published_at,
}));
}
export async function getInboxNotifications(): Promise<NotificationMetadata[]> {
const [changelogs, readIds] = await Promise.all([
fetchGhostPosts("update"),
getReadNotificationIds(),
]);
return changelogs.map((item) => ({
id: item.id,
title: item.title,
href: item.url,
isRead: readIds.has(item.id),
createdAt: item.published_at,
}));
}
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { ChevronsUpDownIcon } from "lucide-react";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { ProjectSelectorUI } from "./ProjectSelectorUI";
type ProjectSelectorMobileMenuButtonProps = {
@@ -14,6 +15,7 @@ type ProjectSelectorMobileMenuButtonProps = {
projects: Project[];
team: Team;
createProject: (team: Team) => void;
client: ThirdwebClient;
};
export function ProjectSelectorMobileMenuButton(
@@ -46,6 +48,7 @@ export function ProjectSelectorMobileMenuButton(
<DynamicHeight>
<ProjectSelectorUI
currentProject={props.currentProject}
client={props.client}
projects={props.projects}
team={props.team}
createProject={() => {
@@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
import { CheckIcon, CirclePlusIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { SearchInput } from "./SearchInput";
export function ProjectSelectorUI(props: {
@@ -17,6 +18,7 @@ export function ProjectSelectorUI(props: {
currentProject: Project | undefined;
team: Team;
createProject: () => void;
client: ThirdwebClient;
}) {
const { projects, currentProject, team } = props;
const [searchProjectTerm, setSearchProjectTerm] = useState("");
@@ -60,8 +62,11 @@ export function ProjectSelectorUI(props: {
>
<Link href={`/team/${team.slug}/${project.slug}`}>
<div className="flex items-center gap-2">
{/* TODO - set Image */}
<ProjectAvatar src="" className="size-6" />
<ProjectAvatar
src={project.image || ""}
className="size-6"
client={props.client}
/>
<span className="truncate"> {project.name}</span>
</div>
{isSelected && (
@@ -9,10 +9,10 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { ChevronsUpDownIcon } from "lucide-react";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { ProjectSelectorUI } from "./ProjectSelectorUI";
import { TeamSelectionUI } from "./TeamSelectionUI";
@@ -23,6 +23,7 @@ type TeamSwitcherProps = {
focus: "project-selection" | "team-selection";
createProject: (team: Team) => void;
account: Pick<Account, "email" | "id"> | undefined;
client: ThirdwebClient;
};
export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
@@ -31,7 +32,6 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
const [hoveredTeam, setHoveredTeam] = useState<Team>();
const projectsToShowOfTeam =
hoveredTeam || currentTeam || teamsAndProjects[0]?.team;
const client = useThirdwebClient();
// if we can't find a single team associated with this user - something is really wrong
if (!projectsToShowOfTeam) {
@@ -93,7 +93,7 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
: undefined
}
account={props.account}
client={client}
client={props.client}
/>
{/* Right */}
@@ -102,6 +102,7 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
currentProject={props.currentProject}
projects={projectsToShow}
team={projectsToShowOfTeam}
client={props.client}
createProject={() => {
setOpen(false);
props.createProject(projectsToShowOfTeam);
@@ -52,6 +52,11 @@ function Variants(props: {
if (!team1 || !team2 || !team3 || !team3Project) {
return <div> failed to get team and project stubs </div>;
}
const getChangelogsStub = () => Promise.resolve([]);
const getInboxNotificationsStub = () => Promise.resolve([]);
const markNotificationAsReadStub = () => Promise.resolve();
return (
<ThirdwebProvider>
<div className="flex min-h-dvh flex-col gap-6 bg-background py-10">
@@ -70,6 +75,9 @@ function Variants(props: {
id: "1",
}}
client={client}
getChangelogNotifications={getChangelogsStub}
getInboxNotifications={getInboxNotificationsStub}
markNotificationAsRead={markNotificationAsReadStub}
/>
</div>
</BadgeContainer>
@@ -89,6 +97,9 @@ function Variants(props: {
connectButton={<ConnectButtonStub />}
createProject={() => {}}
client={client}
getChangelogNotifications={getChangelogsStub}
getInboxNotifications={getInboxNotificationsStub}
markNotificationAsRead={markNotificationAsReadStub}
/>
</div>
</BadgeContainer>
@@ -108,6 +119,9 @@ function Variants(props: {
connectButton={<ConnectButtonStub />}
createProject={() => {}}
client={client}
getChangelogNotifications={getChangelogsStub}
getInboxNotifications={getInboxNotificationsStub}
markNotificationAsRead={markNotificationAsReadStub}
/>
</div>
</BadgeContainer>
@@ -10,6 +10,10 @@ import { SecondaryNav } from "../../../components/Header/SecondaryNav/SecondaryN
import { MobileBurgerMenuButton } from "../../../components/MobileBurgerMenuButton";
import { TeamPlanBadge } from "../../../components/TeamPlanBadge";
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
import {
NotificationButtonUI,
type NotificationMetadata,
} from "../NotificationButton/NotificationButton";
import { ProjectSelectorMobileMenuButton } from "./ProjectSelectorMobileMenuButton";
import { TeamAndProjectSelectorPopoverButton } from "./TeamAndProjectSelectorPopoverButton";
import { TeamSelectorMobileMenuButton } from "./TeamSelectorMobileMenuButton";
@@ -26,6 +30,9 @@ export type TeamHeaderCompProps = {
createProject: (team: Team) => void;
client: ThirdwebClient;
accountAddress: string;
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
};
export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
@@ -68,6 +75,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
focus="team-selection"
createProject={props.createProject}
account={props.account}
client={props.client}
/>
</div>
@@ -79,8 +87,11 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
href={`/team/${props.currentTeam.slug}/${props.currentProject.slug}`}
className="flex flex-row items-center gap-2 font-semibold text-sm"
>
{/* TODO - set project avatar image */}
<ProjectAvatar src="" className="size-6" />
<ProjectAvatar
src={props.currentProject.image || ""}
className="size-6"
client={props.client}
/>
{props.currentProject.name}
</Link>
@@ -91,6 +102,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
focus="project-selection"
createProject={props.createProject}
account={props.account}
client={props.client}
/>
</div>
</>
@@ -103,6 +115,9 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
connectButton={props.connectButton}
client={props.client}
accountAddress={props.accountAddress}
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
</header>
);
@@ -171,19 +186,28 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
projects={projects}
team={props.currentTeam}
createProject={props.createProject}
client={props.client}
/>
</div>
</>
)}
</div>
<MobileBurgerMenuButton
type="loggedIn"
email={props.account?.email}
logout={props.logout}
connectButton={props.connectButton}
accountAddress={props.accountAddress}
/>
<div className="flex items-center gap-3">
<NotificationButtonUI
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
<MobileBurgerMenuButton
type="loggedIn"
email={props.account.email}
logout={props.logout}
connectButton={props.connectButton}
accountAddress={props.accountAddress}
/>
</div>
</header>
);
}
@@ -10,6 +10,11 @@ import { useCallback, useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { LazyCreateProjectDialog } from "../../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { doLogout } from "../../../login/auth-actions";
import {
getChangelogNotifications,
getInboxNotifications,
markNotificationAsRead,
} from "../NotificationButton/fetch-notifications";
import {
type TeamHeaderCompProps,
TeamHeaderDesktopUI,
@@ -58,6 +63,9 @@ export function TeamHeaderLoggedIn(props: {
},
client: getThirdwebClient(),
accountAddress: props.accountAddress,
getChangelogNotifications: getChangelogNotifications,
getInboxNotifications: getInboxNotifications,
markNotificationAsRead: markNotificationAsRead,
};
return (
+2 -5
View File
@@ -1,11 +1,8 @@
import { getTeams } from "@/api/team";
import { getDefaultTeam } from "@/api/team";
import { notFound, redirect } from "next/navigation";
export default async function TeamRootPage() {
// get all teams, then go to the first team
const teams = await getTeams();
const firstTeam = teams?.[0];
const firstTeam = await getDefaultTeam();
if (!firstTeam) {
notFound();
}
@@ -214,11 +214,10 @@ export const MismatchButton = forwardRef<
>
<DialogContent
className={cn(
"z-[10001] gap-0 p-0",
"gap-0 p-0",
dialog === "no-funds" && "max-w-[480px]",
dialog === "pay" && "max-w-[360px] border-none bg-transparent",
)}
dialogOverlayClassName="z-[10000]"
dialogCloseClassName="focus:ring-0"
>
<DynamicHeight>
@@ -190,10 +190,7 @@ export const CmdKSearchModal = (props: {
}
}}
>
<DialogContent
className="z-[10000001] gap-0 p-0"
dialogOverlayClassName="z-[10000000]"
>
<DialogContent className="gap-0 p-0">
{/* Title */}
<DynamicHeight>
<div className="p-6">
@@ -21,10 +21,7 @@ export const ConfigureNetworkModal: React.FC<ConfigureNetworkModalProps> = (
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent
className="z-[10001] max-w-[480px] p-0"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="max-w-[480px] p-0">
<ConfigureNetworks
onNetworkAdded={(chain) => {
addRecentlyUsedChainId(chain.chainId);
@@ -51,6 +51,7 @@ function teamsAndProjectsStub(teamCount: number, projectCount: number) {
projects.push({
id: `project_${i + 1}_${j + 1}`,
name: `Project ${i + 1}_${j + 1}`,
image: `https://picsum.photos/200?random=${i}`,
});
}
@@ -20,7 +20,7 @@ import type { ThirdwebClient } from "thirdweb";
import { Fieldset } from "./common";
export type MinimalTeam = Pick<Team, "id" | "name" | "image" | "slug">;
export type MinimalProject = Pick<Project, "id" | "name">; // TODO: add image when project has image
export type MinimalProject = Pick<Project, "id" | "name" | "image">;
export type TeamAndProjectSelection = {
team: MinimalTeam | undefined;
@@ -206,7 +206,11 @@ export function AddToProjectSelector(props: {
className="py-2.5"
>
<div className="flex items-center gap-2">
<ProjectAvatar src="" className="size-5" />
<ProjectAvatar
src={project.image || ""}
className="size-5"
client={props.client}
/>
<span>{project.name}</span>
</div>
</SelectItem>
@@ -77,8 +77,7 @@ export function DeployStatusModal(props: {
<Dialog open={isModalOpen}>
<DialogContent
dialogCloseClassName="hidden"
className="z-[10001] gap-0 p-0 md:max-w-[480px]"
dialogOverlayClassName="z-[10000]"
className="gap-0 p-0 md:max-w-[480px]"
>
<div className="flex flex-col gap-6 p-6">
<DialogHeader>
@@ -49,10 +49,7 @@ export const ImportModal: React.FC<ImportModalProps> = (props) => {
}
}}
>
<DialogContent
dialogOverlayClassName="z-[9000] rounded-lg"
className="z-[9001]"
>
<DialogContent dialogOverlayClassName="rounded-lg">
<DialogHeader>
<DialogTitle className="font-semibold text-2xl tracking-tight">
Import Contract
@@ -1,54 +0,0 @@
import { formatDistance } from "date-fns";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
type ChangelogItem = {
published_at: string;
title: string;
url: string;
};
export async function Changelog() {
const changelog = await getChangelog();
return (
<div className="relative flex flex-col gap-6 border-border border-l py-2">
{changelog.map((item) => (
<div className="flex flex-row gap-4" key={item.title}>
<div className="-translate-x-1/2 size-2.5 shrink-0 translate-y-1/2 rounded-full bg-border" />
<div className="flex flex-col">
<Link
target="_blank"
href={`${item.url}?utm_source=thirdweb&utm_campaign=changelog`}
role="group"
className="line-clamp-2 text-foreground text-sm hover:underline"
>
{item.title}
</Link>
<div className="mt-1 text-muted-foreground text-xs">
{formatDistance(new Date(item.published_at), Date.now(), {
addSuffix: true,
})}
</div>
</div>
</div>
))}
<Link
href="https://blog.thirdweb.com/changelog?utm_source=thirdweb&utm_campaign=changelog"
target="_blank"
className="!text-foreground flex items-center gap-2 pl-7 text-sm"
>
View More <ArrowRightIcon className="size-4" />
</Link>
</div>
);
}
async function getChangelog() {
const res = await fetch(
"https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=title,url,published_at&filter=tag:changelog&visibility:public&limit=7",
);
const json = await res.json();
return json.posts as ChangelogItem[];
}
@@ -42,10 +42,7 @@ export function ShareFreeWalletsModal(props: {
return (
<Dialog open={props.isOpen} onOpenChange={props.onOpenChange}>
<DialogContent
className="z-[10001] p-0 md:max-w-[380px]"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="p-0 md:max-w-[380px]">
<DynamicHeight>
{screen === "base" && (
<div className="fade-in-0 animate-in duration-300">
@@ -163,7 +160,7 @@ function SendEmailScreen(props: {
<SelectTrigger>
<SelectValue placeholder="Select Email App" />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectContent>
<SelectGroup>
<SelectItem value="default">
Default Email App
@@ -106,8 +106,7 @@ export const CreateProjectDialogUI = (props: {
}}
>
<DialogContent
className="z-[10001] overflow-hidden p-0"
dialogOverlayClassName="z-[10000]"
className="overflow-hidden p-0"
dialogCloseClassName={screen.id === "api-details" ? "hidden" : ""}
>
<DynamicHeight>
@@ -274,7 +273,7 @@ function CreateProjectForm(props: {
<form onSubmit={handleSubmit} autoComplete="off">
<div className="p-6">
<DialogHeader className="mb-4">
<DialogTitle className="text-2xl">Create a Project</DialogTitle>
<DialogTitle className="text-2xl">Create Project</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-6">
@@ -19,7 +19,10 @@ const walletIdToPHName: Record<string, string> = {
injected: "Injected",
};
export const PosthogIdentifier: React.FC = () => {
export const PosthogIdentifierClient: React.FC<{
accountId: string | undefined;
accountAddress: string | undefined;
}> = ({ accountId, accountAddress }) => {
const client = useThirdwebClient();
const account = useActiveAccount();
const chain = useActiveWalletChain();
@@ -43,10 +46,17 @@ export const PosthogIdentifier: React.FC = () => {
// legitimate use-case
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (account?.address) {
posthog.identify(account.address);
if (accountAddress) {
posthog.identify(accountAddress);
}
}, [account?.address]);
}, [accountAddress]);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (accountId) {
posthog.identify(accountId);
}
}, [accountId]);
// legitimate use-case
// eslint-disable-next-line no-restricted-syntax
@@ -0,0 +1,17 @@
import { getRawAccount } from "../../app/account/settings/getAccount";
import { getAuthTokenWalletAddress } from "../../app/api/lib/getAuthToken";
import { PosthogIdentifierClient } from "./PosthogIdentifier";
export async function PosthogIdentifierServer() {
const [account, accountAddress] = await Promise.all([
getRawAccount(),
getAuthTokenWalletAddress(),
]);
return (
<PosthogIdentifierClient
accountId={account?.id}
accountAddress={accountAddress || undefined}
/>
);
}
@@ -77,10 +77,7 @@ export const ErrorProvider: ComponentWithChildren = ({ children }) => {
}
}}
>
<DialogContent
className="z-[10001] max-w-[480px] gap-0 p-0"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="max-w-[480px] gap-0 p-0">
{/* min-w-0 is actually required here */}
<div className="flex min-w-0 flex-col gap-6 p-6">
{/* Header */}
+2 -4
View File
@@ -1,4 +1,4 @@
import { getTeams } from "@/api/team";
import { getDefaultTeam } from "@/api/team";
import { isLoginRequired } from "@/constants/auth";
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
import { type NextRequest, NextResponse } from "next/server";
@@ -165,9 +165,7 @@ export async function middleware(request: NextRequest) {
// redirect /team/~/... to /team/<first_team_slug>/...
if (paths[0] === "team" && paths[1] === "~") {
// TODO - need an API to get the first team to avoid fetching all teams
const teams = await getTeams();
const firstTeam = teams?.[0];
const firstTeam = await getDefaultTeam();
if (firstTeam) {
const modifiedPaths = [...paths];
modifiedPaths[1] = firstTeam.slug;
+2 -2
View File
@@ -25,7 +25,7 @@
"clsx": "^2.1.1",
"jose": "6.0.7",
"lucide-react": "0.476.0",
"next": "15.1.7",
"next": "15.2.0",
"next-themes": "^0.4.4",
"react": "19.0.0",
"react-dom": "19.0.0",
@@ -41,7 +41,7 @@
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"eslint": "^9",
"eslint-config-next": "15.1.7",
"eslint-config-next": "15.2.0",
"postcss": "8.5.3",
"tailwindcss": "3.4.17",
"typescript": "5.7.3"
+2 -2
View File
@@ -37,7 +37,7 @@
"clsx": "^2.1.1",
"jose": "6.0.7",
"lucide-react": "0.476.0",
"next": "15.1.7",
"next": "15.2.0",
"next-themes": "^0.4.4",
"nextjs-toploader": "^1.6.12",
"openapi-types": "^12.1.3",
@@ -59,7 +59,7 @@
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"eslint": "8.57.0",
"eslint-config-next": "15.1.7",
"eslint-config-next": "15.2.0",
"eslint-plugin-react-compiler": "19.0.0-beta-e1e972c-20250221",
"postcss": "8.5.3",
"tailwindcss": "3.4.17",
+2
View File
@@ -39,6 +39,8 @@ next-env.d.ts
# generated files
searchIndex.json
public/llms.txt
public/llms-full.txt
.env
public/sitemap*.xml
+6 -3
View File
@@ -21,7 +21,7 @@
"@dirtycajunrice/klee": "^1.0.6",
"@mdx-js/loader": "^2.3.0",
"@mdx-js/react": "^2.3.0",
"@next/mdx": "15.1.7",
"@next/mdx": "15.2.0",
"@radix-ui/react-dialog": "1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-select": "^2.1.6",
@@ -34,9 +34,11 @@
"date-fns": "4.1.0",
"flexsearch": "^0.7.43",
"github-slugger": "^2.0.0",
"he": "^1.2.0",
"lucide-react": "0.476.0",
"next": "15.1.7",
"next": "15.2.0",
"nextjs-toploader": "^1.6.12",
"node-html-markdown": "^1.3.0",
"node-html-parser": "^6.1.13",
"posthog-js": "1.67.1",
"prettier": "3.3.3",
@@ -53,8 +55,9 @@
"typedoc-better-json": "0.9.4"
},
"devDependencies": {
"@next/eslint-plugin-next": "15.1.7",
"@next/eslint-plugin-next": "15.2.0",
"@types/flexsearch": "^0.7.6",
"@types/he": "^1.2.3",
"@types/mdx": "^2.0.13",
"@types/node": "22.13.5",
"@types/react": "19.0.10",
+6 -3
View File
@@ -1,10 +1,13 @@
import { writeFileSync } from "node:fs";
import { extractSearchData } from "../src/app/api/search/extraction";
import { extractContent } from "../src/app/api/search/extraction";
async function main() {
const rootDir = process.cwd();
const websiteData = await extractSearchData(rootDir);
writeFileSync("./searchIndex.json", JSON.stringify(websiteData, null, 2));
const { searchData, llmContent, llmFullContent } =
await extractContent(rootDir);
writeFileSync("./searchIndex.json", JSON.stringify(searchData, null, 2));
writeFileSync("./public/llms.txt", llmContent);
writeFileSync("./public/llms-full.txt", llmFullContent);
}
main();
+1 -1
View File
@@ -3,7 +3,7 @@ import { sidebar } from "./sidebar";
export default async function Layout(props: { children: React.ReactNode }) {
return (
<DocLayout sideBar={sidebar} editPageButton={true}>
<DocLayout sideBar={sidebar} editPageButton={true} noLLM={true}>
{props.children}
</DocLayout>
);
@@ -1,4 +1,6 @@
import { readFile } from "node:fs/promises";
import he from "he";
import { NodeHtmlMarkdown } from "node-html-markdown";
import {
CommentNode as X_CommentNode,
HTMLElement as X_HTMLElement,
@@ -11,11 +13,35 @@ import { getFilesRecursive } from "./getFilesRecursive";
import { ignoreHeadings } from "./settings";
import { trimExtraSpace } from "./trimExtraSpace";
export async function extractSearchData(rootDir: string): Promise<PageData[]> {
type ExtractedContent = {
searchData: PageData[];
llmContent: string;
llmFullContent: string;
};
const llmsContentHeader = `\
# thirdweb
> Frontend, Backend, and Onchain tools to build complete web3 apps — on every EVM chain.
## Docs
`;
const llmsFullContentHeader = `\
# thirdweb
> Frontend, Backend, and Onchain tools to build complete web3 apps — on every EVM chain.
`;
export async function extractContent(
rootDir: string,
): Promise<ExtractedContent> {
const nextOutputDir = `${rootDir}/.next/server/app`;
const htmlFiles = getFilesRecursive(nextOutputDir, "html");
const pages: PageData[] = [];
let llmContent = "";
let llmFullContent = "";
const noMainFound: string[] = [];
const noH1Found: string[] = [];
@@ -26,7 +52,7 @@ export async function extractSearchData(rootDir: string): Promise<PageData[]> {
const mainEl = parse(htmlContent, {
comment: false,
blockTextElements: {
pre: false, // parse text inside <pre> elements instead of treating it as text
pre: true,
},
}).querySelector("main");
@@ -37,25 +63,38 @@ export async function extractSearchData(rootDir: string): Promise<PageData[]> {
return;
}
const noIndex = mainEl.getAttribute("data-noindex");
if (noIndex) {
if (mainEl.getAttribute("data-noindex") === "true") {
return;
}
const pageTitle = mainEl.querySelector("h1")?.text;
if (!pageTitle) {
noH1Found.push(
filePath.split(".next/server/app")[1]?.replace(".html", "") || "",
);
}
pages.push({
href: filePath.replace(nextOutputDir, "").replace(".html", ""),
title: pageTitle ? trimExtraSpace(pageTitle) : "",
sections: getPageSections(mainEl),
});
// Important: do the search index collection first - we will modify the main element in the next step
// Extract search data
const pageData = extractPageSearchData(
mainEl,
filePath,
nextOutputDir,
pageTitle,
);
if (pageData) {
pages.push(pageData);
}
// Extract LLM content
const { links, full } = extractPageLLMContent(
mainEl,
pageTitle,
filePath,
nextOutputDir,
);
llmContent += links ? `${links}\n` : "";
llmFullContent += full ? `${full}\n` : "";
}),
);
@@ -77,13 +116,147 @@ export async function extractSearchData(rootDir: string): Promise<PageData[]> {
console.warn("\n");
}
return pages;
return {
searchData: pages,
llmContent: `${llmsContentHeader}\n${llmContent}`,
llmFullContent: `${llmsFullContentHeader}\n${llmFullContent}`,
};
}
function getPageSections(main: X_HTMLElement): PageSectionData[] {
function extractPageSearchData(
main: X_HTMLElement,
filePath: string,
nextOutputDir: string,
pageTitle: string | undefined,
): PageData | null {
if (main.getAttribute("data-noindex") === "true") {
return null;
}
return {
href: filePath.replace(nextOutputDir, "").replace(".html", ""),
title: pageTitle ? trimExtraSpace(pageTitle) : "",
sections: getPageSectionsForSearchIndex(main),
};
}
function extractPageLLMContent(
main: X_HTMLElement,
pageTitle: string | undefined,
filePath: string,
nextOutputDir: string,
): { links: string; full: string } {
if (
main.getAttribute("data-noindex") === "true" ||
main.getAttribute("data-no-llm") === "true"
) {
return { links: "", full: "" };
}
const htmlToMarkdown = new NodeHtmlMarkdown({
keepDataImages: false,
});
let linksContent = "";
let fullContent = "";
const pageUrl = filePath.replace(nextOutputDir, "").replace(".html", "");
// Get first non-empty paragraph for description
const paragraphs = main.querySelectorAll("p");
let description = "";
for (const p of paragraphs) {
// skip noindex or no-llm paragraphs
if (
p.getAttribute("data-noindex") === "true" ||
p.getAttribute("data-no-llm") === "true"
) {
continue;
}
description = trimExtraSpace(htmlToMarkdown.translate(p.toString()));
if (description) {
break;
}
}
linksContent += `* [${pageTitle}](${pageUrl}): ${description}`;
// Remove noindex and no-llm elements
const contentElements = main.querySelectorAll("*");
for (const element of contentElements) {
if (
element.getAttribute("data-noindex") === "true" ||
element.getAttribute("data-no-llm") === "true"
) {
element.remove();
}
}
// Shift all heading elements to 1 step down (h1 > h2, h2 > h3, etc.)
const headings = main.querySelectorAll("h1, h2, h3, h4, h5, h6");
for (const heading of headings) {
const headingLevel = Number.parseInt(heading.tagName.replace("H", ""));
const newLevel = Math.min(headingLevel + 1, 6);
heading.tagName = `H${newLevel}`;
}
// prefix all the relative links with the `https://portal.thirdweb.com`
const links = main.querySelectorAll("a");
for (const link of links) {
const [path, hash] = link.getAttribute("href")?.split("#") || [];
if (path?.startsWith("/")) {
link.setAttribute(
"href",
`https://portal.thirdweb.com${path}${hash ? `#${hash}` : ""}`,
);
}
}
// for code blocks inside pre tags -> make them direct descendants of the pre tag
// so they are parsed as blocks by node-html-markdown + add language class
const preTags = main.querySelectorAll("pre");
for (const preTag of preTags) {
const codeBlock = parse(preTag.innerHTML.toString(), {
comment: false,
blockTextElements: {
pre: true,
},
}).querySelector("code");
if (codeBlock) {
const code = codeBlock
.querySelectorAll("div > div > div > div")
.map((x) => x.textContent)
.join("\n")
.trim();
const lang = codeBlock.getAttribute("lang");
codeBlock.textContent = code;
const newCodePreBlock = parse(
`<pre><code class=${lang ? `language-${lang}` : ""}>${he.encode(code)}</code></pre>`,
);
preTag.replaceWith(newCodePreBlock);
}
}
// Convert the cleaned HTML to markdown
fullContent += `${htmlToMarkdown.translate(main.toString())}`;
return {
links: linksContent,
full: fullContent,
};
}
function getPageSectionsForSearchIndex(main: X_HTMLElement): PageSectionData[] {
const sectionData: PageSectionData[] = [];
const ignoreTags = new Set(["code", "nav"].map((t) => t.toUpperCase()));
const ignoreTags = new Set(
["code", "nav", "pre"].map((t) => t.toUpperCase()),
);
function collector(node: X_Node) {
if (node instanceof X_CommentNode) {
@@ -94,9 +267,7 @@ function getPageSections(main: X_HTMLElement): PageSectionData[] {
return;
}
const noIndexAttribute = node.getAttribute("data-noindex");
if (noIndexAttribute === "true") {
if (node.getAttribute("data-noindex") === "true") {
return;
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { sidebar } from "./sidebar";
export default async function Layout(props: { children: React.ReactNode }) {
return (
<DocLayout sideBar={sidebar} editPageButton={true}>
<DocLayout sideBar={sidebar} editPageButton={true} noLLM={true}>
{props.children}
</DocLayout>
);
+1 -1
View File
@@ -40,7 +40,7 @@ Contracts is an end-to-end development suite which provides tools to develop, de
### With Contracts, you can:
- Easily deploy custom or pre-built contracts to over [2,000+ available EVM networks](https://thirdweb.com/chainlist)
- Easily deploy custom or pre-built contracts to [any EVM network](https://thirdweb.com/chainlist)
- Create and publish contracts for your team or community to utilize with an easy-to-deploy interface
- Create reusable contract components or modules to add to modular contracts to upgrade functionality
- Manage contract functionality, upload NFTs, set distribution mechanisms, and more directly from the dashboard
@@ -18,7 +18,7 @@ Remote Procedure Call (RPC) Edge provides reliable access to querying data and i
<DocImage src={rpcDiagramImage} alt="RPC Diagram" />
By default, we provide publicly available RPCs for over 900+ EVM Networks. [View the default RPC endpoints](https://thirdweb.com/chainlist) for each supported chain.
By default, we provide publicly available RPCs to every EVM chain. [View the default RPC endpoints](https://thirdweb.com/chainlist) for each supported chain.
<Callout variant="info" title="Accessing your RPC URL">
To use RPC Edge with other tools like Anvil, take a chain's [public RPC endpoint](https://thirdweb.com/chainlist) and attach your app's client ID from Project > Settings page in [dashboard](https://thirdweb.com/team) `https://<chainId>.rpc.thirdweb.com/<clientId>`.
@@ -74,18 +74,24 @@ async function getEvents() {
### Chain IDs
The API supports chain IDs in the following format:
The API supports chain IDs in the following formats:
- As a subdomain:
```typescript
interface ChainConfig {
chainId: number; // Chain ID of the network you want to query
}
// Example: Using a specific chain
const chainId = "{{chainId}}"; // Replace with your desired chain ID
// Example
const baseUrl = `https://{{chainId}}.insight.thirdweb.com`;
```
- As a query parameter (this is useful if you want to query multiple chains):
```typescript
// Example for a single chain
const url = `https://insight.thirdweb.com/v1/events?chain={{chainId}}`;
// Example for multiple chains
const url = `https://insight.thirdweb.com/v1/events?chain=1&chain=137`;
```
We won't duplicate multichain examples for each endpoint, but you can pass multiple chains in the query parameters pretty much everywhere!
### Base Response Structure
```typescript
@@ -265,6 +271,12 @@ async function safeApiCall() {
GET https://{{chainId}}.insight.thirdweb.com/v1/events
```
or
```typescript
GET https://insight.thirdweb.com/v1/events?chainId={{chainId1}}&chainId={{chainId2}}
```
2. **Get Contract Events**
```typescript
@@ -63,14 +63,22 @@ async function getEvents() {
### Chain IDs
- As a subdomain:
```typescript
type ChainId = number; // Chain ID of the network you want to query (e.g., 1 for Ethereum mainnet)
// Example: Using a specific chain
const chainId = 1; // Ethereum mainnet
const baseUrl = `https://${chainId}.insight.thirdweb.com`;
// Example
const baseUrl = `https://{{chainId}}.insight.thirdweb.com`;
```
- As a query parameter (this is useful if you want to query multiple chains):
```typescript
// Example for a single chain
const url = `https://insight.thirdweb.com/v1/events?chain={{chainId}}`;
// Example for multiple chains
const url = `https://insight.thirdweb.com/v1/events?chain=1&chain=137`;
```
We won't duplicate multichain examples for each endpoint, but you can pass multiple chains in the query parameters pretty much everywhere!
### Base Response Structure
```typescript
@@ -14,6 +14,8 @@ export const metadata = createMetadata({
A blueprint is an API that provides access to on-chain data in a user-friendly format. There's no need for ABIs, decoding, RPC, or web3 knowledge to fetch blockchain data. Every chain exposes the default blueprints below
All blueprints support [multichain queries](/insight/multichain-queries)!
## Events Blueprint
Blockchain events offers developers a powerful way to track and analyze blockchain events emitted by smart contracts. This endpoint provides access to detailed event information, such as the address of the contract that generated the event, the transaction that triggered it, and when and where the event occurred on the blockchain. By offering data points like event topics, block numbers, and transaction hashes, developers can easily filter and search for specific event types, such as token transfers or contract executions. With precise timestamps and block references, the Events Blueprint ensures that developers have real-time access to critical on-chain activity, enabling them to build responsive, high-performance applications that can monitor, analyze, and react to blockchain events seamlessly. Whether working on DeFi, NFTs, or general dApps, this endpoint helps unlock the full potential of blockchain data in any project.
@@ -22,14 +24,14 @@ Blockchain events offers developers a powerful way to track and analyze blockcha
| Name | Description | Example |
| ----------- | ----------- | ----------- |
| Address | The `address` is the identifier of the smart contract or account that emitted the event. It tells you who or what generated the event. | If the event is related to an ERC-20 token transfer, the address would be the smart contract address of the token. For instance, if you’re tracking events for the USDT token, the address would be the contract address of USDT on Ethereum. |
| Chain ID | The `chain_id` is a unique identifier for the blockchain network where the event occurred. Different blockchain networks (e.g., Ethereum, Binance Smart Chain, Polygon) have their own unique chain IDs. | Ethereum’s mainnet has a chain_id of 1, while Binance Smart Chain has a chain_id of 56. This field ensures you know which network the event is coming from, which is crucial when interacting with multiple EVM chains.
| Data | The `data` field contains additional information specific to the event. In many cases, it’s the raw output of the smart contract event. | In an ERC-20 Transfer event, the data might include the number of tokens transferred in raw hexadecimal format. For instance, if 1000 USDT were transferred, this value would be encoded in the event’s data field.
| Address | The `address` is the identifier of the smart contract or account that emitted the event. It tells you who or what generated the event. | If the event is related to an ERC-20 token transfer, the address would be the smart contract address of the token. For instance, if you're tracking events for the USDT token, the address would be the contract address of USDT on Ethereum. |
| Chain ID | The `chain_id` is a unique identifier for the blockchain network where the event occurred. Different blockchain networks (e.g., Ethereum, Binance Smart Chain, Polygon) have their own unique chain IDs. | Ethereum's mainnet has a chain_id of 1, while Binance Smart Chain has a chain_id of 56. This field ensures you know which network the event is coming from, which is crucial when interacting with multiple EVM chains.
| Data | The `data` field contains additional information specific to the event. In many cases, it's the raw output of the smart contract event. | In an ERC-20 Transfer event, the data might include the number of tokens transferred in raw hexadecimal format. For instance, if 1000 USDT were transferred, this value would be encoded in the event's data field.
| Log Index | The `log_index` is the order of the log (event) in the list of all events within the block. It helps you pinpoint exactly where this event is located in the block. | If multiple events occurred within the same block (e.g., multiple token transfers), the log_index tells you the sequence of this particular event. For example, it might be the 3rd event in the block.
| Topics | The `topics` array contains indexed parameters of the event, often used for filtering or categorizing events. The first topic contains the event signature (the function name), while the remaining topics contain indexed event arguments. | In an ERC-20 Transfer event, the first topic would be the hash of the event signature `Transfer(address,address,uint256)`. The second topic would be the sender’s address, and the third would be the receiver’s address. This allows filtering to track all Transfer events for a specific address.
| Topics | The `topics` array contains indexed parameters of the event, often used for filtering or categorizing events. The first topic contains the event signature (the function name), while the remaining topics contain indexed event arguments. | In an ERC-20 Transfer event, the first topic would be the hash of the event signature `Transfer(address,address,uint256)`. The second topic would be the sender's address, and the third would be the receiver's address. This allows filtering to track all Transfer events for a specific address.
| Transaction Hash | The `transaction_hash` is the unique identifier for the transaction that triggered the event. It helps you link the event to the specific transaction that caused it. | If you transfer 1 ETH to another wallet, the transaction hash might be 0x5c7b6f... which uniquely identifies that transaction. Using this hash, you can look up the transaction in any block explorer (e.g., Etherscan) to see all the details.
| Transaction Index | The `transaction_index` tells you the position of the transaction within the block. This helps you identify where in the block this particular transaction was placed relative to others. | If multiple transactions occurred within the same block (e.g., multiple token transfers), the transaction_index tells you the sequence of this particular transaction. For example, it might be the 3rd transaction in the block. | If a block contains 100 transactions, the transaction_index might indicate that this event was triggered by the 45th transaction in the block. This is useful for debugging or tracing the order of execution.
| Block Hash | A `block_hash` is the unique identifier of a block, generated after the block is confirmed. It’s like a tamper-proof record of all the transactions and events in that block. | If you wanted to verify that a particular transaction or event was part of a block, you could use the block hash to check its integrity. Think of it as a digital signature for the entire block, such as 0x91d… representing all transactions within that specific block.
| Block Hash | A `block_hash` is the unique identifier of a block, generated after the block is confirmed. It's like a tamper-proof record of all the transactions and events in that block. | If you wanted to verify that a particular transaction or event was part of a block, you could use the block hash to check its integrity. Think of it as a digital signature for the entire block, such as 0x91d... representing all transactions within that specific block.
| Block Number | The `block_number` indicates the position of the block in the blockchain. It tells you when (in terms of blockchain sequence) the event occurred. | Block number 12,345,678 on Ethereum might contain transactions from a particular moment, such as the transfer of 10 ETH between two accounts. You can think of block numbers like page numbers in a ledger.
| Block Timestamp | The `block_timestamp` is the exact time when the block was mined and added to the blockchain. | If block 12,345,678 was mined on July 1, 2023, at 12:30 PM UTC, the timestamp will reflect this exact moment. This helps you pinpoint when an event, such as a token transfer, actually happened in real time.
@@ -41,7 +43,7 @@ Transaction data equips developers with the tools they need to interact with blo
| Name | Description | Example |
| ----------- | ----------- | ----------- |
| From Address | This is the address that initiated the transaction. It represents the sender who paid for the gas to execute the transaction. | If a user sends 1 ETH from their wallet, the `from_address` will be the sender’s Ethereum address, such as `0xabc123...`.
| From Address | This is the address that initiated the transaction. It represents the sender who paid for the gas to execute the transaction. | If a user sends 1 ETH from their wallet, the `from_address` will be the sender's Ethereum address, such as `0xabc123...`.
| To Address | This is the recipient of the transaction. It could be another wallet or a smart contract. | If sending ETH to a friend, their wallet address, like `0xdef456...`, would be the `to_address`. In the case of interacting with a DeFi platform, the address of the smart contract being called would be the `to_address`.
| Hash | The unique identifier (hash) of the transaction. This hash can be used to look up and verify the transaction on the blockchain. | A transaction might have a hash like `0x5c7b6f...`. You can use this hash to check the transaction status on a block explorer like Etherscan.
| Value | This is the amount of cryptocurrency (in wei) being transferred in the transaction. | If transferring 1 ETH, the `value` would be 1,000,000,000,000,000,000 wei (1 ETH = 10^18 wei).
@@ -50,10 +52,10 @@ Transaction data equips developers with the tools they need to interact with blo
| Max Fee Per Gas | This is the maximum amount of gas fees (in wei) the sender is willing to pay for each gas unit. It was introduced in EIP-1559 to provide a cap on gas costs. | If `max_fee_per_gas` is set to 200 gwei, the sender is ensuring that they will never pay more than this for each gas unit, even during periods of high network congestion.
| Max Priority Fee Per Gas | This is the maximum additional fee the sender is willing to pay to incentivize miners to prioritize their transaction. Also introduced in EIP-1559. | A transaction might specify a `max_priority_fee_per_gas` of 2 gwei, which acts as a "tip" for miners to get their transaction included faster in a block.
| Data | The `data` field contains additional information required by a transaction, such as the input parameters for interacting with a smart contract. | In a call to a DeFi contract's `swap` function, the `data` field will include the encoded function call and arguments (e.g., token amount, recipient address).
| Nonce | The `nonce` is the number of transactions sent from the sender’s address. It ensures that transactions are processed in the correct order and prevents double-spending. | If the sender has sent 5 transactions before, the nonce will be 5. This helps in tracking the sequence of transactions and ensuring that each one is processed correctly. | If the `nonce` is 12, it means this is the 13th transaction (starting from 0) sent from the `from_address`
| Nonce | The `nonce` is the number of transactions sent from the sender's address. It ensures that transactions are processed in the correct order and prevents double-spending. | If the sender has sent 5 transactions before, the nonce will be 5. This helps in tracking the sequence of transactions and ensuring that each one is processed correctly. | If the `nonce` is 12, it means this is the 13th transaction (starting from 0) sent from the `from_address`
| Transaction Index | The position of the transaction within the block. It indicates the order in which the transaction was included relative to other transactions | If the `transaction_index` is 5, this was the 6th transaction included in the block.
| Transaction Type | The type of transaction, typically either legacy (type `0x0` ) or one of the newer types introduced in EIP-1559 (e.g., type `0x2` for transactions that use the new gas fee mechanism). | A `transaction_type` of `0x2` indicates the transaction follows the new EIP-1559 rules for gas fees.
| Access List | The `access_list` is an optional field introduced in EIP-2930 that specifies which addresses and storage slots the transaction intends to interact with. It’s used to optimize gas costs by pre-declaring the data access needed. | When a transaction interacts with a smart contract, the `access_list` may declare the contract address and storage slots the transaction intends to read from or write to. For example, interacting with a DeFi smart contract could include its contract address and the storage slots holding user balances.
| Access List | The `access_list` is an optional field introduced in EIP-2930 that specifies which addresses and storage slots the transaction intends to interact with. It's used to optimize gas costs by pre-declaring the data access needed. | When a transaction interacts with a smart contract, the `access_list` may declare the contract address and storage slots the transaction intends to read from or write to. For example, interacting with a DeFi smart contract could include its contract address and the storage slots holding user balances.
| Chain ID | The `chain_id` identifies the blockchain network on which the transaction was conducted. It prevents replay attacks across different networks. | On Ethereum mainnet, the chain ID is `1`, while on Base Mainnet, the chain ID is `8453` . This field tells you which network the transaction belongs to.
| Block Hash | This is the unique identifier (or "fingerprint") of the block that includes this transaction. The hash ensures that the block hasn't been altered. | If the transaction is included in block `12,345,678`, the block's hash might look like `0xabc123...`, and this hash can be used to reference that particular block on the blockchain.
| Block Number | The `block_number` indicates the specific position of the block in the blockchain that contains the transaction. | If a transaction is included in block `12,345,678`, this number can be used to quickly locate the block and all the transactions it contains.
@@ -67,7 +69,7 @@ Tokens on blockchain can be of different standards, but ones of the most widely
- ERC-20 for fungible tokens
- ERC-721 and ERC-1155 for NFTs
This blueprint provides access to such tokens’ balances information for a given owner address.
This blueprint provides access to such tokens' balances information for a given owner address.
### Blueprint details
@@ -138,3 +138,60 @@ Once you execute the query above you will receive a result similar to this:
]
}
```
## Multichain Queries
Insight also supports querying multiple chains in a single API call. This is useful when you need to retrieve data across different networks without making separate requests.
```typescript
const getMultichainTransfers = async () => {
try {
// Query USDT transfers on Ethereum (1) and Polygon (137)
const response = await fetch('https://insight.thirdweb.com/v1/events?chain=1&chain=137&limit=10', {
headers: {
'x-client-id': <YOUR_THIRDWEB_CLIENT_ID>
}
});
const transfersInfo = await response.json();
return transfersInfo
} catch (error) {
console.error('Error:', error);
}
};
```
When querying multiple chains, the response includes a `chain_ids` array in the metadata and each item in the data array includes its `chain_id`:
```json
{
"meta": {
"chain_ids": [1, 137],
"total_items": 10,
"limit_per_chain": 5,
"page": 0,
"limit": 10,
"total_pages": 1
},
"data": [
{
"chain_id": 1,
"block_number": 14705663,
// ... other Ethereum event data
},
{
"chain_id": 137,
"block_number": 25631042,
// ... other Polygon event data
}
// ... more events from both chains
]
}
```
Key points about multichain queries:
- Use the base URL `https://insight.thirdweb.com` instead of the chain-specific subdomain
- Add multiple `chain` query parameters (e.g., `chain=1&chain=137`)
- The response includes a `chain_ids` array and `limit_per_chain` in the metadata
- Each item in the data array includes its `chain_id` for identification
For more detailed information and advanced use cases, check out our [Multichain Queries](/insight/multichain-queries) documentation.
@@ -0,0 +1,245 @@
import { createMetadata } from "@doc";
export const metadata = createMetadata({
title: "Multichain Queries | thirdweb Insight",
description:
"Learn how to query multiple blockchains simultaneously with thirdweb Insight",
image: {
title: "Insight",
icon: "insight",
},
});
# Multichain Queries
Insight now supports querying multiple blockchain networks in a single API call. This powerful feature allows you to retrieve and analyze data across different chains without making separate requests for each network.
## Why Use Multichain Queries?
- **Efficiency**: Reduce the number of API calls needed to fetch data from multiple chains
- **Simplicity**: Consolidate cross-chain data in a single request and response
- **Performance**: Minimize latency by avoiding sequential requests to different chains
- **Consistency**: Process data from different networks using a unified format
## How to Use Multichain Queries
Instead of using the chain-specific subdomain approach, multichain queries use the base URL with multiple `chain` query parameters:
```typescript
// Traditional single-chain query (using subdomain)
const singleChainUrl = `https://1.insight.thirdweb.com/v1/events`;
// Multichain query (using base URL with chain parameters)
const multiChainUrl = `https://insight.thirdweb.com/v1/events?chain=1&chain=137`;
```
### Authentication
Authentication works the same way as with single-chain queries:
```typescript
// Using client ID in header
const headers = {
"x-client-id": "{{clientId}}"
};
// Or as a query parameter
const url = `https://insight.thirdweb.com/v1/events?chain=1&chain=137&clientId={{clientId}}`;
```
## Response Format
Responses for multichain queries include additional metadata to help you identify which data belongs to which chain:
```json
{
"meta": {
"chain_ids": [1, 137], // List of queried chain IDs
"total_items": 113, // Total count across all chains
"limit_per_chain": 100, // Per-chain limit derived from the request
"page": 0,
"limit": 200,
"total_pages": 1
},
"data": [
{
"chain_id": 1, // Each item includes its chain ID
"block_number": "17859301",
"transaction_hash": "0x123...",
// ... other fields
},
{
"chain_id": 137,
"block_number": "48392021",
// ... other fields
}
]
}
```
Key differences in the response format:
- The `meta` object includes a `chain_ids` array listing all queried chains
- A `limit_per_chain` field indicates how many items were requested per chain
- Each item in the `data` array includes a `chain_id` field to identify its network
## Pagination and Limits
When using multichain queries, the `limit` parameter applies to each chain individually:
```typescript
// This will return up to 20 items from each chain (potentially 40 total items)
const url = `https://insight.thirdweb.com/v1/events?chain=1&chain=137&limit=20`;
```
The `limit_per_chain` in the response metadata shows how many items were requested per chain, while the overall `limit` represents the maximum total items across all chains.
## Examples
### Example 1: Query Events Across Multiple Chains
```typescript
const getMultichainEvents = async () => {
try {
// Query events on Ethereum (1) and Polygon (137)
const response = await fetch(
'https://insight.thirdweb.com/v1/events?chain=1&chain=137&limit=10',
{
headers: {
'x-client-id': '<YOUR_THIRDWEB_CLIENT_ID>'
}
}
);
return await response.json();
} catch (error) {
console.error('Error:', error);
}
};
```
### Example 2: Track Token Balances Across Networks
```typescript
const getMultichainTokenBalances = async (ownerAddress) => {
try {
// Get ERC-20 balances on Ethereum, Polygon, and Arbitrum
const response = await fetch(
`https://insight.thirdweb.com/v1/tokens/erc20/${ownerAddress}?chain=1&chain=137&chain=42161`,
{
headers: {
'x-client-id': '<YOUR_THIRDWEB_CLIENT_ID>'
}
}
);
return await response.json();
} catch (error) {
console.error('Error:', error);
}
};
```
### Example 3: Monitor NFT Collections Across Chains
```typescript
const getMultichainNFTs = async (ownerAddress) => {
try {
// Get NFTs on Ethereum and Base
const response = await fetch(
`https://insight.thirdweb.com/v1/tokens/erc721/${ownerAddress}?chain=1&chain=8453`,
{
headers: {
'x-client-id': '<YOUR_THIRDWEB_CLIENT_ID>'
}
}
);
return await response.json();
} catch (error) {
console.error('Error:', error);
}
};
```
## Supported Endpoints
All Insight blueprints support multichain queries:
- **Events Blueprint**: `/v1/events` - Returns events from specified chains
- **Transactions Blueprint**: `/v1/transactions` - Provides transactions from multiple chains
- **Tokens Blueprint**:
- `/v1/tokens/erc20/:ownerAddress` - Consolidates ERC-20 token balances across chains
- `/v1/tokens/erc721/:ownerAddress` - Consolidates NFT holdings across chains
- `/v1/tokens/erc1155/:ownerAddress` - Consolidates ERC-1155 token balances across chains
## Best Practices
1. **Limit Chain Count**: While you can query multiple chains, it's best to limit the number of chains in a single request to avoid timeouts.
2. **Use Appropriate Limits**: Set reasonable `limit` values to control response size and processing time.
3. **Handle Chain-Specific Errors**: Some chains might return errors while others succeed. Your code should handle partial successes.
4. **Process Data by Chain**: When analyzing the response, group or filter data by `chain_id` for chain-specific analysis.
5. **Consider Rate Limits**: Multichain queries count against your rate limits for each chain queried.
## Use Cases
### Cross-Chain Portfolio Tracking
Track a user's assets across multiple networks to provide a comprehensive view of their holdings:
```typescript
const getPortfolio = async (address) => {
// Get ERC-20 tokens across major chains
const erc20Response = await fetch(
`https://insight.thirdweb.com/v1/tokens/erc20/${address}?chain=1&chain=137&chain=42161&chain=10&chain=8453`,
{ headers: { 'x-client-id': '<YOUR_CLIENT_ID>' } }
);
// Get NFTs across the same chains
const nftResponse = await fetch(
`https://insight.thirdweb.com/v1/tokens/erc721/${address}?chain=1&chain=137&chain=42161&chain=10&chain=8453`,
{ headers: { 'x-client-id': '<YOUR_CLIENT_ID>' } }
);
return {
tokens: await erc20Response.json(),
nfts: await nftResponse.json()
};
};
```
### Cross-Chain Activity Monitoring
Monitor transactions or events across multiple chains for a specific address:
```typescript
const getRecentActivity = async (address) => {
const response = await fetch(
`https://insight.thirdweb.com/v1/transactions?filter_from_address=${address}&chain=1&chain=137&chain=42161&sort_by=block_timestamp&sort_order=desc&limit=10`,
{ headers: { 'x-client-id': '<YOUR_CLIENT_ID>' } }
);
return await response.json();
};
```
### Cross-Chain Protocol Analysis
Analyze protocol activity across different deployments on multiple chains:
```typescript
const getProtocolActivity = async (protocolAddresses) => {
// protocolAddresses = { 1: "0x123...", 137: "0x456...", 42161: "0x789..." }
const queryParams = Object.entries(protocolAddresses)
.map(([chainId, address]) => `chain=${chainId}&filter_address=${address}`)
.join('&');
const response = await fetch(
`https://insight.thirdweb.com/v1/events?${queryParams}&limit=50`,
{ headers: { 'x-client-id': '<YOUR_CLIENT_ID>' } }
);
return await response.json();
};
```
+6
View File
@@ -6,6 +6,7 @@ import {
Brain,
ExternalLink,
MessageCircleQuestionIcon,
Network,
Rocket,
StickyNote,
Wrench,
@@ -41,6 +42,11 @@ export const sidebar: SideBar = {
href: `${insightSlug}/blueprints`,
icon: <Album />,
},
{
name: "Multichain Queries",
href: `${insightSlug}/multichain-queries`,
icon: <Network />,
},
{
name: "Agents & LLMs",
href: `${insightSlug}/agents-and-llms`,
+37 -14
View File
@@ -1,28 +1,51 @@
import { Details } from "@doc";
# Nebula FAQs
### I am still waiting on access to Nebula. How can I be approved?
<Details summary="I am still waiting on access to Nebula. How can I be approved?">
We are rolling out Nebula approval in batches daily - stay tuned!
</Details>
### What underlying model does Nebula use?
<Details summary="What underlying model does Nebula use?">
During Alpha, we are primarily testing the t0 model while conducting limited trials of the upcoming t1 model.
The t0 model utilizes a mixture-of-agents architecture. We are targeting an early Q2 launch for t1, and will
provide additional details around launch.
</Details>
During Alpha, we are primarily testing the t0 model while conducting limited trials of the upcoming t1 model. The t0 model utilizes a mixture-of-agents architecture. We are targeting an early Q2 launch for t1, and will provide additional details around launch.
<Details summary="Does Nebula support ________ chain?">
Nebula supports reading and writing capabilities on any EVM-compatible chain. [View the chainlist](https://thirdweb.com/chainlist) for all supported chains.
</Details>
### Which contracts does Nebula support?
Nebula supports the read and write context of any verified contract or any contract on the thirdweb dashboard. For deploying through Nebula, supported contracts include [Token - ERC20](https://thirdweb.com/thirdweb.eth/TokenERC20), [NFT Collection - ERC721](https://thirdweb.com/thirdweb.eth/TokenERC721), [Edition - ERC1155](https://thirdweb.com/thirdweb.eth/TokenERC1155), and [Split](https://thirdweb.com/thirdweb.eth/Split) contracts.
<Details summary="Does Nebula support multiple languages?">
Yes, you may ask Nebula questions in any spoken language.
</Details>
<Details summary="How can I improve upon Nebula's responses?">
We recommend using [context filters](/nebula/key-concepts/context-filters) to improve relevant responses. You may also modify hyperparameters such as temperature, top-p,
and more using the OpenAI API. Learn more about our [OpenAI API integration](/nebula/plugins/openai).
</Details>
<Details summary="Which contracts does Nebula support?">
Nebula supports the read and write context of any verified contract or any contract on the thirdweb dashboard.
For deploying through Nebula, supported contracts include [Token - ERC20](https://thirdweb.com/thirdweb.eth/TokenERC20),
[NFT Collection - ERC721](https://thirdweb.com/thirdweb.eth/TokenERC721), [Edition - ERC1155](https://thirdweb.com/thirdweb.eth/TokenERC1155),
and [Split](https://thirdweb.com/thirdweb.eth/Split) contracts.
If you have a published contract you would like to enable for deployment through Nebula, please [contact us](https://thirdweb.com/contact-us).
</Details>
### Does Nebula have memory from past conversations?
<Details summary="Does Nebula have memory from past conversations?">
Nebula retains memory within the confines of a session. [Learn more about sessions](/nebula/key-concepts/sessions).
</Details>
### What is the context size of Nebula?
The context size or window is 128k tokens.
### Will Nebula be open source?
<Details summary="What is the context size of Nebula?">
The context size or window is 128k tokens or approximately 96,000 words which may vary depending on the specific language and text characteristics.
</Details>
<Details summary="Will Nebula be open source?">
Nebula is not currently open source. We are exploring open sourcing Nebula in the future.
</Details>
### When will pricing be available for Nebula?
Pricing options will be available in beta. Nebula is still being tested in alpha.
<Details summary="When will pricing be available for Nebula?">
Pricing options will be available in beta. Nebula is still being tested in alpha.
</Details>
+1 -1
View File
@@ -28,7 +28,7 @@ Nebula is currently available in Alpha. [Join the waitlist.](https://thirdweb.co
>
<FeatureCard
title="Proprietary Blockchain Model"
description="Trained on over 2,500+ EVM networks and 1M+ contracts"
description="Trained on every EVM network and 1M+ contracts"
iconUrl={<File />}
/>
+19 -31
View File
@@ -68,21 +68,7 @@ export const sidebar: SideBar = {
icon: <Braces />,
links: [
{
name: "GET",
expanded: true,
links: [
{
name: "List Sessions",
href: "/nebula/api-reference/list-session",
},
{
name: "Get Session",
href: "/nebula/api-reference/get-session",
},
],
},
{
name: "POST",
name: "Chat",
expanded: true,
links: [
{
@@ -93,30 +79,32 @@ export const sidebar: SideBar = {
name: "Execute Action",
href: "/nebula/api-reference/execute",
},
],
},
{
name: "Session",
expanded: true,
links: [
{
name: "List Sessions",
href: "/nebula/api-reference/list-session",
},
{
name: "Get Session",
href: "/nebula/api-reference/get-session",
},
{
name: "Create Session",
href: "/nebula/api-reference/create-session",
},
{
name: "Clear Session",
href: "/nebula/api-reference/clear-session",
},
],
},
{
name: "PUT",
expanded: true,
links: [
{
name: "Update Session",
href: "/nebula/api-reference/update-session",
},
],
},
{
name: "DELETE",
expanded: true,
links: [
{
name: "Clear Session",
href: "/nebula/api-reference/clear-session",
},
{
name: "Delete Session",
href: "/nebula/api-reference/delete-session",
+1 -1
View File
@@ -6,7 +6,7 @@ import DocsHeroLight from "./_images/docs-hero-light.png";
export default function Page() {
return (
<main className="container max-w-[900px] grow pb-20">
<main className="container max-w-[900px] grow pb-20" data-noindex>
<Hero />
<div className="grid grid-cols-1 gap-8">
<FrontendSection />
@@ -15,7 +15,7 @@ export default async function Layout(props: { children: React.ReactNode }) {
</div>
}
>
<div data-noindex>{props.children}</div>
<div>{props.children}</div>
</DocLayout>
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default async function Layout(props: { children: React.ReactNode }) {
</div>
}
>
<div data-noindex>{props.children}</div>
<div>{props.children}</div>
</DocLayout>
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default async function Layout(props: { children: React.ReactNode }) {
</div>
}
>
<div data-noindex>{props.children}</div>
<div>{props.children}</div>
</DocLayout>
);
}
@@ -11,6 +11,7 @@ export function ArticleCard(props: {
const isExternal = props.href.startsWith("http");
return (
<Link
data-noindex
href={props.href}
className="flex cursor-default bg-card"
target={isExternal ? "_blank" : undefined}
@@ -38,6 +39,7 @@ export function ArticleIconCard(props: {
const isExternal = props.href.startsWith("http");
return (
<Link
data-noindex
href={props.href}
className={cn(
"flex items-center gap-4 rounded-lg border bg-card p-4 transition-colors hover:border-active-border",
+47 -49
View File
@@ -121,60 +121,58 @@ async function RenderCode(props: {
return (
<div>
<pre>
{tokens.map((line, i) => {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: index is the identity here
<div key={i}>
{line.map((token, i) => {
const { lightColor, darkColor } = getThemeColors(token);
{tokens.map((line, i) => {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: index is the identity here
<div key={i}>
{line.map((token, i) => {
const { lightColor, darkColor } = getThemeColors(token);
const style = {
"--code-light-color": lightColor,
"--code-dark-color": darkColor,
} as React.CSSProperties;
const style = {
"--code-light-color": lightColor,
"--code-dark-color": darkColor,
} as React.CSSProperties;
const href = props.tokenLinks?.has(token.content)
? props.tokenLinks.get(token.content)
: undefined;
if (href) {
return (
<Link
// biome-ignore lint/suspicious/noArrayIndexKey: index is the identity here
key={i}
href={href || "#"}
className="group/codelink relative py-0.5"
style={style}
>
{/* Token */}
<span className="relative z-codeToken transition-colors duration-200 group-hover/codelink:text-background">
{token.content}
</span>
{/* Line */}
<span
className={cn(
"absolute right-0 bottom-0 left-0 z-codeTokenHighlight inline-block h-[3px] translate-y-[2px] scale-105",
"rounded-sm bg-current opacity-20",
"transition-all duration-200 group-hover/codelink:h-full group-hover/codelink:translate-y-0 group-hover/codelink:opacity-100",
)}
/>
</Link>
);
}
const href = props.tokenLinks?.has(token.content)
? props.tokenLinks.get(token.content)
: undefined;
if (href) {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: index is the identity here
<span key={i} style={style} data-token={token.content}>
{token.content}
</span>
<Link
// biome-ignore lint/suspicious/noArrayIndexKey: index is the identity here
key={i}
href={href || "#"}
className="group/codelink relative py-0.5"
style={style}
>
{/* Token */}
<span className="relative z-codeToken transition-colors duration-200 group-hover/codelink:text-background">
{token.content}
</span>
{/* Line */}
<span
className={cn(
"absolute right-0 bottom-0 left-0 z-codeTokenHighlight inline-block h-[3px] translate-y-[2px] scale-105",
"rounded-sm bg-current opacity-20",
"transition-all duration-200 group-hover/codelink:h-full group-hover/codelink:translate-y-0 group-hover/codelink:opacity-100",
)}
/>
</Link>
);
})}
{line.length === 0 && i !== tokens.length - 1 && " "}
</div>
);
})}
</pre>
}
return (
// biome-ignore lint/suspicious/noArrayIndexKey: index is the identity here
<span key={i} style={style} data-token={token.content}>
{token.content}
</span>
);
})}
{line.length === 0 && i !== tokens.length - 1 && " "}
</div>
);
})}
</div>
);
}
@@ -19,6 +19,7 @@ type DocLayoutProps = {
showTableOfContents?: boolean;
sidebarHeader?: React.ReactNode;
noIndex?: boolean;
noLLM?: boolean;
};
export function DocLayout(props: DocLayoutProps) {
@@ -43,6 +44,7 @@ export function DocLayout(props: DocLayoutProps) {
<main
className="relative flex w-full flex-col overflow-hidden"
data-noindex={props.noIndex}
data-no-llm={props.noLLM}
>
<div className="grow xl:mt-6">{props.children}</div>
<div className="mt-16 xl:mt-20">
+2 -2
View File
@@ -20,7 +20,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"lucide-react": "0.476.0",
"next": "15.1.7",
"next": "15.2.0",
"next-themes": "^0.4.4",
"react": "19.0.0",
"react-dom": "19.0.0",
@@ -34,7 +34,7 @@
"zod": "3.24.2"
},
"devDependencies": {
"@next/eslint-plugin-next": "15.1.7",
"@next/eslint-plugin-next": "15.2.0",
"@types/node": "22.13.5",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
+10
View File
@@ -1,5 +1,15 @@
# @thirdweb-dev/service-utils
## 0.8.11
### Patch Changes
- [#6382](https://github.com/thirdweb-dev/js/pull/6382) [`47c9c43`](https://github.com/thirdweb-dev/js/commit/47c9c43a875a81aa357ebf3c741f256c317c5cfd) Thanks [@arcoraven](https://github.com/arcoraven)! - [service-utils] trim project ID prefix
- [#6387](https://github.com/thirdweb-dev/js/pull/6387) [`0c0d837`](https://github.com/thirdweb-dev/js/commit/0c0d837f2355c4aa43c3f1ceef52b450623be76f) Thanks [@arcoraven](https://github.com/arcoraven)! - [service-utils] Add connect promise so it only connects once
- [#6384](https://github.com/thirdweb-dev/js/pull/6384) [`25b149d`](https://github.com/thirdweb-dev/js/commit/25b149d3a98f99984792874ecb4e3144d2932a08) Thanks [@arcoraven](https://github.com/arcoraven)! - [service-utils] Provide CF req to forward origin header
## 0.8.10
### Patch Changes

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