Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46b20716a5 | ||
|
|
da5d8ec53b | ||
|
|
8020bdbf2b | ||
|
|
3f56252298 | ||
|
|
a9dd26cd57 | ||
|
|
5b24faf1f3 | ||
|
|
714602d13c | ||
|
|
c11ae45f89 | ||
|
|
462138dfae | ||
|
|
67305d3bd2 | ||
|
|
27d3a6eb3e | ||
|
|
f25d1d8da5 | ||
|
|
d35eaa6690 |
@@ -5,7 +5,7 @@ on:
|
||||
types: [opened, edited]
|
||||
|
||||
env:
|
||||
VALID_ISSUE_PREFIXES: "CNCT|DASH|PROT"
|
||||
VALID_ISSUE_PREFIXES: "CNCT|DASH|PROT|INSIGHT"
|
||||
|
||||
jobs:
|
||||
linear:
|
||||
|
||||
@@ -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=""
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 224 KiB |
|
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
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||

|
||||
|
||||
|
||||
### 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"];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
|
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'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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
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} />
|
||||
|
||||