Compare commits

..
Author SHA1 Message Date
Jonas DanielsGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
46b20716a5 Version Packages (#5687)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-11 20:51:28 +13:00
Joaquim VergesandGitHub da5d8ec53b fix: Autoconnect previously linked wallets (#5688) 2024-12-11 19:58:24 +13:00
8020bdbf2b Adds Portuguese (Brazil) translation for wallet connection (#5674)
Signed-off-by: Joaquim Verges <[email protected]>
Co-authored-by: Joaquim Verges <[email protected]>
2024-12-11 15:43:14 +13:00
iuwqyir 3f56252298 change insight auth in docs (#5681)
## Problem solved

https://linear.app/thirdweb/issue/INSIGHT-480/improvements-required-for-insight-playground

Updated the authentication method in the Insight API documentation to use the `x-client-id` header instead of embedding the client ID in the URL path.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the GitHub workflow and documentation related to the `insight` feature. It enhances the valid issue prefixes and provides clearer instructions for authentication in the API usage.

### Detailed summary
- Updated `VALID_ISSUE_PREFIXES` to include `INSIGHT`.
- Improved documentation in `apps/portal/src/app/insight/get-started/page.mdx`:
  - Corrected "clientId" to "client ID".
  - Added a note about using the `x-client-id` header for authentication.
- Modified the API fetch request to include headers for `client ID`.

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

<!-- end pr-codex -->
2024-12-11 00:47:32 +00:00
kien-ngo a9dd26cd57 [SDK] Add missing use-client directives to some components (#5676)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating various components in the `thirdweb` package, specifically related to `Account`, `NFT`, `Chain`, and `Token` functionalities. It introduces new imports and ensures that the components are utilizing the necessary contexts and types.

### Detailed summary
- In `Account/provider.tsx`: Added import for `Address`.
- In `NFT/provider.tsx`: Added import for `ThirdwebContract`.
- In `Account/blobbie.tsx`: Imported `Blobbie` and `useAccountContext`.
- In `Chain/icon.tsx`, `Token/icon.tsx`, `NFT/name.tsx`, and `NFT/media.tsx`: Imported `UseQueryOptions`, `useQuery`, and `getChainMetadata`.

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

<!-- end pr-codex -->
2024-12-11 00:29:03 +00:00
gregfromstl 5b24faf1f3 [SDK] Fix: Pay not respecting ecosystem AA (#5682)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the detection of ecosystem wallets in the `Pay` modal by modifying the logic in the `hasSponsoredTransactionsEnabled` function and adding comprehensive tests for various wallet configurations.

### Detailed summary
- Updated the condition in `hasSponsoredTransactionsEnabled` to check for ecosystem wallets.
- Added tests for `hasSponsoredTransactionsEnabled` covering:
  - Undefined wallet
  - Smart wallet with `sponsorGas` config
  - Smart wallet with `gasless` config
  - In-app wallet with `smartAccount` config
  - Regular wallet without smart account config

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

<!-- end pr-codex -->
2024-12-10 23:56:35 +00:00
MananTank 714602d13c Nebula scope changes in create project modal and project settings page (#5684)
DASH-578
2024-12-10 23:07:24 +00:00
Joe-Thirdweb c11ae45f89 Chain Page Updates for Creator & Metal (#5679)
## Problem solved

Short description of the bug fixed or feature added
Linear: Dash-573
Added to chains for custom chainlist pages

- Metal L2 (1750)
- Creator Chain (66665)
- Treasure Topaz (978658)

Enabled white space preserving for new lines on chain 'About' section.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new assets and updates `chainMetaRecord` to enhance the dashboard with additional blockchain information, including two new chains: `Creator` and `Metal L2`, along with their respective metadata and call-to-action details.

### Detailed summary
- Added new images: `creatorBanner`, `creatorCTA`, `metalBanner`, `metalCTA`.
- Updated `layout.tsx` to use `whitespace-pre-line` for `chainMetadata.about`.
- Introduced `Creator` chain metadata with details and CTA.
- Introduced `Metal L2` chain metadata with details and CTA.
- Added `Treasure Topaz` and `Treasure Mainnet` chain metadata with details and CTA.

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

<!-- end pr-codex -->
2024-12-10 22:25:12 +00:00
MananTank 462138dfae Add Nebula Chat UI (#5483)
DASH-444

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on adding new features and enhancements to the `Nebula` application, including updates to environment variables, improved session handling, and new UI components.

### Detailed summary
- Added `fetch-event-stream` dependency.
- Introduced `NEXT_PUBLIC_NEBULA_URL` in environment variables.
- Enhanced `loginRedirect` to handle optional paths.
- Updated `getValidAccount` to accept optional `pagePath`.
- Implemented new stores for session management.
- Improved UI components like `Chatbar`, `ChatPageLayout`, and `EmptyStateChatPageContent`.
- Added `isValidEncodedRedirectPath` function for redirect validation.
- Modified `NebulaWaitListPage` to directly use team data.
- Enhanced session handling functions in `api/session.ts`.
- Created new types for session management and context filters.
- Updated styles and class names for better UI consistency.

> The following files were skipped due to too many changes: `apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.tsx`, `apps/dashboard/src/app/nebula-app/(app)/components/Chats.stories.tsx`, `apps/dashboard/src/app/nebula-app/(app)/components/NebulaAccountButton.tsx`, `apps/dashboard/src/app/nebula-app/(app)/components/ContextFilters.tsx`, `apps/dashboard/src/app/nebula-app/(app)/chat/history/ChatHistoryPage.tsx`, `apps/dashboard/src/app/nebula-app/(app)/components/Chats.tsx`, `apps/dashboard/src/app/nebula-app/(app)/components/ChatPageContent.tsx`, `pnpm-lock.yaml`

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

<!-- end pr-codex -->
2024-12-10 21:58:33 +00:00
Jakub KrehelandGitHub 67305d3bd2 fix: removed login navigation text (#5680) 2024-12-10 12:52:24 -08:00
Giselle Chacon NessiandGitHub 27d3a6eb3e updated manage billing section information (#5669) 2024-12-10 09:04:29 -08:00
kien-ngo f25d1d8da5 [Playground] Feature: Headless components (#5535)
CNCT-2588

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `playground-web` application by adding new components and improving existing ones related to NFT and account functionalities, showcasing headless UI components for better Web3 integration.

### Detailed summary
- Added `ArticleCardIndex` in `page.tsx`.
- Introduced new navigation link for "Headless UI".
- Expanded `Account` and `NFT` components with detailed examples.
- Improved `useQuery` parameters in `avatar.tsx` and `name.tsx`.
- Created several new components in `nft-examples.tsx` and `account-examples.tsx` for displaying NFT and account information.

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

<!-- end pr-codex -->
2024-12-10 07:15:01 +00:00
jnsdls d35eaa6690 update dependencies (#5667)
closes: DASH-545

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating various package dependencies across multiple `package.json` files and the `pnpm-lock.yaml` file, ensuring compatibility with newer versions and fixing potential issues.

### Detailed summary
- Updated `@types/react` and `@types/react-dom` to `19.0.1` and `19.0.2` respectively in multiple packages.
- Bumped `shiki` version from `1.24.0` to `1.24.1` in several packages.
- Updated `sonner` from `1.7.0` to `1.7.1` in `apps/wallet-ui` and `apps/dashboard`.
- Incremented `@emotion/react` and `@emotion/styled` versions to `11.14.0`.
- Updated `framer-motion` from `11.13.1` to `11.13.3`.
- Updated `@playwright/test` from `1.49.0` to `1.49.1`.
- Updated `@dirtycajunrice/klee` from `1.0.5` to `1.0.6` in `apps/portal`.
- Adjusted dependencies in `pnpm-lock.yaml` to reflect the above changes.

> 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 -->
2024-12-10 05:15:25 +00:00
136 changed files with 6581 additions and 1959 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ on:
types: [opened, edited]
env:
VALID_ISSUE_PREFIXES: "CNCT|DASH|PROT"
VALID_ISSUE_PREFIXES: "CNCT|DASH|PROT|INSIGHT"
jobs:
linear:
+4 -1
View File
@@ -94,4 +94,7 @@ NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
TURNSTILE_SECRET_KEY=""
REDIS_URL=""
ANALYTICS_SERVICE_URL=""
ANALYTICS_SERVICE_URL=""
# Required for Nebula Chat
NEXT_PUBLIC_NEBULA_URL=""
+8 -7
View File
@@ -25,8 +25,8 @@
"@chakra-ui/react": "^2.8.2",
"@chakra-ui/styled-system": "^2.9.2",
"@chakra-ui/theme-tools": "^2.1.2",
"@emotion/react": "11.13.5",
"@emotion/styled": "11.13.5",
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0",
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@n8tb1t/use-scroll-position": "^2.0.3",
@@ -60,8 +60,9 @@
"color": "^4.2.3",
"compare-versions": "^6.1.0",
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"flat": "^6.0.1",
"framer-motion": "11.13.1",
"framer-motion": "11.13.3",
"fuse.js": "7.0.0",
"input-otp": "^1.4.1",
"ioredis": "^5.4.1",
@@ -88,8 +89,8 @@
"recharts": "^2.13.3",
"remark-gfm": "^4.0.0",
"server-only": "^0.0.1",
"shiki": "1.24.0",
"sonner": "^1.7.0",
"shiki": "1.24.1",
"sonner": "^1.7.1",
"spdx-correct": "^3.2.0",
"swagger-ui-react": "^5.18.2",
"tailwind-merge": "^2.5.5",
@@ -104,7 +105,7 @@
"@chromatic-com/storybook": "3.2.2",
"@next/bundle-analyzer": "15.0.4",
"@next/eslint-plugin-next": "15.0.4",
"@playwright/test": "1.49.0",
"@playwright/test": "1.49.1",
"@storybook/addon-essentials": "8.4.7",
"@storybook/addon-interactions": "8.4.7",
"@storybook/addon-links": "8.4.7",
@@ -120,7 +121,7 @@
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.5",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"@types/react-dom": "19.0.2",
"@types/react-table": "^7.7.20",
"@types/spdx-correct": "^3.1.3",
"@types/swagger-ui-react": "^4.18.3",
+12
View File
@@ -2,6 +2,17 @@ import "server-only";
import { API_SERVER_URL } from "@/constants/env";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
type EnabledTeamScope =
| "pay"
| "storage"
| "rpc"
| "bundler"
| "insight"
| "embeddedWallets"
| "relayer"
| "chainsaw"
| "nebula";
export type Team = {
id: string;
name: string;
@@ -15,6 +26,7 @@ export type Team = {
billingStatus: "validPayment" | (string & {}) | null;
billingEmail: string | null;
growthTrialEligible: boolean | null;
enabledScopes: EnabledTeamScope[];
};
export async function getTeamBySlug(slug: string) {
@@ -28,36 +28,34 @@ export const Mobile: Story = {
function Story() {
return (
<div className="min-h-screen bg-background py-4 text-foreground">
<div className="container flex max-w-[1000px] flex-col gap-8 lg:p-10">
<BadgeContainer label="Base">
<DangerSettingCard
title="This is a title"
description="This is a description"
buttonLabel="Some Action"
buttonOnClick={() => {}}
isPending={false}
confirmationDialog={{
title: "This is confirmation title",
description: "This is confirmation description",
}}
/>
</BadgeContainer>
<div className="container flex max-w-[1000px] flex-col gap-8 lg:p-10">
<BadgeContainer label="Base">
<DangerSettingCard
title="This is a title"
description="This is a description"
buttonLabel="Some Action"
buttonOnClick={() => {}}
isPending={false}
confirmationDialog={{
title: "This is confirmation title",
description: "This is confirmation description",
}}
/>
</BadgeContainer>
<BadgeContainer label="Loading">
<DangerSettingCard
title="This is a title"
description="This is a description"
buttonLabel="Some Action"
buttonOnClick={() => {}}
isPending={true}
confirmationDialog={{
title: "This is confirmation title",
description: "This is confirmation description",
}}
/>
</BadgeContainer>
</div>
<BadgeContainer label="Loading">
<DangerSettingCard
title="This is a title"
description="This is a description"
buttonLabel="Some Action"
buttonOnClick={() => {}}
isPending={true}
confirmationDialog={{
title: "This is confirmation title",
description: "This is confirmation description",
}}
/>
</BadgeContainer>
</div>
);
}
@@ -72,7 +72,10 @@ export function MultiNetworkSelector(props: {
onSelectedValuesChange={(chainIds) => {
props.onChange(chainIds.map(Number));
}}
placeholder="Select Chains"
placeholder={
allChains.length === 0 ? "Loading Chains..." : "Select Chains"
}
disabled={allChains.length === 0}
overrideSearchFn={searchFn}
renderOption={renderOption}
/>
@@ -59,6 +59,9 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
maxCount = Number.POSITIVE_INFINITY,
className,
selectedValues,
overrideSearchFn,
renderOption,
searchPlaceholder,
...props
},
ref,
@@ -105,8 +108,6 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
// show 50 initially and then 20 more when reaching the end
const { itemsToShow, lastItemRef } = useShowMore<HTMLButtonElement>(50, 20);
const { overrideSearchFn } = props;
const optionsToShow = useMemo(() => {
const filteredOptions: {
label: string;
@@ -152,7 +153,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
}, [searchValue]);
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal>
<PopoverTrigger asChild>
<Button
ref={ref}
@@ -238,7 +239,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
{/* Search */}
<div className="relative">
<Input
placeholder={props.searchPlaceholder || "Search"}
placeholder={searchPlaceholder || "Search"}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="!h-auto rounded-b-none border-0 border-border border-b py-4 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
@@ -285,9 +286,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
</div>
<div className="min-w-0 grow">
{props.renderOption
? props.renderOption(option)
: option.label}
{renderOption ? renderOption(option) : option.label}
</div>
</Button>
);
@@ -11,6 +11,7 @@ export function ScrollShadow(props: {
scrollableClassName?: string;
disableTopShadow?: boolean;
shadowColor?: string;
shadowClassName?: string;
}) {
const scrollableEl = useRef<HTMLDivElement>(null);
const shadowTopEl = useRef<HTMLDivElement>(null);
@@ -94,7 +95,11 @@ export function ScrollShadow(props: {
}
>
<div
className={cn(styles.scrollShadowTop, styles.scrollShadowY)}
className={cn(
styles.scrollShadowTop,
styles.scrollShadowY,
props.shadowClassName,
)}
ref={shadowTopEl}
style={{
opacity: "0",
@@ -102,21 +107,33 @@ export function ScrollShadow(props: {
}}
/>
<div
className={cn(styles.scrollShadowBottom, styles.scrollShadowY)}
className={cn(
styles.scrollShadowBottom,
styles.scrollShadowY,
props.shadowClassName,
)}
ref={shadowBottomEl}
style={{
opacity: "0",
}}
/>
<div
className={cn(styles.scrollShadowLeft, styles.scrollShadowX)}
className={cn(
styles.scrollShadowLeft,
styles.scrollShadowX,
props.shadowClassName,
)}
ref={shadowLeftEl}
style={{
opacity: "0",
}}
/>
<div
className={cn(styles.scrollShadowRight, styles.scrollShadowX)}
className={cn(
styles.scrollShadowRight,
styles.scrollShadowX,
props.shadowClassName,
)}
ref={shadowRightEl}
style={{
opacity: "0",
@@ -12,6 +12,7 @@ export type CodeProps = {
scrollableClassName?: string;
keepPreviousDataOnCodeChange?: boolean;
copyButtonClassName?: string;
ignoreFormattingErrors?: boolean;
};
export const CodeClient: React.FC<CodeProps> = ({
@@ -21,10 +22,14 @@ export const CodeClient: React.FC<CodeProps> = ({
scrollableClassName,
keepPreviousDataOnCodeChange = false,
copyButtonClassName,
ignoreFormattingErrors,
}) => {
const codeQuery = useQuery({
queryKey: ["html", code],
queryFn: () => getCodeHtml(code, lang),
queryFn: () =>
getCodeHtml(code, lang, {
ignoreFormattingErrors: ignoreFormattingErrors,
}),
placeholderData: keepPreviousDataOnCodeChange
? keepPreviousData
: undefined,
@@ -16,15 +16,28 @@ function isPrettierSupportedLang(lang: BundledLanguage) {
);
}
export async function getCodeHtml(code: string, lang: BundledLanguage) {
export async function getCodeHtml(
code: string,
lang: BundledLanguage,
options?: {
ignoreFormattingErrors?: boolean;
},
) {
const formattedCode = isPrettierSupportedLang(lang)
? await format(code, {
parser: "babel-ts",
plugins: [parserBabel, estree],
printWidth: 60,
}).catch((e) => {
console.error(e);
console.error("Failed to format code");
if (!options?.ignoreFormattingErrors) {
console.error(e);
console.error("Failed to format code");
console.log({
code,
lang,
});
}
return code;
})
: code;
@@ -7,7 +7,7 @@ export function InlineCode({
return (
<code
className={cn(
"mx-0.5 inline rounded-lg border border-border px-1.5 py-[3px] font-mono text-[0.85em] text-foreground",
"mx-0.5 inline rounded-lg border border-border bg-muted px-[0.4em] py-[0.25em] font-mono text-[0.85em] text-foreground",
className,
)}
>
@@ -171,10 +171,22 @@ function useUnderline<El extends HTMLElement>() {
}
update();
let resizeObserver: ResizeObserver | undefined = undefined;
if (containerRef.current) {
resizeObserver = new ResizeObserver(() => {
setTimeout(() => {
update();
}, 100);
});
resizeObserver.observe(containerRef.current);
}
// add event listener for resize
window.addEventListener("resize", update);
return () => {
window.removeEventListener("resize", update);
resizeObserver?.disconnect();
};
}, [activeTabEl]);
@@ -22,3 +22,27 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
Textarea.displayName = "Textarea";
export { Textarea };
export function AutoResizeTextarea(props: TextareaProps) {
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: value is needed in deps array
React.useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`;
}
}, [props.value]);
return (
<Textarea
ref={textareaRef}
{...props}
style={{
...props.style,
overflowY: "hidden",
}}
/>
);
}
+2
View File
@@ -39,3 +39,5 @@ export const BASE_URL = isProd
: (process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
: "http://localhost:3000") || "https://thirdweb-dev.com";
export const NEXT_PUBLIC_NEBULA_URL = process.env.NEXT_PUBLIC_NEBULA_URL;
@@ -189,7 +189,7 @@ export default async function ChainPageLayout(props: {
{/* description */}
{chainMetadata?.about && (
<p className="mb-2 text-muted-foreground text-sm lg:text-base">
<p className="mb-2 whitespace-pre-line text-muted-foreground text-sm lg:text-base">
{chainMetadata.about}
</p>
)}
@@ -313,6 +313,7 @@ export const EmbedSetup: React.FC<EmbedSetupProps> = ({
});
apiKeys.refetch();
}}
enableNebulaServiceByDefault={false}
/>
<Flex gap={8} direction={{ base: "column", md: "row" }}>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

@@ -24,9 +24,13 @@ import laosCTA from "./temp-assets/laosCTA.jpg";
import liskBanner from "./temp-assets/liskBanner.png";
import liskCTA from "./temp-assets/liskCTA.png";
import creatorBanner from "./temp-assets/creatorBanner.png";
import creatorCTA from "./temp-assets/creatorCTA.png";
import lumiaCTA from "./temp-assets/lumia-cta.png";
import lumiaBanner from "./temp-assets/lumiaBanner.png";
import mantleBanner from "./temp-assets/mantle.png";
import metalBanner from "./temp-assets/metalBanner.png";
import metalCTA from "./temp-assets/metalCTA.png";
import plumeBanner from "./temp-assets/plumeBanner.png";
import plumeCTA from "./temp-assets/plumeCTA.png";
import rootStockBanner from "./temp-assets/rootstock-banner.png";
@@ -298,6 +302,18 @@ const chainMetaRecord = {
buttonText: "Learn more",
},
},
//Creator Mainnet
66665: {
headerImgUrl: creatorBanner.src,
about:
"From Builders to Builders, From Idea to Creation ⚡\nCreator is AI Superhero Layer 2 Blockchain 🦇\nBuilt on the OP Stack, leveraging the robust foundation of Optimism 🟡🔴. We adopt the OP Stack's design principles because, as builders, we know they work and align perfectly with our values.\nWhy Creator?\nWe're constructing our Testnet on the Bedrock release of the OP Stack, ensuring a scalable and modular infrastructure that supports innovative and decentralized applications.\nCore Features:\n- Lightning Fast Transactions: Say goodbye to slow speeds with Creator's Layer 2 technology.\n- Low Fees: Lower transaction costs make blockchain accessible to all.\n- Scalable Infrastructure: Creator's network adapts effortlessly as you grow.",
cta: {
backgroundImageUrl: creatorCTA.src,
title: "From Builders to Builders, From Idea to Creation ⚡",
buttonLink: "https://www.creatorchain.io/",
buttonText: "Learn More",
},
},
//Donatuz
42026: {
headerImgUrl: thirdwebBanner.src,
@@ -405,6 +421,18 @@ const chainMetaRecord = {
cta: OP_CTA,
gasSponsored: true,
},
//Metal L2
1750: {
headerImgUrl: metalBanner.src,
about:
"Metal L2 is a banking-focused Layer 2 blockchain built on the Optimism Superchain, designed to enable direct on-chain fiat deposits through its connection to The Digital Banking Network—an open-source blockchain banking protocol developed by Metallicus",
cta: {
backgroundImageUrl: metalCTA.src,
title: "Connect to The Banking Layer, Metal L2",
buttonLink: "https://metall2.com/",
buttonText: "Connect Here",
},
},
//0G-Newton-Testnet
16600: {
headerImgUrl: zeroGBanner.src,
@@ -453,7 +481,7 @@ const chainMetaRecord = {
buttonText: "Learn more",
},
},
//treasure
//treasure-Ruby
978657: {
headerImgUrl: treasureBanner.src,
about:
@@ -465,6 +493,30 @@ const chainMetaRecord = {
buttonText: "Learn more",
},
},
//Treasure Topaz
978658: {
headerImgUrl: treasureBanner.src,
about:
'Treasure is the decentralized game console. Powered by $MAGIC, the Treasure L2 serves as the base layer for the best cryptonative games and projects. Treasure and its network of "Infinity Chains" L3s offers EVM compatibility, massive scale, and decentralized infrastructure enshrined throughout. Combined with a passionate community and builder support, developers on Treasure are equipped with all of the tools they need to not only build great games and products, but also distribute to the masses.',
cta: {
backgroundImageUrl: treasureCTA.src,
title: "Start building on Treasure!",
buttonLink: "https://portal.treasure.lol",
buttonText: "Learn more",
},
},
//Treasure Mainnet
61166: {
headerImgUrl: treasureBanner.src,
about:
'Treasure is the decentralized game console. Powered by $MAGIC, the Treasure L2 serves as the base layer for the best cryptonative games and projects. Treasure and its network of "Infinity Chains" L3s offers EVM compatibility, massive scale, and decentralized infrastructure enshrined throughout. Combined with a passionate community and builder support, developers on Treasure are equipped with all of the tools they need to not only build great games and products, but also distribute to the masses.',
cta: {
backgroundImageUrl: treasureCTA.src,
title: "Start building on Treasure!",
buttonLink: "https://portal.treasure.lol",
buttonText: "Learn more",
},
},
//AssetChain
42421: {
headerImgUrl: assetChainBanner.src,
@@ -21,8 +21,10 @@ export function AccountHeader(props: {
}) {
const myAccountQuery = useAccount();
const router = useDashboardRouter();
const [isCreateProjectDialogOpen, setIsCreateProjectDialogOpen] =
useState(false);
const [createProjectDialogState, setCreateProjectDialogState] = useState<
{ team: Team; isOpen: true } | { isOpen: false }
>({ isOpen: false });
const client = useThirdwebClient();
const wallet = useActiveWallet();
const { disconnect } = useDisconnect();
@@ -43,7 +45,11 @@ export function AccountHeader(props: {
teamsAndProjects: props.teamsAndProjects,
logout: logout,
connectButton: <CustomConnectWallet />,
createProject: () => setIsCreateProjectDialogOpen(true),
createProject: (team: Team) =>
setCreateProjectDialogState({
team,
isOpen: true,
}),
account: myAccountQuery.data,
client,
};
@@ -54,12 +60,20 @@ export function AccountHeader(props: {
<AccountHeaderMobileUI {...headerProps} className="lg:hidden" />
<LazyCreateAPIKeyDialog
open={isCreateProjectDialogOpen}
onOpenChange={setIsCreateProjectDialogOpen}
open={createProjectDialogState.isOpen}
onOpenChange={() =>
setCreateProjectDialogState({
isOpen: false,
})
}
onCreateAndComplete={() => {
// refresh projects
router.refresh();
}}
enableNebulaServiceByDefault={
createProjectDialogState.isOpen &&
createProjectDialogState.team.enabledScopes.includes("nebula")
}
/>
</div>
);
@@ -16,7 +16,7 @@ export type AccountHeaderCompProps = {
logout: () => void;
connectButton: React.ReactNode;
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
createProject: () => void;
createProject: (team: Team) => void;
account: Pick<Account, "email" | "id"> | undefined;
client: ThirdwebClient;
};
@@ -36,7 +36,7 @@ export async function getRawAccount() {
* If there's no account or account onboarding not complete, redirect to login page
* @param pagePath - the path of the current page to redirect back to after login/onboarding
*/
export async function getValidAccount(pagePath: string) {
export async function getValidAccount(pagePath?: string) {
const account = await getRawAccount();
// enforce login & onboarding
@@ -30,7 +30,8 @@ export function SecondaryNavLinks() {
<ResourcesDropdownButton />
<Link
href="/support"
target="_blank"
href="https://thirdweb.com/support"
className="text-muted-foreground text-sm hover:text-foreground"
>
Support
+8 -25
View File
@@ -45,7 +45,7 @@ const wallets = [
export function LoginAndOnboardingPage(props: {
account: Account | undefined;
nextPath: string | undefined;
redirectPath: string;
}) {
return (
<div className="relative flex min-h-screen flex-col overflow-hidden bg-background">
@@ -53,9 +53,6 @@ export function LoginAndOnboardingPage(props: {
<header className="container flex w-full flex-row items-center justify-between px-6 py-4">
<div className="flex shrink-0 items-center gap-3">
<ThirdwebMiniLogo className="size-7 md:size-8" />
<h1 className="font-medium text-lg tracking-tight md:text-xl">
Get started <span className="max-sm:hidden">with thirdweb</span>
</h1>
</div>
<div className="flex items-center gap-3">
@@ -82,7 +79,10 @@ export function LoginAndOnboardingPage(props: {
<main className="container z-10 flex grow flex-col items-center justify-center gap-6 py-12">
<ClientOnly ssr={<LoadingCard />}>
<PageContent nextPath={props.nextPath} account={props.account} />
<PageContent
redirectPath={props.redirectPath}
account={props.account}
/>
</ClientOnly>
</main>
@@ -111,7 +111,7 @@ function LoadingCard() {
}
function PageContent(props: {
nextPath: string | undefined;
redirectPath: string;
account: Account | undefined;
}) {
const [screen, setScreen] = useState<
@@ -130,11 +130,7 @@ function PageContent(props: {
function onComplete() {
setScreen({ id: "complete" });
if (props.nextPath && isValidRedirectPath(props.nextPath)) {
router.replace(props.nextPath);
} else {
router.replace("/team");
}
router.replace(props.redirectPath);
}
if (connectionStatus === "connecting") {
@@ -151,7 +147,7 @@ function PageContent(props: {
<LazyOnboardingUI
account={screen.account}
onComplete={onComplete}
redirectPath={props.nextPath || "/team"}
redirectPath={props.redirectPath}
redirectToCheckout={redirectToCheckout}
/>
</Suspense>
@@ -218,19 +214,6 @@ function CustomConnectEmbed(props: {
);
}
function isValidRedirectPath(encodedPath: string): boolean {
try {
// Decode the URI component
const decodedPath = decodeURIComponent(encodedPath);
// ensure the path always starts with a _single_ slash
// double slash could be interpreted as `//example.com` which is not allowed
return decodedPath.startsWith("/") && !decodedPath.startsWith("//");
} catch {
// If decoding fails, return false
return false;
}
}
type AuroraProps = {
size: { width: string; height: string };
pos: { top: string; left: string };
@@ -0,0 +1,12 @@
export function isValidEncodedRedirectPath(encodedPath: string): boolean {
try {
// Decode the URI component
const decodedPath = decodeURIComponent(encodedPath);
// ensure the path always starts with a _single_ slash
// double slash could be interpreted as `//example.com` which is not allowed
return decodedPath.startsWith("/") && !decodedPath.startsWith("//");
} catch {
// If decoding fails, return false
return false;
}
}
@@ -1,5 +1,9 @@
import { redirect } from "next/navigation";
export function loginRedirect(path: string): never {
export function loginRedirect(path?: string): never {
if (!path) {
redirect("/login");
}
redirect(`/login?next=${encodeURIComponent(path)}`);
}
+7 -1
View File
@@ -1,5 +1,6 @@
import { getRawAccount } from "../account/settings/getAccount";
import { LoginAndOnboardingPage } from "./LoginPage";
import { isValidEncodedRedirectPath } from "./isValidEncodedRedirectPath";
export default async function Page(props: {
searchParams: Promise<{
@@ -15,5 +16,10 @@ export default async function Page(props: {
// if the user is already logged in, wallet is connected and onboarding is complete
// user will be redirected to the next path on the client side without having to do anything
return <LoginAndOnboardingPage account={account} nextPath={nextPath} />;
const redirectPath =
nextPath && isValidEncodedRedirectPath(nextPath) ? nextPath : "/team";
return (
<LoginAndOnboardingPage account={account} redirectPath={redirectPath} />
);
}
@@ -0,0 +1,162 @@
import { NEXT_PUBLIC_NEBULA_URL } from "@/constants/env";
// TODO - copy the source of this library to dashboard
import { stream } from "fetch-event-stream";
import type { SendTransactionOption } from "thirdweb/dist/types/wallets/interfaces/wallet";
import type { ExecuteConfig } from "./types";
export type ContextFilters = {
chainIds?: string[];
contractAddresses?: string[];
};
export async function promptNebula(params: {
message: string;
sessionId: string;
config: ExecuteConfig;
authToken: string;
handleStream: (res: ChatStreamedResponse) => void;
abortController: AbortController;
contextFilters: undefined | ContextFilters;
}) {
const body: Record<string, string | boolean | object> = {
message: params.message,
user_id: "default-user",
session_id: params.sessionId,
stream: true,
execute_config: params.config,
};
if (params.contextFilters) {
body.context_filter = {
chain_ids: params.contextFilters.chainIds || [],
contract_addresses: params.contextFilters.contractAddresses || [],
};
}
const events = await stream(`${NEXT_PUBLIC_NEBULA_URL}/chat`, {
method: "POST",
headers: {
Authorization: `Bearer ${params.authToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: params.abortController.signal,
});
for await (const _event of events) {
if (!_event.data) {
continue;
}
const event = _event as ChatStreamedEvent;
switch (event.event) {
case "delta": {
params.handleStream({
event: "delta",
data: {
v: JSON.parse(event.data).v,
},
});
break;
}
case "presence": {
params.handleStream({
event: "presence",
data: JSON.parse(event.data),
});
break;
}
case "action": {
const data = JSON.parse(event.data);
if (data.type === "sign_transaction") {
let txData = null;
try {
const parsedTxData = JSON.parse(data.data);
if (
parsedTxData !== null &&
typeof parsedTxData === "object" &&
parsedTxData.chainId
) {
txData = parsedTxData;
}
} catch (e) {
console.error("failed to parse action data", e);
}
params.handleStream({
event: "action",
type: "sign_transaction",
data: txData,
});
}
break;
}
case "init": {
const data = JSON.parse(event.data);
params.handleStream({
event: "init",
data: {
session_id: data.session_id,
request_id: data.request_id,
},
});
break;
}
}
}
}
type ChatStreamedResponse =
| {
event: "init";
data: {
session_id: string;
request_id: string;
};
}
| {
event: "presence";
data: {
session_id: string;
request_id: string;
source: "user" | "reviewer" | (string & {});
data: string;
};
}
| {
event: "delta";
data: {
v: string;
};
}
| {
event: "action";
type: "sign_transaction" & (string & {});
data: SendTransactionOption;
};
type ChatStreamedEvent =
| {
event: "init";
data: string;
}
| {
event: "presence";
data: string;
}
| {
event: "delta";
data: string;
}
| {
event: "action";
type: "sign_transaction" & (string & {});
data: string;
};
@@ -0,0 +1,25 @@
import { NEXT_PUBLIC_NEBULA_URL } from "@/constants/env";
import { fetchWithAuthToken } from "../../../../utils/fetchWithAuthToken";
export async function submitFeedback(params: {
authToken: string;
sessionId: string;
requestId: string;
rating: "good" | "bad" | "neutral";
}) {
const res = await fetchWithAuthToken({
method: "POST",
endpoint: `${NEXT_PUBLIC_NEBULA_URL}/feedback`,
body: {
session_id: params.sessionId,
request_id: params.requestId,
feedback_rating:
params.rating === "good" ? 1 : params.rating === "bad" ? -1 : 0,
},
authToken: params.authToken,
});
if (!res.ok) {
throw new Error("Failed to submit feedback");
}
}
@@ -0,0 +1,131 @@
import { NEXT_PUBLIC_NEBULA_URL } from "@/constants/env";
import { fetchWithAuthToken } from "../../../../utils/fetchWithAuthToken";
import type { ContextFilters } from "./chat";
import type { ExecuteConfig, SessionInfo, TruncatedSessionInfo } from "./types";
// TODO - get the spec for return types on /session POST and PUT
export async function createSession(params: {
authToken: string;
config: ExecuteConfig | null;
contextFilters: ContextFilters | undefined;
}) {
const body: Record<string, string | boolean | object> = {
can_execute: !!params.config,
};
if (params.config) {
body.execute_config = params.config;
}
if (params.contextFilters) {
body.context_filter = {
chain_ids: params.contextFilters.chainIds || [],
contract_addresses: params.contextFilters.contractAddresses || [],
};
}
const res = await fetchWithAuthToken({
method: "POST",
endpoint: `${NEXT_PUBLIC_NEBULA_URL}/session`,
body: body,
authToken: params.authToken,
});
if (!res.ok) {
throw new Error("Failed to create session");
}
const data = await res.json();
return data.result as SessionInfo;
}
export async function updateSession(params: {
authToken: string;
config: ExecuteConfig | null;
sessionId: string;
contextFilters: ContextFilters | undefined;
}) {
const body: Record<string, string | boolean | object> = {
can_execute: !!params.config,
};
if (params.config) {
body.execute_config = params.config;
}
if (params.contextFilters) {
body.context_filter = {
chain_ids: params.contextFilters.chainIds || [],
contract_addresses: params.contextFilters.contractAddresses || [],
};
}
const res = await fetchWithAuthToken({
method: "PUT",
endpoint: `${NEXT_PUBLIC_NEBULA_URL}/session/${params.sessionId}`,
body: body,
authToken: params.authToken,
});
if (!res.ok) {
throw new Error("Failed to update session");
}
const data = await res.json();
return data.result as SessionInfo;
}
export async function deleteSession(params: {
authToken: string;
sessionId: string;
}) {
const res = await fetchWithAuthToken({
method: "DELETE",
endpoint: `${NEXT_PUBLIC_NEBULA_URL}/session/${params.sessionId}`,
authToken: params.authToken,
});
if (!res.ok) {
throw new Error("Failed to update session");
}
const data = await res.json();
return data.result as {
id: string;
deleted_at: string;
};
}
export async function getSessions(params: {
authToken: string;
}) {
const res = await fetchWithAuthToken({
method: "GET",
endpoint: `${NEXT_PUBLIC_NEBULA_URL}/session/list`,
authToken: params.authToken,
});
if (!res.ok) {
throw new Error("Failed to update session");
}
const data = await res.json();
return data.result as TruncatedSessionInfo[];
}
export async function getSessionById(params: {
authToken: string;
sessionId: string;
}) {
const res = await fetchWithAuthToken({
method: "GET",
endpoint: `${NEXT_PUBLIC_NEBULA_URL}/session/${params.sessionId}`,
authToken: params.authToken,
});
if (!res.ok) {
throw new Error("Failed to update session");
}
const data = await res.json();
return data.result as SessionInfo;
}
@@ -0,0 +1,49 @@
type EngineConfig = {
mode: "engine";
engine_url: string;
engine_authorization_token: string;
engine_backend_wallet_address: string;
};
type SessionKeyConfig = {
mode: "session_key";
smart_account_address: string;
smart_account_factory_address: string;
smart_account_session_key: string;
};
type ClientConfig = {
mode: "client";
signer_wallet_address: string;
};
export type ExecuteConfig = EngineConfig | SessionKeyConfig | ClientConfig;
export type SessionInfo = {
id: string;
account_id: string;
modal_name: string;
archive_at: string | null;
can_execute: boolean;
execute_config: ExecuteConfig | null;
created_at: string;
deleted_at: string | null;
history: Array<{
role: "user" | "assistant"; // role: action is coming up
content: string;
timestamp: number;
}> | null;
updated_at: string;
archived_at: string | null;
title: string | null;
is_public: boolean | null;
// memory
// action: array<object> | null; <-- type of this is not available on https://nebula-api.thirdweb-dev.com/docs#/default/get_session_session__session_id__get
};
export type TruncatedSessionInfo = {
created_at: string;
id: string;
updated_at: string;
title: string | null;
};
@@ -0,0 +1,49 @@
import { redirect } from "next/navigation";
import { getValidAccount } from "../../../../account/settings/getAccount";
import {
getAuthToken,
getAuthTokenWalletAddress,
} from "../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../login/loginRedirect";
import { getSessionById } from "../../api/session";
import { ChatPageContent } from "../../components/ChatPageContent";
export default async function Page(props: {
params: Promise<{
session_id: string;
}>;
}) {
const params = await props.params;
const pagePath = `/chat/${params.session_id}`;
const authToken = await getAuthToken();
const account = await getValidAccount();
if (!authToken) {
loginRedirect(pagePath);
}
const accountAddress = await getAuthTokenWalletAddress();
if (!accountAddress) {
loginRedirect(pagePath);
}
const session = await getSessionById({
authToken,
sessionId: params.session_id,
}).catch(() => undefined);
if (!session) {
redirect("/");
}
return (
<ChatPageContent
accountAddress={accountAddress}
authToken={authToken}
session={session}
type="new-chat"
account={account}
/>
);
}
@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from "@storybook/react";
import { subDays } from "date-fns";
import { ThirdwebProvider } from "thirdweb/react";
import { accountStub, randomLorem } from "../../../../../stories/stubs";
import { BadgeContainer, mobileViewport } from "../../../../../stories/utils";
import { ChatPageLayout } from "../../components/ChatPageLayout";
import { ChatHistoryPageUI } from "./ChatHistoryPage";
const meta = {
title: "Nebula/history",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {
length: 10,
},
};
export const Mobile: Story = {
args: {
length: 10,
},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function createRandomSessions(length: number) {
const sessions = [];
for (let i = 0; i < length; i++) {
sessions.push({
created_at: new Date().toISOString(),
id: Math.random().toString(),
updated_at: subDays(
new Date(),
Math.floor(Math.random() * 10),
).toISOString(),
title: randomLorem(Math.floor(5 + Math.random() * 15)),
});
}
return sessions;
}
function Story() {
return (
<ThirdwebProvider>
<div className="flex flex-col gap-10 py-10">
<BadgeContainer label="10 chats">
<Variant length={10} />
</BadgeContainer>
<BadgeContainer label="0 chats">
<Variant length={0} />
</BadgeContainer>
<BadgeContainer label="1 chat">
<Variant length={1} />
</BadgeContainer>
<BadgeContainer label="No search result">
<Variant length={10} prefillSearch="xxxxxxxxxxxx" />
</BadgeContainer>
</div>
</ThirdwebProvider>
);
}
function Variant(props: {
length: number;
prefillSearch?: string;
}) {
return (
<ChatPageLayout
account={accountStub()}
accountAddress="0x1234567890"
authToken="xxxxxxxx"
sessions={createRandomSessions(props.length)}
className="h-[700px] border-b lg:h-[800px]"
>
<ChatHistoryPageUI
sessions={createRandomSessions(props.length)}
prefillSearch={props.prefillSearch}
deleteSession={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
/>
</ChatPageLayout>
);
}
@@ -0,0 +1,218 @@
"use client";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useMutation } from "@tanstack/react-query";
import { formatDistance } from "date-fns";
import Fuse from "fuse.js";
import {
EllipsisIcon,
MessageCircleIcon,
SearchIcon,
TrashIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { deleteSession } from "../../api/session";
import type { TruncatedSessionInfo } from "../../api/types";
import { useSessionsWithLocalOverrides } from "../../hooks/useSessionsWithLocalOverrides";
import { deletedSessionsStore } from "../../stores";
export function ChatHistoryPage(props: {
sessions: TruncatedSessionInfo[];
prefillSearch?: string;
authToken: string;
}) {
return (
<ChatHistoryPageUI
{...props}
deleteSession={async (s) => {
await deleteSession({ sessionId: s, authToken: props.authToken });
}}
/>
);
}
export function ChatHistoryPageUI(props: {
sessions: TruncatedSessionInfo[];
prefillSearch?: string;
deleteSession: (sessionId: string) => Promise<void>;
}) {
const [searchVal, setSearchVal] = useState(props.prefillSearch ?? "");
const allSessions = useSessionsWithLocalOverrides(props.sessions);
const fuse = useMemo(() => {
return new Fuse(allSessions, {
keys: [
{
name: "title",
weight: 1,
},
],
threshold: 0.5,
});
}, [allSessions]);
const filteredSessions = useMemo(() => {
if (!searchVal) {
return allSessions;
}
return fuse.search(searchVal).map((e) => e.item);
}, [allSessions, searchVal, fuse]);
return (
<div className="flex grow flex-col overflow-hidden">
<header className="border-b py-4">
<h1 className="px-6 font-semibold text-lg tracking-tight">All Chats</h1>
</header>
<SearchInput
placeholder="Search for a chat"
value={searchVal}
onValueChange={setSearchVal}
/>
{filteredSessions.length > 0 && (
<ScrollShadow
className="container flex-1"
scrollableClassName="max-h-full py-6"
shadowColor="hsl(var(--background))"
>
{filteredSessions.length > 0 && (
<div className="flex flex-col gap-4">
{filteredSessions.map((session) => (
<SessionCard
key={session.id + session.updated_at + session.created_at}
session={session}
deleteSession={props.deleteSession}
/>
))}
</div>
)}
</ScrollShadow>
)}
{/* No Search Results */}
{allSessions.length > 0 && filteredSessions.length === 0 && (
<div className="flex grow flex-col justify-center rounded-lg">
<div className="flex flex-col items-center">
<div className="rounded-xl border bg-muted/50 p-2">
<MessageCircleIcon className="size-5 text-muted-foreground" />
</div>
<div className="h-4" />
<h3 className="text-center"> No Chats Found </h3>
<div className="h-1" />
<p className="text-center text-muted-foreground text-sm">
Try a different search term
</p>
</div>
</div>
)}
{/* No Chats at all */}
{allSessions.length === 0 && (
<div className="flex grow flex-col justify-center">
<div className="flex flex-col items-center">
<div className="rounded-xl border bg-muted/50 p-2">
<MessageCircleIcon className="size-5 text-muted-foreground" />
</div>
<div className="h-4" />
<h3 className="text-center"> No Chats Created </h3>
<div className="h-1" />
<p className="text-center text-muted-foreground text-sm">
Start a new chat to get started.
</p>
<div className="h-5" />
<Button asChild size="sm">
<Link href="/">New Chat</Link>
</Button>
</div>
</div>
)}
</div>
);
}
function SearchInput(props: {
placeholder: string;
value: string;
onValueChange: (value: string) => void;
}) {
return (
<div className="relative">
<Input
placeholder={props.placeholder}
value={props.value}
onChange={(e) => props.onValueChange(e.target.value)}
className="!border-b h-auto rounded-none border-0 px-6 py-4 pl-11 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-5 size-4 text-muted-foreground" />
</div>
);
}
// TODO - add delete chat confirmation dialog
function SessionCard(props: {
session: TruncatedSessionInfo;
deleteSession: (sessionId: string) => Promise<void>;
}) {
const deleteChat = useMutation({
mutationFn: () => {
return props.deleteSession(props.session.id);
},
onSuccess: () => {
const prev = deletedSessionsStore.getValue();
deletedSessionsStore.setValue([...prev, props.session.id]);
},
});
return (
<div className="rounded-lg border bg-muted/50 p-4">
<h3>{props.session.title}</h3>
<div className="mt-4 flex items-center justify-between gap-6 border-t pt-3">
<p className="text-muted-foreground text-sm">
Updated{" "}
{formatDistance(new Date(props.session.updated_at), new Date(), {
addSuffix: true,
})}
</p>
<Popover>
<PopoverTrigger asChild>
<Button className="h-auto w-auto p-1.5" variant="ghost">
<EllipsisIcon className="size-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" side="top" align="end">
<Button
variant="ghost"
className="!text-destructive-text flex w-full justify-start gap-2.5 px-2"
onClick={() => {
const promise = deleteChat.mutateAsync();
toast.promise(promise, {
success: "Chat deleted successfully",
error: "Failed to delete chat",
});
}}
>
{deleteChat.isPending ? (
<Spinner className="size-4" />
) : (
<TrashIcon className="size-4" />
)}
Delete
</Button>
</PopoverContent>
</Popover>
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import { getAuthToken } from "../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../login/loginRedirect";
import { getSessions } from "../../api/session";
import { ChatHistoryPage } from "./ChatHistoryPage";
export default async function Page() {
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect();
}
const sessions = await getSessions({ authToken }).catch(() => []);
return <ChatHistoryPage sessions={sessions} authToken={authToken} />;
}
@@ -0,0 +1,32 @@
import { getValidAccount } from "../../../account/settings/getAccount";
import {
getAuthToken,
getAuthTokenWalletAddress,
} from "../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../login/loginRedirect";
import { ChatPageContent } from "../components/ChatPageContent";
export default async function Page() {
const authToken = await getAuthToken();
const account = await getValidAccount();
if (!authToken) {
loginRedirect();
}
const accountAddress = await getAuthTokenWalletAddress();
if (!accountAddress) {
loginRedirect();
}
return (
<ChatPageContent
accountAddress={accountAddress}
authToken={authToken}
session={undefined}
type="new-chat"
account={account}
/>
);
}
@@ -0,0 +1,70 @@
"use client";
import { Button } from "@/components/ui/button";
import { AutoResizeTextarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { ArrowUpIcon, CircleStopIcon } from "lucide-react";
import { useState } from "react";
import type { ExecuteConfig } from "../api/types";
export function Chatbar(props: {
updateConfig: (config: ExecuteConfig) => void;
config: ExecuteConfig;
sendMessage: (message: string) => void;
isChatStreaming: boolean;
abortChatStream: () => void;
}) {
const [message, setMessage] = useState("");
return (
<div className="rounded-2xl border border-border bg-muted/50 p-2">
<AutoResizeTextarea
placeholder={"Ask Nebula"}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !props.isChatStreaming) {
setMessage("");
props.sendMessage(message);
}
}}
className="min-h-[40px] resize-none border-none bg-transparent pt-2 leading-relaxed focus-visible:ring-0 focus-visible:ring-offset-0"
disabled={props.isChatStreaming}
/>
<div className="-mt-3 flex justify-end gap-3 px-2 pb-2">
{/* Send / Stop */}
{props.isChatStreaming ? (
<Button
variant="default"
className="!h-auto w-auto gap-2 p-2"
onClick={() => {
props.abortChatStream();
}}
>
<CircleStopIcon className="size-4" />
Stop
</Button>
) : (
<Button
aria-label="Send"
disabled={message.trim() === ""}
variant="primary"
className={cn(
"!h-auto w-auto border border-transparent p-2 disabled:opacity-100",
message === "" &&
"border-border bg-muted text-muted-foreground hover:border-transparent hover:text-foreground",
)}
onClick={() => {
if (message.trim() === "") return;
setMessage("");
props.sendMessage(message);
}}
>
<ArrowUpIcon className="size-4" />
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,367 @@
"use client";
/* eslint-disable no-restricted-syntax */
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { useMutation } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { type ContextFilters, promptNebula } from "../api/chat";
import { createSession, updateSession } from "../api/session";
import type { ExecuteConfig, SessionInfo } from "../api/types";
import { newChatPageUrlStore, newSessionsStore } from "../stores";
import { Chatbar } from "./ChatBar";
import { type ChatMessage, Chats } from "./Chats";
import ContextFiltersButton from "./ContextFilters";
import { EmptyStateChatPageContent } from "./EmptyStateChatPageContent";
export function ChatPageContent(props: {
session: SessionInfo | undefined;
authToken: string;
accountAddress: string;
type: "landing" | "new-chat";
account: Account;
}) {
const client = useThirdwebClient();
const [userHasSubmittedMessage, setUserHasSubmittedMessage] = useState(false);
const [messages, setMessages] = useState<Array<ChatMessage>>(() => {
if (props.session?.history) {
return props.session.history.map((message) => ({
text: message.content,
type: message.role,
request_id: undefined,
}));
}
return [];
});
const [_config, setConfig] = useState<ExecuteConfig | null>();
const [contextFilters, setContextFilters] = useState<
ContextFilters | undefined
>(undefined);
const config = _config || {
mode: "client",
signer_wallet_address: props.accountAddress,
};
const [sessionId, _setSessionId] = useState<string | undefined>(
props.session?.id,
);
const [chatAbortController, setChatAbortController] = useState<
AbortController | undefined
>();
function setSessionId(sessionId: string) {
_setSessionId(sessionId);
// update page URL without reloading
window.history.replaceState({}, "", `/chat/${sessionId}`);
const url = new URL(window.location.origin);
// if the current page is landing page, link to /chat
// if current page is new /chat page, link to landing page
if (props.type === "landing") {
url.pathname = "/chat";
} else {
url.pathname = "/";
}
newChatPageUrlStore.setValue(url.href);
}
const messagesEndRef = useRef<HTMLDivElement>(null);
const [isChatStreaming, setIsChatStreaming] = useState(false);
const [isUserSubmittedMessage, setIsUserSubmittedMessage] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
if (!isUserSubmittedMessage) {
return;
}
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isUserSubmittedMessage]);
async function initSession() {
const session = await createSession({
authToken: props.authToken,
config,
contextFilters,
});
setSessionId(session.id);
return session;
}
async function handleSendMessage(message: string) {
setUserHasSubmittedMessage(true);
setMessages((prev) => [
...prev,
{ text: message, type: "user" },
// instant loading indicator feedback to user
{
type: "presence",
text: "Thinking...",
},
]);
setIsChatStreaming(true);
setIsUserSubmittedMessage(true);
const abortController = new AbortController();
try {
// Ensure we have a session ID
let currentSessionId = sessionId;
if (!currentSessionId) {
const session = await initSession();
currentSessionId = session.id;
}
let requestIdForMessage = "";
// add this session on sidebar
if (messages.length === 0) {
const prevValue = newSessionsStore.getValue();
newSessionsStore.setValue([
{
id: currentSessionId,
title: message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
...prevValue,
]);
}
setChatAbortController(abortController);
await promptNebula({
abortController,
message: message,
sessionId: currentSessionId,
config: config,
authToken: props.authToken,
handleStream(res) {
if (abortController.signal.aborted) {
return;
}
if (res.event === "init") {
requestIdForMessage = res.data.request_id;
}
if (res.event === "delta") {
setMessages((prev) => {
const lastMessage = prev[prev.length - 1];
// if last message is presence, overwrite it
if (lastMessage?.type === "presence") {
return [
...prev.slice(0, -1),
{
text: res.data.v,
type: "assistant",
request_id: requestIdForMessage,
},
];
}
// if last message is from chat, append to it
if (lastMessage?.type === "assistant") {
return [
...prev.slice(0, -1),
{
text: lastMessage.text + res.data.v,
type: "assistant",
request_id: requestIdForMessage,
},
];
}
// otherwise, add a new message
return [
...prev,
{
text: res.data.v,
type: "assistant",
request_id: requestIdForMessage,
},
];
});
}
if (res.event === "presence") {
setMessages((prev) => {
const lastMessage = prev[prev.length - 1];
// if last message is presence, overwrite it
if (lastMessage?.type === "presence") {
return [
...prev.slice(0, -1),
{ text: res.data.data, type: "presence" },
];
}
// otherwise, add a new message
return [...prev, { text: res.data.data, type: "presence" }];
});
}
if (res.event === "action") {
if (res.type === "sign_transaction") {
setMessages((prev) => {
let prevMessages = prev;
// if last message is presence, remove it
if (
prevMessages[prevMessages.length - 1]?.type === "presence"
) {
prevMessages = prevMessages.slice(0, -1);
}
return [
...prevMessages,
{
type: "send_transaction",
data: res.data,
},
];
});
}
}
},
contextFilters: contextFilters,
});
} catch (error) {
if (abortController.signal.aborted) {
return;
}
console.error(error);
setMessages((prev) => [
...prev,
{
text: `Error: ${error instanceof Error ? error.message : "Failed to execute command"}`,
type: "error",
},
]);
} finally {
setIsChatStreaming(false);
}
}
async function handleUpdateConfig(newConfig: ExecuteConfig) {
setConfig(newConfig);
try {
if (!sessionId) {
// If no session exists, create a new one
await initSession();
} else {
const newSession = await updateSession({
authToken: props.authToken,
config: newConfig,
sessionId,
contextFilters,
});
if (newSession) {
setSessionId(newSession.id);
}
}
} catch (error) {
console.error("Failed to update session", error);
setMessages((prev) => [
...prev,
{
text: `Error: Failed to ${sessionId ? "update" : "create"} session`,
type: "error",
},
]);
}
}
const updateConfig = useMutation({
mutationFn: handleUpdateConfig,
});
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
return (
<div className="flex grow flex-col overflow-hidden">
<header className="flex justify-end border-b bg-background p-4">
<ContextFiltersButton
contextFilters={contextFilters}
setContextFilters={setContextFilters}
updateContextFilters={async (values) => {
// if session is not yet created, don't need to update sessions - starting a chat will create a session with the context filters
if (sessionId) {
const newSession = await updateSession({
authToken: props.authToken,
config,
sessionId,
contextFilters: values,
});
// setting new id - but it doesn't actually change if the session was already created ( which is the case here )
if (newSession) {
setSessionId(newSession.id);
}
}
}}
/>
</header>
<div className="container relative flex max-w-[800px] grow flex-col overflow-hidden rounded-lg pb-6">
{showEmptyState ? (
<div className="fade-in-0 flex grow animate-in flex-col justify-center">
<EmptyStateChatPageContent
sendMessage={handleSendMessage}
config={config}
updateConfig={() => {
updateConfig.mutate(config);
}}
/>
</div>
) : (
<div className="fade-in-0 relative z-[0] flex max-h-full flex-1 animate-in flex-col overflow-hidden">
<ScrollShadow
className="flex-1"
scrollableClassName="max-h-full"
shadowColor="hsl(var(--background))"
shadowClassName="z-[1]"
>
<Chats
messages={messages}
isChatStreaming={isChatStreaming}
authToken={props.authToken}
sessionId={sessionId}
className="min-w-0 pt-10 pb-32"
account={props.account}
client={client}
/>
{/* Scroll anchor */}
<div ref={messagesEndRef} />
</ScrollShadow>
<Chatbar
sendMessage={handleSendMessage}
config={config}
isChatStreaming={isChatStreaming}
updateConfig={() => {
updateConfig.mutate(config);
}}
abortChatStream={() => {
chatAbortController?.abort();
setChatAbortController(undefined);
setIsChatStreaming(false);
// if last message is presence, remove it
if (messages[messages.length - 1]?.type === "presence") {
setMessages((prev) => prev.slice(0, -1));
}
}}
/>
</div>
)}
<p className="mt-4 text-center text-muted-foreground text-xs opacity-75 lg:text-sm">
Nebula may make mistakes. Please use with discretion
</p>
</div>
</div>
);
}
@@ -0,0 +1,86 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Toaster } from "sonner";
import { ThirdwebProvider } from "thirdweb/react";
import { accountStub, randomLorem } from "../../../../stories/stubs";
import { BadgeContainer, mobileViewport } from "../../../../stories/utils";
import type { TruncatedSessionInfo } from "../api/types";
import { ChatPageLayout } from "./ChatPageLayout";
const meta = {
title: "Nebula/ChatPageLayout",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function generateRandomSessions(count: number): TruncatedSessionInfo[] {
return Array.from({ length: count }, (_, i) => ({
id: i.toString(),
title: randomLorem(Math.floor(Math.random() * 10) + 1),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
}
function Story() {
return (
<ThirdwebProvider>
<div className="flex flex-col gap-10 py-10 lg:container">
<Variant label="No Recent Chats" sessions={[]} />
<Variant label="3 Chats" sessions={generateRandomSessions(3)} />
<Variant label="30 Chats" sessions={generateRandomSessions(30)} />
<Variant
label="No Email"
sessions={generateRandomSessions(30)}
noEmail
/>
</div>
</ThirdwebProvider>
);
}
function Variant(props: {
label: string;
sessions: TruncatedSessionInfo[];
noEmail?: boolean;
}) {
return (
<BadgeContainer label={props.label}>
<ChatPageLayout
accountAddress="0xC569B9FD77d132e10954cA5E6EF617414e314b11"
authToken="xxxxx"
sessions={props.sessions}
className="h-[200px] lg:h-[800px] lg:border"
account={accountStub(
props.noEmail
? {
email: undefined,
}
: undefined,
)}
>
<div className="flex h-full w-full items-center justify-center">
CHILDREN
</div>
</ChatPageLayout>
<Toaster richColors />
</BadgeContainer>
);
}
@@ -0,0 +1,40 @@
import { cn } from "@/lib/utils";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import type { TruncatedSessionInfo } from "../api/types";
import { ChatSidebar } from "./ChatSidebar";
import { MobileNav } from "./NebulaMobileNav";
export function ChatPageLayout(props: {
authToken: string;
accountAddress: string;
sessions: TruncatedSessionInfo[];
children: React.ReactNode;
className?: string;
account: Account;
}) {
return (
<div
className={cn(
"flex h-screen flex-col overflow-hidden bg-background lg:flex-row",
props.className,
)}
>
<aside className="hidden w-[280px] shrink-0 border-border border-r bg-muted/50 lg:block">
<ChatSidebar
sessions={props.sessions}
authToken={props.authToken}
type="desktop"
account={props.account}
/>
</aside>
<MobileNav
sessions={props.sessions}
authToken={props.authToken}
account={props.account}
/>
{props.children}
</div>
);
}
@@ -0,0 +1,87 @@
"use client";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { Button } from "@/components/ui/button";
import { useStore } from "@/lib/reactive";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { ChevronRightIcon, MessageSquareDashedIcon } from "lucide-react";
import Link from "next/link";
import type { TruncatedSessionInfo } from "../api/types";
import { useSessionsWithLocalOverrides } from "../hooks/useSessionsWithLocalOverrides";
import { NebulaIcon } from "../icons/NebulaIcon";
import { newChatPageUrlStore } from "../stores";
import { ChatSidebarLink } from "./ChatSidebarLink";
import { NebulaAccountButton } from "./NebulaAccountButton";
export function ChatSidebar(props: {
sessions: TruncatedSessionInfo[];
authToken: string;
type: "desktop" | "mobile";
account: Account;
}) {
const sessions = useSessionsWithLocalOverrides(props.sessions);
const sessionsToShow = sessions.slice(0, 10);
const newChatPage = useNewChatPageLink();
return (
<div className="flex h-full flex-col p-2">
<div className="flex justify-start p-2">
<Link href="/">
<NebulaIcon className="size-8 text-foreground" />
</Link>
</div>
<div className="p-2">
<div className="h-3" />
<Button asChild variant="primary" className="w-full gap-2">
<Link href={newChatPage}>
<MessageSquareDashedIcon className="size-4" />
New Chat
</Link>
</Button>
</div>
{sessionsToShow.length > 0 && (
<ScrollShadow
className="my-5 flex-1 border-t border-dashed pt-2"
scrollableClassName="max-h-full"
shadowColor="hsl(var(--muted))"
shadowClassName="z-10"
>
<div className="flex flex-col gap-1">
<h3 className="px-2 py-3 text-muted-foreground text-sm">
Recent Chats
</h3>
{sessionsToShow.map((session) => {
return (
<ChatSidebarLink
sessionId={session.id}
title={session.title || session.id}
key={session.id}
authToken={props.authToken}
/>
);
})}
<Link
href="/chat/history"
className="inline-flex items-center gap-1.5 px-2 py-3 text-muted-foreground text-sm underline-offset-4 hover:text-foreground hover:underline"
>
View All <ChevronRightIcon className="size-4 text-foreground" />
</Link>
</div>
</ScrollShadow>
)}
<NebulaAccountButton
account={props.account}
className="mt-auto"
type="full"
/>
</div>
);
}
export function useNewChatPageLink() {
const newChatPage = useStore(newChatPageUrlStore);
return newChatPage || "/chat";
}
@@ -0,0 +1,91 @@
"use client";
import { NavLink } from "@/components/ui/NavLink";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useMutation } from "@tanstack/react-query";
import { EllipsisIcon, TrashIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { toast } from "sonner";
import { deleteSession } from "../api/session";
import { deletedSessionsStore } from "../stores";
// TODO - add delete chat confirmation dialog
export function ChatSidebarLink(props: {
sessionId: string;
title: string;
authToken: string;
}) {
const router = useDashboardRouter();
const pathname = usePathname();
const linkPath = `/chat/${props.sessionId}`;
const isDeletingCurrentPage = pathname === linkPath;
const deleteChat = useMutation({
mutationFn: () => {
return deleteSession({
authToken: props.authToken,
sessionId: props.sessionId,
});
},
onSuccess: () => {
const prev = deletedSessionsStore.getValue();
deletedSessionsStore.setValue([...prev, props.sessionId]);
if (isDeletingCurrentPage) {
router.replace("/");
}
},
});
return (
<div
className="group relative rounded-lg hover:bg-muted"
key={props.sessionId}
>
<Button
asChild
variant="ghost"
className="!h-auto !w-full items-center justify-between px-2 text-left text-sm hover:bg-transparent"
>
<NavLink href={`/chat/${props.sessionId}`} activeClassName="bg-muted">
<span className="line-clamp-1 block truncate">{props.title}</span>
</NavLink>
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
className="-translate-y-1/2 !bg-background absolute top-1/2 right-1 z-10 h-auto w-auto p-1.5 opacity-0 group-hover:opacity-100"
variant="ghost"
>
<EllipsisIcon className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" side="right">
<Button
variant="ghost"
className="!text-destructive-text flex w-full justify-start gap-2.5 px-2"
onClick={() => {
const promise = deleteChat.mutateAsync();
toast.promise(promise, {
success: "Chat deleted successfully",
error: "Failed to delete chat",
});
}}
>
{deleteChat.isPending ? (
<Spinner className="size-4" />
) : (
<TrashIcon className="size-4" />
)}
Delete
</Button>
</PopoverContent>
</Popover>
</div>
);
}
@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BadgeContainer, mobileViewport } from "../../../../stories/utils";
import { Chatbar } from "./ChatBar";
const meta = {
title: "Nebula/Chatbar",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function Story() {
return (
<div className="container flex max-w-[800px] flex-col gap-14 py-10">
<BadgeContainer label="Not Streaming - Client Config">
<Chatbar
abortChatStream={() => {}}
config={{
mode: "client",
signer_wallet_address: "xxxxx",
}}
isChatStreaming={false}
sendMessage={() => {}}
updateConfig={() => {}}
/>
</BadgeContainer>
<BadgeContainer label="Streaming - Client Config">
<Chatbar
abortChatStream={() => {}}
config={{
mode: "client",
signer_wallet_address: "xxxxx",
}}
isChatStreaming={true}
sendMessage={() => {}}
updateConfig={() => {}}
/>
</BadgeContainer>
<BadgeContainer label="Not Streaming - Engine Config">
<Chatbar
abortChatStream={() => {}}
config={{
mode: "engine",
engine_authorization_token: "xxxxx",
engine_backend_wallet_address: "0x1234",
engine_url: "https://some-engine-url.com",
}}
isChatStreaming={false}
sendMessage={() => {}}
updateConfig={() => {}}
/>
</BadgeContainer>
</div>
);
}
@@ -0,0 +1,216 @@
import { getThirdwebClient } from "@/constants/thirdweb.server";
import type { Meta, StoryObj } from "@storybook/react";
import { Toaster } from "sonner";
import { accountStub, randomLorem } from "../../../../stories/stubs";
import { BadgeContainer, mobileViewport } from "../../../../stories/utils";
import { Chats } from "./Chats";
const meta = {
title: "Nebula/Chats",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
const markdownExample = `\
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
This a paragraph
This is another paragraph
This a very long paragraph lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec pur us. Donec euismod, nunc nec vehicula.
ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec pur us. Donec euismod, nunc nec vehicula.
## Empasis
*Italic text*
_Also italic text_
**Bold text**
__Also bold text__
***Bold and italic***
___Also bold and italic___
## Blockquote
> This is a blockquote.
## Lists
### Unordered list
- Item 1
- Item 2
- Nested Item 1
- Nested Item 2
### Ordered list
1. First item
2. Second item
1. Sub-item 1
2. Sub-item 2
### Mixed Nested lists
- Item 1
- Item 2
1. Sub-item 1
2. Sub-item 2
1. First item
2. Second item
- Sub-item 1
- Sub-item 2
### Code
This a a paragraph with some \`inlineCode()\`
This a \`const longerCodeSnippet = "Example. This should be able to handle line breaks as well, it should not be overflowing the page";\`
\`\`\`javascript
// Code block with syntax highlighting
function example() {
console.log("Hello, world!");
}
\`\`\`
### Links
[thirdweb](https://www.thirdweb.com)
### Images
![Alt text](https://picsum.photos/2000/500)
### Horizontal Rule
---
### Tables
| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Row 1 | Data | More Data|
| Row 2 | Data | More Data|
| Column 1 | Column 2 | Column 3 | Column 4 | Column 5 |
|--------------|------------------|--------------|------------------|--------------|
| Row 1 Cell 1 | Row 1 Cell 2 | Row 1 Cell 3 | Row 1 Cell 4 | Row 1 Cell 5 |
| Row 2 Cell 1 | Row 2 Cell 2 | Row 2 Cell 3 | Row 2 Cell 4 | Row 2 Cell 5 |
| Row 3 Cell 1 | Row 3 Cell 2 | Row 3 Cell 3 | Row 3 Cell 4 | Row 3 Cell 5 |
| Row 4 Cell 1 | Row 4 Cell 2 | Row 4 Cell 3 | Row 4 Cell 4 | Row 4 Cell 5 |
| Row 5 Cell 1 | Row 5 Cell 2 | Row 5 Cell 3 | Row 5 Cell 4 | Row 5 Cell 5 |
`;
const responseWithCodeMarkdown = `
${randomLorem(20)}
${markdownExample}
`;
function Story() {
return (
<div className="container flex max-w-[800px] flex-col gap-14 py-10">
<BadgeContainer label="User + Presence">
<Chats
authToken="xxxxx"
isChatStreaming={false}
sessionId="xxxxx"
account={accountStub()}
messages={[
{
text: randomLorem(10),
type: "user",
},
{
text: randomLorem(20),
type: "presence",
},
]}
client={getThirdwebClient()}
/>
</BadgeContainer>
<BadgeContainer label="User + Error">
<Chats
client={getThirdwebClient()}
authToken="xxxxx"
isChatStreaming={false}
sessionId="xxxxx"
account={accountStub()}
messages={[
{
text: randomLorem(10),
type: "user",
},
{
text: randomLorem(20),
type: "error",
},
]}
/>
</BadgeContainer>
<BadgeContainer label="User + Assistant responses">
<Chats
client={getThirdwebClient()}
authToken="xxxxx"
isChatStreaming={false}
sessionId="xxxxx"
account={accountStub()}
messages={[
{
text: randomLorem(10),
type: "user",
},
{
text: randomLorem(40),
type: "assistant",
request_id: "xxxxx",
},
{
text: randomLorem(50),
type: "assistant",
request_id: undefined,
},
{
text: responseWithCodeMarkdown,
type: "assistant",
request_id: undefined,
},
]}
/>
</BadgeContainer>
<Toaster richColors />
</div>
);
}
@@ -0,0 +1,279 @@
import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Alert, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { Account as TWAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useMutation } from "@tanstack/react-query";
import {
AlertCircleIcon,
CheckIcon,
CopyIcon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
import type { Account } from "thirdweb/wallets";
import { TransactionButton } from "../../../../components/buttons/TransactionButton";
import { MarkdownRenderer } from "../../../../components/contract-components/published-contract/markdown-renderer";
import { submitFeedback } from "../api/feedback";
import { NebulaIcon } from "../icons/NebulaIcon";
type SendTransactionOption = Parameters<Account["sendTransaction"]>[0];
export type ChatMessage =
| {
text: string;
type: "user" | "error" | "presence";
}
| {
// assistant type message loaded from history doesn't have request_id
request_id: string | undefined;
text: string;
type: "assistant";
}
| {
type: "send_transaction";
data: SendTransactionOption | null;
};
export function Chats(props: {
messages: Array<ChatMessage>;
isChatStreaming: boolean;
authToken: string;
sessionId: string | undefined;
className?: string;
account: TWAccount | undefined;
client: ThirdwebClient;
}) {
return (
<div className={cn("flex flex-col gap-5 py-4", props.className)}>
{props.messages.map((message, index) => {
const isMessagePending =
props.isChatStreaming && index === props.messages.length - 1;
return (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: index is the unique key
key={index}
>
<div
className={cn(
"fade-in-0 flex min-w-0 animate-in gap-3 duration-300",
)}
>
<div className="-translate-y-[2px] relative shrink-0 ">
{message.type === "user" ? (
<GradientAvatar
id={props.account?.id || "default"}
// TODO- set account image when available in account object
src={""}
className="size-8 shrink-0 rounded-lg"
client={props.client}
/>
) : (
<div
className={cn(
"flex size-8 items-center justify-center rounded-lg",
message.type === "assistant" && "border bg-muted/50",
message.type === "error" && "border",
message.type === "presence" && "border bg-muted/50",
)}
>
{message.type === "presence" && (
<Spinner className="size-4" />
)}
{message.type === "assistant" && (
<NebulaIcon className="size-5 text-muted-foreground" />
)}
{message.type === "error" && (
<AlertCircleIcon className="size-5 text-destructive-text" />
)}
</div>
)}
</div>
<div className="min-w-0 grow">
{message.type === "assistant" ? (
<MarkdownRenderer
skipHtml
markdownText={message.text}
code={{
disableCodeHighlight: isMessagePending,
ignoreFormattingErrors: true,
}}
className="text-foreground"
p={{ className: "text-foreground" }}
li={{ className: "text-foreground" }}
/>
) : message.type === "error" ? (
<span className="text-destructive-text leading-loose">
{message.text}
</span>
) : message.type === "send_transaction" ? (
<SendTransactionButton txData={message.data} />
) : (
<span className="leading-loose">{message.text}</span>
)}
{message.type === "assistant" &&
!props.isChatStreaming &&
props.sessionId &&
message.request_id && (
<MessageActions
messageText={message.text}
authToken={props.authToken}
requestId={message.request_id}
sessionId={props.sessionId}
className="mt-4"
/>
)}
</div>
</div>
</div>
);
})}
</div>
);
}
function MessageActions(props: {
authToken: string;
requestId: string;
sessionId: string;
messageText: string;
className?: string;
}) {
const [isCopied, setIsCopied] = useState(false);
function sendRating(rating: "good" | "bad") {
return submitFeedback({
authToken: props.authToken,
rating,
requestId: props.requestId,
sessionId: props.sessionId,
});
}
const sendPositiveRating = useMutation({
mutationFn: () => sendRating("good"),
onSuccess() {
toast.info("Thanks for the feedback!");
},
onError() {
toast.error("Failed to send feedback");
},
});
const sendBadRating = useMutation({
mutationFn: () => sendRating("bad"),
onSuccess() {
toast.info("Thanks for the feedback!");
},
onError() {
toast.error("Failed to send feedback");
},
});
return (
<div
className={cn("flex items-center gap-2 text-foreground", props.className)}
>
<Button
variant="outline"
size="sm"
className="h-8 gap-2 rounded-lg text-sm"
onClick={() => {
navigator.clipboard.writeText(props.messageText);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 1000);
}}
>
{isCopied ? (
<CheckIcon className="size-3.5 text-green-500" />
) : (
<CopyIcon className="size-3.5" />
)}
Copy
</Button>
<Button
variant="outline"
size="sm"
className="size-8 rounded-lg p-0"
onClick={() => {
sendPositiveRating.mutate();
}}
>
{sendPositiveRating.isPending ? (
<Spinner className="size-4" />
) : (
<ThumbsUpIcon className="size-4" />
)}
</Button>
<Button
variant="outline"
size="sm"
className="size-8 rounded-lg p-0"
onClick={() => {
sendBadRating.mutate();
}}
>
{sendBadRating.isPending ? (
<Spinner className="size-4" />
) : (
<ThumbsDownIcon className="size-4" />
)}
</Button>
</div>
);
}
function SendTransactionButton(props: {
txData: SendTransactionOption | null;
}) {
const account = useActiveAccount();
const sendTxMutation = useMutation({
mutationFn: () => {
if (!account) {
throw new Error("No active account");
}
if (!props.txData) {
throw new Error("Invalid transaction");
}
return account.sendTransaction(props.txData);
},
});
if (!props.txData) {
return (
<Alert variant="destructive">
<AlertCircleIcon className="size-5" />
<AlertTitle>Failed to parse transaction data</AlertTitle>
</Alert>
);
}
return (
<TransactionButton
isPending={sendTxMutation.isPending}
transactionCount={1}
txChainID={props.txData.chainId}
onClick={() => {
const promise = sendTxMutation.mutateAsync();
toast.promise(promise, {
success: "Transaction sent successfully",
error: "Failed to send transaction",
});
}}
className="gap-2"
>
Execute Transaction
</TransactionButton>
);
}
@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { Toaster } from "sonner";
import { BadgeContainer, mobileViewport } from "../../../../stories/utils";
import type { ContextFilters } from "../api/chat";
import ContextFiltersButton from "./ContextFilters";
const meta = {
title: "Nebula/ContextFilters",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function Story() {
return (
<div className="container flex max-w-[1000px] flex-col gap-8 lg:p-10">
<Variant contextFilters={undefined} label="No Filters Set" />
<Variant
contextFilters={{
chainIds: ["1", "2", "3"],
}}
label="Chain ids Set"
/>
<Variant
contextFilters={{
contractAddresses: ["0x1E51e33F9838A5a043E099C60409f62aA564272f"],
}}
label="Contract addresses set"
/>
<Toaster richColors />
</div>
);
}
function Variant(props: {
label: string;
contextFilters: ContextFilters | undefined;
}) {
const [contextFilters, setContextFilters] = useState<
ContextFilters | undefined
>(props.contextFilters);
return (
<BadgeContainer label={props.label}>
<ContextFiltersButton
contextFilters={contextFilters}
setContextFilters={setContextFilters}
updateContextFilters={async (values) => {
console.log("Updating context filters", values);
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
/>
</BadgeContainer>
);
}
@@ -0,0 +1,212 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { AutoResizeTextarea } from "@/components/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod";
import { DialogDescription } from "@radix-ui/react-dialog";
import { useMutation } from "@tanstack/react-query";
import { SlidersHorizontalIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { isAddress } from "thirdweb";
import { z } from "zod";
import { MultiNetworkSelector } from "../../../../@/components/blocks/NetworkSelectors";
import type { ContextFilters } from "../api/chat";
export default function ContextFiltersButton(props: {
contextFilters: ContextFilters | undefined;
setContextFilters: (filters: ContextFilters | undefined) => void;
updateContextFilters: (filters: ContextFilters | undefined) => Promise<void>;
}) {
const [isOpen, setIsOpen] = useState(false);
const updateMutation = useMutation({
mutationFn: props.updateContextFilters,
});
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="gap-2">
<SlidersHorizontalIcon className="size-4" />
{props.contextFilters
? "Edit Context Filters"
: "Set context filters"}
</Button>
</DialogTrigger>
<ContextFilterDialogContent
contextFilters={props.contextFilters}
isPending={updateMutation.isPending}
updateFilters={async (data) => {
const promise = updateMutation.mutateAsync(data);
toast.promise(promise, {
success: "Context filters updated",
error: "Failed to update context filters",
});
promise.then(() => {
props.setContextFilters(data);
setIsOpen(false);
});
}}
/>
</Dialog>
);
}
const formSchema = z.object({
chainIds: z.string().refine(
(s) => {
if (s.trim() === "") {
return true;
}
return s
.trim()
.split(",")
.every((id) => Number.isInteger(Number(id)));
},
{
message: "Chain IDs must be a comma-separated list of integers",
},
),
contractAddresses: z.string().refine(
(s) => {
if (s.trim() === "") {
return true;
}
return s.trim().split(",").every(isAddress);
},
{
message:
"Contract addresses must be a comma-separated list of valid addresses",
},
),
});
function ContextFilterDialogContent(props: {
contextFilters: ContextFilters | undefined;
updateFilters: (filters: ContextFilters | undefined) => void;
isPending: boolean;
}) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
values: {
chainIds: props.contextFilters?.chainIds
? props.contextFilters.chainIds.join(",")
: "",
contractAddresses: props.contextFilters?.contractAddresses
? props.contextFilters.contractAddresses.join(",")
: "",
},
reValidateMode: "onChange",
});
function onSubmit(values: z.infer<typeof formSchema>) {
const { chainIds, contractAddresses } = values;
const chainIdsArray = chainIds.trim().split(",").filter(Boolean);
const contractAddressesArray = contractAddresses
.trim()
.split(",")
.filter(Boolean);
if (chainIdsArray.length === 0 && contractAddressesArray.length === 0) {
props.updateFilters(undefined);
} else {
props.updateFilters({
chainIds: chainIdsArray,
contractAddresses: contractAddressesArray,
});
}
}
return (
<DialogContent className="p-0">
<Form {...form}>
<DialogHeader className="px-6 pt-6 pb-1">
<DialogTitle className="font-semibold text-xl">
Context Filters
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Provide context information to Nebula for your prompts
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-5 px-6">
<FormField
control={form.control}
name="chainIds"
render={() => (
<FormItem>
<FormLabel>Chain IDs</FormLabel>
<FormControl>
<MultiNetworkSelector
selectedChainIds={form
.watch()
.chainIds.split(",")
.filter(Boolean)
.map(Number)}
onChange={(values) => {
console.log("values", values);
form.setValue("chainIds", values.join(","));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contractAddresses"
render={({ field }) => (
<FormItem>
<FormLabel>Contract Addresses</FormLabel>
<FormControl>
<AutoResizeTextarea
{...field}
placeholder="0x123..., 0x456..."
className="min-h-[32px] resize-none"
/>
</FormControl>
<FormDescription>
Comma separated list of contract addresses
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="mt-10 flex justify-end gap-3 border-t bg-muted/50 p-6">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button className="min-w-28 gap-2" type="submit">
{props.isPending && <Spinner className="size-4" />}
Update
</Button>
</div>
</form>
</Form>
</DialogContent>
);
}
@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react";
import { mobileViewport } from "../../../../stories/utils";
import { EmptyStateChatPageContent } from "./EmptyStateChatPageContent";
const meta = {
title: "Nebula/EmptyStateChatPage",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function Story() {
return (
<div className="container flex max-w-[800px] grow flex-col justify-center overflow-hidden">
<EmptyStateChatPageContent
config={{
mode: "client",
signer_wallet_address: "xxxxx",
}}
sendMessage={() => {}}
updateConfig={() => {}}
/>
</div>
);
}
@@ -0,0 +1,150 @@
"use client";
import { ArrowUpRightIcon } from "lucide-react";
import { Button } from "../../../../@/components/ui/button";
import type { ExecuteConfig } from "../api/types";
import { NebulaIcon } from "../icons/NebulaIcon";
import { Chatbar } from "./ChatBar";
export function EmptyStateChatPageContent(props: {
updateConfig: (config: ExecuteConfig) => void;
sendMessage: (message: string) => void;
config: ExecuteConfig;
}) {
return (
<div className="py-16">
<div className="relative py-10">
<FancyBorders />
<div className="flex justify-center">
<div className="rounded-lg border p-1">
<div className="rounded border bg-muted p-1.5">
<NebulaIcon className="size-7 text-muted-foreground" />
</div>
</div>
</div>
<div className="h-5" />
<h1 className="text-center font-semibold text-4xl tracking-tight">
How can I help you <br className="max-sm:hidden" />
with the blockchain today?
</h1>
<div className="h-5" />
<div className="mx-auto max-w-[600px]">
<Chatbar
sendMessage={props.sendMessage}
config={props.config}
isChatStreaming={false}
updateConfig={props.updateConfig}
abortChatStream={() => {
// the page will switch so, no need to handle abort here
}}
/>
<div className="h-5" />
<div className="flex flex-wrap justify-center gap-2.5">
<ExamplePrompt
label="USDC contract address on ethereum"
onClick={() => {
props.sendMessage(
"What is the contract address of USDC on ethereum?",
);
}}
/>
<ExamplePrompt
label="last 5 blocks on polygon"
onClick={() => {
props.sendMessage("What are last 5 blocks on polygon?");
}}
/>
<ExamplePrompt
label="What is thirdweb SDK?"
onClick={() => {
props.sendMessage("What is thirdweb SDK?");
}}
/>
<ExamplePrompt
label="How to add connect wallet button"
onClick={() => {
props.sendMessage(
"How to add connect wallet button on React app?",
);
}}
/>
<ExamplePrompt
label="Show transaction details"
onClick={() => {
props.sendMessage(
"Show transaction details of 0xff9624116c352c8b090203fbbb563baf32d2b1944f9ac281ff2de6b7d948030e on ethereum",
);
}}
/>
</div>
</div>
</div>
</div>
);
}
function ExamplePrompt(props: {
label: string;
onClick: () => void;
}) {
return (
<Button
variant="outline"
className="h-auto gap-1.5 rounded-full bg-muted/50 px-3 py-1 text-muted-foreground text-xs"
onClick={props.onClick}
>
{props.label} <ArrowUpRightIcon className="size-3" />
</Button>
);
}
function FancyBorders() {
return (
<>
{/* fancy borders */}
<div className="">
{/* top */}
<DashedBgDiv
className="-translate-x-1/2 -translate-y-5 absolute top-0 right-0 left-1/2 hidden h-[1px] w-[calc(100%+200px)] lg:block"
type="horizontal"
/>
{/* bottom */}
<DashedBgDiv
className="-translate-x-1/2 absolute right-0 bottom-0 left-1/2 hidden h-[1px] w-[calc(100%+200px)] translate-y-5 lg:block"
type="horizontal"
/>
{/* left */}
<DashedBgDiv
className="-translate-y-1/2 absolute top-1/2 left-8 hidden h-[calc(100%+200px)] w-[1px] lg:block"
type="vertical"
/>
{/* right */}
<DashedBgDiv
className="-translate-y-1/2 absolute top-1/2 right-8 hidden h-[calc(100%+200px)] w-[1px] lg:block"
type="vertical"
/>
</div>
</>
);
}
function DashedBgDiv(props: {
className?: string;
type: "horizontal" | "vertical";
}) {
return (
<div
className={props.className}
style={{
backgroundImage: `linear-gradient(${props.type === "horizontal" ? "90deg" : "180deg"}, hsl(var(--foreground)/20%) 0 30%, transparent 0 100%)`,
backgroundRepeat: "repeat",
backgroundSize: "10px 10px",
maskImage: `linear-gradient(${
props.type === "horizontal" ? "to right" : "to bottom"
}, rgba(0,0,0,0.1), black 20%, black 80%, rgba(0,0,0,0.1))`,
}}
/>
);
}
@@ -0,0 +1,181 @@
"use client";
import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { LogOutIcon, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import Link from "next/link";
import { useCallback, useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { doLogout } from "../../../login/auth-actions";
export function NebulaAccountButton(props: {
account: Account;
className?: string;
type: "compact" | "full";
}) {
const { setTheme, theme } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const client = useThirdwebClient();
const router = useDashboardRouter();
const wallet = useActiveWallet();
const { disconnect } = useDisconnect();
const logout = useCallback(async () => {
try {
await doLogout();
if (wallet) {
disconnect(wallet);
}
router.replace("/");
} catch (e) {
console.error("Failed to log out", e);
}
}, [router, disconnect, wallet]);
const nebulaOrigin =
typeof window !== "undefined" ? window.location.origin : "";
const baseOrigin = nebulaOrigin.replace("nebula.", "");
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
{props.type === "full" ? (
<Button
variant="ghost"
className={cn(
"flex h-auto w-full items-center justify-start gap-3 p-2",
props.className,
)}
>
<div className="shrink-0">
<GradientAvatar
id={props.account?.id || "default"}
src={""}
className="size-9 rounded-lg"
client={client}
/>
</div>
<div className="min-w-0 flex-1 text-left">
<div className="text-foreground text-sm">
{props.account.name}
</div>
{props.account.email && (
<div className="truncate text-muted-foreground text-sm lowercase">
{props.account.email}
</div>
)}
</div>
</Button>
) : (
<Button
variant="ghost"
className={cn(
"flex h-auto w-full items-center justify-start gap-3 rounded-full p-1",
props.className,
)}
>
<div className="shrink-0">
<GradientAvatar
id={props.account?.id || "default"}
src={""}
className="size-9 rounded-full"
client={client}
/>
</div>
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
className="ml-2 w-[300px] rounded-lg p-0"
align="end"
sideOffset={10}
onClick={(e) => {
if (e.target instanceof HTMLAnchorElement) {
setIsOpen(false);
}
}}
>
<div className="border-border border-b p-4 pb-5">
<div>
<p className="text-foreground text-sm">{props.account.name}</p>
{props.account.email && (
<p className="text-muted-foreground text-sm">
{props.account.email}
</p>
)}
</div>
<div className="h-3" />
<div className="[&>*]:!w-full">
<CustomConnectWallet
signInLinkButtonClassName="!w-full"
loadingButtonClassName="!w-full"
connectButtonClassName="!w-full"
detailsButtonClassName="!w-full"
/>
</div>
</div>
<div className="flex flex-col gap-1 p-2">
<Button
asChild
variant="ghost"
className="justify-start px-3 text-muted-foreground text-sm hover:text-foreground"
>
<Link href={`${baseOrigin}/account`} target="_blank">
My Account
</Link>
</Button>
<Button
variant="ghost"
className="flex items-center justify-between gap-2 px-3 text-muted-foreground text-sm hover:text-foreground"
onClick={() => {
setTheme(theme === "light" ? "dark" : "light");
}}
>
<p className="text-sm">Theme</p>
<div className="ml-auto flex items-center gap-2 rounded-lg border border-border px-3 py-1.5 text-foreground">
<Sun
className={cn(
"size-4",
theme === "light" ? "opacity-100" : "opacity-30",
)}
/>
<Moon
className={cn(
"size-4",
theme === "dark" ? "opacity-100" : "opacity-30",
)}
/>
</div>
</Button>
<Button
variant="ghost"
onClick={() => {
logout();
setIsOpen(false);
}}
className="justify-between gap-2 px-3 text-muted-foreground hover:text-foreground"
>
Log Out
<LogOutIcon className="size-4" />
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,73 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { MenuIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import type { TruncatedSessionInfo } from "../api/types";
import { ChatSidebar, useNewChatPageLink } from "./ChatSidebar";
export function MobileNav(props: {
sessions: TruncatedSessionInfo[];
authToken: string;
account: Account;
}) {
const [isOpen, setIsOpen] = useState(false);
const newChatPage = useNewChatPageLink();
return (
<nav className="flex justify-between border-b bg-muted/50 px-4 py-4 lg:hidden">
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
onClick={() => setIsOpen(!isOpen)}
className="h-auto w-auto p-0.5"
>
<MenuIcon className="size-6" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="w-[calc(100vw-80px)] max-w-[300px] p-0"
onClick={(e) => {
if (!(e.target instanceof HTMLElement)) {
return;
}
if (
e.target instanceof HTMLAnchorElement ||
e.target.closest("a")
) {
setIsOpen(false);
}
}}
>
<SheetTitle className="sr-only"> Menu </SheetTitle>
<ChatSidebar
type="mobile"
authToken={props.authToken}
sessions={props.sessions}
account={props.account}
/>
</SheetContent>
</Sheet>
<div className="flex items-center gap-5">
<Button
asChild
variant="primary"
className="h-auto w-auto rounded-lg px-2.5 py-1.5"
>
<Link href={newChatPage}>New Chat</Link>
</Button>
</div>
</nav>
);
}
@@ -0,0 +1,13 @@
import { useStore } from "@/lib/reactive";
import type { TruncatedSessionInfo } from "../api/types";
import { deletedSessionsStore, newSessionsStore } from "../stores";
export function useSessionsWithLocalOverrides(
_sessions: TruncatedSessionInfo[],
) {
const newAddedSessions = useStore(newSessionsStore);
const deletedSessions = useStore(deletedSessionsStore);
return [...newAddedSessions, ..._sessions].filter((s) => {
return !deletedSessions.some((d) => d === s.id);
});
}
@@ -0,0 +1,20 @@
export function NebulaIcon(props: { className?: string }) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={props.className}
aria-hidden="true"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.6615 11.6615C14.1068 9.21621 16.8483 7.67011 19.223 7.07642C21.6439 6.47122 23.3052 6.91518 24.1951 7.80503C24.9754 8.58537 25.4129 9.95904 25.1006 11.9179C25.0142 11.9124 24.9272 11.9097 24.8395 11.9097C22.5804 11.9097 20.7491 13.741 20.7491 16.0001C20.7491 17.0477 21.1429 18.0033 21.7905 18.7269C21.3485 19.2734 20.8643 19.8129 20.3386 20.3386C17.8933 22.7839 15.1519 24.33 12.7771 24.9237C10.3563 25.5289 8.69492 25.0849 7.80507 24.195C7.02475 23.4147 6.5873 22.0411 6.89956 20.0823C6.98571 20.0877 7.07259 20.0905 7.16011 20.0905C9.41918 20.0905 11.2505 18.2592 11.2505 16.0001C11.2505 14.9526 10.8568 13.9971 10.2093 13.2735C10.6514 12.7269 11.1357 12.1873 11.6615 11.6615ZM7.20428 13.9554C7.20426 13.9554 7.20424 13.9554 7.20422 13.9554C7.29523 13.9573 7.38479 13.9652 7.47251 13.9786C8.45376 14.129 9.20531 14.9768 9.20531 16.0001C9.20531 17.1296 8.28964 18.0453 7.16011 18.0453C7.05596 18.0453 6.95362 18.0375 6.85365 18.0225C6.32546 17.9429 5.86345 17.6614 5.5488 17.259C5.54873 17.2592 5.54866 17.2594 5.5486 17.2596C5.27687 16.9124 5.11491 16.4751 5.11491 16.0001C5.11491 14.8706 6.03057 13.9549 7.16011 13.9549C7.17487 13.9549 7.18959 13.955 7.20428 13.9554ZM8.50119 12.1346C9.02224 11.4789 9.59433 10.8364 10.2154 10.2153C15.54 4.89065 22.4465 3.16405 25.6413 6.35885C27.1026 7.82023 27.5343 10.0582 27.0628 12.5661C28.1865 13.2952 28.9299 14.5608 28.9299 16.0001C28.9299 18.2592 27.0985 20.0905 24.8395 20.0905C24.3699 20.0905 23.9188 20.0114 23.4988 19.8657C22.9778 20.5213 22.4058 21.1638 21.7848 21.7847C16.4601 27.1094 9.5537 28.836 6.3589 25.6412C4.8976 24.1799 4.46592 21.9421 4.93723 19.4344C3.81325 18.7053 3.0697 17.4396 3.0697 16.0001C3.0697 13.741 4.90104 11.9097 7.16011 11.9097C7.62984 11.9097 8.08107 11.9889 8.50119 12.1346ZM24.5541 13.9746C24.554 13.9747 24.554 13.9747 24.554 13.9748C24.6473 13.9617 24.7426 13.955 24.8395 13.955C25.4942 13.955 26.077 14.2626 26.4513 14.7411C26.4513 14.7411 26.4513 14.7411 26.4513 14.7411C26.7229 15.0882 26.8847 15.5252 26.8847 16.0001C26.8847 17.1296 25.969 18.0453 24.8395 18.0453C23.7099 18.0453 22.7943 17.1296 22.7943 16.0001C22.7943 14.9674 23.5596 14.1135 24.5541 13.9746ZM16 13.9548C17.1295 13.9548 18.0452 14.8705 18.0452 16C18.0452 17.1296 17.1295 18.0452 16 18.0452C14.8705 18.0452 13.9548 17.1296 13.9548 16C13.9548 14.8705 14.8705 13.9548 16 13.9548Z"
fill="currentColor"
/>
</svg>
);
}
@@ -0,0 +1,103 @@
import { type Team, getTeams } from "@/api/team";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import Link from "next/link";
import type React from "react";
import { getValidAccount } from "../../account/settings/getAccount";
import {
getAuthToken,
getAuthTokenWalletAddress,
} from "../../api/lib/getAuthToken";
import { loginRedirect } from "../../login/loginRedirect";
import { NebulaWaitListPage } from "../../team/[team_slug]/[project_slug]/nebula/components/nebula-waitlist-page";
import { getSessions } from "./api/session";
import { ChatPageLayout } from "./components/ChatPageLayout";
import { NebulaAccountButton } from "./components/NebulaAccountButton";
import { NebulaIcon } from "./icons/NebulaIcon";
export default async function Layout(props: {
children: React.ReactNode;
}) {
const account = await getValidAccount();
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect();
}
const accountAddress = await getAuthTokenWalletAddress();
if (!accountAddress) {
loginRedirect();
}
const teams = await getTeams();
const firstTeam = teams?.[0];
if (!firstTeam) {
loginRedirect();
}
const teamWithNebulaAccess = teams.find((team) =>
team.enabledScopes.includes("nebula"),
);
// if none of them teams have nebula access, request access on first team, and show waitlist page
if (!teamWithNebulaAccess) {
return <NebulaWaitlistPage account={account} team={firstTeam} />;
}
const sessions = await getSessions({
authToken,
}).catch(() => []);
return (
<ChatPageLayout
accountAddress={accountAddress}
authToken={authToken}
sessions={sessions}
account={account}
>
{props.children}
</ChatPageLayout>
);
}
function NebulaWaitlistPage(props: {
account: Account;
team: Team;
}) {
return (
<div className="flex min-h-screen flex-col overflow-hidden">
<header className="border-b">
<div className="container flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2 font-medium text-xl tracking-tight">
<NebulaIcon className="size-8 text-foreground" />
Nebula
</div>
<div className="flex items-center gap-6">
<Link
href="https://thirdweb.com/support"
className="text-muted-foreground text-sm hover:text-foreground"
target="_blank"
>
Support
</Link>
<Link
href="https://portal.thirdweb.com/"
className="text-muted-foreground text-sm hover:text-foreground"
target="_blank"
>
Docs
</Link>
<NebulaAccountButton account={props.account} type="compact" />
</div>
</div>
</header>
{/* page */}
<NebulaWaitListPage team={props.team} hideHeader />
</div>
);
}
@@ -0,0 +1,32 @@
import { getValidAccount } from "../../account/settings/getAccount";
import {
getAuthToken,
getAuthTokenWalletAddress,
} from "../../api/lib/getAuthToken";
import { loginRedirect } from "../../login/loginRedirect";
import { ChatPageContent } from "./components/ChatPageContent";
export default async function Page() {
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect();
}
const accountAddress = await getAuthTokenWalletAddress();
const account = await getValidAccount();
if (!accountAddress) {
loginRedirect();
}
return (
<ChatPageContent
accountAddress={accountAddress}
authToken={authToken}
session={undefined}
type="landing"
account={account}
/>
);
}
@@ -0,0 +1,9 @@
import { createStore } from "@/lib/reactive";
import type { TruncatedSessionInfo } from "./api/types";
export const newChatPageUrlStore = createStore<string | undefined>(undefined);
export const newSessionsStore = createStore<TruncatedSessionInfo[]>([]);
// array of deleted session ids
export const deletedSessionsStore = createStore<string[]>([]);
@@ -0,0 +1,19 @@
import { getRawAccount } from "../../account/settings/getAccount";
import { LoginAndOnboardingPage } from "../../login/LoginPage";
import { isValidEncodedRedirectPath } from "../../login/isValidEncodedRedirectPath";
export default async function NebulaLogin(props: {
searchParams: Promise<{
next?: string;
}>;
}) {
const nextPath = (await props.searchParams).next;
const account = await getRawAccount();
const redirectPath =
nextPath && isValidEncodedRedirectPath(nextPath) ? nextPath : "/";
return (
<LoginAndOnboardingPage account={account} redirectPath={redirectPath} />
);
}
@@ -0,0 +1,10 @@
# Nebula App
- This section of dashboard is only accessible via `nebula` subdomain and not from the `/nebula-app` route. This is done using `middleware.ts`
- Treat this section of dashboard as a completely separate app from the rest of the dashboard.
- For example: redirecting to `/login` inside this layout will render `nebula-app/login` page and not the global login page because this layout is only rendered at top level from `nebula` subdomain.
## Development
- Start dev server - `pnpm run dashboard`
- Go to `http://nebula.localhost:3000` to test the nebula app.
@@ -1,3 +1,5 @@
import { getTeamBySlug } from "@/api/team";
import { loginRedirect } from "../../../../../login/loginRedirect";
import { NebulaWaitListPage } from "../../../[project_slug]/nebula/components/nebula-waitlist-page";
export default async function Page(props: {
@@ -6,11 +8,11 @@ export default async function Page(props: {
}>;
}) {
const params = await props.params;
const team = await getTeamBySlug(params.team_slug);
return (
<NebulaWaitListPage
redirectOnNoTeam={`/login?next=${encodeURIComponent(`/team/${params.team_slug}/~/nebula`)}`}
teamSlug={params.team_slug}
/>
);
if (!team) {
loginRedirect(`/team/${params.team_slug}/~/nebula`);
}
return <NebulaWaitListPage team={team} />;
}
@@ -1,6 +1,9 @@
"use client";
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { ProjectAvatar } from "@/components/blocks/Avatars/ProjectAvatar";
import { CopyButton } from "@/components/ui/CopyButton";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -8,13 +11,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { format } from "date-fns";
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { ProjectAvatar } from "@/components/blocks/Avatars/ProjectAvatar";
import { CopyButton } from "@/components/ui/CopyButton";
import {
Select,
SelectContent,
@@ -23,12 +19,16 @@ import {
} from "@/components/ui/select";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { LazyCreateAPIKeyDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { format } from "date-fns";
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
type SortyById = "name" | "createdAt";
export function TeamProjectsPage(props: {
projects: Project[];
team_slug: string;
team: Team;
}) {
const { projects } = props;
const [searchTerm, setSearchTerm] = useState("");
@@ -69,7 +69,7 @@ export function TeamProjectsPage(props: {
<SelectBy value={sortBy} onChange={setSortBy} />
<AddNewButton
createProject={() => setIsCreateProjectDialogOpen(true)}
teamMembersSettingsPath={`/team/${props.team_slug}/~/settings/members`}
teamMembersSettingsPath={`/team/${props.team.slug}/~/settings/members`}
/>
</div>
</div>
@@ -88,7 +88,7 @@ export function TeamProjectsPage(props: {
<ProjectCard
key={project.id}
project={project}
team_slug={props.team_slug}
team_slug={props.team.slug}
/>
);
})}
@@ -104,6 +104,9 @@ export function TeamProjectsPage(props: {
// refresh projects
router.refresh();
}}
enableNebulaServiceByDefault={props.team.enabledScopes.includes(
"nebula",
)}
/>
</div>
);
@@ -1,15 +1,19 @@
import { getProjects } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { redirect } from "next/navigation";
import { TeamProjectsPage } from "./TeamProjectsPage";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
}) {
const projects = await getProjects((await props.params).team_slug);
const params = await props.params;
const team = await getTeamBySlug(params.team_slug);
return (
<TeamProjectsPage
projects={projects}
team_slug={(await props.params).team_slug}
/>
);
if (!team) {
redirect("/team");
}
const projects = await getProjects(params.team_slug);
return <TeamProjectsPage projects={projects} team={team} />;
}
@@ -1,24 +1,31 @@
import { Button } from "@/components/ui/button";
import { OrbitIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { NebulaIcon } from "../../../../../nebula-app/(app)/icons/NebulaIcon";
import { ShareButton } from "./share-button.client";
export function NebulaWaitListPageUI(props: { teamId: string }) {
export function NebulaWaitListPageUI(props: {
teamId: string;
className?: string;
hideHeader?: boolean;
}) {
return (
<div className="flex grow flex-col">
<div className={cn("flex grow flex-col", props.className)}>
{/* Header */}
<div className="border-b py-10">
<div className="container flex items-center justify-between">
<h1 className="font-semibold text-3xl tracking-tight"> Nebula </h1>
<Button asChild variant="outline">
<Link href="/contact-us" target="_blank">
Contact Us
</Link>
</Button>
{!props.hideHeader && (
<div className="border-b py-10">
<div className="container flex items-center justify-between">
<h1 className="font-semibold text-3xl tracking-tight"> Nebula </h1>
<Button asChild variant="outline">
<Link href="/contact-us" target="_blank">
Contact Us
</Link>
</Button>
</div>
</div>
</div>
)}
<div className="container flex grow flex-col overflow-hidden pt-32 pb-48">
<div className="container flex grow flex-col overflow-hidden py-32">
<CenteredCard
title="You're on the waitlist"
description="You should receive access to Nebula soon!"
@@ -74,7 +81,7 @@ function CenteredCard(props: {
{/* Icon */}
<div className="rounded-xl border p-1">
<div className="rounded-lg border bg-muted/50 p-2">
<OrbitIcon className="size-5 text-muted-foreground" />
<NebulaIcon className="size-5 text-muted-foreground" />
</div>
</div>
@@ -1,21 +1,15 @@
import { joinTeamWaitlist } from "@/actions/joinWaitlist";
import { getTeamBySlug, getTeamNebulaWaitList } from "@/api/team";
import { type Team, getTeamNebulaWaitList } from "@/api/team";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { redirect } from "next/navigation";
import { NebulaWaitListPageUI } from "./nebula-waitlist-page-ui.client";
export async function NebulaWaitListPage(props: {
redirectOnNoTeam: string;
teamSlug: string;
team: Team;
className?: string;
hideHeader?: boolean;
}) {
const team = await getTeamBySlug(props.teamSlug);
if (!team) {
redirect(props.redirectOnNoTeam);
}
const nebulaWaitList = await getTeamNebulaWaitList(team.slug);
const nebulaWaitList = await getTeamNebulaWaitList(props.team.slug);
// this should never happen
if (!nebulaWaitList) {
@@ -28,18 +22,24 @@ export async function NebulaWaitListPage(props: {
if (!nebulaWaitList.onWaitlist) {
const res = await joinTeamWaitlist({
scope: "nebula",
teamSlug: team.slug,
teamSlug: props.team.slug,
}).catch(() => null);
// this should never happen
if (!res?.success) {
return (
<UnexpectedErrorPage message="Failed to join Nebula waitlist status for your team" />
<UnexpectedErrorPage message="Failed to join Nebula waitlist. Please try again later" />
);
}
}
return <NebulaWaitListPageUI teamId={team.id} />;
return (
<NebulaWaitListPageUI
teamId={props.team.id}
className={props.className}
hideHeader={props.hideHeader}
/>
);
}
function UnexpectedErrorPage(props: {
@@ -1,3 +1,5 @@
import { getTeamBySlug } from "@/api/team";
import { loginRedirect } from "../../../../login/loginRedirect";
import { NebulaWaitListPage } from "./components/nebula-waitlist-page";
export default async function Page(props: {
@@ -7,11 +9,11 @@ export default async function Page(props: {
}>;
}) {
const params = await props.params;
const team = await getTeamBySlug(params.team_slug);
return (
<NebulaWaitListPage
redirectOnNoTeam={`/login?next=${encodeURIComponent(`/team/${params.team_slug}/${params.project_slug}/nebula`)}`}
teamSlug={params.team_slug}
/>
);
if (!team) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/nebula`);
}
return <NebulaWaitListPage team={team} />;
}
@@ -60,6 +60,7 @@ function Story() {
payConfig: "/payConfig",
}}
onKeyUpdated={undefined}
showNebulaSettings={false}
/>
<Toaster richColors />
@@ -51,6 +51,7 @@ export function ProjectGeneralSettingsPage(props: {
apiKey: ApiKey;
paths: EditProjectUIPaths;
onKeyUpdated: (() => void) | undefined;
showNebulaSettings: boolean;
}) {
const updateMutation = useUpdateApiKey();
const deleteMutation = useRevokeApiKey();
@@ -62,6 +63,7 @@ export function ProjectGeneralSettingsPage(props: {
deleteMutation={deleteMutation}
paths={props.paths}
onKeyUpdated={props.onKeyUpdated}
showNebulaSettings={props.showNebulaSettings}
/>
);
}
@@ -81,6 +83,7 @@ interface EditApiKeyProps {
deleteMutation: DeleteMutation;
paths: EditProjectUIPaths;
onKeyUpdated: (() => void) | undefined;
showNebulaSettings: boolean;
}
type UpdateAPIForm = UseFormReturn<ApiKeyValidationSchema>;
@@ -233,6 +236,7 @@ export const ProjectGeneralSettingsPageUI: React.FC<EditApiKeyProps> = (
updateMutation={updateMutation}
handleSubmit={handleSubmit}
paths={props.paths}
showNebulaSettings={props.showNebulaSettings}
/>
<DeleteProject
@@ -470,6 +474,7 @@ function EnabledServicesSetting(props: {
handleSubmit: () => void;
apiKey: ApiKey;
paths: EditApiKeyProps["paths"];
showNebulaSettings: boolean;
}) {
const { form, handleSubmit, updateMutation } = props;
@@ -512,7 +517,10 @@ function EnabledServicesSetting(props: {
<div className="flex flex-col">
{fields.map((srv, idx) => {
const service = getServiceByName(srv.name as ServiceName);
const hidden = HIDDEN_SERVICES.includes(service.name);
const hidden =
(service.name === "nebula" && !props.showNebulaSettings) ||
HIDDEN_SERVICES.includes(service.name);
const serviceName = getServiceByName(service.name as ServiceName);
const shouldShow = !hidden && serviceName;
@@ -1,17 +1,18 @@
"use client";
import type { Team } from "@/api/team";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { ApiKey } from "@3rdweb-sdk/react/hooks/useApi";
import { ProjectGeneralSettingsPage } from "./ProjectGeneralSettingsPage";
export function ProjectGeneralSettingsPageForTeams(props: {
team_slug: string;
team: Team;
project_slug: string;
apiKey: ApiKey;
}) {
const router = useDashboardRouter();
const { team_slug, project_slug, apiKey } = props;
const projectSettingsLayout = `/team/${team_slug}/${project_slug}/settings`;
const { team, project_slug, apiKey } = props;
const projectSettingsLayout = `/team/${team.slug}/${project_slug}/settings`;
// TODO - add a Project Image form field on this page
@@ -22,11 +23,12 @@ export function ProjectGeneralSettingsPageForTeams(props: {
aaConfig: `${projectSettingsLayout}/account-abstraction`,
inAppConfig: `${projectSettingsLayout}/in-app-wallets`,
payConfig: `${projectSettingsLayout}/pay`,
afterDeleteRedirectTo: `/team/${team_slug}`,
afterDeleteRedirectTo: `/team/${team.slug}`,
}}
onKeyUpdated={() => {
router.refresh();
}}
showNebulaSettings={team.enabledScopes.includes("nebula")}
/>
);
}
@@ -1,5 +1,6 @@
import { getProject } from "@/api/projects";
import { notFound } from "next/navigation";
import { getTeamBySlug } from "@/api/team";
import { notFound, redirect } from "next/navigation";
import { getAPIKeyForProjectId } from "../../../../api/lib/getAPIKeys";
import { ProjectGeneralSettingsPageForTeams } from "./ProjectGeneralSettingsPageForTeams";
@@ -7,6 +8,13 @@ export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const { team_slug, project_slug } = await props.params;
const team = await getTeamBySlug(team_slug);
if (!team) {
redirect("/team");
}
const project = await getProject(team_slug, project_slug);
if (!project) {
@@ -23,7 +31,7 @@ export default async function Page(props: {
<ProjectGeneralSettingsPageForTeams
apiKey={apiKey}
project_slug={project_slug}
team_slug={team_slug}
team={team}
/>
);
}
@@ -13,7 +13,7 @@ type ProjectSelectorMobileMenuButtonProps = {
currentProject: Project;
projects: Project[];
team: Team;
createProject: () => void;
createProject: (team: Team) => void;
};
export function ProjectSelectorMobileMenuButton(
@@ -49,7 +49,7 @@ export function ProjectSelectorMobileMenuButton(
projects={props.projects}
team={props.team}
createProject={() => {
props.createProject();
props.createProject(props.team);
setOpen(false);
}}
/>
@@ -21,7 +21,7 @@ type TeamSwitcherProps = {
currentProject: Project | undefined;
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
focus: "project-selection" | "team-selection";
createProject: () => void;
createProject: (team: Team) => void;
account: Pick<Account, "email" | "id"> | undefined;
};
@@ -104,7 +104,7 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
team={projectsToShowOfTeam}
createProject={() => {
setOpen(false);
props.createProject();
props.createProject(projectsToShowOfTeam);
}}
/>
)}
@@ -23,7 +23,7 @@ export type TeamHeaderCompProps = {
account: Pick<Account, "email" | "id"> | undefined;
logout: () => void;
connectButton: React.ReactNode;
createProject: () => void;
createProject: (team: Team) => void;
client: ThirdwebClient;
};
@@ -22,8 +22,9 @@ export function TeamHeaderLoggedIn(props: {
currentProject: Project | undefined;
account: Pick<Account, "email" | "id">;
}) {
const [isCreateProjectDialogOpen, setIsCreateProjectDialogOpen] =
useState(false);
const [createProjectDialogState, setCreateProjectDialogState] = useState<
{ team: Team; isOpen: true } | { isOpen: false }
>({ isOpen: false });
const activeWallet = useActiveWallet();
const { disconnect } = useDisconnect();
const router = useDashboardRouter();
@@ -48,7 +49,12 @@ export function TeamHeaderLoggedIn(props: {
account: props.account,
logout: logout,
connectButton: <CustomConnectWallet />,
createProject: () => setIsCreateProjectDialogOpen(true),
createProject: (team: Team) => {
setCreateProjectDialogState({
isOpen: true,
team,
});
},
client: getThirdwebClient(),
};
@@ -58,12 +64,20 @@ export function TeamHeaderLoggedIn(props: {
<TeamHeaderMobileUI {...headerProps} className="lg:hidden" />
<LazyCreateAPIKeyDialog
open={isCreateProjectDialogOpen}
onOpenChange={setIsCreateProjectDialogOpen}
open={createProjectDialogState.isOpen}
onOpenChange={() =>
setCreateProjectDialogState({
isOpen: false,
})
}
onCreateAndComplete={() => {
// refresh projects
router.refresh();
}}
enableNebulaServiceByDefault={
createProjectDialogState.isOpen &&
createProjectDialogState.team.enabledScopes.includes("nebula")
}
/>
</div>
);
@@ -2,6 +2,7 @@
import { type ReactNode, useEffect, useState } from "react";
import type { ComponentWithChildren } from "types/component-with-children";
import { cn } from "../../@/lib/utils";
import styles from "./ClientOnly.module.css";
interface ClientOnlyProps {
@@ -11,6 +12,7 @@ interface ClientOnlyProps {
ssr: ReactNode;
fadeInDuration?: number;
style?: React.CSSProperties;
className?: string;
}
export const ClientOnly: ComponentWithChildren<ClientOnlyProps> = ({
@@ -18,6 +20,7 @@ export const ClientOnly: ComponentWithChildren<ClientOnlyProps> = ({
fadeInDuration,
ssr,
style,
className,
}) => {
const hasMounted = useIsClientMounted();
@@ -27,7 +30,7 @@ export const ClientOnly: ComponentWithChildren<ClientOnlyProps> = ({
return (
<div
className={styles.fadeIn}
className={cn(styles.fadeIn, className)}
style={{
animationDuration: `${fadeInDuration}ms`,
opacity: fadeInDuration ? 0 : 1,
@@ -25,6 +25,12 @@ export const DisableCodeHighlight: Story = {
},
};
export const SkipHTML: Story = {
args: {
skipHtml: true,
},
};
export const Mobile: Story = {
args: {},
parameters: {
@@ -143,17 +149,23 @@ function example() {
### Strikethrough
~~This is strikethrough text.~~
### HTML Elements
<br />
`;
function Story(props: {
disableCodeHighlight?: boolean;
skipHtml?: boolean;
}) {
return (
<div className="container max-w-[800px] py-10">
<MarkdownRenderer
markdownText={markdownExample}
disableCodeHighlight={props.disableCodeHighlight}
code={{
disableCodeHighlight: props.disableCodeHighlight,
}}
skipHtml={props.skipHtml}
/>
</div>
);
@@ -19,8 +19,19 @@ import remarkGfm from "remark-gfm";
export const MarkdownRenderer: React.FC<{
markdownText: string;
className?: string;
disableCodeHighlight?: boolean;
}> = ({ markdownText, className, disableCodeHighlight }) => {
code?: {
disableCodeHighlight?: boolean;
ignoreFormattingErrors?: boolean;
};
p?: {
className?: string;
};
li?: {
className?: string;
};
skipHtml?: boolean;
}> = (markdownProps) => {
const { markdownText, className, code } = markdownProps;
const commonHeadingClassName =
"mb-2 pb-2 leading-5 font-semibold tracking-tight";
@@ -28,6 +39,7 @@ export const MarkdownRenderer: React.FC<{
<div className={className}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
skipHtml={markdownProps.skipHtml}
components={{
h1: (props) => (
<h2
@@ -88,9 +100,9 @@ export const MarkdownRenderer: React.FC<{
code: ({ ...props }) => {
if (props?.className) {
if (disableCodeHighlight) {
if (code?.disableCodeHighlight) {
return (
<div className="mb-4">
<div className="my-4">
<PlainTextCodeBlock
{...cleanedProps(props)}
code={onlyText(props.children).trim()}
@@ -100,11 +112,12 @@ export const MarkdownRenderer: React.FC<{
}
const language = props.className.replace("language-", "");
return (
<div className="mb-4">
<div className="my-4">
<CodeClient
lang={language}
{...cleanedProps(props)}
code={onlyText(props.children).trim()}
ignoreFormattingErrors={code?.ignoreFormattingErrors}
/>
</div>
);
@@ -115,7 +128,10 @@ export const MarkdownRenderer: React.FC<{
p: (props) => (
<p
className="mb-4 text-muted-foreground leading-relaxed"
className={cn(
"mb-4 text-muted-foreground leading-loose",
markdownProps.p?.className,
)}
{...cleanedProps(props)}
/>
),
@@ -159,12 +175,20 @@ export const MarkdownRenderer: React.FC<{
),
li: ({ children: c, ...props }) => (
<li
className="mb-1.5 text-muted-foreground"
className={cn(
"mb-2 text-muted-foreground [&>p]:m-0",
markdownProps.li?.className,
)}
{...cleanedProps(props)}
>
{c}
</li>
),
strong(props) {
return (
<strong className="font-semibold" {...cleanedProps(props)} />
);
},
}}
>
{markdownText}
@@ -3,30 +3,22 @@ import { Button } from "@/components/ui/button";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { useLocalStorage } from "hooks/useLocalStorage";
import { ChevronRightIcon, XIcon } from "lucide-react";
import { usePathname } from "next/navigation";
function shouldHideAnnouncement(pathname: string) {
const paths = pathname.slice(1).split("/");
// hide on /login
if (paths.length === 1 && paths[0] === "login") {
return true;
}
return false;
}
import { useSelectedLayoutSegment } from "next/navigation";
function AnnouncementBanner(props: {
href: string;
label: string;
trackingLabel: string;
}) {
const pathname = usePathname();
const layoutSegment = useSelectedLayoutSegment();
const [hasDismissedAnnouncement, setHasDismissedAnnouncement] =
useLocalStorage(`dismissed-${props.trackingLabel}`, false, true);
if (hasDismissedAnnouncement || shouldHideAnnouncement(pathname || "")) {
if (
hasDismissedAnnouncement ||
layoutSegment === "login" ||
layoutSegment === "nebula-app"
) {
return null;
}
@@ -52,6 +52,7 @@ function Story(props: {
onOpenChange={setIsOpen}
createKeyMutation={mutation}
prefill={props.prefill}
enableNebulaServiceByDefault={false}
/>
<Button
@@ -53,6 +53,7 @@ export type CreateAPIKeyDialogProps = {
onOpenChange: (open: boolean) => void;
onCreateAndComplete?: () => void;
prefill?: CreateAPIKeyPrefillOptions;
enableNebulaServiceByDefault: boolean;
};
const CreateAPIKeyDialog = (props: CreateAPIKeyDialogProps) => {
@@ -76,6 +77,7 @@ export const CreateAPIKeyDialogUI = (props: {
unknown
>;
prefill?: CreateAPIKeyPrefillOptions;
enableNebulaServiceByDefault: boolean;
}) => {
const [screen, setScreen] = useState<
{ id: "create" } | { id: "api-details"; key: ApiKey }
@@ -107,6 +109,7 @@ export const CreateAPIKeyDialogUI = (props: {
setScreen({ id: "api-details", key });
}}
prefill={props.prefill}
enableNebulaServiceByDefault={props.enableNebulaServiceByDefault}
/>
)}
@@ -135,6 +138,7 @@ function CreateAPIKeyForm(props: {
>;
onAPIKeyCreated: (key: ApiKey) => void;
prefill?: CreateAPIKeyPrefillOptions;
enableNebulaServiceByDefault: boolean;
}) {
const [showAlert, setShowAlert] = useState<"no-domain" | "any-domain">();
@@ -153,11 +157,15 @@ function CreateAPIKeyForm(props: {
name: string;
domains: string;
}) {
const servicesToEnableByDefault = props.enableNebulaServiceByDefault
? SERVICES
: SERVICES.filter((srv) => srv.name !== "nebula");
const formattedValues = {
name: values.name,
domains: toArrFromList(values.domains),
// enable all services
services: SERVICES.map((srv) => ({
services: servicesToEnableByDefault.map((srv) => ({
name: srv.name,
targetAddresses: ["*"],
enabled: true,
@@ -149,4 +149,4 @@ export type ApiKeyPayConfigValidationSchema = z.infer<
>;
// FIXME: Remove
export const HIDDEN_SERVICES = ["relayer", "chainsaw", "nebula"];
export const HIDDEN_SERVICES = ["relayer", "chainsaw"];
+23 -2
View File
@@ -22,8 +22,30 @@ export const config = {
};
export async function middleware(request: NextRequest) {
let cookiesToSet: Record<string, string> | undefined = undefined;
const { pathname } = request.nextUrl;
// nebula subdomain handling
const host = request.headers.get("host");
const subdomain = host?.split(".")[0];
const paths = pathname.slice(1).split("/");
// nebula.thirdweb.com -> render page at app/nebula-app
if (subdomain === "nebula" && host) {
const newPaths = ["nebula-app", ...paths];
return rewrite(request, `/${newPaths.join("/")}`, undefined);
}
// requesting page at app/nebula-app on thirdweb.com -> redirect to nebula.thirdweb.com
if (paths[0] === "nebula-app") {
const newPaths = paths.slice(1);
const url = new URL(request.nextUrl.href);
url.host = `nebula.${host}`;
url.pathname = `/${newPaths.join("/")}`;
return NextResponse.redirect(url.href);
}
let cookiesToSet: Record<string, string> | undefined = undefined;
const activeAccount = request.cookies.get(COOKIE_ACTIVE_ACCOUNT)?.value;
const authCookie = activeAccount
? request.cookies.get(COOKIE_PREFIX_TOKEN + getAddress(activeAccount))
@@ -73,7 +95,6 @@ export async function middleware(request: NextRequest) {
}
// remove '/' in front and then split by '/'
const paths = pathname.slice(1).split("/");
// if it's the homepage and we have an auth cookie, redirect to the dashboard
if (paths.length === 1 && paths[0] === "" && authCookie) {
+56 -1
View File
@@ -1,7 +1,11 @@
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import type { TeamSubscription } from "@/api/team-subscription";
import type { ApiKey, ApiKeyService } from "@3rdweb-sdk/react/hooks/useApi";
import type {
Account,
ApiKey,
ApiKeyService,
} from "@3rdweb-sdk/react/hooks/useApi";
import type {
EngineAlert,
EngineAlertRule,
@@ -39,6 +43,16 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
updatedAt: new Date().toISOString(),
billingEmail: "[email protected]",
growthTrialEligible: true,
enabledScopes: [
"pay",
"storage",
"rpc",
"bundler",
"insight",
"embeddedWallets",
"relayer",
"chainsaw",
],
};
return team;
@@ -262,3 +276,44 @@ export function teamSubscriptionsStub(
},
];
}
export function randomLorem(length: number) {
const loremWords = [
"lorem",
"ipsum",
"dolor",
"sit",
"amet",
"consectetur",
"adipiscing",
"elit",
"sed",
"do",
"eiusmod",
"tempor",
"incididunt",
"ut",
"labore",
"et",
"dolore",
"magna",
"aliqua",
];
return Array.from({ length }, () => {
const randomIndex = Math.floor(Math.random() * loremWords.length);
return loremWords[randomIndex];
}).join(" ");
}
export function accountStub(overrides?: Partial<Account>): Account {
return {
email: "[email protected]",
name: "John Doe",
id: "foo",
isStaff: false,
advancedEnabled: false,
creatorWalletAddress: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
...overrides,
};
}
@@ -0,0 +1,50 @@
type FetchWithKeyOptions = {
endpoint: string;
authToken: string;
timeout?: number;
} & (
| {
method: "POST" | "PUT";
body: Record<string, unknown>;
}
| {
method: "GET" | "DELETE";
}
);
export async function fetchWithAuthToken(options: FetchWithKeyOptions) {
const timeout = options.timeout || 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(options.endpoint, {
method: options.method,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${options.authToken}`,
},
body: "body" in options ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
});
if (!response.ok) {
if (response.status === 504) {
throw new Error("Request timed out. Please try again.");
}
throw new Error(`HTTP error! status: ${response.status}`);
}
return response;
} catch (error: unknown) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Request timed out. Please try again.");
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
+2 -2
View File
@@ -39,7 +39,7 @@
"react-dom": "19.0.0",
"react-pick-color": "^2.0.0",
"server-only": "^0.0.1",
"shiki": "1.24.0",
"shiki": "1.24.1",
"tailwind-merge": "^2.5.5",
"thirdweb": "workspace:*",
"timeago.js": "^4.0.2",
@@ -48,7 +48,7 @@
"devDependencies": {
"@types/node": "22.10.1",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"@types/react-dom": "19.0.2",
"eslint": "8.57.0",
"eslint-config-next": "15.0.4",
"eslint-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

@@ -0,0 +1,56 @@
import { APIHeader } from "@/components/blocks/APIHeader";
import {
NftCardDemo,
NftDescriptionBasic,
NftMediaBasic,
NftMediaOverride,
NftNameBasic,
} from "@/components/headless-ui/nft-examples";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { metadataBase } from "@/lib/constants";
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase,
title: "NFT Components",
description:
"Elevate your NFT marketplace with our React headless UI components, engineered for seamless digital asset transactions. These customizable, zero-styling components simplify NFT interactions while giving developers complete freedom to craft their perfect user interface.",
};
export default function Page() {
return (
<ThirdwebProvider>
<main className="container px-0 pb-20">
<APIHeader
title="NFT Components"
description={
<>
Elevate your NFT applications with our React headless UI
components, engineered for seamless digital asset transactions.
These customizable, zero-styling components simplify NFT
interactions while giving developers complete freedom to craft
their perfect user interface.
</>
}
docsLink="https://portal.thirdweb.com/react/v5/connecting-wallets/ui-components"
heroLink="/headless-ui-header.png"
/>
<section className="space-y-8">
<NftMediaBasic />
</section>
<section className="space-y-8">
<NftMediaOverride />
</section>
<section className="space-y-8">
<NftNameBasic />
</section>
<section className="space-y-8">
<NftDescriptionBasic />
</section>
<section className="space-y-8">
<NftCardDemo />
</section>
</main>
</ThirdwebProvider>
);
}
@@ -0,0 +1,80 @@
import { APIHeader } from "@/components/blocks/APIHeader";
import {
AccountAddressBasic,
AccountAddressFormat,
AccountAvatarBasic,
AccountBalanceBasic,
AccountBalanceCustomToken,
AccountBalanceFormat,
AccountBalanceUSD,
AccountBlobbieBasic,
AccountNameBasic,
AccountNameCustom,
ConnectDetailsButtonClone,
} from "@/components/headless-ui/account-examples";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { metadataBase } from "@/lib/constants";
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase,
title: "Account Components",
description:
"Streamline your Web3 development with our React headless UI components for wallet integration. These unstyled, customizable components handle complex wallet operations while giving you complete control over your dApp's design.",
};
export default function Page() {
return (
<ThirdwebProvider>
<main className="container px-0 pb-20">
<APIHeader
title="Account Components"
description={
<>
Streamline your Web3 development with our React headless UI
components for wallet integration. These unstyled, customizable
components handle complex wallet operations while giving you
complete control over your dApp&apos;s design.
</>
}
docsLink="https://portal.thirdweb.com/react/v5/connecting-wallets/ui-components"
heroLink="/headless-ui-header.png"
/>
<section className="space-y-8">
<AccountAddressBasic />
</section>
<section className="space-y-8">
<AccountAddressFormat />
</section>
<section className="space-y-8">
<AccountNameBasic />
</section>
<section className="space-y-8">
<AccountNameCustom />
</section>
<section className="space-y-8">
<AccountBalanceBasic />
</section>
<section className="space-y-8">
<AccountBalanceCustomToken />
</section>
<section className="space-y-8">
<AccountBalanceFormat />
</section>
<section className="space-y-8">
<AccountBalanceUSD />
</section>
<section className="space-y-8">
<AccountAvatarBasic />
</section>
<section className="space-y-8">
<AccountBlobbieBasic />
</section>
<section className="space-y-8">
<ConnectDetailsButtonClone />
</section>
</main>
</ThirdwebProvider>
);
}
+14
View File
@@ -111,4 +111,18 @@ export const navLinks: SidebarLink[] = [
name: "Blockchain API",
href: "/connect/blockchain-api",
},
{
name: "Headless UI",
expanded: true,
links: [
{
name: "Account",
href: "/connect/ui",
},
{
name: "NFT",
href: "/connect/ui/nft",
},
],
},
];
+6
View File
@@ -60,6 +60,12 @@ function WalletsSection() {
description="Performant, and reliable blockchain API"
icon={CodeIcon}
/>
<ArticleCardIndex
href="/connect/ui"
title="Headless UI"
description="Modular & composable set of UIs for your web3 applications"
icon={CodeIcon}
/>
</div>
</section>
);
@@ -0,0 +1,509 @@
"use client";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { ethereum } from "thirdweb/chains";
import {
AccountAddress,
AccountAvatar,
AccountBalance,
type AccountBalanceInfo,
AccountBlobbie,
AccountName,
AccountProvider,
} from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
import { CodeExample } from "../code/code-example";
const vitalikAddress = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045";
/**
* Show full wallet address with AccountAddress
*/
export function AccountAddressBasic() {
return (
<>
<div className="space-y-2">
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
AccountAddress
</h2>
<p className="max-w-[600px] text-lg">
Show the wallet address of the account.
</p>
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountAddress />
</AccountProvider>
}
code={`import { AccountProvider, AccountAddress } from "thirdweb/react";
function App() {
return <AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
<AccountAddress />
</AccountProvider>
}`}
lang="tsx"
/>
</>
);
}
/**
* Shorten wallet address with AccountAddress
*/
export function AccountAddressFormat() {
return (
<>
<div className="mt-8 space-y-2">
<p className="max-w-[600px] text-lg">
Shorten the wallet address using the <b>formatFn</b> prop.
</p>
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountAddress formatFn={shortenAddress} />
</AccountProvider>
}
code={`import { AccountProvider, AccountAddress } from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
function App() {
return <AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
<AccountAddress formatFn={shortenAddress} />
</AccountProvider>
}`}
lang="tsx"
/>
</>
);
}
/**
* Show the social name of an account
*/
export function AccountNameBasic() {
return (
<>
<div className="mt-8 space-y-2">
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
AccountName
</h2>
<p className="max-w-[600px] text-lg">
Show the social alias associated with the account
</p>
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountName loadingComponent={<span>Loading...</span>} />
</AccountProvider>
}
code={`import { AccountProvider, AccountName } from "thirdweb/react";
function App() {
return <AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
<AccountName loadingComponent={<span>Loading...</span>} />
</AccountProvider>
}`}
lang="tsx"
/>
</>
);
}
export function AccountNameCustom() {
return (
<>
<div className="mt-8 space-y-2">
<p className="max-w-[600px] text-lg">
Show account name for a specific social network
</p>
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountName
socialType="lens"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}
code={`import { AccountProvider, AccountName } from "thirdweb/react";
function App() {
return <AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
{/* You can choose between "ens", "lens" and "farcaster" */}
<AccountName socialType="lens" loadingComponent={<span>Loading...</span>} />
</AccountProvider>
}`}
lang="tsx"
/>
</>
);
}
/**
* AccountBalance: Fetch native balance on Ethereum mainnet
*/
export function AccountBalanceBasic() {
return (
<>
<div className="mt-8 space-y-2">
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
AccountBalance
</h2>
<p className="max-w-[600px] text-lg">
Display the current native balance of the wallet.
</p>
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBalance
chain={ethereum}
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}
code={`import { AccountProvider, AccountAddress } from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
function App() {
return <AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
<AccountBalance
chain={ethereum}
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}`}
lang="tsx"
/>
</>
);
}
/**
* AccountBalance: Fetch USDC balance on mainnet
*/
export function AccountBalanceCustomToken() {
return (
<>
<div className="mt-8 space-y-2">
<p className="max-w-[600px] text-lg">
Display the current balance of a custom token
</p>
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBalance
chain={ethereum}
tokenAddress="0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}
code={`import { AccountProvider, AccountAddress } from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
const USDC_ETHEREUM = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
function App() {
return <AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
<AccountBalance
chain={ethereum}
tokenAddress={USDC_ETHEREUM}
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}`}
lang="tsx"
/>
</>
);
}
/**
* AccountBalance: Round up the token balance
*/
export function AccountBalanceFormat() {
return (
<>
<div className="mt-8 space-y-2">
<p className="max-w-[600px] text-lg">
Round up the wallet balance using the <b>formatFn</b> prop.
</p>
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBalance
chain={ethereum}
formatFn={(props: AccountBalanceInfo) =>
`${Math.ceil(props.balance * 1000) / 1000} ${props.symbol}`
}
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}
code={`import { AccountProvider, AccountAddress, type AccountBalanceFormatParams } from "thirdweb/react";
function App() {
return <AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
<AccountBalance
chain={ethereum}
loadingComponent={<span>Loading...</span>}
formatFn={(props: AccountBalanceInfo) => \`\${Math.ceil(props.balance * 1000) / 1000} \${props.symbol}\`} />
</AccountProvider>
}`}
lang="tsx"
/>
</>
);
}
/**
* AccountBalance: Show USD balance
*/
export function AccountBalanceUSD() {
return (
<>
<div className="mt-8 space-y-2">
<p className="max-w-[600px] text-lg">
Display the USD value of the token balance.
</p>
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBalance
chain={ethereum}
showBalanceInFiat="USD"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}
code={`import { AccountProvider, AccountAddress } from "thirdweb/react";
function App() {
return (
<AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
<AccountBalance
chain={ethereum}
showBalanceInFiat="USD"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
)
}`}
lang="tsx"
/>
</>
);
}
/**
* Show the social name of an account
*/
export function AccountAvatarBasic() {
return (
<>
<div className="mt-8 space-y-2">
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
AccountAvatar
</h2>
<p className="max-w-[600px] text-lg">
Show the social avatar associated with the account
</p>
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountAvatar
className="h-20 w-20 rounded-full"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}
code={`import { AccountProvider, AccountAvatar } from "thirdweb/react";
function App() {
return <AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
<AccountAvatar className="w-20 h-20 rounded-full" loadingComponent={<span>Loading...</span>} />
</AccountProvider>
}`}
lang="tsx"
/>
</>
);
}
export function AccountBlobbieBasic() {
return (
<>
<div className="mt-8 space-y-2">
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
AccountBlobbie
</h2>
<p className="max-w-[600px] text-lg">
Show the unique blobbie generated from the wallet address
</p>
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBlobbie className="h-20 w-20 rounded-full" />
</AccountProvider>
}
code={`import { AccountProvider, AccountBlobbie } from "thirdweb/react";
function App() {
return <AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
<AccountBlobbie className="h-20 w-20 rounded-full" />
</AccountProvider>
}`}
lang="tsx"
/>
</>
);
}
export function ConnectDetailsButtonClone() {
return (
<>
<div className="mt-8 space-y-2">
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
Rebuild the Connected-Button
</h2>
<p className="max-w-[600px] text-lg">
Using these headless components, you can easily build your own complex
UI, such as our Connected-button.
</p>
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<button
type="button"
className="flex min-w-[200px] flex-row items-center gap-2 rounded-md border bg-gray-800 px-4 py-2"
>
<AccountAvatar
className="h-10 w-10 rounded-full"
loadingComponent={
<AccountBlobbie className="h-10 w-10 rounded-full" />
}
fallbackComponent={
<AccountBlobbie className="h-10 w-10 rounded-full" />
}
/>
<div className="flex flex-col gap-1">
<AccountName
className="text-left"
loadingComponent={
<AccountAddress
formatFn={shortenAddress}
className="text-left"
/>
}
fallbackComponent={
<AccountAddress
formatFn={shortenAddress}
className="text-left"
/>
}
/>
<div className="flex flex-row items-center gap-2">
<AccountBalance
className="text-left text-sm"
chain={ethereum}
loadingComponent={
<span className="text-left text-sm">Loading...</span>
}
fallbackComponent={
<span className="text-left text-sm">Loading...</span>
}
/>
<AccountBalance
className="text-left text-sm"
chain={ethereum}
showBalanceInFiat="USD"
/>
</div>
</div>
</button>
</AccountProvider>
}
code={`import { AccountProvider, AccountAvatar, AccountName, AccountBalance, AccountAddress, AccountBlobbie } from "thirdweb/react";
function App() {
return (
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<button
type="button"
className="flex min-w-[200px] flex-row items-center gap-2 rounded-md border bg-gray-800 px-4 py-2"
>
<AccountAvatar
className="h-10 w-10 rounded-full"
loadingComponent={
<AccountBlobbie className="h-10 w-10 rounded-full" />
}
fallbackComponent={
<AccountBlobbie className="h-10 w-10 rounded-full" />
}
/>
<div className="flex flex-col gap-1">
<AccountName
className="text-left"
loadingComponent={
<AccountAddress
formatFn={shortenAddress}
className="text-left"
/>
}
fallbackComponent={
<AccountAddress
formatFn={shortenAddress}
className="text-left"
/>
}
/>
<div className="flex flex-row items-center gap-2">
<AccountBalance
className="text-left text-sm"
chain={ethereum}
loadingComponent={
<span className="text-left text-sm">Loading...</span>
}
fallbackComponent={
<span className="text-left text-sm">Loading...</span>
}
/>
<AccountBalance
className="text-left text-sm"
chain={ethereum}
showBalanceInFiat="USD"
/>
</div>
</div>
</button>
</AccountProvider>
)
}`}
lang="tsx"
/>
</>
);
}
@@ -0,0 +1,205 @@
"use client";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { getContract } from "thirdweb";
import { ethereum } from "thirdweb/chains";
import { NFTDescription, NFTMedia, NFTName, NFTProvider } from "thirdweb/react";
import { CodeExample } from "../code/code-example";
const nftContract = getContract({
address: "0xbd3531da5cf5857e7cfaa92426877b022e612cf8",
chain: ethereum,
client: THIRDWEB_CLIENT,
});
export function NftMediaBasic() {
return (
<>
<div className="space-y-2">
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
NFTMedia
</h2>
<p className="max-w-[600px] text-lg">
Show the media of an NFT in a collection.
</p>
</div>
<CodeExample
preview={
<NFTProvider tokenId={0n} contract={nftContract}>
<NFTMedia
className="h-40 w-40 rounded-md"
loadingComponent={<span>Loading...</span>}
/>
</NFTProvider>
}
code={`import { NFTProvider, NFTMedia } from "thirdweb/react";
function App() {
return <NFTProvider tokenId={0n} contract={nftContract}>
<NFTMedia className="rounded-md w-40 h-40" loadingComponent={<span>Loading...</span>} />
</NFTProvider>
}`}
lang="tsx"
/>
</>
);
}
export function NftMediaOverride() {
return (
<>
<div className="mt-8 space-y-2">
<h4 className="font-semibold text-lg">
Override the NFT media using the <b>mediaResolver</b> prop.
</h4>
<p className="max-w-[600px] text-lg">
This prop is very useful when you already have the media src and want
to skip the network requests on the client.
</p>
</div>
<CodeExample
preview={
<NFTProvider tokenId={0n} contract={nftContract}>
<NFTMedia
className="h-40 w-40 rounded-md border"
mediaResolver={{
src: "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/1.mp4",
poster:
"ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/0.png",
}}
/>
</NFTProvider>
}
code={`import { NFTProvider, NFTMedia } from "thirdweb/react";
function App() {
return <NFTProvider tokenId={0n} contract={nftContract}>
<NFTMedia
className="h-40 w-40 rounded-md"
mediaResolver={{
src: "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/1.mp4",
poster: "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/0.png",
}}
/>
</NFTProvider>
}`}
lang="tsx"
/>
</>
);
}
export function NftNameBasic() {
return (
<>
<div className="mt-8 space-y-2">
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
NFTName
</h2>
<p className="max-w-[600px] text-lg">
Show the name of an NFT in a collection.
</p>
</div>
<CodeExample
preview={
<NFTProvider tokenId={0n} contract={nftContract}>
<NFTName loadingComponent={<span>Loading...</span>} />
</NFTProvider>
}
code={`import { NFTProvider, NFTName } from "thirdweb/react";
function App() {
return <NFTProvider tokenId={0n} contract={nftContract}>
<NFTName loadingComponent={<span>Loading...</span>} />
</NFTProvider>
}`}
lang="tsx"
/>
</>
);
}
export function NftDescriptionBasic() {
return (
<>
<div className="mt-8 space-y-2">
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
NFTDescription
</h2>
<p className="max-w-[600px] text-lg">
Show the description of an NFT in a collection.
</p>
</div>
<CodeExample
preview={
<NFTProvider tokenId={0n} contract={nftContract}>
<NFTDescription
className="text-center"
loadingComponent={<span>Loading...</span>}
/>
</NFTProvider>
}
code={`import { NFTProvider, NFTDescription } from "thirdweb/react";
function App() {
return <NFTProvider tokenId={0n} contract={nftContract}>
<NFTDescription className="text-center" loadingComponent={<span>Loading...</span>} />
</NFTProvider>
}`}
lang="tsx"
/>
</>
);
}
export function NftCardDemo() {
return (
<>
<div className="mt-8 space-y-2">
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
Build an NFT Card
</h2>
<p className="max-w-[600px] text-lg">
Using these headless components, you can easily build your own NFT
Card
</p>
</div>
<CodeExample
preview={
<NFTProvider tokenId={0n} contract={nftContract}>
<div className="flex w-[230px] flex-col gap-3 rounded-lg border bg-gray-900 px-1 py-3">
<NFTMedia className="rounded-md px-2 text-center" />
<NFTName className="px-2 font-bold" />
<NFTDescription
className="px-2 text-sm"
loadingComponent={<span>Loading...</span>}
/>
</div>
</NFTProvider>
}
code={`import { NFTProvider, NFTDescription, NFTName, NFTMedia } from "thirdweb/react";
function App() {
return (
<NFTProvider tokenId={0n} contract={nftContract}>
<div className="flex w-[230px] flex-col gap-3 rounded-lg border bg-gray-900 px-1 py-3">
<NFTMedia className="rounded-md px-2 text-center" />
<NFTName className="px-2 font-bold" />
<NFTDescription
className="px-2 text-sm"
loadingComponent={<span>Loading...</span>}
/>
</div>
</NFTProvider>
)
}`}
lang="tsx"
/>
</>
);
}
@@ -25,7 +25,7 @@ export function StyledConnectEmbed(
return account ? (
<div className="flex flex-col gap-8">
<StyledConnectButton />
<StyledConnectButton {...props} />
</div>
) : (
<ConnectEmbed
+3 -3
View File
@@ -16,7 +16,7 @@
"extract-search-data": "pnpm tsx scripts/extractSearchData.ts"
},
"dependencies": {
"@dirtycajunrice/klee": "^1.0.5",
"@dirtycajunrice/klee": "^1.0.6",
"@mdx-js/loader": "^2.3.0",
"@mdx-js/react": "^2.3.0",
"@next/mdx": "15.0.4",
@@ -44,7 +44,7 @@
"remark-gfm": "3.0.1",
"semver": "^7.6.0",
"server-only": "^0.0.1",
"shiki": "1.24.0",
"shiki": "1.24.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
@@ -56,7 +56,7 @@
"@types/mdx": "^2.0.13",
"@types/node": "22.10.1",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"@types/react-dom": "19.0.2",
"@types/react-html-parser": "^2.0.6",
"@types/tryghost__content-api": "^1.3.16",
"@typescript-eslint/eslint-plugin": "7.14.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 111 KiB

@@ -1,5 +1,11 @@
import { DocImage } from "@doc";
import billingSettings from "../assets/billing-settings.png";
import invoicesSettings from "../assets/invoices-settings.png";
import usageSettings from "../assets/usage-settings.png";
import rpcSettings from "../assets/rpc-settings.png";
import storageSettings from "../assets/storage-settings.png";
import { createMetadata } from "@doc";
export const metadata = createMetadata({
@@ -18,27 +24,42 @@ export const metadata = createMetadata({
To update your billing information:
1. Navigate to the Billing section in settings.
2. Select “Manage Billing” to redirect you to the Stripe Customer portal
3. Select “Update information” under the **BILLING INFORMATION** section
4. Update any changes and save.
1. On your dashboard, navigate to **Settings > Billing > Manage Billing**.
2. This will redirect you to the Stripe Customer Portal.
3. In the Stripe Customer Portal, you can:
- Update the card used for payments or add a new one.
- Update billing information, such as your address.
- Save any changes made.
## View past invoices
<DocImage src={billingSettings} />
To view past invoices:
## View Past Invoices
1. Navigate to the Billing section in settings.
2. Select “Manage Billing” to redirect you to the Stripe Customer portal
3. View each past invoices below the INVOICE HISTORY section.
To view your past invoices:
## View account usage
1. On your dashboard, navigate to **Settings > Billing > Manage Billing**.
2. In the Stripe Customer Portal, scroll to the **Invoice History** section.
3. View or download your past invoices.
Check your current usage and limitations by visiting the 'Usage' section in your account settings
<DocImage src={invoicesSettings} />
- Number of RPC requests
- Number of Storage Gateway requests
- Total size of files pinned
- Number of In-App Wallets created
- Total sponsored fees using paymaster for wallets with account abstraction enabled
## View Account Usage
To check your current usage and limits:
1. Visit the **Usage** tab in your dashboard.
2. The **Usage** tab is divided into two sections:
#### Overview
- Number of In-App Wallets created
- Total sponsored fees for wallets with account abstraction enabled
- Number of RPC requests
- Number of Storage Gateway requests
<DocImage src={usageSettings} />
<DocImage src={rpcSettings} />
#### Storage
- Total size of files pinned
<DocImage src={storageSettings} />

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