diff --git a/examples/ui-demo/src/app/config.tsx b/examples/ui-demo/src/app/config.tsx index d827762cf8..176447f23e 100644 --- a/examples/ui-demo/src/app/config.tsx +++ b/examples/ui-demo/src/app/config.tsx @@ -36,9 +36,15 @@ export type Config = { } | undefined; }; + walletType: WalletTypes; supportUrl?: string; }; +export enum WalletTypes { + smart = "smart", + hybrid7702 = "7702", +} + export const DEFAULT_CONFIG: Config = { auth: { showEmail: true, @@ -65,6 +71,7 @@ export const DEFAULT_CONFIG: Config = { logoLight: undefined, logoDark: undefined, }, + walletType: WalletTypes.smart, }; export const queryClient = new QueryClient(); diff --git a/examples/ui-demo/src/app/page.tsx b/examples/ui-demo/src/app/page.tsx index 06ef43b115..d88304c44a 100644 --- a/examples/ui-demo/src/app/page.tsx +++ b/examples/ui-demo/src/app/page.tsx @@ -21,6 +21,10 @@ import { AuthCardWrapper } from "../components/preview/AuthCardWrapper"; import { CodePreview } from "../components/preview/CodePreview"; import { CodePreviewSwitch } from "../components/shared/CodePreviewSwitch"; import { TopNav } from "../components/topnav/TopNav"; +import { Configuration } from "@/components/configuration/Configuration"; +import { Wrapper7702 } from "@/components/shared/7702/Wrapper"; +import { useConfigStore } from "@/state"; +import { WalletTypes } from "./config"; const publicSans = Public_Sans({ subsets: ["latin"], @@ -37,6 +41,7 @@ export default function Home() { const user = useUser(); const theme = useTheme(); const isEOAUser = user?.type === "eoa"; + const { walletType } = useConfigStore(); return (
+
@@ -129,22 +135,45 @@ export default function Home() {
- {!user && } - {isEOAUser && ( -
-
- -
- - -
-
-
- )} - {user && !isEOAUser && } + {user && !isEOAUser && }
); } + +const RenderContent = ({ + user, + isEOAUser, + isSmartWallet, +}: { + user: boolean; + isEOAUser: boolean; + isSmartWallet: boolean; +}) => { + if (!user) { + return ; + } + if (isEOAUser) { + return ( +
+
+ +
+ + +
+
+
+ ); + } + if (isSmartWallet) { + return ; + } + return ; +}; diff --git a/examples/ui-demo/src/components/configuration/Configuration.tsx b/examples/ui-demo/src/components/configuration/Configuration.tsx new file mode 100644 index 0000000000..1b471a08e4 --- /dev/null +++ b/examples/ui-demo/src/components/configuration/Configuration.tsx @@ -0,0 +1,53 @@ +// import { useState } from "react"; +import { cn } from "@/lib/utils"; + +import { SettingsIcon } from "../icons/settings"; +// import { HelpTooltip } from "../shared/HelpTooltip"; +import { WalletTypeSwitch } from "../shared/WalletTypeSwitch"; +import ExternalLink from "../shared/ExternalLink"; +import { useConfigStore } from "@/state"; +import { WalletTypes } from "@/app/config"; + +export const Configuration = ({ className }: { className?: string }) => { + const { setWalletType, walletType } = useConfigStore(); + // const [walletType, setWalletType] = useState(WalletTypes.smart); + + const onSwitchWalletType = () => { + setWalletType( + walletType === WalletTypes.smart + ? WalletTypes.hybrid7702 + : WalletTypes.smart + ); + }; + + return ( +
+
+ + Configuration +
+
+

+ Embedded Wallet Type +

+ {/* */} +
+ +

+ Sentence describing all of the value props fo 7702 and educating the + user. Curious about what this means? + + Learn more. + +

+
+
+ ); +}; diff --git a/examples/ui-demo/src/components/icons/key.tsx b/examples/ui-demo/src/components/icons/key.tsx new file mode 100644 index 0000000000..366380ad26 --- /dev/null +++ b/examples/ui-demo/src/components/icons/key.tsx @@ -0,0 +1,24 @@ +export const Key = ({ className }: { className?: string }) => ( + + + + +); diff --git a/examples/ui-demo/src/components/icons/loading.tsx b/examples/ui-demo/src/components/icons/loading.tsx index ef89746f77..eb5041e0d8 100644 --- a/examples/ui-demo/src/components/icons/loading.tsx +++ b/examples/ui-demo/src/components/icons/loading.tsx @@ -1,5 +1,5 @@ import { useTheme } from "@/state/useTheme"; -export const LoadingIcon = () => { +export const LoadingIcon = ({ className }: { className?: string }) => { const theme = useTheme(); const animationClass = theme === "dark" ? "animate-ui-loading-dark" : "animate-ui-loading-light"; @@ -11,6 +11,7 @@ export const LoadingIcon = () => { viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" + className={className} > ) => ( + + + +); diff --git a/examples/ui-demo/src/components/preview/AuthCardWrapper.tsx b/examples/ui-demo/src/components/preview/AuthCardWrapper.tsx index fb62be1ccf..1914a369f8 100644 --- a/examples/ui-demo/src/components/preview/AuthCardWrapper.tsx +++ b/examples/ui-demo/src/components/preview/AuthCardWrapper.tsx @@ -4,6 +4,9 @@ import { cn } from "@/lib/utils"; import { useTheme } from "@/state/useTheme"; import { AuthCard, useUser } from "@account-kit/react"; import { EOAPostLogin } from "../shared/eoa-post-login/EOAPostLogin"; +import { Wrapper7702 } from "../shared/7702/Wrapper"; +import { useConfigStore } from "@/state"; +import { WalletTypes } from "@/app/config"; import { MintCard } from "../shared/mint-card/MintCard"; import { Debug7702Button } from "../shared/7702/Debug7702Button"; @@ -26,6 +29,7 @@ export function AuthCardWrapper({ className }: { className?: string }) { } const RenderContent = () => { + const { walletType } = useConfigStore(); const user = useUser(); const hasUser = !!user; @@ -55,10 +59,5 @@ const RenderContent = () => { ); } - return ( -
- - -
- ); + return walletType === WalletTypes.smart ? : ; }; diff --git a/examples/ui-demo/src/components/shared/7702/Button.tsx b/examples/ui-demo/src/components/shared/7702/Button.tsx new file mode 100644 index 0000000000..6d19275a90 --- /dev/null +++ b/examples/ui-demo/src/components/shared/7702/Button.tsx @@ -0,0 +1,18 @@ +import { PropsWithChildren } from "react"; + +export const Button = ({ + children, + className, + ...rest +}: PropsWithChildren< + React.ComponentProps<"button"> & { className?: string } +>) => { + return ( + + ); +}; diff --git a/examples/ui-demo/src/components/shared/7702/MintCard7702.tsx b/examples/ui-demo/src/components/shared/7702/MintCard7702.tsx new file mode 100644 index 0000000000..1094b117b4 --- /dev/null +++ b/examples/ui-demo/src/components/shared/7702/MintCard7702.tsx @@ -0,0 +1,94 @@ +import Image from "next/image"; +import { LoadingIcon } from "@/components/icons/loading"; +import { Button } from "./Button"; +import { MintStages } from "./MintStages"; + +type NFTLoadingState = "initial" | "loading" | "success"; +export type MintStatus = { + signing: NFTLoadingState; + gas: NFTLoadingState; + batch: NFTLoadingState; +}; + +export const MintCard7702 = ({ + isLoading, + isDisabled, + status, + nftTransfered, + handleCollectNFT, + uri, +}: { + isLoading: boolean; + isDisabled?: boolean; + status: MintStatus; + nftTransfered: boolean; + handleCollectNFT: () => void; + uri?: string; +}) => { + return ( +
+ {uri ? ( +
+ An NFT +
+ ) : ( +
+ +
+ )} +
+

+ Gasless transactions +

+
+ {!nftTransfered ? ( + <> +

+ Sponsor gas and sign in the background for a one-click transaction + experience. +

+
+

Gas Fee

+

+ + $0.02 + + + Free + +

+
+ + ) : ( + + )} + +
+ ); +}; diff --git a/examples/ui-demo/src/components/shared/7702/MintStages.tsx b/examples/ui-demo/src/components/shared/7702/MintStages.tsx new file mode 100644 index 0000000000..70708d7ec0 --- /dev/null +++ b/examples/ui-demo/src/components/shared/7702/MintStages.tsx @@ -0,0 +1,52 @@ +import { CheckCircleFilledIcon } from "@/components/icons/check-circle-filled"; +import { LoadingIcon } from "@/components/icons/loading"; +import { MintStatus } from "./MintCard7702"; +import { loadingState } from "./Transactions"; + +export const MintStages = ({ status }: { status: MintStatus }) => { + return ( +
+ + + +
+ ); +}; + +const Stage = ({ + icon, + description, + className, +}: { + icon: loadingState; + description: string | JSX.Element; + className?: string; +}) => { + return ( +
+
{getMintIcon(icon)}
+

{description}

+
+ ); +}; + +export const getMintIcon = (icon: loadingState) => { + switch (icon) { + case "loading": + case "initial": + return ; + case "success": + return ( + + ); + } +}; diff --git a/examples/ui-demo/src/components/shared/7702/Transactions.tsx b/examples/ui-demo/src/components/shared/7702/Transactions.tsx new file mode 100644 index 0000000000..c4ea547ea3 --- /dev/null +++ b/examples/ui-demo/src/components/shared/7702/Transactions.tsx @@ -0,0 +1,62 @@ +import { ExternalLinkIcon } from "@/components/icons/external-link"; +import { TransactionType } from "./useTransaction"; +import { CheckCircleFilledIcon } from "@/components/icons/check-circle-filled"; +import { LoadingIcon } from "@/components/icons/loading"; + +export type loadingState = "loading" | "success" | "initial"; + +export const Transactions = ({ + transactions, +}: { + transactions: TransactionType[]; +}) => { + return ( +
+ {transactions.map((transaction, i) => ( + + ))} +
+ ); +}; + +const Transaction = ({ + className, + externalLink, + description, + state, +}: TransactionType & { className?: string }) => { + const getText = () => { + if (state === "initial") { + return "..."; + } + if (state === "initiating") { + return "Initiating buy..."; + } + if (state === "next") { + return "Next buy in 10 seconds..."; + } + return description; + }; + + return ( +
+
+
+ {state === "complete" ? ( + + ) : ( + + )} +
+

{getText()}

+
+ {externalLink && state === "complete" && ( + +
+ +
+
+ )} +
+ ); +}; diff --git a/examples/ui-demo/src/components/shared/7702/TransactionsCard.tsx b/examples/ui-demo/src/components/shared/7702/TransactionsCard.tsx new file mode 100644 index 0000000000..1828f741e5 --- /dev/null +++ b/examples/ui-demo/src/components/shared/7702/TransactionsCard.tsx @@ -0,0 +1,61 @@ +import { Key } from "@/components/icons/key"; +import { Button } from "./Button"; +import { useState } from "react"; +import { Transactions } from "./Transactions"; +import { TransactionType } from "./useTransaction"; + +export const TransactionsCard = ({ + isLoading, + isDisabled, + transactions, + handleTransactions, +}: { + isLoading: boolean; + isDisabled?: boolean; + transactions: TransactionType[]; + handleTransactions: () => void; +}) => { + const [hasClicked, setHasClicked] = useState(false); + const handleClick = () => { + setHasClicked(true); + handleTransactions(); + }; + return ( +
+
+

+ New! +

+ +
+

+ Recurring transactions +

+ {!hasClicked ? ( +

+ Set up a dollar-cost average order by creating a session key with + permission to buy ETH every 10 seconds. +

+ ) : ( + + )} + + +
+ ); +}; diff --git a/examples/ui-demo/src/components/shared/7702/Wrapper.tsx b/examples/ui-demo/src/components/shared/7702/Wrapper.tsx new file mode 100644 index 0000000000..6cfe87382a --- /dev/null +++ b/examples/ui-demo/src/components/shared/7702/Wrapper.tsx @@ -0,0 +1,40 @@ +import { RenderUserConnectionAvatar } from "../user-connection-avatar/RenderUserConnectionAvatar"; +import { MintCard7702 } from "./MintCard7702"; +import { TransactionsCard } from "./TransactionsCard"; +import { useMint } from "./useMint"; +import { useTransactions } from "./useTransaction"; + +export const Wrapper7702 = () => { + const { + isLoading: isLoadingTransactions, + transactions, + handleTransactions, + } = useTransactions(); + const { + isLoading: isLoadingMint, + status, + nftTransfered, + handleCollectNFT, + uri, + } = useMint(); + return ( +
+ + +
+ +
+ ); +}; diff --git a/examples/ui-demo/src/components/shared/7702/useMint.tsx b/examples/ui-demo/src/components/shared/7702/useMint.tsx new file mode 100644 index 0000000000..be0c0072ef --- /dev/null +++ b/examples/ui-demo/src/components/shared/7702/useMint.tsx @@ -0,0 +1,117 @@ +import { + useSendUserOperation, + useSmartAccountClient, +} from "@account-kit/react"; +import { useQuery } from "@tanstack/react-query"; +import { AccountKitNftMinterABI, nftContractAddress } from "@/utils/config"; +import { useCallback, useState } from "react"; +import { useToast } from "@/hooks/useToast"; +import { encodeFunctionData } from "viem"; +import { MintStatus } from "./MintCard7702"; + +const initialValuePropState = { + signing: "initial", + gas: "initial", + batch: "initial", +} satisfies MintStatus; + +export const useMint = () => { + const [status, setStatus] = useState(initialValuePropState); + const [nftTransfered, setNftTransfered] = useState(false); + const isLoading = Object.values(status).some((x) => x === "loading"); + const { setToast } = useToast(); + const { client } = useSmartAccountClient({ type: "LightAccount" }); + const handleSuccess = () => { + setStatus(() => ({ + batch: "success", + gas: "success", + signing: "success", + })); + // Current design does not have a success toast, leaving commented to implement later. + // setToast({ + // type: "success", + // text: ( + // <> + // + // {`You've collected your NFT!`} + // + // + // {`You've successfully collected your NFT! Refresh to mint + // again.`} + // + // + // ), + // open: true, + // }); + }; + + const handleError = (error: Error) => { + console.error(error); + setStatus(initialValuePropState); + setNftTransfered(false); + setToast({ + type: "error", + text: "There was a problem with that action", + open: true, + }); + }; + + const { data: uri } = useQuery({ + queryKey: ["contractURI", nftContractAddress], + queryFn: async () => { + const uri = await client?.readContract({ + address: nftContractAddress, + abi: AccountKitNftMinterABI, + functionName: "baseURI", + }); + return uri; + }, + enabled: !!client && !!client?.readContract, + }); + const { sendUserOperation } = useSendUserOperation({ + client, + waitForTxn: true, + onError: handleError, + onSuccess: handleSuccess, + onMutate: () => { + setTimeout(() => { + setStatus((prev) => ({ ...prev, signing: "success" })); + }, 500); + setTimeout(() => { + setStatus((prev) => ({ ...prev, gas: "success" })); + }, 750); + }, + }); + + const handleCollectNFT = useCallback(async () => { + if (!client) { + console.error("no client"); + return; + } + setNftTransfered(true); + + setStatus({ + signing: "loading", + gas: "loading", + batch: "loading", + }); + sendUserOperation({ + uo: { + target: nftContractAddress, + data: encodeFunctionData({ + abi: AccountKitNftMinterABI, + functionName: "mintTo", + args: [client.getAddress()], + }), + }, + }); + }, [client, sendUserOperation]); + + return { + isLoading, + status, + nftTransfered, + handleCollectNFT, + uri, + }; +}; diff --git a/examples/ui-demo/src/components/shared/7702/useTransaction.ts b/examples/ui-demo/src/components/shared/7702/useTransaction.ts new file mode 100644 index 0000000000..87ae171fdf --- /dev/null +++ b/examples/ui-demo/src/components/shared/7702/useTransaction.ts @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { createPublicClient } from "viem"; +import { mekong, splitMekongTransport } from "./transportSetup"; +import { + createBundlerClientFromExisting, + LocalAccountSigner, +} from "@aa-sdk/core"; +import { privateKeyToAccount } from "viem/accounts"; +import { send7702UO } from "./demoSend7702UO"; + +export type TransactionStages = "initial" | "initiating" | "next" | "complete"; +export type TransactionType = { + state: TransactionStages; + description: string; + externalLink: string; +}; + +const initialState: TransactionType[] = [ + { + state: "initial", + description: "Bought 1 ETH for 4,000 USDC", + externalLink: "www.alchemy.com", + }, + { + state: "initial", + description: "Bought 1 ETH for 3,500 USDC", + externalLink: "www.alchemy.com", + }, + { + state: "initial", + description: "Bought 1 ETH for 4,200 USDC", + externalLink: "www.alchemy.com", + }, +]; + +export const useTransactions = () => { + const [transactions, setTransactions] = + useState(initialState); + + const [isLoading, setIsLoading] = useState(false); + + const handleTransaction = async (transactionIndex: number) => { + setTransactions((prev) => { + const newState = [...prev]; + newState[transactionIndex].state = "initiating"; + if (transactionIndex + 1 < newState.length) { + newState[transactionIndex + 1].state = "next"; + } + return newState; + }); + await new Promise((resolve) => setTimeout(resolve, 3000)); + setTransactions((prev) => { + const newState = [...prev]; + newState[transactionIndex].state = "complete"; + return newState; + }); + const publicClient = createPublicClient({ + chain: mekong, + transport: splitMekongTransport, + }); + const bundlerClient = createBundlerClientFromExisting(publicClient); + const localAccount = privateKeyToAccount( + "0x18bec901c0253fbb203d3423dada59eb720c68f34935185de43d161b0524404b" + ); + send7702UO( + bundlerClient, + splitMekongTransport, + new LocalAccountSigner(localAccount) + ); + }; + // Mock method to fire transactions for 7702 + const handleTransactions = async () => { + console.log({ initialState }); + // initial state is mutated + setIsLoading(true); + setTransactions([ + { + state: "initial", + description: "Bought 1 ETH for 4,000 USDC", + externalLink: "www.alchemy.com", + }, + { + state: "initial", + description: "Bought 1 ETH for 3,500 USDC", + externalLink: "www.alchemy.com", + }, + { + state: "initial", + description: "Bought 1 ETH for 4,200 USDC", + externalLink: "www.alchemy.com", + }, + ]); + for (let i = 0; i < transactions.length; i++) { + await handleTransaction(i); + } + setIsLoading(false); + }; + + return { + transactions, + handleTransactions, + isLoading, + }; +}; diff --git a/examples/ui-demo/src/components/shared/WalletTypeSwitch.tsx b/examples/ui-demo/src/components/shared/WalletTypeSwitch.tsx new file mode 100644 index 0000000000..9b43192654 --- /dev/null +++ b/examples/ui-demo/src/components/shared/WalletTypeSwitch.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Root, Thumb } from "@radix-ui/react-switch"; +import { forwardRef, ElementRef, ComponentPropsWithoutRef } from "react"; + +import { cn } from "@/lib/utils"; + +const selectedStyles = "text-[#475569]"; +const unselectedStyles = "text-[#020617]"; + +const WalletTypeSwitch = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, checked, ...props }, ref) => ( + + +
+
+

+ Smart account +

+
+
+

+ Hybrid account (7702) +

+
+
+
+)); +WalletTypeSwitch.displayName = Root.displayName; + +export { WalletTypeSwitch }; diff --git a/examples/ui-demo/src/components/shared/user-connection-avatar/UserConnectionDetails.tsx b/examples/ui-demo/src/components/shared/user-connection-avatar/UserConnectionDetails.tsx index 4384f19854..255161a128 100644 --- a/examples/ui-demo/src/components/shared/user-connection-avatar/UserConnectionDetails.tsx +++ b/examples/ui-demo/src/components/shared/user-connection-avatar/UserConnectionDetails.tsx @@ -1,3 +1,4 @@ +import { WalletTypes } from "@/app/config"; import { ExternalLinkIcon } from "@/components/icons/external-link"; import { LogoutIcon } from "@/components/icons/logout"; import { DeploymentStatusIndicator } from "@/components/shared/DeploymentStatusIndicator"; @@ -15,8 +16,12 @@ export function UserConnectionDetails({ const user = useUser(); const signer = useSigner(); const { logout } = useLogout(); - const { theme, primaryColor } = useConfigStore( - ({ ui: { theme, primaryColor } }) => ({ theme, primaryColor }) + const { theme, primaryColor, walletType } = useConfigStore( + ({ ui: { theme, primaryColor }, walletType }) => ({ + theme, + primaryColor, + walletType, + }) ); const scaAccount = useAccount({ type: "LightAccount" }); @@ -69,40 +74,60 @@ export function UserConnectionDetails({ {/* Smart Account */}
- Smart account + {walletType === WalletTypes.smart ? "Smart account" : "Address"}
- {/* Status */} -
- Status -
- - - {deploymentStatus ? "Deployed" : "Not deployed"} - -
-
- {/* Signer */} -
- - - Signer - - + + ) : ( +
+ + Delegated to + +
+ + + None + +
+
+ )} {/* Logout */} diff --git a/examples/ui-demo/src/state/store.tsx b/examples/ui-demo/src/state/store.tsx index 6e588ed3cc..e99db24681 100644 --- a/examples/ui-demo/src/state/store.tsx +++ b/examples/ui-demo/src/state/store.tsx @@ -1,4 +1,4 @@ -import { Config, DEFAULT_CONFIG } from "@/app/config"; +import { Config, DEFAULT_CONFIG, WalletTypes } from "@/app/config"; import { getSectionsForConfig } from "@/app/sections"; import { AuthCardHeader } from "@/components/shared/AuthCardHeader"; import { cookieStorage, parseCookie } from "@account-kit/core"; @@ -49,6 +49,7 @@ export type DemoState = Config & { ) => void; setTheme: (theme: Config["ui"]["theme"]) => void; setSupportUrl: (url: string) => void; + setWalletType: (walletType: WalletTypes) => void; }; export const createDemoStore = (initialConfig: Config = DEFAULT_CONFIG) => { @@ -68,6 +69,7 @@ export const createDemoStore = (initialConfig: Config = DEFAULT_CONFIG) => { setTheme, setNftTransferred, nftTransferred, + setWalletType, ...config }) => config, skipHydration: true, @@ -141,6 +143,11 @@ function createInitialState( }, })); }, + setWalletType: (walletType: WalletTypes) => { + set(() => ({ + walletType, + })); + }, }); } diff --git a/examples/ui-demo/tailwind.config.ts b/examples/ui-demo/tailwind.config.ts index c69cbb7552..289f5a047b 100644 --- a/examples/ui-demo/tailwind.config.ts +++ b/examples/ui-demo/tailwind.config.ts @@ -104,6 +104,10 @@ const config = { backgroundImage: { "bg-main": "url('/images/bg-main.webp')", }, + boxShadow: { + smallCard: + "0px 50px 50px 0px rgba(0, 0, 0, 0.09), 0px 12px 27px 0px rgba(0, 0, 0, 0.10)", + }, }, }, plugins: [require("tailwindcss-animate"), require("tailwind-scrollbar")],