= ({
)}
-
+
{lastStepCompleted + 1}/{steps.length} tasks completed
@@ -57,8 +58,11 @@ export const StepsCard: React.FC = ({
{steps.map(({ children, ...step }, index) => {
+ const isActiveStep = index === lastStepCompleted + 1;
const showChildren =
- !step.completed || (step.completed && step.showCompletedChildren);
+ isActiveStep ||
+ (!step.completed && step.showIncompleteChildren !== false) ||
+ (step.completed && step.showCompletedChildren);
return (
= ({
key={index}
>
@@ -103,10 +107,7 @@ export const StepsCard: React.FC
= ({
);
};
-function StepNumberBadge(props: {
- number: number;
- isCompleted: boolean;
-}) {
+function StepNumberBadge(props: { number: number; isCompleted: boolean }) {
return (
{props.isCompleted ? (
diff --git a/apps/dashboard/src/components/shared/TWTable.tsx b/apps/dashboard/src/@/components/blocks/TWTable.tsx
similarity index 97%
rename from apps/dashboard/src/components/shared/TWTable.tsx
rename to apps/dashboard/src/@/components/blocks/TWTable.tsx
index d76bfd2e0f3..b6e90529dd9 100644
--- a/apps/dashboard/src/components/shared/TWTable.tsx
+++ b/apps/dashboard/src/@/components/blocks/TWTable.tsx
@@ -1,6 +1,17 @@
+/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
"use client";
-import { Spinner } from "@/components/ui/Spinner/Spinner";
+import {
+ type ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ type PaginationState,
+ type TableOptions,
+ useReactTable,
+} from "@tanstack/react-table";
+import { EllipsisVerticalIcon, MoveRightIcon } from "lucide-react";
+import pluralize from "pluralize";
+import { type ReactNode, type SetStateAction, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -8,6 +19,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { Spinner } from "@/components/ui/Spinner";
import { Separator } from "@/components/ui/separator";
import {
Table,
@@ -19,17 +31,6 @@ import {
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
-import {
- type ColumnDef,
- type PaginationState,
- type TableOptions,
- flexRender,
- getCoreRowModel,
- useReactTable,
-} from "@tanstack/react-table";
-import { EllipsisVerticalIcon, MoveRightIcon } from "lucide-react";
-import pluralize from "pluralize";
-import { type ReactNode, type SetStateAction, useMemo, useState } from "react";
type CtaMenuItem
= {
icon?: ReactNode;
@@ -107,18 +108,18 @@ export function TWTable(tableProps: TWTableProps) {
() =>
tableProps.pagination
? {
+ manualPagination: true,
+ onPaginationChange: setPagination,
pageCount: Math.ceil(slicedData.length / pageSize),
state: { pagination },
- onPaginationChange: setPagination,
- manualPagination: true,
}
: {},
[pageSize, pagination, slicedData.length, tableProps.pagination],
);
const table = useReactTable({
- data: slicedData,
columns: tableProps.columns,
+ data: slicedData,
...paginationOptions,
getCoreRowModel: getCoreRowModel(),
// TODO - add filtering?
@@ -135,7 +136,7 @@ export function TWTable(tableProps: TWTableProps) {
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => (
-
+
{header.isPlaceholder ? null : (
{flexRender(
@@ -167,16 +168,16 @@ export function TWTable
(tableProps: TWTableProps) {
role="group"
{...(tableProps.onRowClick
? {
- style: {
- cursor: "pointer",
- },
- onClick: () => tableProps.onRowClick?.(row.original),
- _hover: { bg: "blackAlpha.50" },
_dark: {
_hover: {
bg: "whiteAlpha.50",
},
},
+ _hover: { bg: "blackAlpha.50" },
+ onClick: () => tableProps.onRowClick?.(row.original),
+ style: {
+ cursor: "pointer",
+ },
}
: {})}
className={tableProps.bodyRowClassName}
@@ -203,9 +204,9 @@ export function TWTable(tableProps: TWTableProps) {
@@ -215,12 +216,12 @@ export function TWTable(tableProps: TWTableProps) {
({ icon, text, onClick, isDestructive }) => {
return (
onClick(row.original)}
className={cn(
"min-w-[170px] cursor-pointer gap-3 px-3 py-3",
isDestructive && "!text-destructive-text",
)}
+ key={text}
+ onClick={() => onClick(row.original)}
>
{icon}
{text}
@@ -252,24 +253,24 @@ export function TWTable(tableProps: TWTableProps) {
{!tableProps.isPending &&
tableProps.data.length === 0 &&
tableProps.isFetched && (
-
+
- No {pluralize(tableProps.title, 0, false)} found.
+ No {pluralize(tableProps.title, 0, false)} found
)}
tableProps.showMore.pageSize
}
- pageSize={tableProps.showMore?.pageSize || -1}
+ shouldShowMore={slicedData.length < tableProps.data.length}
showMoreLimit={showMoreLimit}
- setShowMoreLimit={setShowMoreLimit}
/>
);
@@ -305,18 +306,18 @@ const ShowMoreButton: React.FC = ({
{shouldShowMore && (
)}
{shouldShowLess && (
diff --git a/apps/dashboard/src/@/components/blocks/TeamPlanBadge.tsx b/apps/dashboard/src/@/components/blocks/TeamPlanBadge.tsx
new file mode 100644
index 00000000000..d44e9a98e87
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/TeamPlanBadge.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import type { Team } from "@/api/team/get-team";
+import { Badge, type BadgeProps } from "@/components/ui/badge";
+import { useDashboardRouter } from "@/lib/DashboardRouter";
+import { cn } from "@/lib/utils";
+
+const teamPlanToBadgeVariant: Record<
+ Team["billingPlan"],
+ BadgeProps["variant"]
+> = {
+ // green
+ accelerate: "success",
+ // gray
+ free: "secondary",
+ growth: "success",
+
+ growth_legacy: "warning",
+ // blue
+ pro: "default",
+ scale: "success",
+ // yellow
+ starter: "warning",
+};
+
+export function getTeamPlanBadgeLabel(
+ plan: Team["billingPlan"],
+ isLegacyPlan: boolean,
+) {
+ if (plan === "growth_legacy") {
+ return "Growth - Legacy";
+ }
+
+ if (isLegacyPlan) {
+ return `${plan} - Legacy`;
+ }
+
+ return plan;
+}
+
+export function TeamPlanBadge(props: {
+ teamSlug: string;
+ plan: Team["billingPlan"];
+ isLegacyPlan: boolean;
+ className?: string;
+ postfix?: string;
+}) {
+ const router = useDashboardRouter();
+
+ function handleNavigateToBilling(e: React.MouseEvent | React.KeyboardEvent) {
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (props.isLegacyPlan) {
+ router.push(`/team/${props.teamSlug}/~/billing`);
+ return;
+ }
+
+ if (props.plan !== "free") {
+ return;
+ }
+ router.push(`/team/${props.teamSlug}/~/billing?showPlans=true`);
+ }
+
+ return (
+
{
+ if (e.key === "Enter" || e.key === " ") {
+ handleNavigateToBilling(e);
+ }
+ }}
+ role={props.plan === "free" || props.isLegacyPlan ? "button" : undefined}
+ tabIndex={props.plan === "free" || props.isLegacyPlan ? 0 : undefined}
+ variant={
+ props.isLegacyPlan ? "warning" : teamPlanToBadgeVariant[props.plan]
+ }
+ >
+ {`${getTeamPlanBadgeLabel(props.plan, props.isLegacyPlan)}${props.postfix || ""}`}
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx b/apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx
new file mode 100644
index 00000000000..862785ad400
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx
@@ -0,0 +1,57 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import { useState } from "react";
+import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils";
+import { TokenSelector } from "./TokenSelector";
+
+const meta = {
+ component: Story,
+ parameters: {
+ nextjs: {
+ appDirectory: true,
+ },
+ },
+ title: "blocks/Cards/TokenSelector",
+} satisfies Meta
;
+
+export default meta;
+type Story = StoryObj;
+
+export const Variants: Story = {
+ args: {},
+};
+
+function Story() {
+ return (
+
+
+
+ );
+}
+
+function Variant(props: { label: string; selectedChainId?: number }) {
+ const [token, setToken] = useState<
+ | {
+ address: string;
+ chainId: number;
+ }
+ | undefined
+ >(undefined);
+
+ return (
+
+ {
+ setToken({
+ address: v.address,
+ chainId: v.chainId,
+ });
+ }}
+ selectedToken={token}
+ showCheck={false}
+ />
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/TokenSelector.tsx b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx
new file mode 100644
index 00000000000..00f66951625
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx
@@ -0,0 +1,210 @@
+import { useCallback, useMemo } from "react";
+import {
+ getAddress,
+ NATIVE_TOKEN_ADDRESS,
+ type ThirdwebClient,
+} from "thirdweb";
+import { shortenAddress } from "thirdweb/utils";
+import type { TokenMetadata } from "@/api/universal-bridge/types";
+import { Img } from "@/components/blocks/Img";
+import { SelectWithSearch } from "@/components/blocks/select-with-search";
+import { Badge } from "@/components/ui/badge";
+import { Spinner } from "@/components/ui/Spinner";
+import { fallbackChainIcon } from "@/constants/chain";
+import { useAllChainsData } from "@/hooks/chains/allChains";
+import { useTokensData } from "@/hooks/tokens";
+import { cn } from "@/lib/utils";
+import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
+
+type Option = { label: string; value: string };
+
+const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
+
+export function TokenSelector(props: {
+ selectedToken: { chainId: number; address: string } | undefined;
+ onChange: (token: TokenMetadata) => void;
+ className?: string;
+ popoverContentClassName?: string;
+ chainId?: number;
+ side?: "left" | "right" | "top" | "bottom";
+ disableAddress?: boolean;
+ align?: "center" | "start" | "end";
+ placeholder?: string;
+ client: ThirdwebClient;
+ disabled?: boolean;
+ enabled?: boolean;
+ showCheck: boolean;
+ addNativeTokenIfMissing: boolean;
+}) {
+ const tokensQuery = useTokensData({
+ chainId: props.chainId,
+ enabled: props.enabled,
+ });
+
+ const { idToChain } = useAllChainsData();
+
+ const tokens = useMemo(() => {
+ if (!tokensQuery.data) {
+ return [];
+ }
+
+ if (props.addNativeTokenIfMissing) {
+ const hasNativeToken = tokensQuery.data.some(
+ (token) => token.address === checksummedNativeTokenAddress,
+ );
+
+ if (!hasNativeToken && props.chainId) {
+ return [
+ {
+ address: checksummedNativeTokenAddress,
+ chainId: props.chainId,
+ decimals: 18,
+ name:
+ idToChain.get(props.chainId)?.nativeCurrency.name ??
+ "Native Token",
+ symbol:
+ idToChain.get(props.chainId)?.nativeCurrency.symbol ?? "ETH",
+ } satisfies TokenMetadata,
+ ...tokensQuery.data,
+ ];
+ }
+ }
+ return tokensQuery.data;
+ }, [
+ tokensQuery.data,
+ props.chainId,
+ props.addNativeTokenIfMissing,
+ idToChain,
+ ]);
+
+ const addressChainToToken = useMemo(() => {
+ const value = new Map();
+ for (const token of tokens) {
+ value.set(`${token.chainId}:${token.address}`, token);
+ }
+ return value;
+ }, [tokens]);
+
+ const options = useMemo(() => {
+ return (
+ tokens.map((token) => {
+ return {
+ label: token.symbol,
+ value: `${token.chainId}:${token.address}`,
+ };
+ }) || []
+ );
+ }, [tokens]);
+
+ const searchFn = useCallback(
+ (option: Option, searchValue: string) => {
+ const token = addressChainToToken.get(option.value);
+ if (!token) {
+ return false;
+ }
+
+ if (Number.isInteger(Number(searchValue))) {
+ return String(token.chainId).startsWith(searchValue);
+ }
+ return (
+ token.name.toLowerCase().includes(searchValue.toLowerCase()) ||
+ token.symbol.toLowerCase().includes(searchValue.toLowerCase()) ||
+ token.address.toLowerCase().includes(searchValue.toLowerCase())
+ );
+ },
+ [addressChainToToken],
+ );
+
+ const renderOption = useCallback(
+ (option: Option) => {
+ const token = addressChainToToken.get(option.value);
+ if (!token) {
+ return option.label;
+ }
+ const resolvedSrc = token.iconUri
+ ? resolveSchemeWithErrorHandler({
+ client: props.client,
+ uri: token.iconUri,
+ })
+ : fallbackChainIcon;
+
+ return (
+
+
+
}
+ key={resolvedSrc}
+ loading={"lazy"}
+ // eslint-disable-next-line @next/next/no-img-element
+ skeleton={
+
+ }
+ src={resolvedSrc}
+ />
+ {token.symbol}
+
+
+ {!props.disableAddress && (
+
+ Address
+ {shortenAddress(token.address, 4)}
+
+ )}
+
+ );
+ },
+ [addressChainToToken, props.disableAddress, props.client],
+ );
+
+ const selectedValue = props.selectedToken
+ ? `${props.selectedToken.chainId}:${props.selectedToken.address}`
+ : undefined;
+
+ // if selected value is not in options, add it
+ if (
+ selectedValue &&
+ !options.find((option) => option.value === selectedValue)
+ ) {
+ options.push({
+ label: props.selectedToken?.address || "Unknown",
+ value: selectedValue,
+ });
+ }
+
+ return (
+ {
+ const token = addressChainToToken.get(tokenAddress);
+ if (!token) {
+ return;
+ }
+ props.onChange(token);
+ }}
+ options={options}
+ overrideSearchFn={searchFn}
+ placeholder={
+ tokensQuery.isPending ? (
+
+
+ Loading tokens
+
+ ) : (
+ props.placeholder || "Select Token"
+ )
+ }
+ popoverContentClassName={props.popoverContentClassName}
+ renderOption={renderOption}
+ searchPlaceholder="Search by name or symbol"
+ showCheck={props.showCheck}
+ side={props.side}
+ value={tokensQuery.isPending ? undefined : selectedValue}
+ />
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/UpsellBannerCard.stories.tsx b/apps/dashboard/src/@/components/blocks/UpsellBannerCard.stories.tsx
new file mode 100644
index 00000000000..693f368568b
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/UpsellBannerCard.stories.tsx
@@ -0,0 +1,69 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import { ArrowRightIcon, RocketIcon, StarIcon } from "lucide-react";
+import { BadgeContainer } from "@/storybook/utils";
+import { UpsellBannerCard } from "./UpsellBannerCard";
+
+function Story() {
+ return (
+
+
+ ,
+ link: "#",
+ text: "View plans",
+ }}
+ description="Upgrade to increase limits and access advanced features."
+ icon={}
+ title="Unlock more with thirdweb"
+ />
+
+
+
+ ,
+ link: "#",
+ text: "Upgrade storage",
+ }}
+ description="Add additional space to your account."
+ icon={}
+ title="Need more storage?"
+ />
+
+
+
+ ,
+ link: "#",
+ text: "Request access",
+ }}
+ description="Get early access to experimental features."
+ title="Join the beta"
+ />
+
+
+ );
+}
+
+const meta = {
+ component: Story,
+ parameters: {
+ nextjs: {
+ appDirectory: true,
+ },
+ },
+ title: "blocks/Banners/UpsellBannerCard",
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Variants: Story = {
+ args: {},
+};
diff --git a/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx b/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx
new file mode 100644
index 00000000000..a9461a8a6e8
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx
@@ -0,0 +1,140 @@
+"use client";
+
+import Link from "next/link";
+import type React from "react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+const ACCENT = {
+ blue: {
+ bgFrom: "from-blue-50 dark:from-blue-900/20",
+ blur: "bg-blue-600",
+ border: "border-blue-600 dark:border-blue-700",
+ btn: "bg-blue-600 text-white hover:bg-blue-700",
+ desc: "text-blue-800 dark:text-blue-300",
+ iconBg: "bg-blue-600 text-white",
+ title: "text-blue-900 dark:text-blue-200",
+ },
+ green: {
+ bgFrom: "from-green-50 dark:from-green-900/20",
+ blur: "bg-green-600",
+ border: "border-green-600 dark:border-green-700",
+ btn: "bg-green-600 text-white hover:bg-green-700",
+ desc: "text-green-800 dark:text-green-300",
+ iconBg: "bg-green-600 text-white",
+ title: "text-green-900 dark:text-green-200",
+ },
+ purple: {
+ bgFrom: "from-purple-50 dark:from-purple-900/20",
+ blur: "bg-purple-600",
+ border: "border-purple-600 dark:border-purple-700",
+ btn: "bg-purple-600 text-white hover:bg-purple-700",
+ desc: "text-purple-800 dark:text-purple-300",
+ iconBg: "bg-purple-600 text-white",
+ title: "text-purple-900 dark:text-purple-200",
+ },
+} as const;
+
+type UpsellBannerCardProps = {
+ title: React.ReactNode;
+ description: React.ReactNode;
+ cta?:
+ | {
+ text: React.ReactNode;
+ icon?: React.ReactNode;
+ target?: "_blank";
+ link: string;
+ }
+ | {
+ text: React.ReactNode;
+ icon?: React.ReactNode;
+ onClick: () => void;
+ };
+ accentColor?: keyof typeof ACCENT;
+ icon?: React.ReactNode;
+};
+
+export function UpsellBannerCard(props: UpsellBannerCardProps) {
+ const color = ACCENT[props.accentColor || "green"];
+
+ return (
+
+ {/* Decorative blur */}
+
+
+
+
+ {props.icon ? (
+
+ {props.icon}
+
+ ) : null}
+
+
+
+ {props.title}
+
+
{props.description}
+
+
+
+ {props.cta && "target" in props.cta ? (
+
+ ) : props.cta && "onClick" in props.cta ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/app-footer.tsx b/apps/dashboard/src/@/components/blocks/app-footer.tsx
deleted file mode 100644
index d40dedab7dd..00000000000
--- a/apps/dashboard/src/@/components/blocks/app-footer.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-import { Button } from "@/components/ui/button";
-import { cn } from "@/lib/utils";
-import { ThirdwebMiniLogo } from "app/components/ThirdwebMiniLogo";
-import { DiscordIcon } from "components/icons/brand-icons/DiscordIcon";
-import { GithubIcon } from "components/icons/brand-icons/GithubIcon";
-import { InstagramIcon } from "components/icons/brand-icons/InstagramIcon";
-import { LinkedInIcon } from "components/icons/brand-icons/LinkedinIcon";
-import { RedditIcon } from "components/icons/brand-icons/RedditIcon";
-import { TiktokIcon } from "components/icons/brand-icons/TiktokIcon";
-import { XIcon } from "components/icons/brand-icons/XIcon";
-import { YoutubeIcon } from "components/icons/brand-icons/YoutubeIcon";
-import Link from "next/link";
-
-type AppFooterProps = {
- className?: string;
-};
-
-export function AppFooter(props: AppFooterProps) {
- return (
-
- );
-}
diff --git a/apps/dashboard/src/@/components/blocks/Avatars/GradientBlobbie.tsx b/apps/dashboard/src/@/components/blocks/avatar/GradientBlobbie.tsx
similarity index 100%
rename from apps/dashboard/src/@/components/blocks/Avatars/GradientBlobbie.tsx
rename to apps/dashboard/src/@/components/blocks/avatar/GradientBlobbie.tsx
diff --git a/apps/dashboard/src/@/components/blocks/avatar/gradient-avatar.stories.tsx b/apps/dashboard/src/@/components/blocks/avatar/gradient-avatar.stories.tsx
new file mode 100644
index 00000000000..397ebef51cd
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/avatar/gradient-avatar.stories.tsx
@@ -0,0 +1,137 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import { useState } from "react";
+import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils";
+import { Button } from "../../ui/button";
+import { GradientAvatar } from "./gradient-avatar";
+
+const meta = {
+ component: Story,
+ title: "blocks/Avatars/GradientAvatar",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Variants: Story = {
+ args: {},
+};
+
+function Story() {
+ return (
+
+
All images below are set with size-20 className
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ToggleTest() {
+ const [data, setData] = useState(
+ undefined,
+ );
+
+ return (
+
+
+
+
Src+ID is: {data ? "set" : "not set"}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/avatar/gradient-avatar.tsx b/apps/dashboard/src/@/components/blocks/avatar/gradient-avatar.tsx
new file mode 100644
index 00000000000..b2ab5ae234c
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/avatar/gradient-avatar.tsx
@@ -0,0 +1,27 @@
+import type { ThirdwebClient } from "thirdweb";
+import { Img } from "@/components/blocks/Img";
+import { cn } from "@/lib/utils";
+import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
+import { GradientBlobbie } from "./GradientBlobbie";
+
+export function GradientAvatar(props: {
+ src: string | undefined;
+ id: string | undefined;
+ className: string;
+ client: ThirdwebClient;
+}) {
+ const resolvedSrc = props.src
+ ? resolveSchemeWithErrorHandler({
+ client: props.client,
+ uri: props.src,
+ })
+ : props.src;
+
+ return (
+
: undefined}
+ src={resolvedSrc}
+ />
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/avatar/project-avatar.stories.tsx b/apps/dashboard/src/@/components/blocks/avatar/project-avatar.stories.tsx
new file mode 100644
index 00000000000..63ee9dfbc13
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/avatar/project-avatar.stories.tsx
@@ -0,0 +1,89 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import { useState } from "react";
+import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils";
+import { Button } from "../../ui/button";
+import { ProjectAvatar } from "./project-avatar";
+
+const meta = {
+ component: Story,
+ parameters: {},
+ title: "blocks/Avatars/ProjectAvatar",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Variants: Story = {
+ args: {},
+};
+
+function Story() {
+ return (
+
+
All images below are set with size-6 className
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ToggleTest() {
+ const [data, setData] = useState(
+ undefined,
+ );
+
+ return (
+
+
+
+
Src+Name is: {data ? "set" : "not set"}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/avatar/project-avatar.tsx b/apps/dashboard/src/@/components/blocks/avatar/project-avatar.tsx
new file mode 100644
index 00000000000..ebd02af7a09
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/avatar/project-avatar.tsx
@@ -0,0 +1,29 @@
+import { BoxIcon } from "lucide-react";
+import type { ThirdwebClient } from "thirdweb";
+import { Img } from "@/components/blocks/Img";
+import { cn } from "@/lib/utils";
+import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
+
+export function ProjectAvatar(props: {
+ src: string | undefined;
+ className: string | undefined;
+ client: ThirdwebClient;
+}) {
+ return (
+
+
+
+ }
+ src={
+ resolveSchemeWithErrorHandler({
+ client: props.client,
+ uri: props.src,
+ }) || ""
+ }
+ />
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx b/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx
index dc2fcc7524f..bdc9e76d304 100644
--- a/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx
+++ b/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx
@@ -1,5 +1,19 @@
"use client";
+import { format } from "date-fns";
+import { useMemo } from "react";
+import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
+import {
+ EmptyChartState,
+ LoadingChartState,
+} from "@/components/analytics/empty-chart-state";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
@@ -8,95 +22,168 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
-import { formatDate } from "date-fns";
-import { useMemo } from "react";
-import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
-import {
- EmptyChartState,
- LoadingChartState,
-} from "../../../../components/analytics/empty-chart-state";
+import { cn } from "@/lib/utils";
type ThirdwebAreaChartProps = {
+ header?: {
+ title: string;
+ description?: string;
+ titleClassName?: string;
+ headerClassName?: string;
+ icon?: React.ReactNode;
+ };
+ customHeader?: React.ReactNode;
// chart config
config: TConfig;
data: Array & { time: number | string | Date }>;
showLegend?: boolean;
+ yAxis?: boolean;
+ xAxis?: {
+ showHour?: boolean;
+ };
+
+ variant?: "stacked" | "individual";
+
// chart className
chartClassName?: string;
isPending: boolean;
+ className?: string;
+ cardContentClassName?: string;
+ hideLabel?: boolean;
+ toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
+ toolTipValueFormatter?: (value: unknown) => React.ReactNode;
+ emptyChartState?: React.ReactElement;
+ margin?: {
+ top?: number;
+ right?: number;
+ bottom?: number;
+ left?: number;
+ };
};
export function ThirdwebAreaChart(
props: ThirdwebAreaChartProps,
) {
const configKeys = useMemo(() => Object.keys(props.config), [props.config]);
+
return (
-
-
- {props.isPending ? (
-
- ) : props.data.length === 0 ? (
-
- ) : (
-
-
- formatDate(new Date(value), "MMM dd")}
- />
- } />
-
- {configKeys.map((key) => (
-
-
-
+ {props.header && (
+
+ {props.header.icon}
+
+ {props.header.title}
+
+ {props.header.description && (
+ {props.header.description}
+ )}
+
+ )}
+
+ {props.customHeader && props.customHeader}
+
+
+
+ {props.isPending ? (
+
+ ) : props.data.length === 0 ? (
+
+ ) : (
+
+
+ {props.yAxis && }
+
+ format(
+ new Date(value),
+ props.xAxis?.showHour ? "MMM dd, HH:mm" : "MMM dd",
+ )
+ }
+ tickLine={false}
+ tickMargin={20}
+ />
+
-
- ))}
-
- {configKeys.map((key) => (
-
- ))}
+
+ {configKeys.map((key) => (
+
+
+
+
+ ))}
+
+ {configKeys.map((key) =>
+ key === "maxLine" ? (
+
+ ) : (
+
+ ),
+ )}
- {props.showLegend && (
- } className="pt-8" />
- )}
-
- )}
-
-
+ {props.showLegend && (
+ }
+ />
+ )}
+
+ )}
+
+
+
);
}
diff --git a/apps/dashboard/src/@/components/blocks/charts/automerge-barchart.stories.tsx b/apps/dashboard/src/@/components/blocks/charts/automerge-barchart.stories.tsx
new file mode 100644
index 00000000000..0f38e4e5006
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/charts/automerge-barchart.stories.tsx
@@ -0,0 +1,180 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import type { InAppWalletAuth } from "thirdweb/wallets";
+import { BadgeContainer } from "@/storybook/utils";
+import { AutoMergeBarChart, type StatData } from "./automerge-barchart";
+
+const meta = {
+ component: Component,
+ title: "Charts/AutoMergeBarChart",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Variants: Story = {
+ args: {},
+};
+
+function Component() {
+ const title = "This is Title";
+ const description =
+ "This is an example of a description about user wallet usage chart";
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const authMethodsToPickFrom: InAppWalletAuth[] = [
+ "google",
+ "apple",
+ "facebook",
+ "discord",
+ "line",
+ "x",
+ "tiktok",
+ "epic",
+ "coinbase",
+ "farcaster",
+ "telegram",
+ "github",
+ "twitch",
+ "steam",
+ "guest",
+ "email",
+ "phone",
+ "passkey",
+ "wallet",
+];
+
+const pickRandomAuthMethod = () => {
+ const picked =
+ authMethodsToPickFrom[
+ Math.floor(Math.random() * authMethodsToPickFrom.length)
+ ] || "google";
+
+ const capitalized = picked.charAt(0).toUpperCase() + picked.slice(1);
+ return capitalized;
+};
+
+function createStatsStub(days: number): StatData[] {
+ const stubbedData: StatData[] = [];
+
+ let d = days;
+ while (d !== 0) {
+ const uniqueWallets = Math.floor(Math.random() * 100);
+ stubbedData.push({
+ label: pickRandomAuthMethod(),
+ date: new Date(2024, 11, d).toLocaleString(),
+ count: uniqueWallets,
+ });
+
+ if (Math.random() > 0.7) {
+ d--;
+ }
+ }
+
+ return stubbedData;
+}
diff --git a/apps/dashboard/src/@/components/blocks/charts/automerge-barchart.tsx b/apps/dashboard/src/@/components/blocks/charts/automerge-barchart.tsx
new file mode 100644
index 00000000000..b930a5572d9
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/charts/automerge-barchart.tsx
@@ -0,0 +1,173 @@
+"use client";
+import { format } from "date-fns";
+import { useMemo } from "react";
+import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
+import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
+import type { ChartConfig } from "@/components/ui/chart";
+import { TotalValueChartHeader } from "./chart-header";
+
+export type StatData = {
+ date: string;
+ count: number;
+ label: string;
+};
+
+type ChartData = Record & {
+ time: string; // human readable date
+};
+
+const defaultLabel = "Unknown";
+
+// merges the multiple stats in single bar if they share the same date
+// shows the top `maxLabelsToShow` labels, merge the rest into "Others"
+
+export function AutoMergeBarChart(props: {
+ stats: StatData[];
+ isPending: boolean;
+ title: string;
+ emptyChartState: React.ReactElement | undefined;
+ description: string | undefined;
+ maxLabelsToShow: number;
+ exportButton:
+ | {
+ fileName: string;
+ }
+ | undefined;
+ viewMoreLink: string | undefined;
+}) {
+ const { stats, maxLabelsToShow } = props;
+
+ const { chartConfig, chartData } = useMemo(() => {
+ const _chartConfig: ChartConfig = {};
+ const _chartDataMap: Map = new Map();
+ const labelToCountMap: Map = new Map();
+ // for each stat, add it in _chartDataMap
+ for (const stat of stats) {
+ const chartData = _chartDataMap.get(stat.date);
+
+ // if no data for current day - create new entry
+ if (!chartData && stat.count > 0) {
+ _chartDataMap.set(stat.date, {
+ time: stat.date,
+ [stat.label || defaultLabel]: stat.count,
+ } as ChartData);
+ } else if (chartData) {
+ chartData[stat.label || defaultLabel] =
+ (chartData[stat.label || defaultLabel] || 0) + stat.count;
+ }
+
+ labelToCountMap.set(
+ stat.label || defaultLabel,
+ stat.count + (labelToCountMap.get(stat.label || defaultLabel) || 0),
+ );
+ }
+
+ const labelsSorted = Array.from(labelToCountMap.entries())
+ .sort((a, b) => b[1] - a[1])
+ .map((w) => w[0]);
+
+ const labelsToShow = labelsSorted.slice(0, maxLabelsToShow);
+ const labelsToShowAsOther = labelsSorted.slice(maxLabelsToShow);
+
+ // replace chainIdsToTagAsOther chainId with "other"
+ for (const data of _chartDataMap.values()) {
+ for (const dataKey in data) {
+ if (labelsToShowAsOther.includes(dataKey)) {
+ data.others = (data.others || 0) + (data[dataKey] || 0);
+ delete data[dataKey];
+ }
+ }
+ }
+
+ labelsToShow.forEach((walletType, i) => {
+ _chartConfig[walletType] = {
+ color: `hsl(var(--chart-${(i % 10) + 1}))`,
+ label: labelsToShow[i],
+ };
+ });
+
+ // Add Other
+ if (labelsToShow.length >= maxLabelsToShow) {
+ labelsToShow.push("others");
+ _chartConfig.others = {
+ color: "hsl(var(--muted-foreground))",
+ label: "Others",
+ };
+ }
+
+ return {
+ chartConfig: _chartConfig,
+ chartData: Array.from(_chartDataMap.values()).sort(
+ (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
+ ),
+ };
+ }, [stats, maxLabelsToShow]);
+
+ const uniqueLabels = Object.keys(chartConfig);
+
+ const total = useMemo(() => {
+ return props.stats.reduce((acc, stat) => acc + stat.count, 0);
+ }, [props.stats]);
+
+ return (
+
+ ) : (
+
+
+
+ {props.title}
+
+ {props.description && (
+
+ {props.description}
+
+ )}
+
+
+ {props.exportButton && props.stats.length > 0 && (
+
{
+ // Shows the number of each type of wallet connected on all dates
+ const header = ["Time", ...uniqueLabels];
+ const rows = chartData.map((data) => {
+ const { time, ...rest } = data;
+ return [
+ new Date(time).toISOString(),
+ ...uniqueLabels.map((w) => (rest[w] || 0).toString()),
+ ];
+ });
+ return { header, rows };
+ }}
+ />
+ )}
+
+ )
+ }
+ data={chartData}
+ emptyChartState={props.emptyChartState}
+ hideLabel={false}
+ isPending={props.isPending}
+ showLegend
+ toolTipLabelFormatter={(_v, item) => {
+ if (Array.isArray(item)) {
+ const time = item[0].payload.time as number;
+ return format(new Date(time), "MMM d, yyyy");
+ }
+ return undefined;
+ }}
+ variant="stacked"
+ />
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx b/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx
index 42de009738b..cdf1c7a6604 100644
--- a/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx
+++ b/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx
@@ -1,7 +1,12 @@
"use client";
+import { format } from "date-fns";
+import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
-
+import {
+ EmptyChartState,
+ LoadingChartState,
+} from "@/components/analytics/empty-chart-state";
import {
Card,
CardContent,
@@ -17,18 +22,16 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
-import { formatDate } from "date-fns";
-import { useMemo } from "react";
-import {
- EmptyChartState,
- LoadingChartState,
-} from "../../../../components/analytics/empty-chart-state";
-import { cn } from "../../../lib/utils";
+import { cn } from "@/lib/utils";
type ThirdwebBarChartProps = {
// metadata
- title: string;
- description?: string;
+ header?: {
+ title: string;
+ description?: string;
+ titleClassName?: string;
+ };
+ customHeader?: React.ReactNode;
// chart config
config: TConfig;
data: Array & { time: number | string | Date }>;
@@ -38,8 +41,13 @@ type ThirdwebBarChartProps = {
chartClassName?: string;
isPending: boolean;
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
+ toolTipValueFormatter?: (value: unknown) => React.ReactNode;
hideLabel?: boolean;
- titleClassName?: string;
+ emptyChartState?: React.ReactElement;
+ className?: string;
+ xAxis?: {
+ showHour?: boolean;
+ };
};
export function ThirdwebBarChart(
@@ -51,30 +59,40 @@ export function ThirdwebBarChart(
props.variant || configKeys.length > 4 ? "stacked" : "grouped";
return (
-
-
-
- {props.title}
-
- {props.description && (
- {props.description}
- )}
-
-
-
+
+ {props.header && (
+
+
+ {props.header.title}
+
+ {props.header.description && (
+ {props.header.description}
+ )}
+
+ )}
+
+ {props.customHeader && props.customHeader}
+
+
+
{props.isPending ? (
) : props.data.length === 0 ? (
-
+
) : (
+ format(
+ new Date(value),
+ props.xAxis?.showHour ? "MMM dd, HH:mm" : "MMM dd",
+ )
+ }
tickLine={false}
- axisLine={false}
tickMargin={10}
- tickFormatter={(value) => formatDate(new Date(value), "MMM d")}
/>
(
props.hideLabel !== undefined ? props.hideLabel : true
}
labelFormatter={props.toolTipLabelFormatter}
+ valueFormatter={props.toolTipValueFormatter}
/>
}
/>
{props.showLegend && (
- } />
+ }
+ />
)}
- {configKeys.map((key, idx) => (
+ {configKeys.map((key) => (
))}
diff --git a/apps/dashboard/src/@/components/blocks/charts/chart-header.tsx b/apps/dashboard/src/@/components/blocks/charts/chart-header.tsx
new file mode 100644
index 00000000000..9172ab7d3aa
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/charts/chart-header.tsx
@@ -0,0 +1,60 @@
+import { ArrowUpRightIcon } from "lucide-react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { SkeletonContainer } from "@/components/ui/skeleton";
+
+export function TotalValueChartHeader(props: {
+ total: number;
+ title: string;
+ isUsd?: boolean;
+ isPending: boolean;
+ viewMoreLink: string | undefined;
+}) {
+ return (
+
+
+
{
+ return (
+
+ {props.isUsd
+ ? compactUSDFormatter.format(value)
+ : compactNumberFormatter.format(value)}
+
+ );
+ }}
+ />
+
+ {props.title}
+
+
+ {props.viewMoreLink && (
+
+ )}
+
+ );
+}
+
+const compactNumberFormatter = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ maximumFractionDigits: 2,
+});
+
+const compactUSDFormatter = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ maximumFractionDigits: 2,
+ style: "currency",
+ currency: "USD",
+});
diff --git a/apps/dashboard/src/@/components/blocks/client-only.tsx b/apps/dashboard/src/@/components/blocks/client-only.tsx
new file mode 100644
index 00000000000..26b36f6d503
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/client-only.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { type ReactNode, useEffect, useState } from "react";
+import { cn } from "@/lib/utils";
+
+interface ClientOnlyProps {
+ /**
+ * Use this to server render a skeleton or loading state
+ */
+ ssr: ReactNode;
+ className?: string;
+ children: ReactNode;
+}
+
+export const ClientOnly: React.FC = ({
+ children,
+ ssr,
+ className,
+}) => {
+ const hasMounted = useIsClientMounted();
+
+ if (!hasMounted) {
+ return <> {ssr} >;
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+function useIsClientMounted() {
+ const [hasMounted, setHasMounted] = useState(false);
+
+ // eslint-disable-next-line no-restricted-syntax
+ useEffect(() => {
+ setHasMounted(true);
+ }, []);
+
+ return hasMounted;
+}
diff --git a/apps/dashboard/src/@/components/blocks/code-segment.client.tsx b/apps/dashboard/src/@/components/blocks/code-segment.client.tsx
deleted file mode 100644
index 594a088bb1d..00000000000
--- a/apps/dashboard/src/@/components/blocks/code-segment.client.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-"use client";
-import { CodeClient } from "@/components/ui/code/code.client";
-import type React from "react";
-import { type Dispatch, type SetStateAction, useMemo } from "react";
-import { TabButtons } from "../ui/tabs";
-
-export type CodeEnvironment =
- | "javascript"
- | "typescript"
- | "react"
- | "react-native"
- | "unity";
-
-type SupportedEnvironment = {
- environment: CodeEnvironment;
- title: string;
-};
-
-type CodeSnippet = Partial>;
-
-const Environments: SupportedEnvironment[] = [
- {
- environment: "javascript",
- title: "JavaScript",
- },
- {
- environment: "typescript",
- title: "TypeScript",
- },
- {
- environment: "react",
- title: "React",
- },
- {
- environment: "react-native",
- title: "React Native",
- },
- {
- environment: "unity",
- title: "Unity",
- },
-];
-
-interface CodeSegmentProps {
- snippet: CodeSnippet;
- environment: CodeEnvironment;
- setEnvironment:
- | Dispatch>
- | ((language: CodeEnvironment) => void);
- isInstallCommand?: boolean;
- hideTabs?: boolean;
- onlyTabs?: boolean;
-}
-
-export const CodeSegment: React.FC = ({
- snippet,
- environment,
- setEnvironment,
- isInstallCommand,
- hideTabs,
- onlyTabs,
-}) => {
- const activeEnvironment: CodeEnvironment = useMemo(() => {
- return (
- snippet[environment] ? environment : Object.keys(snippet)[0]
- ) as CodeEnvironment;
- }, [environment, snippet]);
-
- const activeSnippet = useMemo(() => {
- return snippet[activeEnvironment];
- }, [activeEnvironment, snippet]);
-
- const lines = useMemo(
- () => (activeSnippet ? activeSnippet.split("\n") : []),
- [activeSnippet],
- );
-
- const code = lines.join("\n").trim();
-
- const environments = Environments.filter(
- (env) =>
- Object.keys(snippet).includes(env.environment) &&
- snippet[env.environment],
- );
-
- return (
-
- {!hideTabs && (
- ({
- label: env.title,
- onClick: () => setEnvironment(env.environment),
- isActive: activeEnvironment === env.environment,
- name: env.title,
- isEnabled: true,
- }))}
- tabClassName="text-sm gap-2 !text-sm"
- tabIconClassName="size-4"
- tabContainerClassName="px-3 pt-1.5 gap-0.5"
- hideBottomLine={!!onlyTabs}
- />
- )}
-
- {onlyTabs ? null : (
- <>
-
- >
- )}
-
- );
-};
diff --git a/apps/dashboard/src/@/components/blocks/code/code-segment.client.tsx b/apps/dashboard/src/@/components/blocks/code/code-segment.client.tsx
new file mode 100644
index 00000000000..4ff5dd36ccd
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/code/code-segment.client.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import type React from "react";
+import { type Dispatch, type SetStateAction, useMemo } from "react";
+import { CodeClient } from "@/components/ui/code/code.client";
+import { TabButtons } from "@/components/ui/tabs";
+import { cn } from "@/lib/utils";
+
+export type CodeEnvironment =
+ | "api"
+ | "javascript"
+ | "typescript"
+ | "react"
+ | "react-native"
+ | "dotnet"
+ | "unity"
+ | "curl";
+
+type SupportedEnvironment = {
+ environment: CodeEnvironment;
+ title: string;
+};
+
+type CodeSnippet = Partial>;
+
+const Environments: SupportedEnvironment[] = [
+ {
+ environment: "api",
+ title: "API",
+ },
+ {
+ environment: "javascript",
+ title: "JavaScript",
+ },
+ {
+ environment: "typescript",
+ title: "TypeScript",
+ },
+ {
+ environment: "react",
+ title: "React",
+ },
+ {
+ environment: "react-native",
+ title: "React Native",
+ },
+ {
+ environment: "dotnet",
+ title: ".NET",
+ },
+ {
+ environment: "unity",
+ title: "Unity",
+ },
+ {
+ environment: "curl",
+ title: "cURL",
+ },
+];
+
+interface CodeSegmentProps {
+ snippet: CodeSnippet;
+ environment: CodeEnvironment;
+ setEnvironment:
+ | Dispatch>
+ | ((language: CodeEnvironment) => void);
+ isInstallCommand?: boolean;
+ hideTabs?: boolean;
+ onlyTabs?: boolean;
+ codeContainerClassName?: string;
+}
+
+export const CodeSegment: React.FC = ({
+ snippet,
+ environment,
+ setEnvironment,
+ isInstallCommand,
+ hideTabs,
+ onlyTabs,
+ codeContainerClassName,
+}) => {
+ const activeEnvironment: CodeEnvironment = useMemo(() => {
+ return (
+ snippet[environment] ? environment : Object.keys(snippet)[0]
+ ) as CodeEnvironment;
+ }, [environment, snippet]);
+
+ const activeSnippet = useMemo(() => {
+ return snippet[activeEnvironment];
+ }, [activeEnvironment, snippet]);
+
+ const lines = useMemo(
+ () => (activeSnippet ? activeSnippet.split("\n") : []),
+ [activeSnippet],
+ );
+
+ const code = lines.join("\n").trim();
+
+ const environments = Environments.filter(
+ (env) =>
+ Object.keys(snippet).includes(env.environment) &&
+ snippet[env.environment],
+ );
+
+ return (
+
+ {!hideTabs && (
+ ({
+ isActive: activeEnvironment === env.environment,
+ label: env.title,
+ name: env.title,
+ onClick: () => setEnvironment(env.environment),
+ }))}
+ />
+ )}
+
+ {onlyTabs ? null : (
+
+ )}
+
+ );
+};
diff --git a/apps/dashboard/src/@/components/blocks/code-segment.stories.tsx b/apps/dashboard/src/@/components/blocks/code/code-segment.stories.tsx
similarity index 91%
rename from apps/dashboard/src/@/components/blocks/code-segment.stories.tsx
rename to apps/dashboard/src/@/components/blocks/code/code-segment.stories.tsx
index 7b2c0999073..bd28ca1ca34 100644
--- a/apps/dashboard/src/@/components/blocks/code-segment.stories.tsx
+++ b/apps/dashboard/src/@/components/blocks/code/code-segment.stories.tsx
@@ -1,3 +1,5 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import { useState } from "react";
import {
Select,
SelectContent,
@@ -6,35 +8,26 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import type { Meta, StoryObj } from "@storybook/react";
-import { useState } from "react";
-import { BadgeContainer, mobileViewport } from "../../../stories/utils";
+import { BadgeContainer } from "@/storybook/utils";
import { type CodeEnvironment, CodeSegment } from "./code-segment.client";
const meta = {
- title: "blocks/CodeSegment",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
+ title: "blocks/CodeSegment",
} satisfies Meta;
export default meta;
type Story = StoryObj;
-export const Desktop: Story = {
+export const Variants: Story = {
args: {},
};
-export const Mobile: Story = {
- args: {},
- parameters: {
- viewport: mobileViewport("iphone14"),
- },
-};
-
type Mode = "default" | "no-tabs" | "only-tabs";
function Story() {
@@ -44,10 +37,10 @@ function Story() {
return (
-
+
);
@@ -99,23 +92,23 @@ function Variant(props: {
return (
);
diff --git a/apps/dashboard/src/@/components/blocks/code/downloadable-code.tsx b/apps/dashboard/src/@/components/blocks/code/downloadable-code.tsx
new file mode 100644
index 00000000000..b72009b2743
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/code/downloadable-code.tsx
@@ -0,0 +1,45 @@
+"use client";
+import { ArrowDownToLineIcon, FileTextIcon } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { CodeClient } from "@/components/ui/code/code.client";
+import { handleDownload } from "../download-file-button";
+
+export function DownloadableCode(props: {
+ code: string;
+ lang: "csv" | "json";
+ fileNameWithExtension: string;
+}) {
+ return (
+
+
+
+
+ {props.fileNameWithExtension}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/color-mode-toggle.tsx b/apps/dashboard/src/@/components/blocks/color-mode-toggle.tsx
new file mode 100644
index 00000000000..c0313bff257
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/color-mode-toggle.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { MoonIcon, SunIcon } from "lucide-react";
+import { useTheme } from "next-themes";
+import { ClientOnly } from "@/components/blocks/client-only";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { Skeleton } from "../ui/skeleton";
+
+export function ToggleThemeButton(props: { className?: string }) {
+ const { setTheme, theme } = useTheme();
+
+ return (
+ }
+ >
+
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/dismissible-alert.tsx b/apps/dashboard/src/@/components/blocks/dismissible-alert.tsx
new file mode 100644
index 00000000000..85f082af1ae
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/dismissible-alert.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { XIcon } from "lucide-react";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { useLocalStorage } from "@/hooks/useLocalStorage";
+
+export function DismissibleAlert(
+ props: {
+ title: React.ReactNode;
+ header?: React.ReactNode;
+ className?: string;
+ description: React.ReactNode;
+ children?: React.ReactNode;
+ } & (
+ | {
+ preserveState: true;
+ localStorageId: string;
+ }
+ | {
+ preserveState: false;
+ }
+ ),
+) {
+ if (props.preserveState) {
+ return ;
+ }
+
+ return ;
+}
+
+function DismissibleAlertWithLocalStorage(props: {
+ title: React.ReactNode;
+ header?: React.ReactNode;
+ description: React.ReactNode;
+ children?: React.ReactNode;
+ localStorageId: string;
+}) {
+ const [isVisible, setIsVisible] = useLocalStorage(
+ props.localStorageId,
+ true,
+ false,
+ );
+
+ if (!isVisible) return null;
+
+ return setIsVisible(false)} />;
+}
+
+function DismissibleAlertWithoutLocalStorage(props: {
+ title: React.ReactNode;
+ description: React.ReactNode;
+ children?: React.ReactNode;
+}) {
+ const [isVisible, setIsVisible] = useState(true);
+
+ if (!isVisible) return null;
+
+ return setIsVisible(false)} />;
+}
+
+function AlertUI(props: {
+ title: React.ReactNode;
+ header?: React.ReactNode;
+ description: React.ReactNode;
+ children?: React.ReactNode;
+ className?: string;
+ onClose: () => void;
+}) {
+ return (
+
+
+
+
+ {props.header}
+
{props.title}
+
+ {props.description}
+
+ {props.children}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/distribution-chart.tsx b/apps/dashboard/src/@/components/blocks/distribution-chart.tsx
new file mode 100644
index 00000000000..b34094aadda
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/distribution-chart.tsx
@@ -0,0 +1,93 @@
+import { cn } from "@/lib/utils";
+
+export type Segment = {
+ label: string;
+ percent: number;
+ value: string;
+ color: string;
+};
+
+type DistributionBarChartProps = {
+ segments: Segment[];
+ title?: string;
+ titleClassName?: string;
+ barClassName?: string;
+};
+
+export function DistributionBarChart(props: DistributionBarChartProps) {
+ const totalPercentage = props.segments.reduce(
+ (sum, segment) => sum + segment.percent,
+ 0,
+ );
+
+ const invalidTotalPercentage = totalPercentage !== 100;
+
+ return (
+
+ {props.title && (
+
+
+ {props.title}
+
+
+ Total: {totalPercentage}%
+
+
+ )}
+
+ {/* Bar */}
+
+ {props.segments.map((segment) => {
+ return (
+
0 && "border-r-2 border-background",
+ )}
+ key={segment.label}
+ style={{
+ backgroundColor: segment.color,
+ width: `${segment.percent}%`,
+ }}
+ />
+ );
+ })}
+
+
+ {/* Legends */}
+
+ {props.segments.map((segment) => {
+ return (
+
+
+
100 || segment.percent < 0) &&
+ "text-destructive-text",
+ )}
+ >
+ {segment.label}: {segment.value}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/download-file-button.tsx b/apps/dashboard/src/@/components/blocks/download-file-button.tsx
new file mode 100644
index 00000000000..df9e55c4a05
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/download-file-button.tsx
@@ -0,0 +1,47 @@
+import { ArrowDownToLineIcon } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+export function handleDownload(params: {
+ fileContent: string;
+ fileNameWithExtension: string;
+ fileFormat: "text/csv" | "application/json";
+}) {
+ const link = document.createElement("a");
+ const blob = new Blob([params.fileContent], {
+ type: params.fileFormat,
+ });
+ const objectURL = URL.createObjectURL(blob);
+ link.href = objectURL;
+ link.download = params.fileNameWithExtension;
+ link.click();
+ URL.revokeObjectURL(objectURL);
+}
+
+export function DownloadFileButton(props: {
+ fileContent: string;
+ fileNameWithExtension: string;
+ fileFormat: "text/csv" | "application/json";
+ label: string;
+ variant?: "default" | "outline";
+ className?: string;
+ iconClassName?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.stories.tsx b/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.stories.tsx
new file mode 100644
index 00000000000..ae0ebd29c7f
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.stories.tsx
@@ -0,0 +1,53 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import { DropZone } from "./drop-zone";
+
+const meta = {
+ component: DropZone,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ title: "blocks/DropZone",
+} satisfies Meta
;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ accept: undefined,
+ description: "This is a description for drop zone",
+ isError: false,
+ onDrop: () => {},
+ resetButton: undefined,
+ title: "This is a title",
+ },
+};
+
+export const ErrorState: Story = {
+ args: {
+ accept: undefined,
+ description: "This is a description",
+ isError: true,
+ onDrop: () => {},
+ resetButton: undefined,
+ title: "this is title",
+ },
+};
+
+export const ErrorStateWithResetButton: Story = {
+ args: {
+ accept: undefined,
+ description: "This is a description",
+ isError: true,
+ onDrop: () => {},
+ resetButton: {
+ label: "Remove Files",
+ onClick: () => {},
+ },
+ title: "this is title",
+ },
+};
diff --git a/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.tsx b/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.tsx
new file mode 100644
index 00000000000..054d60f41a2
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.tsx
@@ -0,0 +1,81 @@
+import { UploadIcon, XIcon } from "lucide-react";
+import { useDropzone } from "react-dropzone";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+export function DropZone(props: {
+ isError: boolean;
+ onDrop: (acceptedFiles: File[]) => void;
+ title: string;
+ description: string;
+ resetButton:
+ | {
+ label: string;
+ onClick: () => void;
+ }
+ | undefined;
+ className?: string;
+ accept: string | undefined;
+}) {
+ const { getRootProps, getInputProps } = useDropzone({
+ onDrop: props.onDrop,
+ });
+
+ const { resetButton } = props;
+
+ return (
+
+
+
+ {!props.isError && (
+
+
+
+
+
+ {props.title}
+
+
+ {props.description}
+
+
+ )}
+
+ {props.isError && (
+
+
+
+
+
+ {props.title}
+
+
+ {props.description}
+
+
+ {resetButton && (
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/error-fallbacks/unexpect-value-error-message.tsx b/apps/dashboard/src/@/components/blocks/error-fallbacks/unexpect-value-error-message.tsx
index 2642f46882f..8ce65036fe3 100644
--- a/apps/dashboard/src/@/components/blocks/error-fallbacks/unexpect-value-error-message.tsx
+++ b/apps/dashboard/src/@/components/blocks/error-fallbacks/unexpect-value-error-message.tsx
@@ -1,6 +1,6 @@
-import { CopyTextButton } from "@/components/ui/CopyTextButton";
-import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { useMemo } from "react";
+import { CopyTextButton } from "@/components/ui/CopyTextButton";
+import { ScrollShadow } from "@/components/ui/ScrollShadow";
export function UnexpectedValueErrorMessage(props: {
value: unknown;
@@ -30,11 +30,11 @@ export function UnexpectedValueErrorMessage(props: {
diff --git a/apps/dashboard/src/@/components/blocks/faq-section.tsx b/apps/dashboard/src/@/components/blocks/faq-section.tsx
new file mode 100644
index 00000000000..a06315888c6
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/faq-section.tsx
@@ -0,0 +1,68 @@
+"use client";
+import { ChevronDownIcon } from "lucide-react";
+import { useId, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { DynamicHeight } from "@/components/ui/DynamicHeight";
+import { cn } from "@/lib/utils";
+
+export function FaqAccordion(props: {
+ faqs: Array<{ title: string; description: string }>;
+}) {
+ return (
+
+ {props.faqs.map((faq, faqIndex) => (
+
+ ))}
+
+ );
+}
+
+function FaqItem(props: {
+ title: string;
+ description: string;
+ className?: string;
+}) {
+ const [isOpen, setIsOpenn] = useState(false);
+ const contentId = useId();
+ return (
+
+
+
+
+
+
+
+ {props.description}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/file-preview.tsx b/apps/dashboard/src/@/components/blocks/file-preview.tsx
new file mode 100644
index 00000000000..a28e1697261
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/file-preview.tsx
@@ -0,0 +1,72 @@
+import { ImageOffIcon } from "lucide-react";
+import { useEffect, useState } from "react";
+import type { ThirdwebClient } from "thirdweb";
+import { MediaRenderer } from "thirdweb/react";
+import { Img } from "@/components/blocks/Img";
+import { fileToBlobUrl } from "@/lib/file-to-url";
+import { cn } from "@/lib/utils";
+
+export function FilePreview(props: {
+ srcOrFile: File | string | undefined;
+ fallback?: React.ReactNode;
+ className?: string;
+ client: ThirdwebClient;
+ imgContainerClassName?: string;
+}) {
+ const [objectUrl, setObjectUrl] = useState("");
+
+ // eslint-disable-next-line no-restricted-syntax
+ useEffect(() => {
+ if (props.srcOrFile instanceof File) {
+ const url = fileToBlobUrl(props.srcOrFile);
+ setObjectUrl(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else if (typeof props.srcOrFile === "string") {
+ setObjectUrl(props.srcOrFile);
+ } else {
+ setObjectUrl("");
+ }
+ }, [props.srcOrFile]);
+
+ // shortcut just for images
+ const isImage =
+ props.srcOrFile instanceof File &&
+ props.srcOrFile.type.startsWith("image/");
+
+ if (!objectUrl) {
+ return (
+
+
+
+ );
+ }
+
+ if (isImage) {
+ return (
+
+ );
+ }
+
+ return (
+ div]:!bg-accent [&_a]:!text-muted-foreground [&_a]:!no-underline [&_svg]:!size-6 [&_svg]:!text-muted-foreground relative overflow-hidden",
+ )}
+ client={props.client}
+ src={objectUrl}
+ />
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx b/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx
new file mode 100644
index 00000000000..26ce5518be2
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx
@@ -0,0 +1,340 @@
+"use client";
+
+import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
+import { usePathname } from "next/navigation";
+import { useMemo } from "react";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { DynamicHeight } from "@/components/ui/DynamicHeight";
+import { NavLink } from "@/components/ui/NavLink";
+import { Separator } from "@/components/ui/separator";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSub,
+ SidebarMenuSubItem,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+} from "@/components/ui/sidebar";
+import { cn } from "@/lib/utils";
+
+type ShadcnSidebarBaseLink = {
+ href: string;
+ label: React.ReactNode;
+ exactMatch?: boolean;
+ icon?: React.FC<{ className?: string }>;
+ isActive?: (pathname: string) => boolean;
+};
+
+export type ShadcnSidebarLink =
+ | ShadcnSidebarBaseLink
+ | {
+ group: string;
+ links: ShadcnSidebarLink[];
+ }
+ | {
+ separator: true;
+ }
+ | {
+ subMenu: Omit;
+ links: Omit[];
+ };
+
+export function FullWidthSidebarLayout(props: {
+ contentSidebarLinks: ShadcnSidebarLink[];
+ footerSidebarLinks?: ShadcnSidebarLink[];
+ children: React.ReactNode;
+ className?: string;
+}) {
+ const { contentSidebarLinks, children, footerSidebarLinks } = props;
+ return (
+
+ {/* left - sidebar */}
+
+
+
+
+
+ {footerSidebarLinks && (
+
+
+
+ )}
+
+
+
+
+ {/* right - content */}
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+function MobileSidebarTrigger(props: { links: ShadcnSidebarLink[] }) {
+ const activeLink = useActiveShadcnSidebarLink(props.links);
+ const parentSubNav = findParentSubmenu(props.links, activeLink?.href);
+
+ return (
+
+
+
+ {parentSubNav && (
+ <>
+ {parentSubNav.subMenu.label}
+
+ >
+ )}
+ {activeLink && {activeLink.label}}
+
+ );
+}
+
+function useActiveShadcnSidebarLink(links: ShadcnSidebarLink[]) {
+ const pathname = usePathname();
+
+ const activeLink = useMemo(() => {
+ function isActive(link: ShadcnSidebarBaseLink) {
+ if (link.exactMatch) {
+ return link.href === pathname;
+ }
+ return pathname?.startsWith(link.href);
+ }
+
+ function walk(
+ navLinks: ShadcnSidebarLink[],
+ ): ShadcnSidebarBaseLink | undefined {
+ for (const link of navLinks) {
+ if ("subMenu" in link) {
+ for (const subLink of link.links) {
+ if (isActive(subLink)) {
+ return subLink;
+ }
+ }
+ } else if ("href" in link) {
+ if (isActive(link)) {
+ return link;
+ }
+ }
+
+ if ("links" in link && !("subMenu" in link)) {
+ const nested = walk(link.links);
+ if (nested) {
+ return nested;
+ }
+ }
+ }
+
+ return undefined;
+ }
+
+ return walk(links);
+ }, [links, pathname]);
+
+ return activeLink;
+}
+
+function findParentSubmenu(
+ links: ShadcnSidebarLink[],
+ activeHref: string | undefined,
+): Extract | undefined {
+ if (!activeHref) {
+ return undefined;
+ }
+
+ for (const link of links) {
+ if ("subMenu" in link) {
+ if (link.links.some((subLink) => subLink.href === activeHref)) {
+ return link;
+ }
+ }
+
+ if ("links" in link && !("subMenu" in link)) {
+ const nested = findParentSubmenu(link.links, activeHref);
+ if (nested) {
+ return nested;
+ }
+ }
+ }
+
+ return undefined;
+}
+
+function useIsSubnavActive(links: ShadcnSidebarBaseLink[]) {
+ const pathname = usePathname();
+
+ const isSubnavActive = useMemo(() => {
+ function isActive(link: ShadcnSidebarBaseLink) {
+ if (link.exactMatch) {
+ return link.href === pathname;
+ }
+ return pathname?.startsWith(link.href);
+ }
+
+ return links.some(isActive);
+ }, [links, pathname]);
+
+ return isSubnavActive;
+}
+
+function RenderSidebarGroup(props: {
+ sidebarLinks: ShadcnSidebarLink[];
+ groupName: string;
+}) {
+ return (
+
+
+ {props.groupName}
+
+
+
+
+
+ );
+}
+
+function RenderSidebarSubmenu(props: {
+ links: ShadcnSidebarBaseLink[];
+ subMenu: Omit;
+}) {
+ const sidebar = useSidebar();
+ const isSubnavActive = useIsSubnavActive(props.links);
+ return (
+
+
+
+
+
+
+ {props.subMenu.icon && (
+
+ )}
+ {props.subMenu.label}
+
+
+
+
+
+
+ {props.links.map((link) => {
+ return (
+
+ {
+ sidebar.setOpenMobile(false);
+ }}
+ >
+ {link.icon && }
+ {link.label}
+
+
+ );
+ })}
+
+
+
+
+
+
+ );
+}
+
+function RenderSidebarMenu(props: { links: ShadcnSidebarLink[] }) {
+ const sidebar = useSidebar();
+ return (
+
+ {props.links.map((link, idx) => {
+ // link
+ if ("href" in link) {
+ return (
+
+
+ {
+ sidebar.setOpenMobile(false);
+ }}
+ >
+ {link.icon && }
+ {link.label}
+
+
+
+ );
+ }
+
+ // separator
+ if ("separator" in link) {
+ return (
+
+ );
+ }
+
+ // subnav
+ if ("subMenu" in link) {
+ return (
+
+ );
+ }
+
+ // group
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/fund-wallets-modal/fund-wallets-modal.stories.tsx b/apps/dashboard/src/@/components/blocks/fund-wallets-modal/fund-wallets-modal.stories.tsx
new file mode 100644
index 00000000000..d44624544c0
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/fund-wallets-modal/fund-wallets-modal.stories.tsx
@@ -0,0 +1,49 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import { useState } from "react";
+import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
+import { Button } from "@/components/ui/button";
+import { storybookThirdwebClient } from "@/storybook/utils";
+import { FundWalletModal } from "./index";
+
+const meta: Meta = {
+ title: "Blocks/FundWalletModal",
+ component: Variant,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Test: Story = {
+ args: {},
+};
+function Variant() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/fund-wallets-modal/index.tsx b/apps/dashboard/src/@/components/blocks/fund-wallets-modal/index.tsx
new file mode 100644
index 00000000000..730557f9725
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/fund-wallets-modal/index.tsx
@@ -0,0 +1,292 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
+import { useTheme } from "next-themes";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import type { ThirdwebClient } from "thirdweb";
+import { defineChain } from "thirdweb/chains";
+import { CheckoutWidget, useActiveWalletChain } from "thirdweb/react";
+import { z } from "zod";
+import {
+ reportFundWalletFailed,
+ reportFundWalletSuccessful,
+} from "@/analytics/report";
+import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
+import { TokenSelector } from "@/components/blocks/TokenSelector";
+import { Button } from "@/components/ui/button";
+import { DecimalInput } from "@/components/ui/decimal-input";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { getSDKTheme } from "@/utils/sdk-component-theme";
+import { WalletAddress } from "../wallet-address";
+
+const formSchema = z.object({
+ chainId: z.number({
+ required_error: "Chain is required",
+ }),
+ token: z.object(
+ {
+ chainId: z.number(),
+ address: z.string(),
+ symbol: z.string(),
+ name: z.string(),
+ decimals: z.number(),
+ },
+ {
+ required_error: "Token is required",
+ },
+ ),
+ amount: z
+ .string()
+ .min(1, "Amount is required")
+ .refine((value) => {
+ const num = Number(value);
+ return !Number.isNaN(num) && num > 0;
+ }, "Amount must be greater than 0"),
+});
+
+type FormData = z.infer;
+
+type FundWalletModalProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ description: string;
+ recipientAddress: string;
+ client: ThirdwebClient;
+ defaultChainId?: number;
+ checkoutWidgetTitle?: string;
+};
+
+export function FundWalletModal(props: FundWalletModalProps) {
+ return (
+
+ );
+}
+
+function FundWalletModalContent(props: FundWalletModalProps) {
+ const [step, setStep] = useState<"form" | "checkout">("form");
+ const activeChain = useActiveWalletChain();
+ const { theme } = useTheme();
+
+ const form = useForm({
+ defaultValues: {
+ chainId: props.defaultChainId || activeChain?.id || 1,
+ token: undefined,
+ amount: "0.1",
+ },
+ mode: "onChange",
+ resolver: zodResolver(formSchema),
+ });
+
+ const selectedChainId = form.watch("chainId");
+
+ return (
+
+ {step === "form" ? (
+
+
+ ) : (
+
+
+ {props.title}
+ {props.description}
+
+
+
+ {
+ reportFundWalletSuccessful();
+ }}
+ onError={(error) => {
+ reportFundWalletFailed({
+ errorMessage: error.message,
+ });
+ }}
+ />
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/grid-pattern-embed-container.tsx b/apps/dashboard/src/@/components/blocks/grid-pattern-embed-container.tsx
new file mode 100644
index 00000000000..bae0ec0865e
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/grid-pattern-embed-container.tsx
@@ -0,0 +1,23 @@
+import { GridPattern } from "@/components/ui/background-patterns";
+
+export function GridPatternEmbedContainer(props: {
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/markdown-renderer.tsx b/apps/dashboard/src/@/components/blocks/markdown-renderer.tsx
new file mode 100644
index 00000000000..8d10d475af4
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/markdown-renderer.tsx
@@ -0,0 +1 @@
+export { MarkdownRenderer } from "@workspace/ui/components/markdown-renderer";
diff --git a/apps/dashboard/src/@/components/blocks/media-renderer.tsx b/apps/dashboard/src/@/components/blocks/media-renderer.tsx
new file mode 100644
index 00000000000..a7148606bd5
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/media-renderer.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { MediaRenderer, type MediaRendererProps } from "thirdweb/react";
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+
+export function CustomMediaRenderer(props: MediaRendererProps) {
+ const [loadedSrc, setLoadedSrc] = useState(undefined);
+
+ // eslint-disable-next-line no-restricted-syntax
+ useEffect(() => {
+ if (loadedSrc && loadedSrc !== props.src) {
+ setLoadedSrc(undefined);
+ }
+ }, [loadedSrc, props.src]);
+
+ return (
+ {
+ if (props.src) {
+ setLoadedSrc(props.src);
+ }
+ }}
+ >
+ {!loadedSrc && }
+ div]:!bg-accent [&_a]:!text-muted-foreground [&_a]:!no-underline [&_svg]:!size-6 [&_svg]:!text-muted-foreground relative z-10",
+ )}
+ />
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/multi-select.stories.tsx b/apps/dashboard/src/@/components/blocks/multi-select.stories.tsx
index 7b9636a04b1..e39aea925b2 100644
--- a/apps/dashboard/src/@/components/blocks/multi-select.stories.tsx
+++ b/apps/dashboard/src/@/components/blocks/multi-select.stories.tsx
@@ -1,54 +1,47 @@
-import type { Meta, StoryObj } from "@storybook/react";
+import type { Meta, StoryObj } from "@storybook/nextjs";
import { useMemo, useState } from "react";
-import { BadgeContainer, mobileViewport } from "../../../stories/utils";
+import { BadgeContainer } from "@/storybook/utils";
import { MultiSelect } from "./multi-select";
const meta = {
- title: "blocks/MultiSelect",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
+ title: "blocks/MultiSelect",
} satisfies Meta;
export default meta;
type Story = StoryObj;
-export const Desktop: Story = {
- args: {},
-};
-
-export const Mobile: Story = {
+export const Variants: Story = {
args: {},
- parameters: {
- viewport: mobileViewport("iphone14"),
- },
};
function createList(len: number) {
return Array.from({ length: len }, (_, i) => ({
- value: `${i}`,
label: `Item ${i}`,
+ value: `${i}`,
}));
}
function Story() {
return (
-
-
-
+
+
+
);
@@ -66,13 +59,13 @@ function VariantTest(props: {
return (
{
setValues(values);
}}
+ options={list}
placeholder="Select items"
- maxCount={props.maxCount}
+ selectedValues={values}
/>
);
diff --git a/apps/dashboard/src/@/components/blocks/multi-select.tsx b/apps/dashboard/src/@/components/blocks/multi-select.tsx
index 3b3249ee9b2..3f2190b7196 100644
--- a/apps/dashboard/src/@/components/blocks/multi-select.tsx
+++ b/apps/dashboard/src/@/components/blocks/multi-select.tsx
@@ -1,26 +1,19 @@
-/* eslint-disable no-restricted-syntax */
+/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
"use client";
+import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
+import { forwardRef, useCallback, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
+import { ScrollShadow } from "@/components/ui/ScrollShadow";
import { Separator } from "@/components/ui/separator";
+import { useShowMore } from "@/hooks/useShowMore";
import { cn } from "@/lib/utils";
-import { CheckIcon, ChevronDown, SearchIcon, XIcon } from "lucide-react";
-import {
- forwardRef,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
-import { useShowMore } from "../../lib/useShowMore";
-import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow";
-import { Input } from "../ui/input";
interface MultiSelectProps
extends React.ButtonHTMLAttributes
{
@@ -47,7 +40,13 @@ interface MultiSelectProps
searchTerm: string,
) => boolean;
+ popoverContentClassName?: string;
+ customTrigger?: React.ReactNode;
renderOption?: (option: { value: string; label: string }) => React.ReactNode;
+ align?: "center" | "start" | "end";
+ side?: "left" | "right" | "top" | "bottom";
+ showSelectedValuesInModal?: boolean;
+ customSearchInput?: React.ReactNode;
}
export const MultiSelect = forwardRef(
@@ -62,6 +61,10 @@ export const MultiSelect = forwardRef(
overrideSearchFn,
renderOption,
searchPlaceholder,
+ popoverContentClassName,
+ showSelectedValuesInModal = false,
+ customSearchInput,
+ customTrigger,
...props
},
ref,
@@ -141,138 +144,137 @@ export const MultiSelect = forwardRef(
// scroll to top when options change
const popoverElRef = useRef(null);
- // biome-ignore lint/correctness/useExhaustiveDependencies:
- useEffect(() => {
- const scrollContainer =
- popoverElRef.current?.querySelector("[data-scrollable]");
- if (scrollContainer) {
- scrollContainer.scrollTo({
- top: 0,
- });
- }
- }, [searchValue]);
+
+ // Filter out customTrigger from props to avoid passing it to Button
+ const buttonProps = Object.fromEntries(
+ Object.entries(props).filter(([key]) => key !== "customTrigger"),
+ ) as React.ButtonHTMLAttributes;
return (
-
+
-