From 45616099a51ddcf2b5d75a2d6fab755c6b548a3c Mon Sep 17 00:00:00 2001 From: DC Date: Sat, 21 Dec 2024 00:59:15 -0800 Subject: [PATCH 1/6] paykit: direct payment, API optional --- .../src/components/Common/Modal/index.tsx | 69 ++-- .../components/Common/OrderHeader/index.tsx | 193 +++++------ .../connectkit/src/components/DaimoPay.tsx | 87 +++-- .../src/components/DaimoPayButton/index.tsx | 323 +++++++++--------- .../src/components/DaimoPayModal/index.tsx | 4 +- .../components/Pages/Confirmation/index.tsx | 6 +- .../components/Pages/PayWithToken/index.tsx | 4 +- .../Pages/SelectDepositAddressChain/index.tsx | 4 +- .../components/Pages/SelectMethod/index.tsx | 13 +- .../components/Pages/SelectToken/index.tsx | 4 +- .../Pages/Solana/PayWithSolanaToken/index.tsx | 4 +- .../Pages/Solana/SelectSolanaToken/index.tsx | 4 +- .../Pages/WaitingDepositAddress/index.tsx | 4 +- .../components/Pages/WaitingOther/index.tsx | 4 +- packages/connectkit/src/hooks/hookTypes.ts | 15 + .../connectkit/src/hooks/useDaimoPayStatus.ts | 6 +- packages/connectkit/src/hooks/useModal.ts | 4 - .../src/hooks/usePayWithSolanaToken.ts | 35 +- .../connectkit/src/hooks/usePayWithToken.ts | 85 +++++ .../{usePaymentInfo.ts => usePaymentState.ts} | 226 +++++++----- packages/connectkit/src/index.ts | 34 +- packages/connectkit/src/types.ts | 5 + packages/connectkit/src/utils/exports.ts | 8 + 23 files changed, 667 insertions(+), 474 deletions(-) create mode 100644 packages/connectkit/src/hooks/hookTypes.ts create mode 100644 packages/connectkit/src/hooks/usePayWithToken.ts rename packages/connectkit/src/hooks/{usePaymentInfo.ts => usePaymentState.ts} (64%) create mode 100644 packages/connectkit/src/utils/exports.ts diff --git a/packages/connectkit/src/components/Common/Modal/index.tsx b/packages/connectkit/src/components/Common/Modal/index.tsx index bbd59d5c..c3fce09b 100644 --- a/packages/connectkit/src/components/Common/Modal/index.tsx +++ b/packages/connectkit/src/components/Common/Modal/index.tsx @@ -209,7 +209,7 @@ const Modal: React.FC = ({ selectedTokenOption, selectedSolanaTokenOption, selectedDepositAddressOption, - } = context.paymentInfo; + } = context.paymentState; const wallet = useWallet(context.connector?.id); @@ -559,43 +559,38 @@ const Modal: React.FC = ({ - {Object.keys(pages).map((key) => { - const page = pages[key]; - return ( - // TODO: We may need to use the follow check avoid unnecessary computations, but this causes a bug where the content flashes - // (key === pageId || key === prevPage) && ( - prevDepth - ? "active-scale-up" - : "active" - : "" - } - exitAnim={ - key !== pageId - ? currentDepth < prevDepth - ? "exit-scale-down" - : "exit" - : "" - } + {Object.keys(pages).map((key) => ( + prevDepth + ? "active-scale-up" + : "active" + : "" + } + exitAnim={ + key !== pageId + ? currentDepth < prevDepth + ? "exit-scale-down" + : "exit" + : "" + } + > + - - {page} - - - ); - })} + {pages[key]} + + + ))} diff --git a/packages/connectkit/src/components/Common/OrderHeader/index.tsx b/packages/connectkit/src/components/Common/OrderHeader/index.tsx index ded9b20a..c1913c35 100644 --- a/packages/connectkit/src/components/Common/OrderHeader/index.tsx +++ b/packages/connectkit/src/components/Common/OrderHeader/index.tsx @@ -15,105 +15,14 @@ import styled from "../../../styles/styled"; import { usePayContext } from "../../DaimoPay"; import Button from "../Button"; -const CoinLogos = ({ $size = 24 }: { $size?: number }) => { - const logos = [ - , - , - , - , - , - , - , - ]; - - const logoBlock = (element: React.ReactElement, index: number) => ( - - {element} - - ); - - return ( - {logos.map((element, index) => logoBlock(element, index))} - ); -}; - -const InputUnderlineField = ({ - value, - onChange, - onBlur, - onKeyDown, -}: { - value: string; - onChange: (e: React.ChangeEvent) => void; - onBlur: (e: React.FocusEvent) => void; - onKeyDown: (e: React.KeyboardEvent) => void; -}) => { - // subtract width for decimal point if necessary - const width = value.length - 0.5 * (value.includes(".") ? 1 : 0) + "ch"; - - const selectAll = (e: React.FocusEvent) => { - // When entering edit mode, select the amount for quicker editing - setTimeout(() => e.target.select(), 100); - }; - - return ( -
- - -
- ); -}; - -const InputField = styled(motion.input)<{ $width?: string }>` - box-sizing: border-box; - background-color: transparent; - outline: none; - width: ${(props) => props.$width || "5ch"}; - line-height: inherit; - font-size: inherit; - font-weight: inherit; - color: inherit; - border: none; - padding: 0; - &:focus { - box-sizing: border-box; - outline: none; - border: none; - } -`; - -const Underline = styled(motion.div)` - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 2px; - background-color: var(--ck-body-color-muted); -`; - +/** Shows payment amount. */ export const OrderHeader = ({ minified = false }: { minified?: boolean }) => { - const { paymentInfo } = usePayContext(); + const { paymentState } = usePayContext(); const amount = - paymentInfo.daimoPayOrder?.destFinalCallTokenAmount.usd.toFixed(2); + paymentState.daimoPayOrder?.destFinalCallTokenAmount.usd.toFixed(2); const isEditable = - paymentInfo.daimoPayOrder?.mode === DaimoPayOrderMode.CHOOSE_AMOUNT; + paymentState.daimoPayOrder?.mode === DaimoPayOrderMode.CHOOSE_AMOUNT; const [editableAmount, setEditableAmount] = useState(amount ?? ""); @@ -122,7 +31,7 @@ export const OrderHeader = ({ minified = false }: { minified?: boolean }) => { const handleSave = () => { if (!isEditing) return; - paymentInfo.setChosenUsd(Number(editableAmount)); + paymentState.setChosenUsd(Number(editableAmount)); setIsEditing(false); }; @@ -236,6 +145,98 @@ export const OrderHeader = ({ minified = false }: { minified?: boolean }) => { } }; +function CoinLogos({ $size = 24 }: { $size?: number }) { + const logos = [ + , + , + , + , + , + , + , + ]; + + const logoBlock = (element: React.ReactElement, index: number) => ( + + {element} + + ); + + return ( + {logos.map((element, index) => logoBlock(element, index))} + ); +} + +function InputUnderlineField({ + value, + onChange, + onBlur, + onKeyDown, +}: { + value: string; + onChange: (e: React.ChangeEvent) => void; + onBlur: (e: React.FocusEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; +}) { + // subtract width for decimal point if necessary + const width = value.length - 0.5 * (value.includes(".") ? 1 : 0) + "ch"; + + const selectAll = (e: React.FocusEvent) => { + // When entering edit mode, select the amount for quicker editing + setTimeout(() => e.target.select(), 100); + }; + + return ( +
+ + +
+ ); +} + +const InputField = styled(motion.input)<{ $width?: string }>` + box-sizing: border-box; + background-color: transparent; + outline: none; + width: ${(props) => props.$width || "5ch"}; + line-height: inherit; + font-size: inherit; + font-weight: inherit; + color: inherit; + border: none; + padding: 0; + &:focus { + box-sizing: border-box; + outline: none; + border: none; + } +`; + +const Underline = styled(motion.div)` + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 2px; + background-color: var(--ck-body-color-muted); +`; + const TitleAmount = styled(motion.h1)<{ $error?: boolean; $valid?: boolean; diff --git a/packages/connectkit/src/components/DaimoPay.tsx b/packages/connectkit/src/components/DaimoPay.tsx index 7850dcb9..2fcbb127 100644 --- a/packages/connectkit/src/components/DaimoPay.tsx +++ b/packages/connectkit/src/components/DaimoPay.tsx @@ -1,22 +1,3 @@ -import { Buffer } from "buffer"; -import React, { - createContext, - createElement, - useEffect, - useMemo, - useState, -} from "react"; -import { - CustomTheme, - DaimoPayContextOptions, - DaimoPayModalOptions, - Languages, - Mode, - Theme, -} from "../types"; - -import defaultTheme from "../styles/defaultTheme"; - import { DaimoPayIntentStatus, DaimoPayOrder, @@ -25,8 +6,17 @@ import { debugJson, retryBackoff, } from "@daimo/common"; +import { Buffer } from "buffer"; +import React, { + createContext, + createElement, + useEffect, + useMemo, + useState, +} from "react"; import { ThemeProvider } from "styled-components"; import { useAccount, WagmiContext } from "wagmi"; + import { REQUIRED_CHAINS } from "../defaultConfig"; import { useChainIsSupported } from "../hooks/useChainIsSupported"; import { useChains } from "../hooks/useChains"; @@ -35,7 +25,16 @@ import { useConnectCallbackProps, } from "../hooks/useConnectCallback"; import { useThemeFont } from "../hooks/useGoogleFont"; -import { PaymentInfo, usePaymentInfo } from "../hooks/usePaymentInfo"; +import { PaymentState, usePaymentState } from "../hooks/usePaymentState"; +import defaultTheme from "../styles/defaultTheme"; +import { + CustomTheme, + DaimoPayContextOptions, + DaimoPayModalOptions, + Languages, + Mode, + Theme, +} from "../types"; import { createTrpcClient } from "../utils/trpc"; import { DaimoPayModal } from "./DaimoPayModal"; import { SolanaContextProvider, SolanaWalletName } from "./contexts/solana"; @@ -54,6 +53,7 @@ export enum ROUTES { SOLANA_SELECT_TOKEN = "daimoPaySolanaSelectToken", SOLANA_PAY_WITH_TOKEN = "daimoPaySolanaPayWithToken", + // Unused routes. Kept to minimize connectkit merge conflicts. ONBOARDING = "onboarding", ABOUT = "about", CONNECTORS = "connectors", @@ -63,11 +63,13 @@ export enum ROUTES { SWITCHNETWORKS = "switchNetworks", } +/** Chosen Ethereum wallet, eg MM or Rainbow. Specifies wallet ID. */ type Connector = { id: string; }; type Error = string | React.ReactNode | null; +/** Daimo Pay internal context. */ type ContextValue = { theme: Theme; setTheme: React.Dispatch>; @@ -83,25 +85,25 @@ type ContextValue = { setRoute: React.Dispatch>; connector: Connector; setConnector: React.Dispatch>; - solanaConnector: SolanaWalletName | undefined; - setSolanaConnector: React.Dispatch< - React.SetStateAction - >; errorMessage: Error; debugMode?: boolean; log: (...props: any) => void; displayError: (message: string | React.ReactNode | null, code?: any) => void; resize: number; triggerResize: () => void; + + // All options below are new, specific to Daimo Pay. + /** Chosen Solana wallet, eg Phantom.*/ + solanaConnector: SolanaWalletName | undefined; + setSolanaConnector: React.Dispatch< + React.SetStateAction + >; /** Global options, across all pay buttons and payments. */ options?: DaimoPayContextOptions; /** Loads a payment, then shows the modal to complete payment. */ - loadAndShowPayment: ( - payId: string, - modalOptions: DaimoPayModalOptions, - ) => Promise; + showPayment: (modalOptions: DaimoPayModalOptions) => Promise; /** Payment status & callbacks. */ - paymentInfo: PaymentInfo; + paymentState: PaymentState; /** TRPC API client. Internal use only. */ trpc: any; } & useConnectCallbackProps; @@ -254,7 +256,7 @@ const DaimoPayProviderWithoutSolana = ({ // downstream hooks like useDaimoPayStatus() to work correctly, we must set // set refresh context when payment status changes; done via setDaimoPayOrder. const [daimoPayOrder, setDaimoPayOrder] = useState(); - const paymentInfo = usePaymentInfo({ + const paymentState = usePaymentState({ trpc, daimoPayOrder, setDaimoPayOrder, @@ -280,21 +282,18 @@ const DaimoPayProviderWithoutSolana = ({ log(`[PAY] polling in ${intervalMs}ms`); setTimeout( - () => retryBackoff("refreshOrder", () => paymentInfo.refreshOrder()), + () => retryBackoff("refreshOrder", () => paymentState.refreshOrder()), intervalMs, ); }, [daimoPayOrder]); - const loadAndShowPayment = async ( - payId: string, - modalOptions: DaimoPayModalOptions, - ) => { - log(`[PAY] showing order ${payId}, options ${debugJson(modalOptions)}`); - await paymentInfo.setPayId(payId); + const showPayment = async (modalOptions: DaimoPayModalOptions) => { + const { daimoPayOrder, payParams } = paymentState; + const id = daimoPayOrder?.id; + log(`[PAY] showing payment ${debugJson({ id, payParams, modalOptions })}`); - paymentInfo.setModalOptions(modalOptions); + paymentState.setModalOptions(modalOptions); - const daimoPayOrder = paymentInfo.daimoPayOrder; if ( daimoPayOrder && daimoPayOrder.mode === DaimoPayOrderMode.HYDRATED && @@ -343,8 +342,8 @@ const DaimoPayProviderWithoutSolana = ({ // Above: generic ConnectKit context // Below: Daimo Pay context - loadAndShowPayment, - paymentInfo, + showPayment, + paymentState, trpc, }; @@ -365,8 +364,8 @@ const DaimoPayProviderWithoutSolana = ({ ); }; -/** Provides context for DaimoPayButton and hooks. Place in app root, layout, or - * similar. +/** + * Provides context for DaimoPayButton and hooks. Place in app root or layout. */ export const DaimoPayProvider = (props: DaimoPayProviderProps) => { return ( @@ -376,7 +375,7 @@ export const DaimoPayProvider = (props: DaimoPayProviderProps) => { ); }; -/** Meant for internal use. This will be non-exported in a future SDK version. */ +/** Daimo Pay internal context. */ export const usePayContext = () => { const context = React.useContext(Context); if (!context) throw Error("DaimoPay Hook must be inside a Provider."); diff --git a/packages/connectkit/src/components/DaimoPayButton/index.tsx b/packages/connectkit/src/components/DaimoPayButton/index.tsx index 77d6b02d..3b426a46 100644 --- a/packages/connectkit/src/components/DaimoPayButton/index.tsx +++ b/packages/connectkit/src/components/DaimoPayButton/index.tsx @@ -1,128 +1,217 @@ import React, { useEffect } from "react"; -import { useAccount, useEnsName } from "wagmi"; import useIsMounted from "../../hooks/useIsMounted"; -import { truncateEthAddress } from "./../../utils"; import { usePayContext } from "../DaimoPay"; import { TextContainer } from "./styles"; import { AnimatePresence, Variants } from "framer-motion"; -import { Chain } from "viem"; -import { useChainIsSupported } from "../../hooks/useChainIsSupported"; +import { Address, Hex } from "viem"; import { ResetContainer } from "../../styles"; -import { CustomTheme, Mode, Theme } from "../../types"; +import { CustomTheme, Mode, PaymentOption, Theme } from "../../types"; import ThemedButton, { ThemeContainer } from "../Common/ThemedButton"; -const contentVariants: Variants = { - initial: { - zIndex: 2, - opacity: 0, - x: "-100%", - }, - animate: { - opacity: 1, - x: 0.1, - transition: { - duration: 0.4, - ease: [0.25, 1, 0.5, 1], - }, - }, - exit: { - zIndex: 1, - opacity: 0, - x: "-100%", - pointerEvents: "none", - position: "absolute", - transition: { - duration: 0.4, - ease: [0.25, 1, 0.5, 1], - }, - }, -}; +type PayButtonCommonProps = + | { + /** + * Your public app ID. Specify either (payId) or (appId + parameters). + */ + appId: string; + /** + * Optional nonce. If set, generates a deterministic payID. See docs. + */ + nonce?: bigint; + /** + * Destination chain ID. + */ + toChain: number; + /** + * The destination token to send, completing payment. Must be an ERC-20 + * token or the zero address, indicating the native token / ETH. + */ + toToken: Address; + /** + * The amount of destination token to send (transfer or approve). + */ + toAmount: bigint; + /** + * The destination address to transfer to, or contract to call. + */ + toAddress: Address; + /** + * Optional calldata to call an arbitrary function on `toAddress`. + */ + toCallData?: Hex; + /** + * The intent verb, such as "Pay", "Deposit", or "Purchase". + */ + intent?: string; + /** + * Payment options. By default, all are enabled. + */ + paymentOptions?: PaymentOption[]; + /** + * Preferred chain IDs. Assets on these chains will appear first. + */ + preferredChains?: number[]; + } + | { + /** The payment ID, generated via the Daimo Pay API. Replaces params above. */ + payId: string; + }; -type Hash = `0x${string}`; +type DaimoPayButtonProps = PayButtonCommonProps & { + /** Light mode, dark mode, or auto. */ + mode?: Mode; + /** Named theme. See docs for options. */ + theme?: Theme; + /** Custom theme. See docs for options. */ + customTheme?: CustomTheme; + /** Automatically close the modal after a successful payment. */ + closeOnSuccess?: boolean; + /** Get notified when the user clicks, opening the payment modal. */ + onClick?: (open: () => void) => void; +}; -type DaimoPayButtonRendererProps = { - /** The payment ID, generated via the Daimo Pay API. */ - payId: string; +type DaimoPayButtonCustomProps = PayButtonCommonProps & { /** Automatically close the modal after a successful payment. */ closeOnSuccess?: boolean; /** Custom renderer */ - children?: (renderProps: { - show?: () => void; - hide?: () => void; - chain?: Chain & { - unsupported?: boolean; - }; - unsupported: boolean; - isConnected: boolean; - isConnecting: boolean; - address?: Hash; - truncatedAddress?: string; - ensName?: string; - payId?: string; + children: (renderProps: { + show: () => void; + hide: () => void; }) => React.ReactNode; }; -/** Like DaimoPayButton, but with custom styling. */ -const DaimoPayButtonRenderer: React.FC = ({ - payId, - closeOnSuccess, - children, -}) => { - const isMounted = useIsMounted(); +/** + * A button that shows the Daimo Pay checkout. Replaces the traditional + * Connect Wallet » approve » execute sequence with a single action. + */ +export function DaimoPayButton(props: DaimoPayButtonProps) { + const { theme, mode, customTheme, onClick } = props; const context = usePayContext(); - const { address, chain } = useAccount(); - const isChainSupported = useChainIsSupported(chain?.id); + return ( + + {({ show }) => ( + + { + if (onClick) { + onClick(show); + } else { + show(); + } + }} + > + + + + + + )} + + ); +} - const { data: ensName } = useEnsName({ - chainId: 1, - address: address, - }); +/** Like DaimoPayButton, but with custom styling. */ +function DaimoPayButtonCustom(props: DaimoPayButtonCustomProps) { + const isMounted = useIsMounted(); + const context = usePayContext(); // Pre-load payment info in background. - const { setPayId } = context.paymentInfo; - useEffect(() => { - setPayId(payId); - }, [payId]); - - const hide = () => context.setOpen(false); + const { paymentState } = context; + useEffect( + () => { + if ("payId" in props) { + paymentState.setPayId(props.payId); + } else if ("appId" in props) { + paymentState.setPayParams(props); + } else { + console.error(`[BUTTON] must specify either payId or appId`); + } + }, + "payId" in props + ? [props.payId] + : [ + // Use JSON to avoid reloading every render + // eg. given paymentOptions={array literal} + JSON.stringify([ + props.appId, + props.nonce, + props.toChain, + props.toAddress, + props.toToken, + "" + props.toAmount, + props.toCallData, + props.paymentOptions, + props.preferredChains, + ]), + ], + ); + const { children, closeOnSuccess } = props; const modalOptions = { closeOnSuccess }; - const show = () => context.loadAndShowPayment(payId, modalOptions); + const show = () => context.showPayment(modalOptions); + const hide = () => context.setOpen(false); - if (!children) return null; if (!isMounted) return null; return ( <> {children({ - payId, show, hide, - chain: chain, - unsupported: !isChainSupported, - isConnected: !!address, - isConnecting: context.open, - address: address, - truncatedAddress: address ? truncateEthAddress(address) : undefined, - ensName: ensName?.toString(), })} ); -}; +} + +DaimoPayButtonCustom.displayName = "DaimoPayButton.Custom"; -DaimoPayButtonRenderer.displayName = "DaimoPayButton.Custom"; +DaimoPayButton.Custom = DaimoPayButtonCustom; + +const contentVariants: Variants = { + initial: { + zIndex: 2, + opacity: 0, + x: "-100%", + }, + animate: { + opacity: 1, + x: 0.1, + transition: { + duration: 0.4, + ease: [0.25, 1, 0.5, 1], + }, + }, + exit: { + zIndex: 1, + opacity: 0, + x: "-100%", + pointerEvents: "none", + position: "absolute", + transition: { + duration: 0.4, + ease: [0.25, 1, 0.5, 1], + }, + }, +}; function DaimoPayButtonInner() { - const { paymentInfo } = usePayContext(); - const label = paymentInfo?.daimoPayOrder?.metadata?.intent ?? "Pay"; + const { paymentState } = usePayContext(); + const label = paymentState?.daimoPayOrder?.metadata?.intent ?? "Pay"; return ( ); } - -type DaimoPayButtonProps = { - /** The payment ID, generated via the Daimo Pay API. */ - payId: string; - /** Light mode, dark mode, or auto. */ - mode?: Mode; - /** Named theme. See docs for options. */ - theme?: Theme; - /** Custom theme. See docs for options. */ - customTheme?: CustomTheme; - /** Automatically close the modal after a successful payment. */ - closeOnSuccess?: boolean; - /** Get notified when the user clicks, opening the payment modal. */ - onClick?: (open: () => void) => void; -}; - -/** A button that shows the Daimo Pay checkout. Replaces the traditional - * Connect Wallet » approve » execute sequence with a single action. - */ -export function DaimoPayButton({ - payId, - theme, - mode, - customTheme, - closeOnSuccess, - onClick, -}: DaimoPayButtonProps) { - const isMounted = useIsMounted(); - - const context = usePayContext(); - - // Pre-load payment info in background. - const { setPayId } = context.paymentInfo; - useEffect(() => { - setPayId(payId); - }, [payId]); - - const modalOptions = { closeOnSuccess }; - const show = () => context.loadAndShowPayment(payId, modalOptions); - - if (!isMounted) return null; - - return ( - - { - if (onClick) { - onClick(show); - } else { - show(); - } - }} - > - - - - - - ); -} - -DaimoPayButton.Custom = DaimoPayButtonRenderer; diff --git a/packages/connectkit/src/components/DaimoPayModal/index.tsx b/packages/connectkit/src/components/DaimoPayModal/index.tsx index 326548ac..77e90441 100644 --- a/packages/connectkit/src/components/DaimoPayModal/index.tsx +++ b/packages/connectkit/src/components/DaimoPayModal/index.tsx @@ -46,7 +46,7 @@ export const DaimoPayModal: React.FC<{ setSelectedTokenOption, setSelectedDepositAddressOption, setSelectedSolanaTokenOption, - } = context.paymentInfo; + } = context.paymentState; const { isConnected, chain } = useAccount(); const chainIsSupported = useChainIsSupported(chain?.id); @@ -62,8 +62,6 @@ export const DaimoPayModal: React.FC<{ context.route !== ROUTES.SELECT_METHOD && context.route !== ROUTES.CONFIRMATION; - const showInfoButton = closeable; - const onBack = () => { if (context.route === ROUTES.DOWNLOAD) { context.setRoute(ROUTES.CONNECT); diff --git a/packages/connectkit/src/components/Pages/Confirmation/index.tsx b/packages/connectkit/src/components/Pages/Confirmation/index.tsx index 9937b148..69935f61 100644 --- a/packages/connectkit/src/components/Pages/Confirmation/index.tsx +++ b/packages/connectkit/src/components/Pages/Confirmation/index.tsx @@ -15,8 +15,8 @@ import styled from "../../../styles/styled"; import PoweredByFooter from "../../Common/PoweredByFooter"; const Confirmation: React.FC = () => { - const { paymentInfo } = usePayContext(); - const { daimoPayOrder } = paymentInfo; + const { paymentState } = usePayContext(); + const { daimoPayOrder } = paymentState; const { done, txURL } = (() => { if (daimoPayOrder && daimoPayOrder.mode === DaimoPayOrderMode.HYDRATED) { @@ -33,7 +33,7 @@ const Confirmation: React.FC = () => { assert(txHash != null, `Dest ${destStatus}, but missing txHash`); const txURL = getChainExplorerTxUrl(chainId, txHash); - paymentInfo.onSuccess({ txHash, txURL }); + paymentState.onSuccess({ txHash, txURL }); return { done: true, txURL, diff --git a/packages/connectkit/src/components/Pages/PayWithToken/index.tsx b/packages/connectkit/src/components/Pages/PayWithToken/index.tsx index 153aca6b..d7f91e7d 100644 --- a/packages/connectkit/src/components/Pages/PayWithToken/index.tsx +++ b/packages/connectkit/src/components/Pages/PayWithToken/index.tsx @@ -26,8 +26,8 @@ enum PayState { } const PayWithToken: React.FC = () => { - const { triggerResize, paymentInfo, setRoute, log } = usePayContext(); - const { selectedTokenOption, payWithToken } = paymentInfo; + const { triggerResize, paymentState, setRoute, log } = usePayContext(); + const { selectedTokenOption, payWithToken } = paymentState; const [payState, setPayState] = useState( PayState.RequestingPayment, ); diff --git a/packages/connectkit/src/components/Pages/SelectDepositAddressChain/index.tsx b/packages/connectkit/src/components/Pages/SelectDepositAddressChain/index.tsx index 5c9d06fc..4e4211fa 100644 --- a/packages/connectkit/src/components/Pages/SelectDepositAddressChain/index.tsx +++ b/packages/connectkit/src/components/Pages/SelectDepositAddressChain/index.tsx @@ -8,9 +8,9 @@ import OptionsList from "../../Common/OptionsList"; import { OrderHeader } from "../../Common/OrderHeader"; const SelectDepositAddressChain: React.FC = () => { - const { setRoute, paymentInfo } = usePayContext(); + const { setRoute, paymentState } = usePayContext(); const { setSelectedDepositAddressOption, depositAddressOptions } = - paymentInfo; + paymentState; return ( diff --git a/packages/connectkit/src/components/Pages/SelectMethod/index.tsx b/packages/connectkit/src/components/Pages/SelectMethod/index.tsx index 6ca90add..8da693e4 100644 --- a/packages/connectkit/src/components/Pages/SelectMethod/index.tsx +++ b/packages/connectkit/src/components/Pages/SelectMethod/index.tsx @@ -56,17 +56,12 @@ function getDepositAddressOption(depositAddressOptions: { }) { const { setRoute } = usePayContext(); - console.log( - `[SELECT_METHOD] depositAddressOptions: ${JSON.stringify( - depositAddressOptions, - )}`, - ); - if ( !depositAddressOptions.loading && depositAddressOptions.options.length === 0 - ) + ) { return null; + } return { id: "depositAddress", @@ -85,13 +80,13 @@ const SelectMethod: React.FC = () => { const { address, isConnected, connector } = useAccount(); const { disconnectAsync } = useDisconnect(); - const { setRoute, paymentInfo, log } = usePayContext(); + const { setRoute, paymentState, log } = usePayContext(); const { setSelectedExternalOption, externalPaymentOptions, depositAddressOptions, senderEnsName, - } = paymentInfo; + } = paymentState; const displayName = senderEnsName ?? (address ? getAddressContraction(address) : "wallet"); diff --git a/packages/connectkit/src/components/Pages/SelectToken/index.tsx b/packages/connectkit/src/components/Pages/SelectToken/index.tsx index 1827767c..b5e1d220 100644 --- a/packages/connectkit/src/components/Pages/SelectToken/index.tsx +++ b/packages/connectkit/src/components/Pages/SelectToken/index.tsx @@ -45,8 +45,8 @@ const ChainContainer = styled(motion.div)` `; const SelectToken: React.FC = () => { - const { setRoute, paymentInfo } = usePayContext(); - const { setSelectedTokenOption, walletPaymentOptions } = paymentInfo; + const { setRoute, paymentState } = usePayContext(); + const { setSelectedTokenOption, walletPaymentOptions } = paymentState; return ( diff --git a/packages/connectkit/src/components/Pages/Solana/PayWithSolanaToken/index.tsx b/packages/connectkit/src/components/Pages/Solana/PayWithSolanaToken/index.tsx index df2f252f..b92d8f21 100644 --- a/packages/connectkit/src/components/Pages/Solana/PayWithSolanaToken/index.tsx +++ b/packages/connectkit/src/components/Pages/Solana/PayWithSolanaToken/index.tsx @@ -23,8 +23,8 @@ enum PayState { } const PayWithSolanaToken: React.FC = () => { - const { triggerResize, paymentInfo, setRoute } = usePayContext(); - const { selectedSolanaTokenOption, payWithSolanaToken } = paymentInfo; + const { triggerResize, paymentState, setRoute } = usePayContext(); + const { selectedSolanaTokenOption, payWithSolanaToken } = paymentState; const [payState, setPayState] = useState( PayState.RequestingPayment, ); diff --git a/packages/connectkit/src/components/Pages/Solana/SelectSolanaToken/index.tsx b/packages/connectkit/src/components/Pages/Solana/SelectSolanaToken/index.tsx index 39f606db..6f0fef4e 100644 --- a/packages/connectkit/src/components/Pages/Solana/SelectSolanaToken/index.tsx +++ b/packages/connectkit/src/components/Pages/Solana/SelectSolanaToken/index.tsx @@ -13,8 +13,8 @@ import OptionsList from "../../../Common/OptionsList"; import { OrderHeader } from "../../../Common/OrderHeader"; const SelectSolanaToken: React.FC = () => { - const { paymentInfo, setRoute } = usePayContext(); - const { solanaPaymentOptions, setSelectedSolanaTokenOption } = paymentInfo; + const { paymentState, setRoute } = usePayContext(); + const { solanaPaymentOptions, setSelectedSolanaTokenOption } = paymentState; return ( diff --git a/packages/connectkit/src/components/Pages/WaitingDepositAddress/index.tsx b/packages/connectkit/src/components/Pages/WaitingDepositAddress/index.tsx index ea6a4986..313d0cb3 100644 --- a/packages/connectkit/src/components/Pages/WaitingDepositAddress/index.tsx +++ b/packages/connectkit/src/components/Pages/WaitingDepositAddress/index.tsx @@ -21,11 +21,11 @@ import { OrDivider } from "../../Common/Modal"; const WaitingDepositAddress: React.FC = () => { const context = usePayContext(); - const { triggerResize, paymentInfo, setRoute } = context; + const { triggerResize, paymentState, setRoute } = context; const trpc = context.trpc as TrpcClient; const { daimoPayOrder, payWithDepositAddress, selectedDepositAddressOption } = - paymentInfo; + paymentState; useEffect(() => { const checkForSourcePayment = async () => { diff --git a/packages/connectkit/src/components/Pages/WaitingOther/index.tsx b/packages/connectkit/src/components/Pages/WaitingOther/index.tsx index 040cff0c..172f037e 100644 --- a/packages/connectkit/src/components/Pages/WaitingOther/index.tsx +++ b/packages/connectkit/src/components/Pages/WaitingOther/index.tsx @@ -19,7 +19,7 @@ import SquircleSpinner from "../../Spinners/SquircleSpinner"; const WaitingOther: React.FC = () => { const context = usePayContext(); - const { triggerResize, paymentInfo, setRoute } = context; + const { triggerResize, paymentState, setRoute } = context; const trpc = context.trpc as TrpcClient; const { @@ -27,7 +27,7 @@ const WaitingOther: React.FC = () => { payWithExternal, paymentWaitingMessage, daimoPayOrder, - } = paymentInfo; + } = paymentState; const [externalURL, setExternalURL] = useState(null); diff --git a/packages/connectkit/src/hooks/hookTypes.ts b/packages/connectkit/src/hooks/hookTypes.ts new file mode 100644 index 00000000..1e7e9837 --- /dev/null +++ b/packages/connectkit/src/hooks/hookTypes.ts @@ -0,0 +1,15 @@ +import { + DaimoPayHydratedOrder, + DaimoPayOrder, + ExternalPaymentOptionData, + ExternalPaymentOptions, +} from "@daimo/common"; + +export type CreateOrHydrateFn = (opts: { + order: DaimoPayOrder; + refundAddress?: string; + externalPaymentOption?: ExternalPaymentOptions; +}) => Promise<{ + hydratedOrder: DaimoPayHydratedOrder; + externalPaymentOptionData: ExternalPaymentOptionData | null; +}>; diff --git a/packages/connectkit/src/hooks/useDaimoPayStatus.ts b/packages/connectkit/src/hooks/useDaimoPayStatus.ts index 559687df..8820c675 100644 --- a/packages/connectkit/src/hooks/useDaimoPayStatus.ts +++ b/packages/connectkit/src/hooks/useDaimoPayStatus.ts @@ -20,10 +20,10 @@ import { PaymentStatus } from "../types"; export function useDaimoPayStatus(): | { paymentId: string; status: PaymentStatus } | undefined { - const { paymentInfo } = usePayContext(); - if (!paymentInfo || !paymentInfo.daimoPayOrder) return undefined; + const { paymentState } = usePayContext(); + if (!paymentState || !paymentState.daimoPayOrder) return undefined; - const order = paymentInfo.daimoPayOrder; + const order = paymentState.daimoPayOrder; const paymentId = writeDaimoPayOrderID(order.id); if (order.mode === DaimoPayOrderMode.HYDRATED) { if (order.intentStatus !== DaimoPayIntentStatus.PENDING) { diff --git a/packages/connectkit/src/hooks/useModal.ts b/packages/connectkit/src/hooks/useModal.ts index 241c3b77..bbb6a0e4 100644 --- a/packages/connectkit/src/hooks/useModal.ts +++ b/packages/connectkit/src/hooks/useModal.ts @@ -35,9 +35,5 @@ export const useModal = ({ onConnect, onDisconnect }: UseModalProps = {}) => { close(); } }, - // Disconnected Routes - openAbout: () => gotoAndOpen(ROUTES.ABOUT), - openOnboarding: () => gotoAndOpen(ROUTES.ONBOARDING), - openSwitchNetworks: () => gotoAndOpen(ROUTES.SWITCHNETWORKS), }; }; diff --git a/packages/connectkit/src/hooks/usePayWithSolanaToken.ts b/packages/connectkit/src/hooks/usePayWithSolanaToken.ts index a466d219..833b02cb 100644 --- a/packages/connectkit/src/hooks/usePayWithSolanaToken.ts +++ b/packages/connectkit/src/hooks/usePayWithSolanaToken.ts @@ -1,6 +1,6 @@ import { + assert, assertNotNull, - BigIntStr, DaimoPayOrder, PlatformType, SolanaPublicKey, @@ -9,34 +9,41 @@ import { useConnection, useWallet } from "@solana/wallet-adapter-react"; import { VersionedTransaction } from "@solana/web3.js"; import { hexToBytes } from "viem"; import { TrpcClient } from "../utils/trpc"; +import { CreateOrHydrateFn } from "./hookTypes"; export function usePayWithSolanaToken({ trpc, - orderId, + daimoPayOrder, setDaimoPayOrder, - chosenFinalTokenAmount, + createOrHydrate, platform, + log, }: { trpc: TrpcClient; - orderId: bigint | undefined; + daimoPayOrder: DaimoPayOrder | undefined; setDaimoPayOrder: (order: DaimoPayOrder) => void; - chosenFinalTokenAmount: BigIntStr | undefined; + createOrHydrate: CreateOrHydrateFn; platform: PlatformType | undefined; + log: (message: string) => void; }) { const { connection } = useConnection(); const wallet = useWallet(); const payWithSolanaToken = async (inputToken: SolanaPublicKey) => { - if (!wallet.publicKey || !orderId || !chosenFinalTokenAmount || !platform) { - throw new Error("Invalid parameters"); - } + assert(!!wallet.publicKey, "No wallet connected"); + assert(!!platform && !!daimoPayOrder); - const { hydratedOrder } = await trpc.hydrateOrder.query({ - id: orderId.toString(), - chosenFinalTokenAmount, - platform, + const orderId = daimoPayOrder.id; + const { hydratedOrder } = await createOrHydrate({ + order: daimoPayOrder, }); + log( + `[CHECKOUT] Hydrated order: ${JSON.stringify( + hydratedOrder, + )}, checking out with Solana ${inputToken}`, + ); + const txHash = await (async () => { try { const serializedTx = await trpc.getSolanaSwapAndBurnTx.query({ @@ -56,10 +63,12 @@ export function usePayWithSolanaToken({ } })(); + // TOOD: get the actual amount sent from the tx logs. + // We are currently using a fake amount = 0. trpc.processSolanaSourcePayment.mutate({ orderId: orderId.toString(), startIntentTxHash: txHash, - amount: chosenFinalTokenAmount, + amount: "0", // TODO: replace. token: inputToken, }); diff --git a/packages/connectkit/src/hooks/usePayWithToken.ts b/packages/connectkit/src/hooks/usePayWithToken.ts new file mode 100644 index 00000000..3061474a --- /dev/null +++ b/packages/connectkit/src/hooks/usePayWithToken.ts @@ -0,0 +1,85 @@ +import { + assert, + assertNotNull, + DaimoPayOrder, + DaimoPayTokenAmount, + PlatformType, +} from "@daimo/common"; +import { Address, erc20Abi, getAddress, zeroAddress } from "viem"; +import { useSendTransaction, useWriteContract } from "wagmi"; +import { TrpcClient } from "../utils/trpc"; +import { CreateOrHydrateFn } from "./hookTypes"; + +export function usePayWithToken({ + trpc, + senderAddr, + daimoPayOrder, + setDaimoPayOrder, + createOrHydrate, + platform, + log, +}: { + trpc: TrpcClient; + createOrHydrate: CreateOrHydrateFn; + senderAddr: Address | undefined; + daimoPayOrder: DaimoPayOrder | undefined; + setDaimoPayOrder: (order: DaimoPayOrder) => void; + platform: PlatformType | undefined; + log: (message: string) => void; +}) { + const { writeContractAsync } = useWriteContract(); + const { sendTransactionAsync } = useSendTransaction(); + + /** Commit to a token + amount = initiate payment. */ + const payWithToken = async (tokenAmount: DaimoPayTokenAmount) => { + assert(!!daimoPayOrder && !!platform); + + const { hydratedOrder } = await createOrHydrate({ + order: daimoPayOrder, + refundAddress: senderAddr, + }); + + log( + `[CHECKOUT] Hydrated order: ${JSON.stringify( + hydratedOrder, + )}, checking out with ${tokenAmount.token.token}`, + ); + + const txHash = await (async () => { + try { + if (tokenAmount.token.token === zeroAddress) { + return await sendTransactionAsync({ + to: hydratedOrder.intentAddr, + value: BigInt(tokenAmount.amount), + }); + } else { + return await writeContractAsync({ + abi: erc20Abi, + address: getAddress(tokenAmount.token.token), + functionName: "transfer", + args: [hydratedOrder.intentAddr, BigInt(tokenAmount.amount)], + }); + } + } catch (e) { + console.error(`[CHECKOUT] Error sending token: ${e}`); + setDaimoPayOrder(hydratedOrder); + throw e; + } finally { + setDaimoPayOrder(hydratedOrder); + } + })(); + + if (txHash) { + await trpc.processSourcePayment.mutate({ + orderId: daimoPayOrder.id.toString(), + sourceInitiateTxHash: txHash, + sourceChainId: tokenAmount.token.chainId, + sourceFulfillerAddr: assertNotNull(senderAddr), + sourceToken: tokenAmount.token.token, + sourceAmount: tokenAmount.amount, + }); + } + }; + + return { payWithToken }; +} diff --git a/packages/connectkit/src/hooks/usePaymentInfo.ts b/packages/connectkit/src/hooks/usePaymentState.ts similarity index 64% rename from packages/connectkit/src/hooks/usePaymentInfo.ts rename to packages/connectkit/src/hooks/usePaymentState.ts index 3aeba44a..2489da7d 100644 --- a/packages/connectkit/src/hooks/usePaymentInfo.ts +++ b/packages/connectkit/src/hooks/usePaymentState.ts @@ -3,6 +3,7 @@ import { assertNotNull, DaimoPayOrder, DaimoPayTokenAmount, + debugJson, DepositAddressPaymentOptionData, DepositAddressPaymentOptionMetadata, DepositAddressPaymentOptions, @@ -12,23 +13,20 @@ import { readDaimoPayOrderID, SolanaPublicKey, } from "@daimo/common"; -import { erc20Abi, ethereum } from "@daimo/contract"; +import { ethereum } from "@daimo/contract"; +import { useWallet } from "@solana/wallet-adapter-react"; import { useCallback, useEffect, useState } from "react"; -import { getAddress, parseUnits, zeroAddress } from "viem"; -import { - useAccount, - useEnsName, - useSendTransaction, - useWriteContract, -} from "wagmi"; +import { Address, Hex, parseUnits } from "viem"; +import { useAccount, useEnsName } from "wagmi"; -import { useWallet } from "@solana/wallet-adapter-react"; -import { DaimoPayModalOptions } from "../types"; +import { DaimoPayModalOptions, PaymentOption } from "../types"; +import { generateNonce } from "../utils/exports"; import { detectPlatform } from "../utils/platform"; import { TrpcClient } from "../utils/trpc"; import { useDepositAddressOptions } from "./useDepositAddressOptions"; import { useExternalPaymentOptions } from "./useExternalPaymentOptions"; import { usePayWithSolanaToken } from "./usePayWithSolanaToken"; +import { usePayWithToken } from "./usePayWithToken"; import { SolanaPaymentOption, useSolanaPaymentOptions, @@ -43,9 +41,36 @@ export type SourcePayment = Parameters< TrpcClient["processSourcePayment"]["mutate"] >[0]; -/** Loads a DaimoPayOrder + manages the corresponding modal. */ -export interface PaymentInfo { - setPayId: (id: string | null) => Promise; +/** Payment parameters. The payment is created only after user taps pay. */ +export interface PayParams { + /** App ID, for authentication. */ + appId: string; + /** Optional nonce. If set, generates a deterministic payID. See docs. */ + nonce?: bigint; + /** Destination chain ID. */ + toChain: number; + /** The destination token to send. */ + toToken: Address; + /** The amount of the token to send. */ + toAmount: bigint; + /** The final address to transfer to or contract to call. */ + toAddress: Address; + /** Calldata for final call, or empty data for transfer. */ + toCallData?: Hex; + /** The intent verb, such as Pay, Deposit, or Purchase. Default: Pay */ + intent?: string; + /** Payment options. By default, all are enabled. */ + paymentOptions?: PaymentOption[]; + /** Preferred chain IDs. */ + preferredChains?: number[]; +} + +/** Creates (or loads) a payment and manages the corresponding modal. */ +export interface PaymentState { + setPayId: (id: string | undefined) => void; + setPayParams: (payParams: PayParams | undefined) => void; + payParams: PayParams | undefined; + daimoPayOrder: DaimoPayOrder | undefined; modalOptions: DaimoPayModalOptions; setModalOptions: (modalOptions: DaimoPayModalOptions) => void; @@ -82,7 +107,7 @@ export interface PaymentInfo { senderEnsName: string | undefined; } -export function usePaymentInfo({ +export function usePaymentState({ trpc, daimoPayOrder, setDaimoPayOrder, @@ -94,7 +119,7 @@ export function usePaymentInfo({ setDaimoPayOrder: (o: DaimoPayOrder) => void; setOpen: (showModal: boolean) => void; log: (...args: any[]) => void; -}): PaymentInfo { +}): PaymentState { // Browser state. const [platform, setPlatform] = useState(); useEffect(() => { @@ -107,14 +132,13 @@ export function usePaymentInfo({ chainId: ethereum.chainId, address: senderAddr, }); - const { writeContractAsync } = useWriteContract(); - const { sendTransactionAsync } = useSendTransaction(); // Solana wallet state. const solanaWallet = useWallet(); const solanaPubKey = solanaWallet.publicKey?.toBase58(); // Daimo Pay order state. + const [payParams, setPayParamsState] = useState(); const [paymentWaitingMessage, setPaymentWaitingMessage] = useState(); // Payment UI config. @@ -143,13 +167,62 @@ export function usePaymentInfo({ usdRequired: daimoPayOrder?.destFinalCallTokenAmount.usd ?? 0, }); + /** Create a new order or hydrate an existing one. */ + const createOrHydrate = async ({ + order, + refundAddress, + externalPaymentOption, + }: { + order: DaimoPayOrder; + refundAddress?: string; + externalPaymentOption?: ExternalPaymentOptions; + }) => { + assert(!!platform, "missing platform"); + + if (payParams == null) { + log(`[CHECKOUT] hydrating existing order ${order.id}`); + return await trpc.hydrateOrder.query({ + id: order.id.toString(), + chosenFinalTokenAmount: order.destFinalCallTokenAmount.amount, + platform, + refundAddress, + externalPaymentOption, + }); + } + + log(`[CHECKOUT] creating+hydrating new order ${order.id}`); + return await trpc.createOrder.mutate({ + appId: payParams.appId, + paymentInput: { + ...payParams, + id: order.id.toString(), + toAmount: order.destFinalCallTokenAmount.amount, + toNonce: order.id.toString(), + metadata: order.metadata, + }, + platform, + refundAddress, + externalPaymentOption, + }); + }; + + const { payWithToken } = usePayWithToken({ + trpc, + senderAddr, + daimoPayOrder, + setDaimoPayOrder, + createOrHydrate, + platform, + log, + }); + const { payWithSolanaToken } = usePayWithSolanaToken({ trpc, - orderId: daimoPayOrder?.id ?? undefined, + daimoPayOrder, setDaimoPayOrder, - chosenFinalTokenAmount: - daimoPayOrder?.destFinalCallTokenAmount.amount ?? undefined, + createOrHydrate, platform, + log, }); const [selectedExternalOption, setSelectedExternalOption] = @@ -164,68 +237,20 @@ export function usePaymentInfo({ const [selectedDepositAddressOption, setSelectedDepositAddressOption] = useState(); - const payWithToken = async (tokenAmount: DaimoPayTokenAmount) => { + const payWithExternal = async (option: ExternalPaymentOptions) => { assert(!!daimoPayOrder && !!platform); - const { hydratedOrder } = await trpc.hydrateOrder.query({ - id: daimoPayOrder.id.toString(), - chosenFinalTokenAmount: daimoPayOrder.destFinalCallTokenAmount.amount, - platform, - refundAddress: senderAddr, + const { hydratedOrder, externalPaymentOptionData } = await createOrHydrate({ + order: daimoPayOrder, + externalPaymentOption: option, }); + assert(!!externalPaymentOptionData, "missing externalPaymentOptionData"); log( `[CHECKOUT] Hydrated order: ${JSON.stringify( hydratedOrder, - )}, checking out with ${tokenAmount.token.token}`, + )}, checking out with external payment: ${option}`, ); - const txHash = await (async () => { - try { - if (tokenAmount.token.token === zeroAddress) { - return await sendTransactionAsync({ - to: hydratedOrder.intentAddr, - value: BigInt(tokenAmount.amount), - }); - } else { - return await writeContractAsync({ - abi: erc20Abi, - address: getAddress(tokenAmount.token.token), - functionName: "transfer", - args: [hydratedOrder.intentAddr, BigInt(tokenAmount.amount)], - }); - } - } catch (e) { - console.error(`[CHECKOUT] Error sending token: ${e}`); - setDaimoPayOrder(hydratedOrder); - throw e; - } finally { - setDaimoPayOrder(hydratedOrder); - } - })(); - - if (txHash) { - await trpc.processSourcePayment.mutate({ - orderId: daimoPayOrder.id.toString(), - sourceInitiateTxHash: txHash, - sourceChainId: tokenAmount.token.chainId, - sourceFulfillerAddr: assertNotNull(senderAddr), - sourceToken: tokenAmount.token.token, - sourceAmount: tokenAmount.amount, - }); - } - }; - - const payWithExternal = async (option: ExternalPaymentOptions) => { - assert(!!daimoPayOrder && !!platform); - const { hydratedOrder, externalPaymentOptionData } = - await trpc.hydrateOrder.query({ - id: daimoPayOrder.id.toString(), - externalPaymentOption: option, - chosenFinalTokenAmount: daimoPayOrder.destFinalCallTokenAmount.amount, - platform, - }); - - assert(!!externalPaymentOptionData); setPaymentWaitingMessage(externalPaymentOptionData.waitingMessage); setDaimoPayOrder(hydratedOrder); @@ -235,14 +260,18 @@ export function usePaymentInfo({ const payWithDepositAddress = async ( option: DepositAddressPaymentOptions, ) => { - assert(!!daimoPayOrder && !!platform); - const { hydratedOrder } = await trpc.hydrateOrder.query({ - id: daimoPayOrder.id.toString(), - chosenFinalTokenAmount: daimoPayOrder.destFinalCallTokenAmount.amount, - platform, + assert(!!daimoPayOrder); + const { hydratedOrder } = await createOrHydrate({ + order: daimoPayOrder, }); setDaimoPayOrder(hydratedOrder); + log( + `[CHECKOUT] Hydrated order: ${JSON.stringify( + hydratedOrder, + )}, checking out with deposit address: ${option}`, + ); + const depositAddressOption = await trpc.getDepositAddressOptionData.query({ input: option, usdRequired: daimoPayOrder.destFinalCallTokenAmount.usd, @@ -272,6 +301,9 @@ export function usePaymentInfo({ token.decimals, ); + // TODO: remove amount from destFinalCall, it is redundant with + // destFinalCallTokenAmount. Here, we only modify one and not the other. + setDaimoPayOrder({ ...daimoPayOrder, destFinalCallTokenAmount: { @@ -283,7 +315,7 @@ export function usePaymentInfo({ }; const setPayId = useCallback( - async (payId: string | null) => { + async (payId: string | undefined) => { if (!payId) return; const id = readDaimoPayOrderID(payId).toString(); @@ -297,14 +329,42 @@ export function usePaymentInfo({ console.error(`[CHECKOUT] No order found for ${payId}`); return; } - - log(`[CHECKOUT] Parsed order: ${JSON.stringify(order)}`); + log(`[CHECKOUT] fetched order: ${JSON.stringify(order)}`); setDaimoPayOrder(order); }, [daimoPayOrder], ); + /** Called whenever params change. */ + const setPayParams = async (payParams: PayParams | undefined) => { + console.log(`[CHECKOUT] setting payParams: ${debugJson(payParams)}`); + assert(payParams != null); + setPayParamsState(payParams); + + const genID = payParams.nonce ?? generateNonce(); + + const payment = await trpc.previewOrder.query({ + id: genID.toString(), + toChain: payParams.toChain, + toToken: payParams.toToken, + toAmount: payParams.toAmount.toString(), + toAddress: payParams.toAddress, + toCallData: payParams.toCallData, + toNonce: genID.toString(), + metadata: { + intent: payParams.intent ?? "Pay", + items: [], + payer: { + paymentOptions: payParams.paymentOptions, + preferredChains: payParams.preferredChains, + }, + }, + }); + + setDaimoPayOrder(payment); + }; + const onSuccess = ({ txHash, txURL }: { txHash: string; txURL?: string }) => { if (modalOptions?.closeOnSuccess) { log(`[CHECKOUT] transaction succeeded, closing: ${txHash} ${txURL}`); @@ -314,6 +374,8 @@ export function usePaymentInfo({ return { setPayId, + payParams, + setPayParams, daimoPayOrder, modalOptions, setModalOptions, diff --git a/packages/connectkit/src/index.ts b/packages/connectkit/src/index.ts index e96c4ebe..c30aae7e 100644 --- a/packages/connectkit/src/index.ts +++ b/packages/connectkit/src/index.ts @@ -1,22 +1,28 @@ +export type * as Types from "./types"; + +// Configure Daimo Pay +export { DaimoPayProvider } from "./components/DaimoPay"; export { default as getDefaultConfig } from "./defaultConfig"; -export * as Types from "./types"; -export { wallets } from "./wallets"; -// TODO: remove Context and usePayContext exports following SDK refactor. -export { - Context, - DaimoPayProvider, - usePayContext, -} from "./components/DaimoPay"; +// Pay button export { DaimoPayButton } from "./components/DaimoPayButton"; + +// Hooks to track payment status + UI status. +export { useDaimoPayStatus } from "./hooks/useDaimoPayStatus"; + +/** TODO: replace with useDaimoPay() */ export { useModal } from "./hooks/useModal"; +// These first two just return configured wagmi chains = not necessarily +// supported by Daimo Pay. +// export { useChainIsSupported } from "./hooks/useChainIsSupported"; +// export { useChains } from "./hooks/useChains"; +// export { default as useIsMounted } from "./hooks/useIsMounted"; + +// For convenience, export components to show connected account. export { default as Avatar } from "./components/Common/Avatar"; export { default as ChainIcon } from "./components/Common/Chain"; +export { wallets } from "./wallets"; -// Hooks -export { useChainIsSupported } from "./hooks/useChainIsSupported"; -export { useChains } from "./hooks/useChains"; -export { useDaimoPayStatus } from "./hooks/useDaimoPayStatus"; - -export { default as useIsMounted } from "./hooks/useIsMounted"; +// Export utilities. +export * from "./utils/exports"; diff --git a/packages/connectkit/src/types.ts b/packages/connectkit/src/types.ts index 0f336f6c..9d960407 100644 --- a/packages/connectkit/src/types.ts +++ b/packages/connectkit/src/types.ts @@ -62,3 +62,8 @@ export type PaymentStatus = | "payment_started" | "payment_completed" | "payment_bounced"; + +// TODO: for now, these match ExternalPaymentOptions. In future, we can add +// higher level categories like "Solana", "BitcoinEtc", "Card". +/** Additional payment options. Onchain payments are always enabled. */ +export type PaymentOption = "Daimo" | "Coinbase" | "Binance" | "RampNetwork"; diff --git a/packages/connectkit/src/utils/exports.ts b/packages/connectkit/src/utils/exports.ts new file mode 100644 index 00000000..4249fe6c --- /dev/null +++ b/packages/connectkit/src/utils/exports.ts @@ -0,0 +1,8 @@ +// Exported utilities, useful for @daimo/pay users. + +import { bytesToBigInt } from "viem"; + +/** Generates a globally-unique 32-byte nonce. */ +export function generateNonce(): bigint { + return bytesToBigInt(crypto.getRandomValues(new Uint8Array(32))); +} From 8d22417f0ae7ea875e8b90b60db8b12a7187f3e0 Mon Sep 17 00:00:00 2001 From: DC Date: Wed, 25 Dec 2024 07:28:23 -0700 Subject: [PATCH 2/6] sdk: add newPayId, onPayment[Started,Completed,Bounced] --- packages/connectkit/package.json | 8 +- packages/connectkit/rollup.config.dev.js | 53 -------- ...rollup.config.prod.js => rollup.config.js} | 20 +-- .../src/components/Common/Avatar/index.tsx | 1 + .../src/components/Common/Chain/index.tsx | 1 + .../src/components/DaimoPayButton/index.tsx | 124 +++++++++++++----- packages/connectkit/src/defaultConfig.ts | 2 +- packages/connectkit/src/hooks/useModal.ts | 1 + .../connectkit/src/hooks/usePaymentState.ts | 17 ++- packages/connectkit/src/index.ts | 10 +- packages/connectkit/src/types.ts | 27 ++++ packages/connectkit/src/utils/exports.ts | 8 +- packages/connectkit/src/wallets/index.ts | 3 +- 13 files changed, 156 insertions(+), 119 deletions(-) delete mode 100644 packages/connectkit/rollup.config.dev.js rename packages/connectkit/{rollup.config.prod.js => rollup.config.js} (72%) diff --git a/packages/connectkit/package.json b/packages/connectkit/package.json index b2475d43..83cc3393 100644 --- a/packages/connectkit/package.json +++ b/packages/connectkit/package.json @@ -9,7 +9,7 @@ "main": "./src/index.ts", "type": "module", "exports": { - "import": "./build/index.es.js", + "import": "./build/src/index.js", "types": "./build/index.d.ts" }, "types": "./build/index.d.ts", @@ -21,9 +21,9 @@ "README.md" ], "scripts": { - "start": "rollup --config rollup.config.dev.js -w", - "dev": "rollup --config rollup.config.dev.js -w", - "build": "rollup --config rollup.config.prod.js && rm -rf build/packages", + "start": "rollup --config rollup.config.js -w", + "dev": "rollup --config rollup.config.js -w", + "build": "rollup --config rollup.config.js && rm -rf build/packages", "lint": "eslint --max-warnings=0" }, "keywords": [ diff --git a/packages/connectkit/rollup.config.dev.js b/packages/connectkit/rollup.config.dev.js deleted file mode 100644 index 7d9e5308..00000000 --- a/packages/connectkit/rollup.config.dev.js +++ /dev/null @@ -1,53 +0,0 @@ -import json from "@rollup/plugin-json"; -import dts from "rollup-plugin-dts"; -import peerDepsExternal from "rollup-plugin-peer-deps-external"; -import typescript from "rollup-plugin-typescript2"; -// import createTransformer from "typescript-plugin-styled-components"; - -import packageJson from "./package.json" with { type: "json" }; - -// const styledComponentsTransformer = createTransformer({ -// displayName: true, -// }); - -export default [ - // Build bundle: index.js - { - input: ["./src/index.ts"], - external: ["react", "react-dom", "framer-motion", "wagmi"], - output: [ - { - file: packageJson.exports.import, - format: "esm", - sourcemap: false, - }, - ], - plugins: [ - peerDepsExternal(), - json(), - typescript({ - useTsconfigDeclarationDir: true, - exclude: "node_modules/**", - // transformers: [ - // () => ({ - // before: [styledComponentsTransformer], - // }), - // ], - }), - ], - }, - // Build types: index.d.ts - { - input: "./build/packages/paykit/packages/connectkit/src/index.d.ts", - output: { file: "build/index.d.ts", format: "esm" }, - plugins: [ - dts({ - exclude: ["**/pay-api/**"], - compilerOptions: { - importsNotUsedAsValues: "remove", - preserveValueImports: false, - }, - }), - ], - }, -]; diff --git a/packages/connectkit/rollup.config.prod.js b/packages/connectkit/rollup.config.js similarity index 72% rename from packages/connectkit/rollup.config.prod.js rename to packages/connectkit/rollup.config.js index acdda555..e1505259 100644 --- a/packages/connectkit/rollup.config.prod.js +++ b/packages/connectkit/rollup.config.js @@ -3,24 +3,26 @@ import dts from "rollup-plugin-dts"; import peerDepsExternal from "rollup-plugin-peer-deps-external"; import typescript from "rollup-plugin-typescript2"; -import packageJson from "./package.json" with { type: "json" }; - +/** @type {import('rollup').RollupOptions[]} */ export default [ // Build bundle: index.js { input: ["./src/index.ts"], external: ["react", "react-dom", "framer-motion", "wagmi"], - output: { - file: packageJson.exports.import, - format: "esm", - sourcemap: true, - }, + output: [ + { + dir: "build", + format: "esm", + sourcemap: true, + preserveModules: true, + }, + ], plugins: [ peerDepsExternal(), json(), typescript({ useTsconfigDeclarationDir: true, - exclude: ["node_modules/**"], + exclude: "node_modules/**", }), ], }, @@ -30,9 +32,7 @@ export default [ output: { file: "build/index.d.ts", format: "esm" }, plugins: [ dts({ - exclude: ["**/pay-api/**"], compilerOptions: { - importsNotUsedAsValues: "remove", preserveValueImports: false, }, }), diff --git a/packages/connectkit/src/components/Common/Avatar/index.tsx b/packages/connectkit/src/components/Common/Avatar/index.tsx index 525ed6c8..bd8b3c96 100644 --- a/packages/connectkit/src/components/Common/Avatar/index.tsx +++ b/packages/connectkit/src/components/Common/Avatar/index.tsx @@ -19,6 +19,7 @@ export type CustomAvatarProps = { radius: number; }; +/** Icon for an Ethereum address. Supports ENS names and avatars. */ const Avatar: React.FC<{ address?: Hash | undefined; name?: string | undefined; diff --git a/packages/connectkit/src/components/Common/Chain/index.tsx b/packages/connectkit/src/components/Common/Chain/index.tsx index 5f26bc55..1e9c076a 100644 --- a/packages/connectkit/src/components/Common/Chain/index.tsx +++ b/packages/connectkit/src/components/Common/Chain/index.tsx @@ -51,6 +51,7 @@ const Spinner = ( ); +/** Icon for an EVM chain, given chain ID. No ID shows a loading spinner. */ const Chain: React.FC<{ id?: number; unsupported?: boolean; diff --git a/packages/connectkit/src/components/DaimoPayButton/index.tsx b/packages/connectkit/src/components/DaimoPayButton/index.tsx index 3b426a46..807a087b 100644 --- a/packages/connectkit/src/components/DaimoPayButton/index.tsx +++ b/packages/connectkit/src/components/DaimoPayButton/index.tsx @@ -4,22 +4,33 @@ import useIsMounted from "../../hooks/useIsMounted"; import { usePayContext } from "../DaimoPay"; import { TextContainer } from "./styles"; +import { + assertNotNull, + DaimoPayIntentStatus, + DaimoPayOrderMode, + DaimoPayOrderStatusSource, + PaymentBouncedEvent, + PaymentCompletedEvent, + PaymentStartedEvent, + writeDaimoPayOrderID, +} from "@daimo/common"; import { AnimatePresence, Variants } from "framer-motion"; import { Address, Hex } from "viem"; +import { PayParams } from "../../hooks/usePaymentState"; import { ResetContainer } from "../../styles"; import { CustomTheme, Mode, PaymentOption, Theme } from "../../types"; import ThemedButton, { ThemeContainer } from "../Common/ThemedButton"; -type PayButtonCommonProps = +type PayButtonPaymentProps = | { /** * Your public app ID. Specify either (payId) or (appId + parameters). */ appId: string; /** - * Optional nonce. If set, generates a deterministic payID. See docs. + * Optional deterministic payId. See docs. */ - nonce?: bigint; + newPayId?: string; /** * Destination chain ID. */ @@ -53,12 +64,25 @@ type PayButtonCommonProps = * Preferred chain IDs. Assets on these chains will appear first. */ preferredChains?: number[]; + /** + * Preferred tokens. These appear first in the token list. + */ + preferredTokens?: { chain: number; address: Address }[]; } | { /** The payment ID, generated via the Daimo Pay API. Replaces params above. */ payId: string; }; +type PayButtonCommonProps = PayButtonPaymentProps & { + /** Called when user sends payment and transaction is seen on chain */ + onPaymentStarted?: (event: PaymentStartedEvent) => void; + /** Called when destination transfer or call completes successfully */ + onPaymentCompleted?: (event: PaymentCompletedEvent) => void; + /** Called when destination call reverts and funds are refunded */ + onPaymentBounced?: (event: PaymentBouncedEvent) => void; +}; + type DaimoPayButtonProps = PayButtonCommonProps & { /** Light mode, dark mode, or auto. */ mode?: Mode; @@ -127,35 +151,73 @@ function DaimoPayButtonCustom(props: DaimoPayButtonCustomProps) { const context = usePayContext(); // Pre-load payment info in background. + // Reload when any of the info changes. + let payParams: PayParams | null = + "appId" in props + ? { + appId: props.appId, + payId: props.newPayId, + toChain: props.toChain, + toAddress: props.toAddress, + toToken: props.toToken, + toAmount: props.toAmount, + toCallData: props.toCallData, + paymentOptions: props.paymentOptions, + preferredChains: props.preferredChains, + preferredTokens: props.preferredTokens, + } + : null; + let payId = "payId" in props ? props.payId : null; + const { paymentState } = context; - useEffect( - () => { - if ("payId" in props) { - paymentState.setPayId(props.payId); - } else if ("appId" in props) { - paymentState.setPayParams(props); - } else { - console.error(`[BUTTON] must specify either payId or appId`); - } - }, - "payId" in props - ? [props.payId] - : [ - // Use JSON to avoid reloading every render - // eg. given paymentOptions={array literal} - JSON.stringify([ - props.appId, - props.nonce, - props.toChain, - props.toAddress, - props.toToken, - "" + props.toAmount, - props.toCallData, - props.paymentOptions, - props.preferredChains, - ]), - ], - ); + useEffect(() => { + if (payId != null) { + paymentState.setPayId(payId); + } else if (payParams != null) { + paymentState.setPayParams(payParams); + } + }, [payId, ...Object.values(payParams || {})]); + + // Payment events: call these three event handlers. + const { onPaymentStarted, onPaymentCompleted, onPaymentBounced } = props; + + const order = paymentState.daimoPayOrder; + const hydOrder = order?.mode === DaimoPayOrderMode.HYDRATED ? order : null; + const isStarted = + hydOrder?.sourceStatus !== DaimoPayOrderStatusSource.WAITING_PAYMENT; + + useEffect(() => { + if (hydOrder == null || !isStarted) return; + onPaymentStarted?.({ + paymentId: writeDaimoPayOrderID(hydOrder.id), + type: "payment_started", + chainId: assertNotNull(hydOrder.sourceTokenAmount).token.chainId, + txHash: assertNotNull(hydOrder.sourceInitiateTxHash), + }); + }, [isStarted]); + + useEffect(() => { + if (hydOrder == null) return; + if (hydOrder.intentStatus === DaimoPayIntentStatus.PENDING) return; + + const commonFields = { + paymentId: writeDaimoPayOrderID(hydOrder.id), + chainId: assertNotNull(hydOrder.destFinalCallTokenAmount).token.chainId, + txHash: assertNotNull( + hydOrder.destFastFinishTxHash ?? hydOrder.destClaimTxHash, + ), + }; + if (hydOrder.intentStatus === DaimoPayIntentStatus.SUCCESSFUL) { + onPaymentCompleted?.({ type: "payment_completed", ...commonFields }); + } else if (hydOrder.intentStatus === DaimoPayIntentStatus.REFUNDED) { + onPaymentBounced?.({ type: "payment_bounced", ...commonFields }); + } + }, [hydOrder?.intentStatus]); + + // Validation + if ((payId == null) == (payParams == null)) { + throw new Error("Must specify either payId or appId, not both"); + } const { children, closeOnSuccess } = props; const modalOptions = { closeOnSuccess }; diff --git a/packages/connectkit/src/defaultConfig.ts b/packages/connectkit/src/defaultConfig.ts index 73a11397..551c9cc6 100644 --- a/packages/connectkit/src/defaultConfig.ts +++ b/packages/connectkit/src/defaultConfig.ts @@ -50,7 +50,7 @@ export const REQUIRED_CHAINS: CreateConfigParameters["chains"] = [ baseSepolia, ]; -/** A utility for use with wagmi's createConfig(). */ +/** Daimo Pay recommended config, for use with wagmi's createConfig(). */ const defaultConfig = ({ appName = "Daimo Pay", appIcon, diff --git a/packages/connectkit/src/hooks/useModal.ts b/packages/connectkit/src/hooks/useModal.ts index bbb6a0e4..7ef63629 100644 --- a/packages/connectkit/src/hooks/useModal.ts +++ b/packages/connectkit/src/hooks/useModal.ts @@ -6,6 +6,7 @@ import { type UseModalProps = {} & useConnectCallbackProps; +/** Opens and closes the payment modal. */ export const useModal = ({ onConnect, onDisconnect }: UseModalProps = {}) => { const context = usePayContext(); diff --git a/packages/connectkit/src/hooks/usePaymentState.ts b/packages/connectkit/src/hooks/usePaymentState.ts index 2489da7d..0ab037a4 100644 --- a/packages/connectkit/src/hooks/usePaymentState.ts +++ b/packages/connectkit/src/hooks/usePaymentState.ts @@ -20,7 +20,7 @@ import { Address, Hex, parseUnits } from "viem"; import { useAccount, useEnsName } from "wagmi"; import { DaimoPayModalOptions, PaymentOption } from "../types"; -import { generateNonce } from "../utils/exports"; +import { generatePayId } from "../utils/exports"; import { detectPlatform } from "../utils/platform"; import { TrpcClient } from "../utils/trpc"; import { useDepositAddressOptions } from "./useDepositAddressOptions"; @@ -45,8 +45,8 @@ export type SourcePayment = Parameters< export interface PayParams { /** App ID, for authentication. */ appId: string; - /** Optional nonce. If set, generates a deterministic payID. See docs. */ - nonce?: bigint; + /** Optional deterministic payId. See docs. */ + payId?: string; /** Destination chain ID. */ toChain: number; /** The destination token to send. */ @@ -63,6 +63,8 @@ export interface PayParams { paymentOptions?: PaymentOption[]; /** Preferred chain IDs. */ preferredChains?: number[]; + /** Preferred tokens. These appear first in the token list. */ + preferredTokens?: { chain: number; address: Address }[]; } /** Creates (or loads) a payment and manages the corresponding modal. */ @@ -303,7 +305,6 @@ export function usePaymentState({ // TODO: remove amount from destFinalCall, it is redundant with // destFinalCallTokenAmount. Here, we only modify one and not the other. - setDaimoPayOrder({ ...daimoPayOrder, destFinalCallTokenAmount: { @@ -342,22 +343,24 @@ export function usePaymentState({ assert(payParams != null); setPayParamsState(payParams); - const genID = payParams.nonce ?? generateNonce(); + const newPayId = payParams.payId ?? generatePayId(); + const newId = readDaimoPayOrderID(newPayId).toString(); const payment = await trpc.previewOrder.query({ - id: genID.toString(), + id: newId, toChain: payParams.toChain, toToken: payParams.toToken, toAmount: payParams.toAmount.toString(), toAddress: payParams.toAddress, toCallData: payParams.toCallData, - toNonce: genID.toString(), + toNonce: newId, metadata: { intent: payParams.intent ?? "Pay", items: [], payer: { paymentOptions: payParams.paymentOptions, preferredChains: payParams.preferredChains, + preferredTokens: payParams.preferredTokens, }, }, }); diff --git a/packages/connectkit/src/index.ts b/packages/connectkit/src/index.ts index c30aae7e..2d446ea0 100644 --- a/packages/connectkit/src/index.ts +++ b/packages/connectkit/src/index.ts @@ -10,14 +10,8 @@ export { DaimoPayButton } from "./components/DaimoPayButton"; // Hooks to track payment status + UI status. export { useDaimoPayStatus } from "./hooks/useDaimoPayStatus"; -/** TODO: replace with useDaimoPay() */ -export { useModal } from "./hooks/useModal"; - -// These first two just return configured wagmi chains = not necessarily -// supported by Daimo Pay. -// export { useChainIsSupported } from "./hooks/useChainIsSupported"; -// export { useChains } from "./hooks/useChains"; -// export { default as useIsMounted } from "./hooks/useIsMounted"; +// TODO: replace with useDaimoPay() more comprehensive status. +export { useModal as useDaimoPayModal } from "./hooks/useModal"; // For convenience, export components to show connected account. export { default as Avatar } from "./components/Common/Avatar"; diff --git a/packages/connectkit/src/types.ts b/packages/connectkit/src/types.ts index 9d960407..c54c5436 100644 --- a/packages/connectkit/src/types.ts +++ b/packages/connectkit/src/types.ts @@ -63,6 +63,33 @@ export type PaymentStatus = | "payment_completed" | "payment_bounced"; +// TODO: move types here from daimo-common/daimoPay.ts: +// type PayEventBase = { +// /** The type of payment event. */ +// type: PaymentStatus; +// /** The unique payment ID. */ +// paymentId: string; +// /** The chain ID where the payment transaction was sent. */ +// chainId?: number; +// /** The transaction hash, if available. */ +// txHash?: string; +// }; + +// export type PayEventStarted = PayEventBase & { +// type: "payment_started"; +// }; + +// export type PayEventCompleted = PayEventBase & { +// type: "payment_completed"; +// }; + +// export type PayEventBounced = PayEventBase & { +// type: "payment_bounced"; +// }; + +// /** Payment event. This matches the payload for webhooks. See doc. */ +// export type PayEvent = PayEventStarted | PayEventCompleted | PayEventBounced; + // TODO: for now, these match ExternalPaymentOptions. In future, we can add // higher level categories like "Solana", "BitcoinEtc", "Card". /** Additional payment options. Onchain payments are always enabled. */ diff --git a/packages/connectkit/src/utils/exports.ts b/packages/connectkit/src/utils/exports.ts index 4249fe6c..3b859efa 100644 --- a/packages/connectkit/src/utils/exports.ts +++ b/packages/connectkit/src/utils/exports.ts @@ -1,8 +1,10 @@ // Exported utilities, useful for @daimo/pay users. +import { writeDaimoPayOrderID } from "@daimo/common"; import { bytesToBigInt } from "viem"; -/** Generates a globally-unique 32-byte nonce. */ -export function generateNonce(): bigint { - return bytesToBigInt(crypto.getRandomValues(new Uint8Array(32))); +/** Generates a globally-unique payId. */ +export function generatePayId(): string { + const id = bytesToBigInt(crypto.getRandomValues(new Uint8Array(32))); + return writeDaimoPayOrderID(id); } diff --git a/packages/connectkit/src/wallets/index.ts b/packages/connectkit/src/wallets/index.ts index e39a9e30..52e9bf1d 100644 --- a/packages/connectkit/src/wallets/index.ts +++ b/packages/connectkit/src/wallets/index.ts @@ -3,8 +3,7 @@ import { CreateConnectorFn } from "wagmi"; import { walletConfigs } from "./walletConfigs"; -// export type WalletIds = Extract; - +/** Ethereum wallets, by name. */ export const wallets: { [key: string]: CreateConnectorFn; } = Object.keys(walletConfigs).reduce((acc, key) => { From ae569dc1283298feccf157553468148e56b7ddde Mon Sep 17 00:00:00 2001 From: DC Date: Thu, 26 Dec 2024 23:35:44 -0800 Subject: [PATCH 3/6] sdk: remove newPayId, add amountEditable --- .../connectkit/src/components/DaimoPayButton/index.tsx | 10 +++++----- packages/connectkit/src/hooks/usePayWithSolanaToken.ts | 1 - packages/connectkit/src/hooks/usePaymentState.ts | 9 ++++----- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/connectkit/src/components/DaimoPayButton/index.tsx b/packages/connectkit/src/components/DaimoPayButton/index.tsx index 807a087b..3de13b27 100644 --- a/packages/connectkit/src/components/DaimoPayButton/index.tsx +++ b/packages/connectkit/src/components/DaimoPayButton/index.tsx @@ -27,10 +27,6 @@ type PayButtonPaymentProps = * Your public app ID. Specify either (payId) or (appId + parameters). */ appId: string; - /** - * Optional deterministic payId. See docs. - */ - newPayId?: string; /** * Destination chain ID. */ @@ -44,6 +40,10 @@ type PayButtonPaymentProps = * The amount of destination token to send (transfer or approve). */ toAmount: bigint; + /** + * Let the user edit the amount to send. + */ + amountEditable?: boolean; /** * The destination address to transfer to, or contract to call. */ @@ -156,12 +156,12 @@ function DaimoPayButtonCustom(props: DaimoPayButtonCustomProps) { "appId" in props ? { appId: props.appId, - payId: props.newPayId, toChain: props.toChain, toAddress: props.toAddress, toToken: props.toToken, toAmount: props.toAmount, toCallData: props.toCallData, + isAmountEditable: props.amountEditable ?? false, paymentOptions: props.paymentOptions, preferredChains: props.preferredChains, preferredTokens: props.preferredTokens, diff --git a/packages/connectkit/src/hooks/usePayWithSolanaToken.ts b/packages/connectkit/src/hooks/usePayWithSolanaToken.ts index 833b02cb..4ded5dd0 100644 --- a/packages/connectkit/src/hooks/usePayWithSolanaToken.ts +++ b/packages/connectkit/src/hooks/usePayWithSolanaToken.ts @@ -68,7 +68,6 @@ export function usePayWithSolanaToken({ trpc.processSolanaSourcePayment.mutate({ orderId: orderId.toString(), startIntentTxHash: txHash, - amount: "0", // TODO: replace. token: inputToken, }); diff --git a/packages/connectkit/src/hooks/usePaymentState.ts b/packages/connectkit/src/hooks/usePaymentState.ts index 0ab037a4..b26e18dc 100644 --- a/packages/connectkit/src/hooks/usePaymentState.ts +++ b/packages/connectkit/src/hooks/usePaymentState.ts @@ -45,8 +45,6 @@ export type SourcePayment = Parameters< export interface PayParams { /** App ID, for authentication. */ appId: string; - /** Optional deterministic payId. See docs. */ - payId?: string; /** Destination chain ID. */ toChain: number; /** The destination token to send. */ @@ -57,6 +55,8 @@ export interface PayParams { toAddress: Address; /** Calldata for final call, or empty data for transfer. */ toCallData?: Hex; + /** Let the user edit the amount to send. */ + isAmountEditable: boolean; /** The intent verb, such as Pay, Deposit, or Purchase. Default: Pay */ intent?: string; /** Payment options. By default, all are enabled. */ @@ -199,7 +199,6 @@ export function usePaymentState({ ...payParams, id: order.id.toString(), toAmount: order.destFinalCallTokenAmount.amount, - toNonce: order.id.toString(), metadata: order.metadata, }, platform, @@ -343,7 +342,7 @@ export function usePaymentState({ assert(payParams != null); setPayParamsState(payParams); - const newPayId = payParams.payId ?? generatePayId(); + const newPayId = generatePayId(); const newId = readDaimoPayOrderID(newPayId).toString(); const payment = await trpc.previewOrder.query({ @@ -353,7 +352,7 @@ export function usePaymentState({ toAmount: payParams.toAmount.toString(), toAddress: payParams.toAddress, toCallData: payParams.toCallData, - toNonce: newId, + isAmountEditable: payParams.isAmountEditable, metadata: { intent: payParams.intent ?? "Pay", items: [], From 772e7434839136119beca6ed9325a71cc73b8ad9 Mon Sep 17 00:00:00 2001 From: DC Date: Fri, 27 Dec 2024 05:08:52 -0800 Subject: [PATCH 4/6] paykit: add examples --- examples/nextjs-app/.env | 2 + examples/nextjs-app/app/page.tsx | 49 ----- examples/nextjs-app/package.json | 5 +- examples/nextjs-app/postcss.config.js | 6 + examples/nextjs-app/src/app/DemoBasic.tsx | 36 ++++ examples/nextjs-app/src/app/DemoCheckout.tsx | 70 ++++++ examples/nextjs-app/src/app/DemoContract.tsx | 114 ++++++++++ examples/nextjs-app/src/app/DemoDeposit.tsx | 38 ++++ examples/nextjs-app/{ => src}/app/layout.tsx | 12 +- examples/nextjs-app/src/app/page.tsx | 41 ++++ .../nextjs-app/{ => src}/app/providers.tsx | 5 +- examples/nextjs-app/src/app/shared.tsx | 17 ++ examples/nextjs-app/{ => src}/config.ts | 7 +- .../src/shared/tailwind-catalyst/alert.tsx | 95 +++++++++ .../src/shared/tailwind-catalyst/button.tsx | 201 ++++++++++++++++++ .../src/shared/tailwind-catalyst/dialog.tsx | 108 ++++++++++ .../src/shared/tailwind-catalyst/fieldset.tsx | 121 +++++++++++ .../src/shared/tailwind-catalyst/heading.tsx | 33 +++ .../src/shared/tailwind-catalyst/input.tsx | 104 +++++++++ .../src/shared/tailwind-catalyst/link.tsx | 21 ++ .../src/shared/tailwind-catalyst/table.tsx | 187 ++++++++++++++++ .../src/shared/tailwind-catalyst/text.tsx | 60 ++++++ examples/nextjs-app/src/styles/tailwind.css | 3 + examples/nextjs-app/styles/globals.css | 8 - examples/nextjs-app/tailwind.config.js | 9 + examples/nextjs/.env.example | 4 - examples/nextjs/.eslintrc.json | 3 - examples/nextjs/.gitignore | 37 ---- examples/nextjs/README.md | 6 - examples/nextjs/components/Web3Provider.tsx | 24 --- examples/nextjs/next.config.js | 7 - examples/nextjs/package.json | 26 --- examples/nextjs/pages/_app.tsx | 14 -- examples/nextjs/pages/index.tsx | 23 -- examples/nextjs/styles/globals.css | 8 - examples/nextjs/tsconfig.json | 30 --- .../src/components/DaimoPayButton/index.tsx | 34 +-- .../connectkit/src/hooks/usePaymentState.ts | 13 +- 38 files changed, 1304 insertions(+), 277 deletions(-) create mode 100644 examples/nextjs-app/.env delete mode 100644 examples/nextjs-app/app/page.tsx create mode 100644 examples/nextjs-app/postcss.config.js create mode 100644 examples/nextjs-app/src/app/DemoBasic.tsx create mode 100644 examples/nextjs-app/src/app/DemoCheckout.tsx create mode 100644 examples/nextjs-app/src/app/DemoContract.tsx create mode 100644 examples/nextjs-app/src/app/DemoDeposit.tsx rename examples/nextjs-app/{ => src}/app/layout.tsx (51%) create mode 100644 examples/nextjs-app/src/app/page.tsx rename examples/nextjs-app/{ => src}/app/providers.tsx (75%) create mode 100644 examples/nextjs-app/src/app/shared.tsx rename examples/nextjs-app/{ => src}/config.ts (64%) create mode 100644 examples/nextjs-app/src/shared/tailwind-catalyst/alert.tsx create mode 100644 examples/nextjs-app/src/shared/tailwind-catalyst/button.tsx create mode 100644 examples/nextjs-app/src/shared/tailwind-catalyst/dialog.tsx create mode 100644 examples/nextjs-app/src/shared/tailwind-catalyst/fieldset.tsx create mode 100644 examples/nextjs-app/src/shared/tailwind-catalyst/heading.tsx create mode 100644 examples/nextjs-app/src/shared/tailwind-catalyst/input.tsx create mode 100644 examples/nextjs-app/src/shared/tailwind-catalyst/link.tsx create mode 100644 examples/nextjs-app/src/shared/tailwind-catalyst/table.tsx create mode 100644 examples/nextjs-app/src/shared/tailwind-catalyst/text.tsx create mode 100644 examples/nextjs-app/src/styles/tailwind.css delete mode 100644 examples/nextjs-app/styles/globals.css create mode 100644 examples/nextjs-app/tailwind.config.js delete mode 100644 examples/nextjs/.env.example delete mode 100644 examples/nextjs/.eslintrc.json delete mode 100644 examples/nextjs/.gitignore delete mode 100644 examples/nextjs/README.md delete mode 100644 examples/nextjs/components/Web3Provider.tsx delete mode 100644 examples/nextjs/next.config.js delete mode 100644 examples/nextjs/package.json delete mode 100644 examples/nextjs/pages/_app.tsx delete mode 100644 examples/nextjs/pages/index.tsx delete mode 100644 examples/nextjs/styles/globals.css delete mode 100644 examples/nextjs/tsconfig.json diff --git a/examples/nextjs-app/.env b/examples/nextjs-app/.env new file mode 100644 index 00000000..2aa1bb9f --- /dev/null +++ b/examples/nextjs-app/.env @@ -0,0 +1,2 @@ + +NEXT_PUBLIC_DAIMOPAY_API_URL="http://localhost:4000" diff --git a/examples/nextjs-app/app/page.tsx b/examples/nextjs-app/app/page.tsx deleted file mode 100644 index a7cd0da7..00000000 --- a/examples/nextjs-app/app/page.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -import { DaimoPayButton } from "@daimo/pay"; -import { useAccount, useConnect, useDisconnect } from "wagmi"; - -function App() { - const account = useAccount(); - const { connectors, connect, status, error } = useConnect(); - const { disconnect } = useDisconnect(); - - return ( - <> -
-

Account

- - -
- status: {account.status} -
- addresses: {JSON.stringify(account.addresses)} -
- chainId: {account.chainId} -
- - {account.status === "connected" && ( - - )} -
-
-

Connect

- {connectors.map((connector) => ( - - ))} -
{status}
-
{error?.message}
-
- - ); -} - -export default App; diff --git a/examples/nextjs-app/package.json b/examples/nextjs-app/package.json index be47aa73..c0ad413b 100644 --- a/examples/nextjs-app/package.json +++ b/examples/nextjs-app/package.json @@ -8,11 +8,14 @@ "lint": "next lint" }, "dependencies": { - "@tanstack/react-query": "^5.51.11", "@daimo/pay": "*", + "@tanstack/react-query": "^5.51.11", + "autoprefixer": "^10.4.20", "next": "14.2.13", + "postcss": "^8.4.49", "react": "18.2.0", "react-dom": "18.2.0", + "tailwindcss": "^3.4.17", "viem": "^2.21.10", "wagmi": "^2.12.0" }, diff --git a/examples/nextjs-app/postcss.config.js b/examples/nextjs-app/postcss.config.js new file mode 100644 index 00000000..33ad091d --- /dev/null +++ b/examples/nextjs-app/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/examples/nextjs-app/src/app/DemoBasic.tsx b/examples/nextjs-app/src/app/DemoBasic.tsx new file mode 100644 index 00000000..6521b773 --- /dev/null +++ b/examples/nextjs-app/src/app/DemoBasic.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { baseUSDC } from "@daimo/contract"; +import { DaimoPayButton } from "@daimo/pay"; +import { getAddress } from "viem"; +import { Text, TextLink } from "../shared/tailwind-catalyst/text"; +import { APP_ID, Container, printEvent } from "./shared"; + +export function DemoBasic() { + return ( + + + This shows a basic payment from any coin on any chain. The recipient + receives USDC on Base. + +
+ + + + View on Github ↗ + + + + ); +} diff --git a/examples/nextjs-app/src/app/DemoCheckout.tsx b/examples/nextjs-app/src/app/DemoCheckout.tsx new file mode 100644 index 00000000..02a7544d --- /dev/null +++ b/examples/nextjs-app/src/app/DemoCheckout.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { getAddressContraction, PaymentStartedEvent } from "@daimo/common"; +import { baseUSDC } from "@daimo/contract"; +import { DaimoPayButton } from "@daimo/pay"; +import { useCallback, useState } from "react"; +import { getAddress } from "viem"; +import { Code, Text, TextLink } from "../shared/tailwind-catalyst/text"; +import { APP_ID, Columns, Container, printEvent } from "./shared"; + +export function DemoCheckout() { + const [payId, setPayId] = useState(); + + const start = useCallback((e: PaymentStartedEvent) => { + printEvent(e); + const payId = e.paymentId; + setPayId(payId); + // Save payId to your backend here. This ensures that you'll be able to + // correlate all incoming payments even if the user loses network, etc. + // await saveCartCheckout(payId, ...); + }, []); + + return ( + + + For robust checkout, save the payId in onPaymentStarted. + This ensures you'll be able to correlate incoming payments with a + cart (or a user ID, form submission, etc) even if the user closes the + tab. + + + In addition to callbacks like onPaymentSucceeded, Daimo Pay + supports{" "} + webhooks{" "} + to track payment status reliably on the backend. + +
+ +
+ +
+
+ PayID {payId ? getAddressContraction(payId) : "TBD"} +
+
+ + + View on Github ↗ + + + + ); +} diff --git a/examples/nextjs-app/src/app/DemoContract.tsx b/examples/nextjs-app/src/app/DemoContract.tsx new file mode 100644 index 00000000..dfe93c2d --- /dev/null +++ b/examples/nextjs-app/src/app/DemoContract.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { PaymentCompletedEvent, PaymentStartedEvent } from "@daimo/common"; +import { arbitrum, getChainExplorerByChainId } from "@daimo/contract"; +import { DaimoPayButton } from "@daimo/pay"; +import { useState } from "react"; +import { encodeFunctionData, parseAbi, zeroAddress } from "viem"; +import { useReadContract } from "wagmi"; +import { Text, TextLink } from "../shared/tailwind-catalyst/text"; +import { APP_ID, Columns, Container, printEvent } from "./shared"; + +export function DemoContract() { + const counterAddr = "0x7f3c168DD11379748EeF71Bea70371eBA3327Ca5"; + const counterAbi = parseAbi([ + "function increment(string) external payable", + "function counter() external view returns (uint256)", + ]); + + // Load count from chain + const { + data: count, + error, + refetch, + } = useReadContract({ + chainId: arbitrum.chainId, + abi: counterAbi, + address: counterAddr, + functionName: "counter", + }); + + // Show transaction, once we're done + const [successUrl, setSuccessUrl] = useState(); + + // Reload on successful action + const onStart = (e: PaymentStartedEvent) => { + printEvent(e); + setSuccessUrl(undefined); + }; + const onSuccess = (e: PaymentCompletedEvent) => { + printEvent(e); + setSuccessUrl(`${getChainExplorerByChainId(e.chainId)}/tx/${e.txHash}`); + refetch(); + }; + + return ( + + + Daimo Pay supports arbitrary contract calls. This lets you offer + one-click checkout for digital goods. It also enables optimal + onboarding. + + + For example, imagine a new user who wants to use a prediction market. + You can let them place a prediction immediately as part of their + onboarding, paying from any coin on any chain. + + + Demo: pay 0.0001 ETH to increment{" "} + + this counter + + . + + +
+ +
+
+ Count {Number(count)} +
+
+ +
+ + + View on Github ↗ + + +
+
+ {error && {error.message}} + {successUrl && ( + + + View transaction + + + )} +
+
+
+ ); +} diff --git a/examples/nextjs-app/src/app/DemoDeposit.tsx b/examples/nextjs-app/src/app/DemoDeposit.tsx new file mode 100644 index 00000000..57c14c6e --- /dev/null +++ b/examples/nextjs-app/src/app/DemoDeposit.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { baseUSDC } from "@daimo/contract"; +import { DaimoPayButton } from "@daimo/pay"; +import { getAddress } from "viem"; +import { Code, Text, TextLink } from "../shared/tailwind-catalyst/text"; +import { APP_ID, Container, printEvent } from "./shared"; + +export function DemoDeposit() { + return ( + + + Onboard users in one click. For any-chain deposits, set an initial + amount and use amountEditable to let the user choose. + +
+ + + + View on Github ↗ + + + + ); +} diff --git a/examples/nextjs-app/app/layout.tsx b/examples/nextjs-app/src/app/layout.tsx similarity index 51% rename from examples/nextjs-app/app/layout.tsx rename to examples/nextjs-app/src/app/layout.tsx index f4940184..8dd66f8b 100644 --- a/examples/nextjs-app/app/layout.tsx +++ b/examples/nextjs-app/src/app/layout.tsx @@ -1,12 +1,12 @@ -import type { Metadata } from 'next'; -import { type ReactNode } from 'react'; -import '../styles/globals.css'; +import type { Metadata } from "next"; +import { type ReactNode } from "react"; +import { Providers } from "./providers"; -import { Providers } from './providers'; +import "../styles/tailwind.css"; export const metadata: Metadata = { - title: 'ConnectKit Next.js Example', - description: 'By Family', + title: "ConnectKit Next.js Example", + description: "By Family", }; export default function RootLayout(props: { children: ReactNode }) { diff --git a/examples/nextjs-app/src/app/page.tsx b/examples/nextjs-app/src/app/page.tsx new file mode 100644 index 00000000..0cb8040a --- /dev/null +++ b/examples/nextjs-app/src/app/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "../shared/tailwind-catalyst/button"; +import { Heading } from "../shared/tailwind-catalyst/heading"; +import { DemoBasic } from "./DemoBasic"; +import { DemoCheckout } from "./DemoCheckout"; +import { DemoContract } from "./DemoContract"; +import { DemoDeposit } from "./DemoDeposit"; + +type DemoType = "basic" | "contract" | "checkout" | "deposit"; + +export default function DemoButtonPage() { + const [demo, setDemo] = useState("basic"); + + const Btn = ({ type, children }: { type: DemoType; children: string }) => ( + + ); + + return ( +
+ DaimoPayButton Examples + +
+ Basic + Contract + Checkout + Deposit +
+ +
+ {demo === "basic" && } + {demo === "contract" && } + {demo === "checkout" && } + {demo === "deposit" && } +
+
+ ); +} diff --git a/examples/nextjs-app/app/providers.tsx b/examples/nextjs-app/src/app/providers.tsx similarity index 75% rename from examples/nextjs-app/app/providers.tsx rename to examples/nextjs-app/src/app/providers.tsx index d150ca1d..be1c1ec1 100644 --- a/examples/nextjs-app/app/providers.tsx +++ b/examples/nextjs-app/src/app/providers.tsx @@ -7,12 +7,15 @@ import { WagmiProvider } from "wagmi"; import { DaimoPayProvider } from "@daimo/pay"; import { config } from "../config"; +const apiUrl = + process.env.NEXT_PUBLIC_DAIMOPAY_API_URL || "http://localhost:4000"; + const queryClient = new QueryClient(); export function Providers(props: { children: ReactNode }) { return ( - {props.children} + {props.children} ); diff --git a/examples/nextjs-app/src/app/shared.tsx b/examples/nextjs-app/src/app/shared.tsx new file mode 100644 index 00000000..754f3a46 --- /dev/null +++ b/examples/nextjs-app/src/app/shared.tsx @@ -0,0 +1,17 @@ +import { DaimoPayEvent } from "@daimo/common"; +import { getChainExplorerByChainId } from "@daimo/contract"; + +export const APP_ID = "daimopay-demo"; + +export function Container({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function Columns({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function printEvent(e: DaimoPayEvent) { + const url = getChainExplorerByChainId(e.chainId); + console.log(`${e.type} payment ${e.paymentId}: ${url}/tx/${e.txHash}`); +} diff --git a/examples/nextjs-app/config.ts b/examples/nextjs-app/src/config.ts similarity index 64% rename from examples/nextjs-app/config.ts rename to examples/nextjs-app/src/config.ts index 727d020a..c3d2488e 100644 --- a/examples/nextjs-app/config.ts +++ b/examples/nextjs-app/src/config.ts @@ -1,12 +1,15 @@ import { getDefaultConfig } from "@daimo/pay"; -import { createConfig } from "wagmi"; -import { mainnet, polygon, optimism, arbitrum } from "wagmi/chains"; +import { createConfig, http } from "wagmi"; +import { arbitrum, mainnet, optimism, polygon } from "wagmi/chains"; export const config = createConfig( getDefaultConfig({ appName: "ConnectKit Next.js demo", chains: [mainnet, polygon, optimism, arbitrum], walletConnectProjectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!, + transports: { + [arbitrum.id]: http("https://arbitrum-one-rpc.publicnode.com"), + }, }), ); diff --git a/examples/nextjs-app/src/shared/tailwind-catalyst/alert.tsx b/examples/nextjs-app/src/shared/tailwind-catalyst/alert.tsx new file mode 100644 index 00000000..846eb96a --- /dev/null +++ b/examples/nextjs-app/src/shared/tailwind-catalyst/alert.tsx @@ -0,0 +1,95 @@ +import * as Headless from '@headlessui/react' +import clsx from 'clsx' +import type React from 'react' +import { Text } from './text' + +const sizes = { + xs: 'sm:max-w-xs', + sm: 'sm:max-w-sm', + md: 'sm:max-w-md', + lg: 'sm:max-w-lg', + xl: 'sm:max-w-xl', + '2xl': 'sm:max-w-2xl', + '3xl': 'sm:max-w-3xl', + '4xl': 'sm:max-w-4xl', + '5xl': 'sm:max-w-5xl', +} + +export function Alert({ + size = 'md', + className, + children, + ...props +}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit< + Headless.DialogProps, + 'as' | 'className' +>) { + return ( + + + +
+
+ + {children} + +
+
+
+ ) +} + +export function AlertTitle({ + className, + ...props +}: { className?: string } & Omit) { + return ( + + ) +} + +export function AlertDescription({ + className, + ...props +}: { className?: string } & Omit, 'as' | 'className'>) { + return ( + + ) +} + +export function AlertBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) { + return
+} + +export function AlertActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) { + return ( +
+ ) +} diff --git a/examples/nextjs-app/src/shared/tailwind-catalyst/button.tsx b/examples/nextjs-app/src/shared/tailwind-catalyst/button.tsx new file mode 100644 index 00000000..78f5b270 --- /dev/null +++ b/examples/nextjs-app/src/shared/tailwind-catalyst/button.tsx @@ -0,0 +1,201 @@ +import * as Headless from "@headlessui/react"; +import clsx from "clsx"; +import React, { forwardRef } from "react"; +import { Link } from "./link"; + +const styles = { + base: [ + // Base + "relative isolate inline-flex items-center justify-center gap-x-2 rounded-lg border text-base/6 font-semibold", + // Sizing + "px-[calc(theme(spacing[3.5])-1px)] py-[calc(theme(spacing[2.5])-1px)] sm:px-[calc(theme(spacing.3)-1px)] sm:py-[calc(theme(spacing[1.5])-1px)] sm:text-sm/6", + // Focus + "focus:outline-none data-[focus]:outline data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-blue-500", + // Disabled + "data-[disabled]:opacity-50", + // Icon + "[&>[data-slot=icon]]:-mx-0.5 [&>[data-slot=icon]]:my-0.5 [&>[data-slot=icon]]:size-5 [&>[data-slot=icon]]:shrink-0 [&>[data-slot=icon]]:text-[--btn-icon] [&>[data-slot=icon]]:sm:my-1 [&>[data-slot=icon]]:sm:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:data-[hover]:[--btn-icon:ButtonText]", + ], + solid: [ + // Optical border, implemented as the button background to avoid corner artifacts + "border-transparent bg-[--btn-border]", + // Dark mode: border is rendered on `after` so background is set to button background + "dark:bg-[--btn-bg]", + // Button background, implemented as foreground layer to stack on top of pseudo-border layer + "before:absolute before:inset-0 before:-z-10 before:rounded-[calc(theme(borderRadius.lg)-1px)] before:bg-[--btn-bg]", + // Drop shadow, applied to the inset `before` layer so it blends with the border + "before:shadow", + // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo + "dark:before:hidden", + // Dark mode: Subtle white outline is applied using a border + "dark:border-white/5", + // Shim/overlay, inset to match button foreground and used for hover state + highlight shadow + "after:absolute after:inset-0 after:-z-10 after:rounded-[calc(theme(borderRadius.lg)-1px)]", + // Inner highlight shadow + "after:shadow-[shadow:inset_0_1px_theme(colors.white/15%)]", + // White overlay on hover + "after:data-[active]:bg-[--btn-hover-overlay] after:data-[hover]:bg-[--btn-hover-overlay]", + // Dark mode: `after` layer expands to cover entire button + "dark:after:-inset-px dark:after:rounded-lg", + // Disabled + "before:data-[disabled]:shadow-none after:data-[disabled]:shadow-none", + ], + outline: [ + // Base + "border-zinc-950/10 text-zinc-950 data-[active]:bg-zinc-950/[2.5%] data-[hover]:bg-zinc-950/[2.5%]", + // Dark mode + "dark:border-white/15 dark:text-white dark:[--btn-bg:transparent] dark:data-[active]:bg-white/5 dark:data-[hover]:bg-white/5", + // Icon + "[--btn-icon:theme(colors.zinc.500)] data-[active]:[--btn-icon:theme(colors.zinc.700)] data-[hover]:[--btn-icon:theme(colors.zinc.700)] dark:data-[active]:[--btn-icon:theme(colors.zinc.400)] dark:data-[hover]:[--btn-icon:theme(colors.zinc.400)]", + ], + plain: [ + // Base + "border-transparent text-zinc-950 data-[active]:bg-zinc-950/5 data-[hover]:bg-zinc-950/5", + // Dark mode + "dark:text-white dark:data-[active]:bg-white/10 dark:data-[hover]:bg-white/10", + // Icon + "[--btn-icon:theme(colors.zinc.500)] data-[active]:[--btn-icon:theme(colors.zinc.700)] data-[hover]:[--btn-icon:theme(colors.zinc.700)] dark:[--btn-icon:theme(colors.zinc.500)] dark:data-[active]:[--btn-icon:theme(colors.zinc.400)] dark:data-[hover]:[--btn-icon:theme(colors.zinc.400)]", + ], + colors: { + "dark/zinc": [ + "text-white [--btn-bg:theme(colors.zinc.900)] [--btn-border:theme(colors.zinc.950/90%)] [--btn-hover-overlay:theme(colors.white/10%)]", + "dark:text-white dark:[--btn-bg:theme(colors.zinc.600)] dark:[--btn-hover-overlay:theme(colors.white/5%)]", + "[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.300)] data-[hover]:[--btn-icon:theme(colors.zinc.300)]", + ], + light: [ + "text-zinc-950 [--btn-bg:white] [--btn-border:theme(colors.zinc.950/10%)] [--btn-hover-overlay:theme(colors.zinc.950/2.5%)] data-[active]:[--btn-border:theme(colors.zinc.950/15%)] data-[hover]:[--btn-border:theme(colors.zinc.950/15%)]", + "dark:text-white dark:[--btn-hover-overlay:theme(colors.white/5%)] dark:[--btn-bg:theme(colors.zinc.800)]", + "[--btn-icon:theme(colors.zinc.500)] data-[active]:[--btn-icon:theme(colors.zinc.700)] data-[hover]:[--btn-icon:theme(colors.zinc.700)] dark:[--btn-icon:theme(colors.zinc.500)] dark:data-[active]:[--btn-icon:theme(colors.zinc.400)] dark:data-[hover]:[--btn-icon:theme(colors.zinc.400)]", + ], + "dark/white": [ + "text-white [--btn-bg:theme(colors.zinc.900)] [--btn-border:theme(colors.zinc.950/90%)] [--btn-hover-overlay:theme(colors.white/10%)]", + "dark:text-zinc-950 dark:[--btn-bg:white] dark:[--btn-hover-overlay:theme(colors.zinc.950/5%)]", + "[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.300)] data-[hover]:[--btn-icon:theme(colors.zinc.300)] dark:[--btn-icon:theme(colors.zinc.500)] dark:data-[active]:[--btn-icon:theme(colors.zinc.400)] dark:data-[hover]:[--btn-icon:theme(colors.zinc.400)]", + ], + dark: [ + "text-white [--btn-bg:theme(colors.zinc.900)] [--btn-border:theme(colors.zinc.950/90%)] [--btn-hover-overlay:theme(colors.white/10%)]", + "dark:[--btn-hover-overlay:theme(colors.white/5%)] dark:[--btn-bg:theme(colors.zinc.800)]", + "[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.300)] data-[hover]:[--btn-icon:theme(colors.zinc.300)]", + ], + white: [ + "text-zinc-950 [--btn-bg:white] [--btn-border:theme(colors.zinc.950/10%)] [--btn-hover-overlay:theme(colors.zinc.950/2.5%)] data-[active]:[--btn-border:theme(colors.zinc.950/15%)] data-[hover]:[--btn-border:theme(colors.zinc.950/15%)]", + "dark:[--btn-hover-overlay:theme(colors.zinc.950/5%)]", + "[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.500)] data-[hover]:[--btn-icon:theme(colors.zinc.500)]", + ], + zinc: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.zinc.600)] [--btn-border:theme(colors.zinc.700/90%)]", + "dark:[--btn-hover-overlay:theme(colors.white/5%)]", + "[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.300)] data-[hover]:[--btn-icon:theme(colors.zinc.300)]", + ], + indigo: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.indigo.500)] [--btn-border:theme(colors.indigo.600/90%)]", + "[--btn-icon:theme(colors.indigo.300)] data-[active]:[--btn-icon:theme(colors.indigo.200)] data-[hover]:[--btn-icon:theme(colors.indigo.200)]", + ], + cyan: [ + "text-cyan-950 [--btn-bg:theme(colors.cyan.300)] [--btn-border:theme(colors.cyan.400/80%)] [--btn-hover-overlay:theme(colors.white/25%)]", + "[--btn-icon:theme(colors.cyan.500)]", + ], + red: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.red.600)] [--btn-border:theme(colors.red.700/90%)]", + "[--btn-icon:theme(colors.red.300)] data-[active]:[--btn-icon:theme(colors.red.200)] data-[hover]:[--btn-icon:theme(colors.red.200)]", + ], + orange: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.orange.500)] [--btn-border:theme(colors.orange.600/90%)]", + "[--btn-icon:theme(colors.orange.300)] data-[active]:[--btn-icon:theme(colors.orange.200)] data-[hover]:[--btn-icon:theme(colors.orange.200)]", + ], + amber: [ + "text-amber-950 [--btn-hover-overlay:theme(colors.white/25%)] [--btn-bg:theme(colors.amber.400)] [--btn-border:theme(colors.amber.500/80%)]", + "[--btn-icon:theme(colors.amber.600)]", + ], + yellow: [ + "text-yellow-950 [--btn-hover-overlay:theme(colors.white/25%)] [--btn-bg:theme(colors.yellow.300)] [--btn-border:theme(colors.yellow.400/80%)]", + "[--btn-icon:theme(colors.yellow.600)] data-[active]:[--btn-icon:theme(colors.yellow.700)] data-[hover]:[--btn-icon:theme(colors.yellow.700)]", + ], + lime: [ + "text-lime-950 [--btn-hover-overlay:theme(colors.white/25%)] [--btn-bg:theme(colors.lime.300)] [--btn-border:theme(colors.lime.400/80%)]", + "[--btn-icon:theme(colors.lime.600)] data-[active]:[--btn-icon:theme(colors.lime.700)] data-[hover]:[--btn-icon:theme(colors.lime.700)]", + ], + green: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.green.600)] [--btn-border:theme(colors.green.700/90%)]", + "[--btn-icon:theme(colors.white/60%)] data-[active]:[--btn-icon:theme(colors.white/80%)] data-[hover]:[--btn-icon:theme(colors.white/80%)]", + ], + emerald: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.emerald.600)] [--btn-border:theme(colors.emerald.700/90%)]", + "[--btn-icon:theme(colors.white/60%)] data-[active]:[--btn-icon:theme(colors.white/80%)] data-[hover]:[--btn-icon:theme(colors.white/80%)]", + ], + teal: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.teal.600)] [--btn-border:theme(colors.teal.700/90%)]", + "[--btn-icon:theme(colors.white/60%)] data-[active]:[--btn-icon:theme(colors.white/80%)] data-[hover]:[--btn-icon:theme(colors.white/80%)]", + ], + sky: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.sky.500)] [--btn-border:theme(colors.sky.600/80%)]", + "[--btn-icon:theme(colors.white/60%)] data-[active]:[--btn-icon:theme(colors.white/80%)] data-[hover]:[--btn-icon:theme(colors.white/80%)]", + ], + blue: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.blue.600)] [--btn-border:theme(colors.blue.700/90%)]", + "[--btn-icon:theme(colors.blue.400)] data-[active]:[--btn-icon:theme(colors.blue.300)] data-[hover]:[--btn-icon:theme(colors.blue.300)]", + ], + violet: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.violet.500)] [--btn-border:theme(colors.violet.600/90%)]", + "[--btn-icon:theme(colors.violet.300)] data-[active]:[--btn-icon:theme(colors.violet.200)] data-[hover]:[--btn-icon:theme(colors.violet.200)]", + ], + purple: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.purple.500)] [--btn-border:theme(colors.purple.600/90%)]", + "[--btn-icon:theme(colors.purple.300)] data-[active]:[--btn-icon:theme(colors.purple.200)] data-[hover]:[--btn-icon:theme(colors.purple.200)]", + ], + fuchsia: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.fuchsia.500)] [--btn-border:theme(colors.fuchsia.600/90%)]", + "[--btn-icon:theme(colors.fuchsia.300)] data-[active]:[--btn-icon:theme(colors.fuchsia.200)] data-[hover]:[--btn-icon:theme(colors.fuchsia.200)]", + ], + pink: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.pink.500)] [--btn-border:theme(colors.pink.600/90%)]", + "[--btn-icon:theme(colors.pink.300)] data-[active]:[--btn-icon:theme(colors.pink.200)] data-[hover]:[--btn-icon:theme(colors.pink.200)]", + ], + rose: [ + "text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.rose.500)] [--btn-border:theme(colors.rose.600/90%)]", + "[--btn-icon:theme(colors.rose.300)] data-[active]:[--btn-icon:theme(colors.rose.200)] data-[hover]:[--btn-icon:theme(colors.rose.200)]", + ], + }, +}; + +type ButtonProps = ( + | { color?: keyof typeof styles.colors; outline?: never; plain?: never } + | { color?: never; outline: boolean; plain?: never } + | { color?: never; outline?: never; plain: true } +) & { className?: string; children: React.ReactNode } & ( + | Omit + | Omit, "className"> + ); + +export const Button = forwardRef(function Button( + { color, outline, plain, className, children, ...props }: ButtonProps, + ref: React.ForwardedRef, +) { + let classes = clsx( + className, + styles.base, + outline + ? styles.outline + : plain + ? styles.plain + : clsx(styles.solid, styles.colors[color ?? "dark/zinc"]), + ); + + return "href" in props ? ( + } + > + {children} + + ) : ( + + {children} + + ); +}); diff --git a/examples/nextjs-app/src/shared/tailwind-catalyst/dialog.tsx b/examples/nextjs-app/src/shared/tailwind-catalyst/dialog.tsx new file mode 100644 index 00000000..62a663dd --- /dev/null +++ b/examples/nextjs-app/src/shared/tailwind-catalyst/dialog.tsx @@ -0,0 +1,108 @@ +import * as Headless from "@headlessui/react"; +import clsx from "clsx"; +import type React from "react"; +import { Text } from "./text"; + +const sizes = { + xs: "sm:max-w-xs", + sm: "sm:max-w-sm", + md: "sm:max-w-md", + lg: "sm:max-w-lg", + xl: "sm:max-w-xl", + "2xl": "sm:max-w-2xl", + "3xl": "sm:max-w-3xl", + "4xl": "sm:max-w-4xl", + "5xl": "sm:max-w-5xl", +}; + +export function Dialog({ + size = "lg", + className, + children, + ...props +}: { + size?: keyof typeof sizes; + className?: string; + children: React.ReactNode; +} & Omit) { + return ( + + + +
+
+ + {children} + +
+
+
+ ); +} + +export function DialogTitle({ + className, + ...props +}: { className?: string } & Omit< + Headless.DialogTitleProps, + "as" | "className" +>) { + return ( + + ); +} + +export function DialogDescription({ + className, + ...props +}: { className?: string } & Omit< + Headless.DescriptionProps, + "as" | "className" +>) { + return ( + + ); +} + +export function DialogBody({ + className, + ...props +}: React.ComponentPropsWithoutRef<"div">) { + return
; +} + +export function DialogActions({ + className, + ...props +}: React.ComponentPropsWithoutRef<"div">) { + return ( +
+ ); +} diff --git a/examples/nextjs-app/src/shared/tailwind-catalyst/fieldset.tsx b/examples/nextjs-app/src/shared/tailwind-catalyst/fieldset.tsx new file mode 100644 index 00000000..1bf28fc8 --- /dev/null +++ b/examples/nextjs-app/src/shared/tailwind-catalyst/fieldset.tsx @@ -0,0 +1,121 @@ +import * as Headless from "@headlessui/react"; +import clsx from "clsx"; +import type React from "react"; + +export function Fieldset({ + className, + ...props +}: { className?: string } & Omit) { + return ( + *+[data-slot=control]]:mt-6 [&>[data-slot=text]]:mt-1", + )} + /> + ); +} + +export function Legend({ + className, + ...props +}: { className?: string } & Omit) { + return ( + + ); +} + +export function FieldGroup({ + className, + ...props +}: React.ComponentPropsWithoutRef<"div">) { + return ( +
+ ); +} + +export function Field({ + className, + ...props +}: { className?: string } & Omit) { + return ( + [data-slot=label]+[data-slot=control]]:mt-3", + "[&>[data-slot=label]+[data-slot=description]]:mt-1", + "[&>[data-slot=description]+[data-slot=control]]:mt-3", + "[&>[data-slot=control]+[data-slot=description]]:mt-3", + "[&>[data-slot=control]+[data-slot=error]]:mt-3", + "[&>[data-slot=label]]:font-medium", + )} + /> + ); +} + +export function Label({ + className, + ...props +}: { className?: string } & Omit) { + return ( + + ); +} + +export function Description({ + className, + ...props +}: { className?: string } & Omit< + Headless.DescriptionProps, + "as" | "className" +>) { + return ( + + ); +} + +export function ErrorMessage({ + className, + ...props +}: { className?: string } & Omit< + Headless.DescriptionProps, + "as" | "className" +>) { + return ( + + ); +} diff --git a/examples/nextjs-app/src/shared/tailwind-catalyst/heading.tsx b/examples/nextjs-app/src/shared/tailwind-catalyst/heading.tsx new file mode 100644 index 00000000..17227082 --- /dev/null +++ b/examples/nextjs-app/src/shared/tailwind-catalyst/heading.tsx @@ -0,0 +1,33 @@ +import clsx from "clsx"; + +type HeadingProps = { + level?: 1 | 2 | 3 | 4 | 5 | 6; +} & React.ComponentPropsWithoutRef<"h1" | "h2" | "h3" | "h4" | "h5" | "h6">; + +export function Heading({ className, level = 1, ...props }: HeadingProps) { + let Element: `h${typeof level}` = `h${level}`; + + return ( + + ); +} + +export function Subheading({ className, level = 2, ...props }: HeadingProps) { + let Element: `h${typeof level}` = `h${level}`; + + return ( + + ); +} diff --git a/examples/nextjs-app/src/shared/tailwind-catalyst/input.tsx b/examples/nextjs-app/src/shared/tailwind-catalyst/input.tsx new file mode 100644 index 00000000..008083bd --- /dev/null +++ b/examples/nextjs-app/src/shared/tailwind-catalyst/input.tsx @@ -0,0 +1,104 @@ +import * as Headless from "@headlessui/react"; +import clsx from "clsx"; +import React, { forwardRef } from "react"; + +export function InputGroup({ + children, +}: React.ComponentPropsWithoutRef<"span">) { + return ( + [data-slot=icon]]:pointer-events-none [&>[data-slot=icon]]:absolute [&>[data-slot=icon]]:top-3 [&>[data-slot=icon]]:z-10 [&>[data-slot=icon]]:size-5 sm:[&>[data-slot=icon]]:top-2.5 sm:[&>[data-slot=icon]]:size-4", + "[&>[data-slot=icon]:first-child]:left-3 sm:[&>[data-slot=icon]:first-child]:left-2.5 [&>[data-slot=icon]:last-child]:right-3 sm:[&>[data-slot=icon]:last-child]:right-2.5", + "[&>[data-slot=icon]]:text-zinc-500 dark:[&>[data-slot=icon]]:text-zinc-400", + )} + > + {children} + + ); +} + +const dateTypes = ["date", "datetime-local", "month", "time", "week"]; +type DateType = (typeof dateTypes)[number]; + +export const Input = forwardRef(function Input( + { + className, + ...props + }: { + className?: string; + type?: + | "email" + | "number" + | "password" + | "search" + | "tel" + | "text" + | "url" + | DateType; + } & Omit, + ref: React.ForwardedRef, +) { + return ( + + + + ); +}); diff --git a/examples/nextjs-app/src/shared/tailwind-catalyst/link.tsx b/examples/nextjs-app/src/shared/tailwind-catalyst/link.tsx new file mode 100644 index 00000000..ad08d704 --- /dev/null +++ b/examples/nextjs-app/src/shared/tailwind-catalyst/link.tsx @@ -0,0 +1,21 @@ +/** + * TODO: Update this component to use your client-side framework's link + * component. We've provided examples of how to do this for Next.js, Remix, and + * Inertia.js in the Catalyst documentation: + * + * https://catalyst.tailwindui.com/docs#client-side-router-integration + */ + +import * as Headless from "@headlessui/react"; +import React, { forwardRef } from "react"; + +export const Link = forwardRef(function Link( + props: { href: string } & React.ComponentPropsWithoutRef<"a">, + ref: React.ForwardedRef, +) { + return ( + + + + ); +}); diff --git a/examples/nextjs-app/src/shared/tailwind-catalyst/table.tsx b/examples/nextjs-app/src/shared/tailwind-catalyst/table.tsx new file mode 100644 index 00000000..a3ee37a0 --- /dev/null +++ b/examples/nextjs-app/src/shared/tailwind-catalyst/table.tsx @@ -0,0 +1,187 @@ +"use client"; + +import clsx from "clsx"; +import type React from "react"; +import { createContext, useContext, useState } from "react"; +import { Link } from "./link"; + +const TableContext = createContext<{ + bleed: boolean; + dense: boolean; + grid: boolean; + striped: boolean; +}>({ + bleed: false, + dense: false, + grid: false, + striped: false, +}); + +export function Table({ + bleed = false, + dense = false, + grid = false, + striped = false, + className, + children, + ...props +}: { + bleed?: boolean; + dense?: boolean; + grid?: boolean; + striped?: boolean; +} & React.ComponentPropsWithoutRef<"div">) { + return ( + + } + > +
+
+
+ + {children} +
+
+
+
+
+ ); +} + +export function TableHead({ + className, + ...props +}: React.ComponentPropsWithoutRef<"thead">) { + return ( + + ); +} + +export function TableBody(props: React.ComponentPropsWithoutRef<"tbody">) { + return ; +} + +const TableRowContext = createContext<{ + href?: string; + target?: string; + title?: string; +}>({ + href: undefined, + target: undefined, + title: undefined, +}); + +export function TableRow({ + href, + target, + title, + className, + ...props +}: { + href?: string; + target?: string; + title?: string; +} & React.ComponentPropsWithoutRef<"tr">) { + let { striped } = useContext(TableContext); + + return ( + + } + > + + + ); +} + +export function TableHeader({ + className, + ...props +}: React.ComponentPropsWithoutRef<"th">) { + let { bleed, grid } = useContext(TableContext); + + return ( + + ); +} + +export function TableCell({ + className, + children, + ...props +}: React.ComponentPropsWithoutRef<"td">) { + let { bleed, dense, grid, striped } = useContext(TableContext); + let { href, target, title } = useContext(TableRowContext); + let [cellRef, setCellRef] = useState(null); + + return ( + + {href && ( + + )} + {children} + + ); +} diff --git a/examples/nextjs-app/src/shared/tailwind-catalyst/text.tsx b/examples/nextjs-app/src/shared/tailwind-catalyst/text.tsx new file mode 100644 index 00000000..044414b2 --- /dev/null +++ b/examples/nextjs-app/src/shared/tailwind-catalyst/text.tsx @@ -0,0 +1,60 @@ +import clsx from "clsx"; +import { Link } from "./link"; + +export function Text({ + className, + ...props +}: React.ComponentPropsWithoutRef<"p">) { + return ( +

+ ); +} + +export function TextLink({ + className, + ...props +}: React.ComponentPropsWithoutRef) { + return ( + + ); +} + +export function Strong({ + className, + ...props +}: React.ComponentPropsWithoutRef<"strong">) { + return ( + + ); +} + +export function Code({ + className, + ...props +}: React.ComponentPropsWithoutRef<"code">) { + return ( + + ); +} diff --git a/examples/nextjs-app/src/styles/tailwind.css b/examples/nextjs-app/src/styles/tailwind.css new file mode 100644 index 00000000..b5c61c95 --- /dev/null +++ b/examples/nextjs-app/src/styles/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/examples/nextjs-app/styles/globals.css b/examples/nextjs-app/styles/globals.css deleted file mode 100644 index 826a946e..00000000 --- a/examples/nextjs-app/styles/globals.css +++ /dev/null @@ -1,8 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} \ No newline at end of file diff --git a/examples/nextjs-app/tailwind.config.js b/examples/nextjs-app/tailwind.config.js new file mode 100644 index 00000000..d267f16a --- /dev/null +++ b/examples/nextjs-app/tailwind.config.js @@ -0,0 +1,9 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./src/**/*.{ts,tsx}"], + theme: { + extend: {}, + }, + darkMode: "false", + plugins: [], +}; diff --git a/examples/nextjs/.env.example b/examples/nextjs/.env.example deleted file mode 100644 index e3d9c33c..00000000 --- a/examples/nextjs/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -NEXT_PUBLIC_ALCHEMY_ID= -NEXT_PUBLIC_INFURA_ID= -NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= -SESSION_SECRET= diff --git a/examples/nextjs/.eslintrc.json b/examples/nextjs/.eslintrc.json deleted file mode 100644 index bffb357a..00000000 --- a/examples/nextjs/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/examples/nextjs/.gitignore b/examples/nextjs/.gitignore deleted file mode 100644 index ed854fa1..00000000 --- a/examples/nextjs/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts - diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md deleted file mode 100644 index 5f340e00..00000000 --- a/examples/nextjs/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# [Next.js](https://nextjs.org/) + [TypeScript](https://www.typescriptlang.org/) + ConnectKit Example - -This is a simple example of how to implement ConnectKit with [Next.js](https://nextjs.org/) in TypeScript. - -- If you'd like to look at an example online, try this [CodeSandbox](https://codesandbox.io/s/qnvyqe?file=/README.md) -- Or you want to run the example locally have a look at the [instructions in the main README](https://github.com/family/connectkit/blob/main/README.md#running-examples-locally) diff --git a/examples/nextjs/components/Web3Provider.tsx b/examples/nextjs/components/Web3Provider.tsx deleted file mode 100644 index 52588050..00000000 --- a/examples/nextjs/components/Web3Provider.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from "react"; - -import { DaimoPayProvider, getDefaultConfig } from "@daimo/pay"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { WagmiProvider, createConfig } from "wagmi"; - -const config = createConfig( - getDefaultConfig({ - appName: "ConnectKit Next.js demo", - walletConnectProjectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!, - }), -); - -const queryClient = new QueryClient(); - -export const Web3Provider = ({ children }: { children: React.ReactNode }) => { - return ( - - - {children} - - - ); -}; diff --git a/examples/nextjs/next.config.js b/examples/nextjs/next.config.js deleted file mode 100644 index 3d3bc999..00000000 --- a/examples/nextjs/next.config.js +++ /dev/null @@ -1,7 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - reactStrictMode: true, - swcMinify: true, -}; - -module.exports = nextConfig; diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json deleted file mode 100644 index 76f7c46a..00000000 --- a/examples/nextjs/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@daimo/pay-nextjs-example", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "@daimo/pay": "*", - "@tanstack/react-query": "^5.51.11", - "next": "14.2.13", - "react": "18.2.0", - "react-dom": "18.2.0", - "tslib": "^2.7.0", - "viem": "^2.21.10", - "wagmi": "^2.12.0" - }, - "devDependencies": { - "@types/node": "^20.14.12", - "@types/react": "^18.2.47", - "eslint": "^8.56.0", - "eslint-config-next": "14.2.13" - } -} diff --git a/examples/nextjs/pages/_app.tsx b/examples/nextjs/pages/_app.tsx deleted file mode 100644 index 67cd15f7..00000000 --- a/examples/nextjs/pages/_app.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import '../styles/globals.css'; -import type { AppProps } from 'next/app'; - -import { Web3Provider } from '../components/Web3Provider'; - -function MyApp({ Component, pageProps }: AppProps) { - return ( - - - - ); -} - -export default MyApp; diff --git a/examples/nextjs/pages/index.tsx b/examples/nextjs/pages/index.tsx deleted file mode 100644 index 0c7061f4..00000000 --- a/examples/nextjs/pages/index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { DaimoPayButton, useDaimoPayStatus } from "@daimo/pay"; -import type { NextPage } from "next"; - -const Home: NextPage = () => { - const status = useDaimoPayStatus(); - - return ( -

- {status?.status === "payment_completed" ? "🎉" : "💰"} - -
- ); -}; - -export default Home; diff --git a/examples/nextjs/styles/globals.css b/examples/nextjs/styles/globals.css deleted file mode 100644 index 826a946e..00000000 --- a/examples/nextjs/styles/globals.css +++ /dev/null @@ -1,8 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} \ No newline at end of file diff --git a/examples/nextjs/tsconfig.json b/examples/nextjs/tsconfig.json deleted file mode 100644 index 75f0c777..00000000 --- a/examples/nextjs/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "compilerOptions": { - "strict": true, - "target": "ESNext", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "noEmit": false, - "baseUrl": ".", - "paths": { - "@/*": ["./*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"], - "noImplicitAny": false -} diff --git a/packages/connectkit/src/components/DaimoPayButton/index.tsx b/packages/connectkit/src/components/DaimoPayButton/index.tsx index 3de13b27..7e1a58d5 100644 --- a/packages/connectkit/src/components/DaimoPayButton/index.tsx +++ b/packages/connectkit/src/components/DaimoPayButton/index.tsx @@ -39,7 +39,7 @@ type PayButtonPaymentProps = /** * The amount of destination token to send (transfer or approve). */ - toAmount: bigint; + toUnits: string; /** * Let the user edit the amount to send. */ @@ -92,8 +92,8 @@ type DaimoPayButtonProps = PayButtonCommonProps & { customTheme?: CustomTheme; /** Automatically close the modal after a successful payment. */ closeOnSuccess?: boolean; - /** Get notified when the user clicks, opening the payment modal. */ - onClick?: (open: () => void) => void; + /** Disable interaction. */ + disabled?: boolean; }; type DaimoPayButtonCustomProps = PayButtonCommonProps & { @@ -111,7 +111,7 @@ type DaimoPayButtonCustomProps = PayButtonCommonProps & { * Connect Wallet » approve » execute sequence with a single action. */ export function DaimoPayButton(props: DaimoPayButtonProps) { - const { theme, mode, customTheme, onClick } = props; + const { theme, mode, customTheme } = props; const context = usePayContext(); return ( @@ -122,21 +122,13 @@ export function DaimoPayButton(props: DaimoPayButtonProps) { $useMode={mode ?? context.mode} $customTheme={customTheme ?? context.customTheme} > - { - if (onClick) { - onClick(show); - } else { - show(); - } - }} - > + - + @@ -159,9 +151,10 @@ function DaimoPayButtonCustom(props: DaimoPayButtonCustomProps) { toChain: props.toChain, toAddress: props.toAddress, toToken: props.toToken, - toAmount: props.toAmount, + toUnits: props.toUnits, toCallData: props.toCallData, isAmountEditable: props.amountEditable ?? false, + intent: props.intent, paymentOptions: props.paymentOptions, preferredChains: props.preferredChains, preferredTokens: props.preferredTokens, @@ -226,14 +219,7 @@ function DaimoPayButtonCustom(props: DaimoPayButtonCustomProps) { if (!isMounted) return null; - return ( - <> - {children({ - show, - hide, - })} - - ); + return <>{children({ show, hide })}; } DaimoPayButtonCustom.displayName = "DaimoPayButton.Custom"; @@ -267,7 +253,7 @@ const contentVariants: Variants = { }, }; -function DaimoPayButtonInner() { +function DaimoPayButtonInner({ disabled }: { disabled?: boolean }) { const { paymentState } = usePayContext(); const label = paymentState?.daimoPayOrder?.metadata?.intent ?? "Pay"; diff --git a/packages/connectkit/src/hooks/usePaymentState.ts b/packages/connectkit/src/hooks/usePaymentState.ts index b26e18dc..5d8052fc 100644 --- a/packages/connectkit/src/hooks/usePaymentState.ts +++ b/packages/connectkit/src/hooks/usePaymentState.ts @@ -16,7 +16,7 @@ import { import { ethereum } from "@daimo/contract"; import { useWallet } from "@solana/wallet-adapter-react"; import { useCallback, useEffect, useState } from "react"; -import { Address, Hex, parseUnits } from "viem"; +import { Address, formatUnits, Hex, parseUnits } from "viem"; import { useAccount, useEnsName } from "wagmi"; import { DaimoPayModalOptions, PaymentOption } from "../types"; @@ -50,7 +50,7 @@ export interface PayParams { /** The destination token to send. */ toToken: Address; /** The amount of the token to send. */ - toAmount: bigint; + toUnits: string; /** The final address to transfer to or contract to call. */ toAddress: Address; /** Calldata for final call, or empty data for transfer. */ @@ -193,12 +193,17 @@ export function usePaymentState({ } log(`[CHECKOUT] creating+hydrating new order ${order.id}`); + // Update units, if amountEditable the user may have changed the amount. + const toUnits = formatUnits( + BigInt(order.destFinalCallTokenAmount.amount), + order.destFinalCallTokenAmount.token.decimals, + ); return await trpc.createOrder.mutate({ appId: payParams.appId, paymentInput: { ...payParams, id: order.id.toString(), - toAmount: order.destFinalCallTokenAmount.amount, + toUnits: toUnits, metadata: order.metadata, }, platform, @@ -349,7 +354,7 @@ export function usePaymentState({ id: newId, toChain: payParams.toChain, toToken: payParams.toToken, - toAmount: payParams.toAmount.toString(), + toUnits: payParams.toUnits, toAddress: payParams.toAddress, toCallData: payParams.toCallData, isAmountEditable: payParams.isAmountEditable, From a7ee8e15549264deda224bf77fd32e530e8744b5 Mon Sep 17 00:00:00 2001 From: DC Date: Fri, 27 Dec 2024 06:34:07 -0800 Subject: [PATCH 5/6] paykit: fix getWalletOptions sorting --- examples/nextjs-app/src/app/DemoContract.tsx | 3 ++- examples/nextjs-app/src/app/DemoDeposit.tsx | 1 + examples/nextjs-app/src/app/providers.tsx | 4 +++- packages/connectkit/src/hooks/usePaymentState.ts | 2 ++ packages/connectkit/src/hooks/useWalletPaymentOptions.ts | 6 ++++++ 5 files changed, 14 insertions(+), 2 deletions(-) diff --git a/examples/nextjs-app/src/app/DemoContract.tsx b/examples/nextjs-app/src/app/DemoContract.tsx index dfe93c2d..314499dc 100644 --- a/examples/nextjs-app/src/app/DemoContract.tsx +++ b/examples/nextjs-app/src/app/DemoContract.tsx @@ -64,6 +64,7 @@ export function DemoContract() { . +
- Count {Number(count)} + Count {count != null && Number(count)}
diff --git a/examples/nextjs-app/src/app/DemoDeposit.tsx b/examples/nextjs-app/src/app/DemoDeposit.tsx index 57c14c6e..e5457d0e 100644 --- a/examples/nextjs-app/src/app/DemoDeposit.tsx +++ b/examples/nextjs-app/src/app/DemoDeposit.tsx @@ -22,6 +22,7 @@ export function DemoDeposit() { toToken={getAddress(baseUSDC.token)} intent="Deposit" amountEditable + preferredChains={[10]} /* Show assets on Optimism first. */ onPaymentStarted={printEvent} onPaymentCompleted={printEvent} /> diff --git a/examples/nextjs-app/src/app/providers.tsx b/examples/nextjs-app/src/app/providers.tsx index be1c1ec1..7df56876 100644 --- a/examples/nextjs-app/src/app/providers.tsx +++ b/examples/nextjs-app/src/app/providers.tsx @@ -15,7 +15,9 @@ export function Providers(props: { children: ReactNode }) { return ( - {props.children} + + {props.children} + ); diff --git a/packages/connectkit/src/hooks/usePaymentState.ts b/packages/connectkit/src/hooks/usePaymentState.ts index 5d8052fc..ac6aece7 100644 --- a/packages/connectkit/src/hooks/usePaymentState.ts +++ b/packages/connectkit/src/hooks/usePaymentState.ts @@ -158,6 +158,8 @@ export function usePaymentState({ address: senderAddr, usdRequired: daimoPayOrder?.destFinalCallTokenAmount.usd, destChainId: daimoPayOrder?.destFinalCallTokenAmount.token.chainId, + preferredChains: daimoPayOrder?.metadata.payer?.preferredChains, + preferredTokens: daimoPayOrder?.metadata.payer?.preferredTokens, }); const solanaPaymentOptions = useSolanaPaymentOptions({ trpc, diff --git a/packages/connectkit/src/hooks/useWalletPaymentOptions.ts b/packages/connectkit/src/hooks/useWalletPaymentOptions.ts index bdfaec8f..7a77a30b 100644 --- a/packages/connectkit/src/hooks/useWalletPaymentOptions.ts +++ b/packages/connectkit/src/hooks/useWalletPaymentOptions.ts @@ -11,11 +11,15 @@ export function useWalletPaymentOptions({ address, usdRequired, destChainId, + preferredChains, + preferredTokens, }: { trpc: TrpcClient; address: string | undefined; usdRequired: number | undefined; destChainId: number | undefined; + preferredChains: number[] | undefined; + preferredTokens: { chain: number; address: string }[] | undefined; }) { const [options, setOptions] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -31,6 +35,8 @@ export function useWalletPaymentOptions({ payerAddress: address, usdRequired, destChainId, + preferredChains, + preferredTokens, }); setOptions(newOptions); } catch (error) { From db93f56736c39a896221ec8958e37dd85885d4c7 Mon Sep 17 00:00:00 2001 From: DC Date: Fri, 27 Dec 2024 06:34:36 -0800 Subject: [PATCH 6/6] paykit: 1.0.0 --- packages/connectkit/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/connectkit/package.json b/packages/connectkit/package.json index 83cc3393..9768a58c 100644 --- a/packages/connectkit/package.json +++ b/packages/connectkit/package.json @@ -1,7 +1,7 @@ { "name": "@daimo/pay", "private": false, - "version": "0.3.21", + "version": "1.0.0", "author": "Daimo", "homepage": "https://pay.daimo.com", "license": "BSD-2-Clause license",