diff --git a/examples/next/src/components/Profile.tsx b/examples/next/src/components/Profile.tsx index eb6657b0a5..92ea4d3ec7 100644 --- a/examples/next/src/components/Profile.tsx +++ b/examples/next/src/components/Profile.tsx @@ -10,10 +10,17 @@ import { ETH_CONTRACT_ADDRESS, } from "@cartridge/controller-ui/utils"; -export const BUNDLE_REGISTRY_MAINNET = +// Nums bundle registries +export const NUMS_REGISTRY_MAINNET = "0x1a8516498b484f209aefbbf5af67765a2b1e3889fd00902811f18576a4616b0"; -export const BUNDLE_REGISTRY_SEPOLIA = +export const NUMS_REGISTRY_SEPOLIA = "0x3110295929fc665972ae2ea4b99d5fa57547aa56d140dc73a7e85ddcaf5eaf1"; +export const ABYSS_REGISTRY_MAINNET = + "0x529c074ad9540d56a8d6e7086bb3d7a3d8ebaf60dbbf4abb1aeae336be833fe"; + +// legacy starterpack registry (deprecated) +export const STARTERPACK_REGISTRY_MAINNET = + "0x3eb03b8f2be0ec2aafd186d72f6d8f3dd320dbc89f2b6802bca7465f6ccaa43"; export function Profile() { const { account, connector } = useAccount(); diff --git a/examples/next/src/components/Starterpack.tsx b/examples/next/src/components/Starterpack.tsx index e6372b9787..3f8a889c8e 100644 --- a/examples/next/src/components/Starterpack.tsx +++ b/examples/next/src/components/Starterpack.tsx @@ -5,7 +5,11 @@ import { constants, num } from "starknet"; import { useAccount, useNetwork } from "@starknet-react/core"; import ControllerConnector from "@cartridge/connector/controller"; import { Button, Input } from "@cartridge/controller-ui"; -import { BUNDLE_REGISTRY_MAINNET, BUNDLE_REGISTRY_SEPOLIA } from "./Profile"; +import { + ABYSS_REGISTRY_MAINNET, + NUMS_REGISTRY_MAINNET, + NUMS_REGISTRY_SEPOLIA, +} from "./Profile"; export const Starterpack = () => { const { account, connector } = useAccount(); @@ -19,14 +23,14 @@ export const Starterpack = () => { bundleId: 1, socialBundleId: 0, starterpackId: 0, - registryAddress: BUNDLE_REGISTRY_MAINNET, + registryAddress: NUMS_REGISTRY_MAINNET, }; } return { bundleId: 1, socialBundleId: 0, starterpackId: 0, - registryAddress: BUNDLE_REGISTRY_SEPOLIA, + registryAddress: NUMS_REGISTRY_SEPOLIA, }; }, [chain]); @@ -143,6 +147,16 @@ export const Starterpack = () => { > Nums Social Bundle +
diff --git a/package.json b/package.json index c4555ad431..8351f84385 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "prepare": "husky" }, "dependencies": { - "@cartridge/presets": "github:cartridge-gg/presets#e94909f", + "@cartridge/presets": "catalog:", "@cartridge/controller-ui": "workspace:*", "tailwindcss": "catalog:", "@graphql-codegen/cli": "^2.6.2", diff --git a/packages/keychain/src/components/NavigationHeader.tsx b/packages/keychain/src/components/NavigationHeader.tsx index b0ec8186e5..de8c23d044 100644 --- a/packages/keychain/src/components/NavigationHeader.tsx +++ b/packages/keychain/src/components/NavigationHeader.tsx @@ -1,7 +1,8 @@ +import { useCallback } from "react"; import { LayoutHeader, HeaderProps } from "@cartridge/controller-ui"; import { useConnection } from "@/hooks/connection"; import { useNavigation } from "@/context/navigation"; -import { useCallback } from "react"; +import { useCreditsContext } from "@/components/credits/provider"; export type NavigationHeaderProps = { // Navigation props @@ -48,11 +49,14 @@ export function NavigationHeader({ navigateToRoot(); }, [onClose, closeModal, navigateToRoot]); + const { initiateCreditsDeposit } = useCreditsContext(); + return ( ); diff --git a/packages/keychain/src/components/connect/create/social/index.ts b/packages/keychain/src/components/connect/create/social/index.ts index 5361906e48..fb80cebe96 100644 --- a/packages/keychain/src/components/connect/create/social/index.ts +++ b/packages/keychain/src/components/connect/create/social/index.ts @@ -14,6 +14,7 @@ export const useSocialAuthentication = ( socialProvider: SocialProvider, username: string, isSignup: boolean, + forceAccountSelection = false, ) => { if (!chainId) { throw new Error("No chainId"); @@ -25,7 +26,10 @@ export const useSocialAuthentication = ( rpcUrl, socialProvider, ); - const { account, error, success } = await turnkeyWallet.connect(isSignup); + const { account, error, success } = await turnkeyWallet.connect( + isSignup, + forceAccountSelection, + ); if (error?.includes("Account mismatch")) { setChangeWallet?.(true); return; @@ -57,9 +61,15 @@ export const useSocialAuthentication = ( ); return { - signup: (socialProvider: SocialProvider, username: string) => - signup(socialProvider, username, true), - login: (socialProvider: SocialProvider, username: string) => - signup(socialProvider, username, false), + signup: ( + socialProvider: SocialProvider, + username: string, + forceAccountSelection = false, + ) => signup(socialProvider, username, true, forceAccountSelection), + login: ( + socialProvider: SocialProvider, + username: string, + forceAccountSelection = false, + ) => signup(socialProvider, username, false, forceAccountSelection), }; }; diff --git a/packages/keychain/src/components/connect/create/useCreateController.ts b/packages/keychain/src/components/connect/create/useCreateController.ts index 10dcfcae16..7350c12d91 100644 --- a/packages/keychain/src/components/connect/create/useCreateController.ts +++ b/packages/keychain/src/components/connect/create/useCreateController.ts @@ -267,6 +267,11 @@ export function useCreateController({ >(undefined); const signupStartedRef = useRef(false); const signupResolvedRef = useRef(false); + // The OAuth (Auth0/Turnkey) redirect callback consumes a single-use + // transaction. Guard against StrictMode double-invocation and dependency- + // driven re-runs so handleRedirectCallback runs exactly once — otherwise the + // second call throws "Invalid state" and the page loops on the login screen. + const redirectHandledRef = useRef(false); const signupStartTimeRef = useRef(performance.now()); const authStepRef = useRef(AuthenticationStep.FillForm); @@ -579,7 +584,11 @@ export function useCreateController({ } case "google": case "discord": - signupResponse = await signupWithSocial(authenticationMode, username); + signupResponse = await signupWithSocial( + authenticationMode, + username, + changeWallet, + ); if (!signupResponse) { return; } @@ -694,6 +703,7 @@ export function useCreateController({ params, handleCompletion, searchParams, + changeWallet, ], ); @@ -943,7 +953,11 @@ export function useCreateController({ case "google": case "discord": { setWaitingForConfirmation(true); - loginResponse = await loginWithSocial(authenticationMethod, username); + loginResponse = await loginWithSocial( + authenticationMethod, + username, + changeWallet, + ); if (!loginResponse) { return; } @@ -1053,15 +1067,28 @@ export function useCreateController({ smsAuth, smsState, setWaitingForConfirmation, + changeWallet, ], ); useEffect(() => { if (!chainId) return; + if (redirectHandledRef.current) return; if ( window.location.search.includes("code") && window.location.search.includes("state") ) { + redirectHandledRef.current = true; + const redirectUrl = window.location.href; + // Strip the single-use OAuth params from the address bar immediately. + // handleRedirect() consumes them from the captured `redirectUrl`, so a + // reload (or a remount that resets the guard) can't replay the already- + // consumed callback and fail with "Invalid state" — and the user is left + // on a clean URL from which a fresh login can be initiated. + const cleanUrl = new URL(redirectUrl); + cleanUrl.searchParams.delete("code"); + cleanUrl.searchParams.delete("state"); + window.history.replaceState({}, "", cleanUrl.toString()); (async () => { setIsLoading(true); try { @@ -1080,14 +1107,8 @@ export function useCreateController({ searchParams, chainId, rpcUrl, - } = await turnkeyWallet.handleRedirect( - window.location.href, - setError, - ); + } = await turnkeyWallet.handleRedirect(redirectUrl, setError); - if (error) { - throw error; - } if ( !username || isSignup === undefined || @@ -1173,7 +1194,6 @@ export function useCreateController({ })(); } }, [ - error, setIsLoading, finishLogin, finishSignup, diff --git a/packages/keychain/src/components/credits/AmountSelectionDrawer.tsx b/packages/keychain/src/components/credits/AmountSelectionDrawer.tsx new file mode 100644 index 0000000000..7497e1f549 --- /dev/null +++ b/packages/keychain/src/components/credits/AmountSelectionDrawer.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { + Drawer, + DrawerContent, + DepositIcon, + Button, +} from "@cartridge/controller-ui"; +import { CREDITS_DESCRIPTION } from "@/components/inventory/token"; +import { AmountSelection } from "@/components/funding/AmountSelection"; + +interface AmountSelectionDrawerProps { + isOpen: boolean; + onClose: () => void; + minAmount?: number; + maxAmount?: number; + isLoading?: boolean; + onContinue: (amount: number) => void; +} + +export function AmountSelectionDrawer({ + isOpen, + onClose, + minAmount, + maxAmount, + isLoading, + onContinue, +}: AmountSelectionDrawerProps) { + const [amount, setAmount] = useState(0); + return ( + + }> + + +
+ {CREDITS_DESCRIPTION} +
+ + +
+
+ ); +} diff --git a/packages/keychain/src/components/credits/CheckoutDrawer.tsx b/packages/keychain/src/components/credits/CheckoutDrawer.tsx new file mode 100644 index 0000000000..aea9fc463b --- /dev/null +++ b/packages/keychain/src/components/credits/CheckoutDrawer.tsx @@ -0,0 +1,270 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Drawer, + DrawerContent, + DepositIcon, + Button, + TokenCard, +} from "@cartridge/controller-ui"; +import { PaymentMethodSelection } from "@/components/purchase/checkout/onchain/wallet-drawer"; +import { WalletSelector } from "@/components/purchase/checkout/onchain/selector"; +import { CostBreakdown } from "@/components/purchase/review/cost"; +import { ErrorAlert } from "@/components/ErrorAlert"; +import { useTokens } from "@/hooks/token"; +import { useConnection } from "@/hooks/connection"; +import { + createStarknetCryptoPayment, + waitForCryptoPaymentConfirmation, +} from "@/hooks/payments/crypto"; +import { USDC_ADDRESSES, USDC_ICON } from "@/utils/ekubo"; +import { formatCredits, usdToCreditUnits, usdToUsdcWei } from "@/utils/credits"; +import type { TokenOption } from "@/context"; +import { CallData, cairo } from "starknet"; +import { ConfirmingTransaction } from "@/components/purchase/pending/confirming-transaction"; +import { ErrorCard } from "@/components/purchase/checkout/onchain/error"; +import { useCreditsContext } from "./provider"; + +interface CheckoutDrawerProps { + isOpen: boolean; + onClose: () => void; + paymentMethod: PaymentMethodSelection | null; + amount: number; + onChangeMethod: () => void; + onChangeAmount: () => void; +} + +export function CheckoutDrawer({ + isOpen, + onClose, + paymentMethod, + amount, + onChangeMethod, + onChangeAmount, +}: CheckoutDrawerProps) { + const { credits } = useTokens(); + const { controller } = useConnection(); + const [error, setError] = useState(null); + const { onDepositStarted, onDepositFinished, depositInProgress } = + useCreditsContext(); + + const isController = paymentMethod?.type === "controller"; + + // $1 of credits is always $1 of USDC, so while USDC is the only rail we can + // build the quote locally (1:1) — no backend quote needed. Future rails + // (other tokens, Coinflow, Apple Pay) will swap this for a real quote and an + // expanded token list; CostBreakdown stays the same. + const usdcToken = useMemo( + () => ({ + name: "USD Coin", + symbol: "USDC", + decimals: 6, + address: controller + ? (USDC_ADDRESSES[controller.chainId()] ?? "usdc") + : "usdc", + icon: USDC_ICON, + contract: {} as TokenOption["contract"], + }), + [controller], + ); + + const { usdcBalance, handlePurchaseWithController } = useControllerPurchase({ + usdcToken, + amount, + }); + + const hasInsufficientBalance = + usdcBalance !== undefined && usdcBalance < amount; + + // initialize + useEffect(() => { + setError(null); + }, [isOpen]); + + // main payment switcher + const handlePurchase = useCallback(async () => { + if (!paymentMethod || !amount) return; + onDepositStarted(paymentMethod, amount); + setError(null); + try { + if (isController) { + await handlePurchaseWithController(); + } + await onDepositFinished(); + console.log(`USD deposit successful.`); + await credits.refetch?.(); + } catch (e) { + console.error(`USD deposit error:`, e); + const error = (e instanceof Error ? e : new Error(String(e))).message; + await onDepositFinished(error); + setError(error); + } + }, [ + paymentMethod, + amount, + isController, + credits, + handlePurchaseWithController, + onDepositStarted, + onDepositFinished, + setError, + ]); + + const isProcessing = depositInProgress?.status === "processing"; + const isSuccess = depositInProgress?.status === "success"; + + // auto close on success + useEffect(() => { + if (isSuccess && isOpen) { + onClose(); + } + }, [isSuccess, isOpen, onClose]); + + const canPurchase = useMemo(() => { + if (!amount || !paymentMethod || isProcessing) { + return false; + } + if (isController) { + return !hasInsufficientBalance && !!controller; + } + return false; + }, [ + amount, + paymentMethod, + isProcessing, + isController, + hasInsufficientBalance, + controller, + ]); + + return ( + + }> + {isProcessing || isSuccess ? ( + <> + + + + + ) : ( + <> + + + + + {hasInsufficientBalance && ( + + )} + + {error && ( + + )} + + {}} + tokenSelectDisabled + value={ + {amount.toFixed(2)} + } + /> + + + + )} + + + ); +} + +// Encapsulates the controller (USDC) payment rail: the controller's USDC +// balance used to gate the purchase, and the transaction that fronts USDC to a +// derived deposit address. Lives here so CheckoutDrawer stays focused on UI. +function useControllerPurchase({ + usdcToken, + amount, +}: { + usdcToken: TokenOption; + amount: number; +}) { + const { tokens } = useTokens(); + const { controller, isMainnet } = useConnection(); + + const amountInUsdcWei = useMemo(() => usdToUsdcWei(amount), [amount]); + + // Paying with the controller means it fronts USDC, so the relevant balance to + // gate on is the controller's USDC — mirror the onchain checkout's + // insufficient-balance warning. useTokens() keeps balances refreshed, so read + // it from there (amount is USD, 1 USDC = $1). + const usdcBalance = useMemo(() => { + if (usdcToken.address === "usdc") return undefined; + const match = tokens.find((t) => { + try { + return ( + BigInt(t.metadata.address || "0x0") === BigInt(usdcToken.address) + ); + } catch { + return false; + } + }); + return match?.balance.amount ?? 0; + }, [tokens, usdcToken.address]); + + const handlePurchaseWithController = useCallback(async () => { + if (!controller || !amount) return; + + // Deposit model (credits-unification Phase 1b): create a crypto payment, + // send USDC from the controller to its derived deposit address, then poll + // until the sweeper grants the account credits. + const payment = await createStarknetCryptoPayment({ + tokenAddress: usdcToken.address, + tokenAmount: amountInUsdcWei, + isMainnet, + }); + + const calls = [ + { + contractAddress: usdcToken.address, + entrypoint: "transfer", + calldata: CallData.compile({ + recipient: payment.depositAddress, + amount: cairo.uint256(amountInUsdcWei), + }), + }, + ]; + + // Pay gas from the controller's own native fee token (STRK) instead of + // the paymaster: estimate the fee, then submit a direct V3 invoke with + // that maxFee. Passing a feeSource (PAYMASTER/CREDITS) would route this + // back through outside-execution, so we deliberately omit it. + const maxFee = await controller.estimateInvokeFee(calls); + const response = await controller.execute(calls, maxFee); + + const transactionHash = response.transaction_hash; + console.log(`Credits purchase transaction:`, transactionHash); + + await waitForCryptoPaymentConfirmation(payment.id); + + // keep for testing workflow... + // await new Promise((resolve) => setTimeout(resolve, 2000)); + // throw new Error("Bump!"); + }, [controller, amount, usdcToken.address, amountInUsdcWei, isMainnet]); + + return { usdcBalance, handlePurchaseWithController }; +} diff --git a/packages/keychain/src/components/credits/DepositCredits.tsx b/packages/keychain/src/components/credits/DepositCredits.tsx new file mode 100644 index 0000000000..26970b87ff --- /dev/null +++ b/packages/keychain/src/components/credits/DepositCredits.tsx @@ -0,0 +1,81 @@ +import { useEffect, useMemo, useState } from "react"; +import { + WalletSelectionDrawer, + type PaymentMethodSelection, +} from "../purchase/checkout/onchain/wallet-drawer"; +import { AmountSelectionDrawer } from "./AmountSelectionDrawer"; +import { CheckoutDrawer } from "./CheckoutDrawer"; +import { useCreditsContext } from "./provider"; + +interface DepositCreditsProps { + isOpen: boolean; + onClose: () => void; +} + +export function DepositCredits({ isOpen, onClose }: DepositCreditsProps) { + const [paymentMethod, setPaymentMethod] = + useState(null); + const [amount, setAmount] = useState(0); + const { depositInProgress } = useCreditsContext(); + + const isController = paymentMethod?.type == "controller"; + + // initialize on open and close + useEffect(() => { + setPaymentMethod(null); + setAmount(0); + }, [isOpen]); + + const step = useMemo(() => { + if (depositInProgress) return "checkout"; + if (!paymentMethod) return "method"; + if (!amount) return "amount"; + return "checkout"; + }, [paymentMethod, amount, depositInProgress]); + + return ( + <> + { + if (step == "method") { + onClose(); + } + }} + setSelected={(selection: PaymentMethodSelection) => { + setPaymentMethod(selection); + }} + // showFiatOptions={countryCode === "US"} + showFiatOptions={false} + showController={true} + showCrypto={false} + /> + + { + if (step == "amount") { + onClose(); + } + }} + onContinue={async (amount: number) => { + setAmount(amount); + }} + /> + + { + if (step == "checkout") { + onClose(); + } + }} + paymentMethod={depositInProgress?.paymentMethod || paymentMethod} + amount={depositInProgress?.amount || amount} + onChangeMethod={() => setPaymentMethod(null)} + onChangeAmount={() => setAmount(0)} + /> + + ); +} diff --git a/packages/keychain/src/components/credits/provider.tsx b/packages/keychain/src/components/credits/provider.tsx new file mode 100644 index 0000000000..23a29525b7 --- /dev/null +++ b/packages/keychain/src/components/credits/provider.tsx @@ -0,0 +1,108 @@ +import { + PropsWithChildren, + createContext, + useCallback, + useContext, + useState, +} from "react"; +import { DepositCredits } from "./DepositCredits"; +import { PaymentMethodSelection } from "../purchase/checkout/onchain/wallet-drawer"; + +export type CreditDepositStatus = "processing" | "success" | "error" | "idle"; + +export type CreditsContextValue = { + initiateCreditsDeposit: (onSuccessCallback?: () => Promise) => void; + onDepositStarted: ( + paymentMethod: PaymentMethodSelection, + amount: number, + ) => void; + onDepositFinished: (error?: string) => Promise; + depositInProgress: null | { + paymentMethod: PaymentMethodSelection; + amount: number; + status: CreditDepositStatus; + }; +}; + +type DepositInProgress = CreditsContextValue["depositInProgress"]; + +export const CreditsContext = createContext({ + initiateCreditsDeposit: () => {}, + onDepositStarted: () => {}, + onDepositFinished: async () => {}, + depositInProgress: null, +}); + +export function CreditsProvider({ children }: PropsWithChildren) { + const [isDepositOpen, setIsDepositOpen] = useState(false); + + const [depositInProgress, setDepositInProgress] = + useState(null); + + const [depositSuccessCallback, setDepositSuccessCallback] = useState< + (() => Promise) | undefined + >(undefined); + const initiateCreditsDeposit = useCallback( + (onSuccessCallback?: () => Promise) => { + setDepositSuccessCallback(() => onSuccessCallback); + setIsDepositOpen(true); + }, + [], + ); + + const onDepositStarted = useCallback( + async (paymentMethod: PaymentMethodSelection, amount: number) => { + setDepositInProgress({ + paymentMethod, + amount, + status: "processing", + }); + }, + [], + ); + + const onDepositFinished = useCallback( + async (error?: string) => { + setDepositInProgress((prev) => { + if (!prev) return null; + return { + ...prev, + status: error ? "error" : "success", + }; + }); + if (!error) { + await depositSuccessCallback?.(); + } + }, + [depositSuccessCallback, setDepositInProgress], + ); + + const handleClose = useCallback(() => { + setDepositSuccessCallback(undefined); + setDepositInProgress(null); + setIsDepositOpen(false); + }, []); + + return ( + + {children} + + + + ); +} + +export const useCreditsContext = () => { + const context = useContext(CreditsContext); + if (!context) { + throw new Error("useCreditsContext must be used within an CreditsProvider"); + } + return context; +}; diff --git a/packages/keychain/src/components/funding/AmountSelection.stories.tsx b/packages/keychain/src/components/funding/AmountSelection.stories.tsx index 7c4d9f360f..9827800d6e 100644 --- a/packages/keychain/src/components/funding/AmountSelection.stories.tsx +++ b/packages/keychain/src/components/funding/AmountSelection.stories.tsx @@ -5,7 +5,7 @@ import { AmountSelection } from "./AmountSelection"; const meta = { component: AmountSelection, args: { - enableCustom: true, + onChange: () => {}, }, } satisfies Meta; diff --git a/packages/keychain/src/components/funding/AmountSelection.tsx b/packages/keychain/src/components/funding/AmountSelection.tsx index 2ab7d5c66d..23912c9538 100644 --- a/packages/keychain/src/components/funding/AmountSelection.tsx +++ b/packages/keychain/src/components/funding/AmountSelection.tsx @@ -1,28 +1,40 @@ -import { Button, DollarIcon, Input } from "@cartridge/controller-ui"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button, DollarIcon, Input, Error } from "@cartridge/controller-ui"; import { cn } from "@cartridge/controller-ui/utils"; -import { useCallback, useRef, useState } from "react"; -export const USD_AMOUNTS = [10, 25, 50]; +export const CREDIT_AMOUNTS = [10, 20, 50]; + +const DEFAULT_MIN_AMOUNT = 2; +const DEFAULT_MAX_AMOUNT = 1000; type AmountSelectionProps = { - usdAmounts?: number[]; + creditAmounts?: number[]; lockSelection?: boolean; enableCustom?: boolean; - onChange?: (usdAmount: number) => void; + minAmount?: number; + maxAmount?: number; + onChange: (creditAmount: number) => void; }; export function AmountSelection({ - usdAmounts = USD_AMOUNTS, - lockSelection, - enableCustom, + creditAmounts = CREDIT_AMOUNTS, + lockSelection = false, + enableCustom = true, + minAmount, + maxAmount, onChange, }: AmountSelectionProps) { - const [selectedUSD, setSelectedUSD] = useState( - usdAmounts[0], + const [selectedAmount, setSelectedAmount] = useState( + creditAmounts[0], ); const [custom, setCustom] = useState(false); + const [error, setError] = useState(null); const inputRef = useRef(null); + useEffect(() => { + onChange(selectedAmount && !error ? selectedAmount : 0); + }, [onChange, selectedAmount, error]); + // Focus on the input const setFocus = useCallback(() => { // wait for the input to be rendered @@ -36,79 +48,80 @@ export function AmountSelection({ return (
-
- Amount -
-
-
- {usdAmounts.map((value) => ( - - ))} - {enableCustom && ( - - )} -
- {custom && ( -
- { - const clean = e.target.value.replace(/^0+/, ""); - const amount = Number.parseInt(clean, 10); - if (!isNaN(amount)) { - onChange?.(amount); - setSelectedUSD(amount); - } else { - onChange?.(0); - setSelectedUSD(undefined); - } - }} - onFocus={() => setCustom(true)} - /> - -
+
+ {creditAmounts.map((value) => ( + + ))} + {enableCustom && ( + )}
+ {custom && ( +
+ { + const clean = e.target.value.replace(/^0+/, ""); + const amount = clean ? Number.parseInt(clean, 10) : undefined; + setSelectedAmount(amount); + const min = minAmount ?? DEFAULT_MIN_AMOUNT; + const max = maxAmount ?? DEFAULT_MAX_AMOUNT; + if (amount && amount < min) { + setError(`$${min} minimum for purchases`); + } else if (amount && amount > max) { + setError(`$${max} maximum for purchases`); + } else { + setError(null); + } + }} + onFocus={() => setCustom(true)} + onClear={() => { + setSelectedAmount(undefined); + setError(null); + }} + /> + +
+ )} + {error && }
); } diff --git a/packages/keychain/src/components/funding/Balance.tsx b/packages/keychain/src/components/funding/Balance.tsx index 253db0c734..c403868eb6 100644 --- a/packages/keychain/src/components/funding/Balance.tsx +++ b/packages/keychain/src/components/funding/Balance.tsx @@ -10,6 +10,7 @@ import { CardTitle, TokenCard, TokenSummary, + UsdColorIcon, } from "@cartridge/controller-ui"; import { useCreditBalance } from "@cartridge/controller-ui/utils"; @@ -47,12 +48,12 @@ export function Balance({ types, title, amount }: BalanceProps) { {types.includes(BalanceType.CREDITS) && ( } + title={"USD"} amount={ amount ? `${amount.toFixed(2).toString()}` - : `${creditBalance.formatted} CREDITS` + : `${creditBalance.formatted} USD` } /> )} diff --git a/packages/keychain/src/components/funding/Deposit.tsx b/packages/keychain/src/components/funding/Deposit.tsx index 5b47f4ad73..1e7463e9e4 100644 --- a/packages/keychain/src/components/funding/Deposit.tsx +++ b/packages/keychain/src/components/funding/Deposit.tsx @@ -167,6 +167,9 @@ function DepositInner({ onComplete }: DepositProps) { +
+ Amount +
{error && ( diff --git a/packages/keychain/src/components/funding/index.tsx b/packages/keychain/src/components/funding/index.tsx index 3c4d0a4e0b..a7c8cb709e 100644 --- a/packages/keychain/src/components/funding/index.tsx +++ b/packages/keychain/src/components/funding/index.tsx @@ -40,7 +40,13 @@ export function Funding({ title, isSlot }: FundingProps) { {!isSlot && ( - + )} diff --git a/packages/keychain/src/components/inventory/collection/collectible-listing.tsx b/packages/keychain/src/components/inventory/collection/collectible-listing.tsx index 5557f86200..dee1c10b5a 100644 --- a/packages/keychain/src/components/inventory/collection/collectible-listing.tsx +++ b/packages/keychain/src/components/inventory/collection/collectible-listing.tsx @@ -26,7 +26,7 @@ import { STRK_CONTRACT_ADDRESS, USDC_CONTRACT_ADDRESS, } from "@cartridge/controller-ui/utils"; -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useParams, useSearchParams } from "react-router-dom"; import placeholder from "/placeholder.svg?url"; import { ListHeader } from "./send/header"; @@ -34,7 +34,7 @@ import { useTokens } from "@/hooks/token"; import { useConnection } from "@/hooks/connection"; import { AllowArray, cairo, Call, CallData, FeeEstimate } from "starknet"; import { useToast } from "@/context/toast"; -import { ArcadeContext } from "@/context/arcade"; +import { useArcadeContext } from "@/context/arcade"; import { useEntrypoints } from "@/hooks/entrypoints"; import { useNavigation } from "@/context/navigation"; import { useCollection } from "@/hooks/collection"; @@ -64,8 +64,7 @@ const EXPIRATIONS = [ ]; export function CollectibleListing() { - const arcadeContext = useContext(ArcadeContext); - const provider = arcadeContext?.provider; + const { marketplaceAddress } = useArcadeContext(); const { controller } = useConnection(); const { goBack } = useNavigation(); const { address: contractAddress, tokenId } = useParams(); @@ -193,13 +192,6 @@ export function CollectibleListing() { [setSelected], ); - // Memoize marketplace address - const marketplaceAddress = useMemo(() => { - return provider?.manifest.contracts.find((c: { tag: string }) => - c.tag?.includes("Marketplace"), - )?.address; - }, [provider?.manifest.contracts]); - // Build transactions when validation is complete const buildTransactions = useMemo(() => { if ( @@ -404,7 +396,7 @@ const ListingConfirmation = ({ }[]; currency: { name: string; - image: string; + image: string | React.ReactNode; price: number; value: string; }; @@ -622,9 +614,8 @@ const Price = ({ value={selected?.metadata.address} onValueChange={onChangeToken} defaultValue={selected?.metadata.address} - disabled={tokens.length <= 1} > - + {tokens.map((token) => ( ({}); const [amount, setAmount] = useState(0); - const arcadeContext = useContext(ArcadeContext); - const provider = arcadeContext?.provider; + const { marketplaceAddress } = useArcadeContext(); const { toast } = useToast(); const { data: marketplaceFeeConfig } = useMarketplaceFees(); @@ -244,13 +243,6 @@ export function CollectiblePurchase() { [setRoyalties], ); - // Memoize marketplace address - const marketplaceAddress = useMemo(() => { - return provider?.manifest.contracts.find((c: { tag: string }) => - c.tag?.includes("Marketplace"), - )?.address; - }, [provider?.manifest.contracts]); - // Build transactions const buildTransactions = useMemo(() => { if ( diff --git a/packages/keychain/src/components/inventory/collection/collection-asset.tsx b/packages/keychain/src/components/inventory/collection/collection-asset.tsx index 666eed7fb1..3dffdcf25e 100644 --- a/packages/keychain/src/components/inventory/collection/collection-asset.tsx +++ b/packages/keychain/src/components/inventory/collection/collection-asset.tsx @@ -365,7 +365,13 @@ export function CollectionAsset() { ); } -const Price = ({ amount, image }: { amount?: number; image?: string }) => { +const Price = ({ + amount, + image, +}: { + amount?: number; + image?: string | React.ReactNode; +}) => { const containerRef = useRef(null); const [width, setWidth] = useState(0); diff --git a/packages/keychain/src/components/inventory/collection/collection-listing.tsx b/packages/keychain/src/components/inventory/collection/collection-listing.tsx index 9ee93a594e..7e1fbb5e0f 100644 --- a/packages/keychain/src/components/inventory/collection/collection-listing.tsx +++ b/packages/keychain/src/components/inventory/collection/collection-listing.tsx @@ -23,7 +23,7 @@ import { STRK_CONTRACT_ADDRESS, USDC_CONTRACT_ADDRESS, } from "@cartridge/controller-ui/utils"; -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useParams, useSearchParams } from "react-router-dom"; import { useCollection } from "@/hooks/collection"; import placeholder from "/placeholder.svg?url"; @@ -31,7 +31,7 @@ import { ListHeader } from "./send/header"; import { useTokens } from "@/hooks/token"; import { AllowArray, cairo, Call, CallData, FeeEstimate } from "starknet"; import { useToast } from "@/context/toast"; -import { ArcadeContext } from "@/context/arcade"; +import { useArcadeContext } from "@/context/arcade"; import { useEntrypoints } from "@/hooks/entrypoints"; import { useConnection } from "@/hooks/connection"; import { useNavigation } from "@/context/navigation"; @@ -61,8 +61,7 @@ const EXPIRATIONS = [ ]; export function CollectionListing() { - const arcadeContext = useContext(ArcadeContext); - const provider = arcadeContext?.provider; + const { marketplaceAddress } = useArcadeContext(); const { controller } = useConnection(); const { goBack } = useNavigation(); const { address: contractAddress, tokenId } = useParams(); @@ -172,13 +171,6 @@ export function CollectionListing() { [setSelected], ); - // Memoize marketplace address to prevent infinite useEffect loop - const marketplaceAddress = useMemo(() => { - return provider?.manifest.contracts.find((c: { tag: string }) => - c.tag?.includes("Marketplace"), - )?.address; - }, [provider?.manifest.contracts]); - // Build transactions when validation is complete const buildTransactions = useMemo(() => { if ( @@ -357,7 +349,7 @@ const ListingConfirmation = ({ }[]; currency: { name: string; - image: string; + image: string | React.ReactNode; price: number; value: string; }; @@ -502,9 +494,8 @@ const Price = ({ value={selected?.metadata.address} onValueChange={onChangeToken} defaultValue={selected?.metadata.address} - disabled={tokens.length <= 1} > - + {tokens.map((token) => ( ({}); const [amount, setAmount] = useState(0); - const arcadeContext = useContext(ArcadeContext); - const provider = arcadeContext?.provider; + const { marketplaceAddress } = useArcadeContext(); const { toast } = useToast(); const [searchParams] = useSearchParams(); @@ -231,13 +230,6 @@ export function CollectionPurchase() { [setRoyalties], ); - // Memoize marketplace address - const marketplaceAddress = useMemo(() => { - return provider?.manifest.contracts.find((c: { tag: string }) => - c.tag?.includes("Marketplace"), - )?.address; - }, [provider?.manifest.contracts]); - // Build transactions const buildTransactions = useMemo(() => { if ( diff --git a/packages/keychain/src/components/inventory/token/token.tsx b/packages/keychain/src/components/inventory/token/token.tsx index 856d7f3f91..2c8d94b10b 100644 --- a/packages/keychain/src/components/inventory/token/token.tsx +++ b/packages/keychain/src/components/inventory/token/token.tsx @@ -1,3 +1,4 @@ +import { useCallback, useMemo } from "react"; import { Link, useParams } from "react-router-dom"; import { LayoutContent, @@ -7,10 +8,11 @@ import { ERC20Detail, ERC20Header, PaperPlaneIcon, - InfoIcon, Skeleton, Thumbnail, useDisclosure, + UsdColorIcon, + ActivityTokenCardAction, } from "@cartridge/controller-ui"; import { useData } from "@/hooks/data"; @@ -19,16 +21,22 @@ import { isPublicChain, useCreditBalance, } from "@cartridge/controller-ui/utils"; +// import { useNavigation } from "@/context/navigation"; import { useExplorer } from "@starknet-react/core"; import { constants, getChecksumAddress } from "starknet"; import { useAccount, useUsernames } from "@/hooks/account"; +import { useCreditsHistory } from "@/hooks/credits-history"; +import { + CreditsHistoryTransactionType, + CreditsPaymentMethod, +} from "@/utils/api"; import { useToken } from "@/hooks/token"; -import { useCallback, useMemo } from "react"; import { useConnection } from "@/hooks/connection"; import { useVersion } from "@/hooks/version"; -import { useNavigation } from "@/context/navigation"; import { EmptyState, LoadingState } from "@/components/activity"; import { SendTokenDrawer } from "./send/send-drawer"; +import { useCreditsContext } from "@/components/credits/provider"; +import { formatCredits } from "@/utils/credits"; export function Token() { const { address } = useParams<{ address: string }>(); @@ -41,46 +49,131 @@ export function Token() { } } +export const CREDITS_DESCRIPTION = + "USD Credits are an account balance that can be used to pay for games and network activity."; + +const PAYMENT_METHOD_LABELS: Record = { + [CreditsPaymentMethod.Card]: "Card", + [CreditsPaymentMethod.Crypto]: "Wallet", + [CreditsPaymentMethod.Free]: "Free", + [CreditsPaymentMethod.Credits]: "Credits", +}; + function Credits() { // TODO: Get parent from keychain connection if needed - const { navigate } = useNavigation(); + // const { navigate } = useNavigation(); const account = useAccount(); const username = account?.username || ""; + const { initiateCreditsDeposit } = useCreditsContext(); const credit = useCreditBalance({ username, interval: 30000, }); + const { items: history, status: historyStatus } = useCreditsHistory(); + + const entries = useMemo< + { + id: string; + transactionHash?: string | null; + amount: string; + value: string; + action: ActivityTokenCardAction; + item: string; + date: string; + timestamp: number; + }[] + >(() => { + return history.map((item) => { + const timestamp = new Date(item.createdAt).getTime(); + const amount = formatCredits(item.amount, 0); + const isDeposit = + item.transactionType === CreditsHistoryTransactionType.Credit; + return { + id: item.id, + transactionHash: item.transactionHash, + amount: `${amount.formatted} USD`, + value: `$${amount.usd.toFixed(2)}`, + action: isDeposit + ? "deposit" + : item.paymentMethod === CreditsPaymentMethod.Free + ? "claimed" + : "spend", + item: isDeposit + ? PAYMENT_METHOD_LABELS[item.paymentMethod] + : item.comment || "Network Fee", + date: getDate(timestamp), + timestamp, + }; + }); + }, [history]); // Show loading state while credits are being fetched if (credit.balance.value === undefined) { return ; } + const format = formatCredits(credit.balance.value); + return ( <>
- -

{`${Number(credit.balance.value) / 10 ** 6} CREDITS`}

+ } size="lg" rounded /> +
+

{`${format.formatted} USD`}

+

{`$${format.usd.toFixed(2)}`}

+
-
- -

- Credits are used to pay for network activity. They are not tokens - and cannot be transferred or refunded. -

+
+ {CREDITS_DESCRIPTION}
+ + {historyStatus === "loading" ? ( + + ) : historyStatus === "error" || !entries.length ? ( + + ) : ( +
+ {Object.entries( + entries.reduce( + (acc, entry) => { + if (!acc[entry.date]) { + acc[entry.date] = []; + } + acc[entry.date].push(entry); + return acc; + }, + {} as Record, + ), + ).map(([date, items]) => ( +
+

+ {date} +

+ {items.map((item) => ( + } + action={item.action} + timestamp={item.timestamp} + /> + ))} +
+ ))} +
+ )} -
- -
- {step === "method" ? ( - <> - {showFiatOptions && isCoinflowEnabled && ( - } - onClick={handleCoinflowSelect} - className={cn( - "group flex flex-row gap-2 bg-background-200 hover:bg-background-300 rounded-lg p-3 justify-between cursor-pointer", - "rounded-lg", - isApplePayLoading && "opacity-50 pointer-events-none", - )} - /> - )} - {showFiatOptions && !isAndroid && ( - } - onClick={handleApplePaySelect} - className={cn( - "rounded-lg", - isApplePayLoading && "opacity-50 pointer-events-none", - )} - /> - )} + + + {step === "method" ? ( + <> + {showController && ( + + )} + {showCredits && ( + } + onClick={handleCreditsSelect} + className={cn( + "rounded-lg", + isApplePayLoading && "opacity-50 pointer-events-none", + )} + /> + )} + {showFiatOptions && isCoinflowEnabled && ( + } + onClick={handleCoinflowSelect} + className={cn( + "group flex flex-row gap-2 bg-background-200 hover:bg-background-300 rounded-lg p-3 justify-between cursor-pointer", + "rounded-lg", + isApplePayLoading && "opacity-50 pointer-events-none", + )} + /> + )} + {showFiatOptions && !isAndroid && ( + } + onClick={handleApplePaySelect} + className={cn( + "rounded-lg", + isApplePayLoading && "opacity-50 pointer-events-none", + )} + /> + )} + {showCrypto && ( } onClick={handleWalletStepSelect} className={cn( @@ -356,45 +423,45 @@ export function WalletSelectionDrawer({ isApplePayLoading && "opacity-50 pointer-events-none", )} /> - - ) : step === "network" ? ( - selectedNetworks.length > 0 ? ( - selectedNetworks.map((network) => ( - handleNetworkSelect(network)} - className={cn( - "rounded-lg", - isApplePayLoading && "opacity-50 pointer-events-none", - )} - /> - )) - ) : ( -
- No networks available -
- ) - ) : isDetecting ? ( -
- -
- ) : walletElements.length > 0 ? ( - walletElements + )} + + ) : step === "network" ? ( + selectedNetworks.length > 0 ? ( + selectedNetworks.map((network) => ( + handleNetworkSelect(network)} + className={cn( + "rounded-lg", + isApplePayLoading && "opacity-50 pointer-events-none", + )} + /> + )) ) : (
- No wallets detected + No networks available
- )} - - {error && ( -
- {error.message} -
- )} -
- - + ) + ) : isDetecting ? ( +
+ +
+ ) : walletElements.length > 0 ? ( + walletElements + ) : ( +
+ No wallets detected +
+ )} + + {error && ( +
+ {error.message} +
+ )} + + ); } diff --git a/packages/keychain/src/components/purchase/pending/pending.stories.tsx b/packages/keychain/src/components/purchase/pending/pending.stories.tsx index 6e9ff77295..0d6e3adc0a 100644 --- a/packages/keychain/src/components/purchase/pending/pending.stories.tsx +++ b/packages/keychain/src/components/purchase/pending/pending.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { BridgePending, PurchasePending, ClaimPending } from "./index"; -import { CreditIcon } from "@cartridge/controller-ui"; +import { UsdColorIcon } from "@cartridge/controller-ui"; import { ItemType, StarterpackProviders } from "@/context"; import { ExternalWalletType } from "@cartridge/controller"; @@ -37,7 +37,7 @@ export const CryptoPurchaseWithCredits: Story = { items={[ { title: "Credits", - icon: , + icon: , value: 1000, type: ItemType.CREDIT, }, @@ -81,7 +81,7 @@ export const CryptoPurchaseWithNFT: Story = { }, { title: "Credits", - icon: , + icon: , value: 500, type: ItemType.CREDIT, }, @@ -120,7 +120,7 @@ export const CryptoPurchaseWithoutWallet: Story = { items={[ { title: "Welcome Credits", - icon: , + icon: , value: 250, type: ItemType.CREDIT, }, @@ -148,7 +148,7 @@ export const OnchainPurchaseWithCredits: Story = { items={[ { title: "Onchain Credits", - icon: , + icon: , value: 1500, type: ItemType.CREDIT, }, @@ -177,7 +177,7 @@ export const OnchainPurchaseWithMultipleItems: Story = { }, { title: "Battle Credits", - icon: , + icon: , value: 3000, type: ItemType.CREDIT, }, @@ -208,7 +208,7 @@ export const ClaimFreeCredits: Story = { items={[ { title: "Welcome Credits", - icon: , + icon: , value: 100, type: ItemType.CREDIT, }, @@ -238,7 +238,7 @@ export const ClaimFreeNFT: Story = { }, { title: "Bonus Credits", - icon: , + icon: , value: 50, type: ItemType.CREDIT, }, @@ -263,7 +263,7 @@ export const CryptoPurchaseLoading: Story = { items={[ { title: "Loading Credits", - icon: , + icon: , value: 1000, type: ItemType.CREDIT, }, @@ -416,7 +416,7 @@ export const MixedTokensAndCredits: Story = { items={[ { title: "Bonus Credits", - icon: , + icon: , value: 1000, type: ItemType.CREDIT, }, diff --git a/packages/keychain/src/components/purchase/receiving.tsx b/packages/keychain/src/components/purchase/receiving.tsx index e3f869b915..b29bfae90e 100644 --- a/packages/keychain/src/components/purchase/receiving.tsx +++ b/packages/keychain/src/components/purchase/receiving.tsx @@ -5,6 +5,7 @@ import { CardListContent, CardTitle, cn, + UsdColorIcon, Spinner, Thumbnail, } from "@cartridge/controller-ui"; @@ -47,7 +48,7 @@ export function Receiving({ .map((item, index) => { const Logo = ( } size="lg" rounded /> diff --git a/packages/keychain/src/components/purchase/review/cost.tsx b/packages/keychain/src/components/purchase/review/cost.tsx index b579917356..b1b375d4cb 100644 --- a/packages/keychain/src/components/purchase/review/cost.tsx +++ b/packages/keychain/src/components/purchase/review/cost.tsx @@ -1,3 +1,4 @@ +import { ReactNode, useCallback, useMemo, useEffect } from "react"; import { ArbitrumIcon, BaseIcon, @@ -14,18 +15,113 @@ import { StarknetIcon, Thumbnail, TokenSelectHeader, + UsdColorIcon, } from "@cartridge/controller-ui"; import { ExternalPlatform, humanizeString } from "@cartridge/controller"; import { OnchainFeesTooltip } from "./onchain-tooltip"; -import type { Quote } from "@/context"; -import { useCallback, useMemo, useEffect } from "react"; +import type { Quote, TokenOption } from "@/context"; import { useOnchainPurchaseContext, useCreditPurchaseContext } from "@/context"; +import { formatCredits } from "@/utils/credits"; import { num } from "starknet"; +// Pseudo-token rendered in the token selector when paying with credits. +export const CREDITS_TOKEN: TokenOption = { + name: "USD", + symbol: "USD", + decimals: 6, + address: "credits", + icon: , + contract: {} as TokenOption["contract"], + isCredits: true, +}; + export const convertCentsToDollars = (cents: number): string => { return `$${(cents / 100).toFixed(2)}`; }; +/** + * Presentational cost breakdown: an optional "Purchase on " banner, a + * Total row (label + optional fees tooltip + value), and a payment-token + * selector. It is intentionally data-source agnostic — every rail (onchain + * token, credits/USDC, and later Coinflow/Apple Pay) computes its own `value`, + * `feesTooltip`, and token list and feeds them in. See OnchainCostBreakdown for + * the onchain-purchase adapter. + */ +export function CostBreakdown({ + platform, + tokens, + selectedToken, + onSelectToken, + tokenSelectDisabled, + feesTooltip, + isLoading, + value, +}: { + platform?: ExternalPlatform; + tokens: TokenOption[]; + selectedToken?: TokenOption; + onSelectToken: (address: string) => void; + tokenSelectDisabled?: boolean; + feesTooltip?: ReactNode; + isLoading?: boolean; + value: ReactNode; +}) { + return ( + + {platform && ( + +
+ Purchase on +
+
+ )} + +
+ +
+
+ Total + {feesTooltip} +
+ {isLoading ? ( + + ) : ( +
{value}
+ )} +
+
+ +
+
+ ); +} + /** * Onchain Cost Breakdown - for token-based payments directly to smart contracts */ @@ -52,10 +148,16 @@ export function OnchainCostBreakdown({ quantity, isApplePaySelected, isCoinflowSelected, + isCreditsSelected, coinbaseQuote, isFetchingCoinbaseQuote, } = useOnchainPurchaseContext(); - const { coinflowQuote, isCoinflowQuoteLoading } = useCreditPurchaseContext(); + const { + coinflowQuote, + isCoinflowQuoteLoading, + creditsQuote, + isCreditsQuoteLoading, + } = useCreditPurchaseContext(); const { decimals } = quote.paymentTokenMetadata; // When credit card is selected, use the Coinflow backend quote so that // pricing is correct even for non-USDC starterpacks (handles Ekubo swap). @@ -84,7 +186,7 @@ export function OnchainCostBreakdown({ // Check if payment token matches selected token (use selectedToken, not displayToken) const isPaymentTokenSameAsSelected = useMemo(() => { - if (!selectedToken || !quote) return false; + if (!selectedToken || !quote || selectedToken.isCredits) return false; return num.toHex(quote.paymentToken) === num.toHex(selectedToken.address); }, [selectedToken, quote]); @@ -138,112 +240,83 @@ export function OnchainCostBreakdown({ [availableTokens, setSelectedToken], ); - return ( - - {platform && ( - -
- Purchase on -
-
- )} + const value = isCreditsSelected ? ( + isCreditsQuoteLoading ? ( + + ) : creditsQuote ? ( + + {formatCredits(creditsQuote.requiredCredits).formatted} + + ) : ( + + ) + ) : isCoinflowSelected ? ( + isCoinflowQuoteLoading ? ( + + ) : coinflowCostDetails ? ( + + {formatAmount(coinflowCostDetails.totalInCents / 100)} + + ) : ( + + ) + ) : isApplePaySelected ? ( + coinbaseQuote ? ( + + {`$${Number(coinbaseQuote.paymentTotal.amount).toFixed(2)}`} + + ) : ( + + ) + ) : isUsingLayerswap ? ( + feeEstimationError ? ( + + ) : layerswapTotal !== null && displayToken ? ( + + {formatAmount(layerswapTotal)} + + ) : ( + + ) + ) : isPaymentTokenSameAsSelected ? ( + {formatAmount(paymentAmount)} + ) : ( + convertedEquivalent !== null && + displayToken && ( + + {formatAmount(convertedEquivalent)} + + ) + ); -
- -
-
- Total - } - defaultOpen={openFeesTooltip} - quote={quote} - quantity={quantity} - layerswapFees={isUsingLayerswap ? layerswapFees : undefined} - coinbaseQuote={isApplePaySelected ? coinbaseQuote : undefined} - creditCardFeeInCents={ - coinflowCostDetails - ? coinflowCostDetails.cardFeeInCents + - coinflowCostDetails.gasFeeInCents - : undefined - } - /> -
- {isFetchingConversion || isFetchingCoinbaseQuote ? ( - - ) : ( -
- {isCoinflowSelected ? ( - isCoinflowQuoteLoading ? ( - - ) : coinflowCostDetails ? ( - - {formatAmount(coinflowCostDetails.totalInCents / 100)} - - ) : ( - - ) - ) : isApplePaySelected ? ( - coinbaseQuote ? ( - - {`$${Number(coinbaseQuote.paymentTotal.amount).toFixed(2)}`} - - ) : ( - - ) - ) : isUsingLayerswap ? ( - feeEstimationError ? ( - - ) : layerswapTotal !== null && displayToken ? ( - - {formatAmount(layerswapTotal)} - - ) : ( - - ) - ) : isPaymentTokenSameAsSelected ? ( - - {formatAmount(paymentAmount)} - - ) : ( - convertedEquivalent !== null && - displayToken && ( - - {formatAmount(convertedEquivalent)} - - ) - )} -
- )} -
-
- -
-
+ return ( + } + defaultOpen={openFeesTooltip} + quote={quote} + quantity={quantity} + layerswapFees={isUsingLayerswap ? layerswapFees : undefined} + coinbaseQuote={isApplePaySelected ? coinbaseQuote : undefined} + creditCardFeeInCents={ + coinflowCostDetails + ? coinflowCostDetails.cardFeeInCents + + coinflowCostDetails.gasFeeInCents + : undefined + } + /> + } + /> ); } diff --git a/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx b/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx index f8d5f744f2..4064b140de 100644 --- a/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx +++ b/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx @@ -6,6 +6,7 @@ import { TokenOption, } from "@/context/starterpack/onchain-purchase"; import { ReactNode } from "react"; +import { USDC_ICON } from "@/utils/ekubo"; // USDC address with leading zeros (tests normalization) const USDC_ADDRESS = @@ -17,7 +18,7 @@ const mockUsdcToken = { name: "USD Coin", symbol: "USDC", decimals: 6, - icon: "https://static.cartridge.gg/tokens/usdc.svg", + icon: USDC_ICON, contract: {} as TokenOption["contract"], } satisfies TokenOption; @@ -58,12 +59,15 @@ const MockOnchainPurchaseProvider = ({ children }: { children: ReactNode }) => { waitForDeposit: async () => "0xmocktxhash", isApplePaySelected: false, isCoinflowSelected: false, + isCreditsRailSelected: false, + isCreditsSelected: false, paymentLink: undefined, isCreatingOrder: false, coinbaseQuote: undefined, isFetchingCoinbaseQuote: false, onApplePaySelect: () => {}, onCoinflowSelect: () => {}, + onCreditsSelect: () => {}, onCreateCoinbaseOrder: async () => undefined, openPaymentPopup: () => {}, closePaymentPopup: () => {}, diff --git a/packages/keychain/src/components/purchase/review/token-utils.ts b/packages/keychain/src/components/purchase/review/token-utils.ts index 2bfc4d0a59..9125deeb4f 100644 --- a/packages/keychain/src/components/purchase/review/token-utils.ts +++ b/packages/keychain/src/components/purchase/review/token-utils.ts @@ -1,5 +1,5 @@ import { USDC_CONTRACT_ADDRESS } from "@cartridge/controller-ui/utils"; -import { USDC_ADDRESSES, USDCE_ADDRESSES } from "@/utils/ekubo"; +import { USDC_ADDRESSES, USDC_ICON, USDCE_ADDRESSES } from "@/utils/ekubo"; /** * Normalize a token address for comparison @@ -87,7 +87,7 @@ export function getTokenSymbol(tokenAddress: string): string { */ export function getTokenIcon(tokenAddress: string): string | null { if (isUsdcToken(tokenAddress)) { - return "https://static.cartridge.gg/tokens/usdc.svg"; + return USDC_ICON; } return null; } diff --git a/packages/keychain/src/components/purchase/starterpack/starter-item.stories.tsx b/packages/keychain/src/components/purchase/starterpack/starter-item.stories.tsx index f0128032fe..813a76540f 100644 --- a/packages/keychain/src/components/purchase/starterpack/starter-item.stories.tsx +++ b/packages/keychain/src/components/purchase/starterpack/starter-item.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { StarterItem } from "./starter-item"; import { ItemType } from "@/context"; +import { UsdColorIcon } from "@cartridge/controller-ui"; const meta: Meta = { component: StarterItem, @@ -46,7 +47,7 @@ export const Credit: Story = { type: ItemType.ERC20, title: "100 Credits", subtitle: "Get 100 credits to use in the marketplace", - icon: "https://static.cartridge.gg/presets/credit/icon.svg", + icon: , value: 100, }, }; @@ -56,7 +57,7 @@ export const FreeCredit: Story = { type: ItemType.CREDIT, title: "Free Credits", subtitle: "Get 50 free credits to start your journey", - icon: "https://static.cartridge.gg/presets/credit/icon.svg", + icon: , value: 50, }, }; diff --git a/packages/keychain/src/components/purchase/success.stories.tsx b/packages/keychain/src/components/purchase/success.stories.tsx index 7d431cdb10..04ee23f5d9 100644 --- a/packages/keychain/src/components/purchase/success.stories.tsx +++ b/packages/keychain/src/components/purchase/success.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { PurchaseSuccessInner } from "./success"; -import { CreditIcon } from "@cartridge/controller-ui"; +import { UsdColorIcon } from "@cartridge/controller-ui"; import { ItemType } from "@/context"; const meta = { @@ -24,8 +24,8 @@ export const Credits: Story = { type: "onchain", items: [ { - title: "Credits", - icon: , + title: "USD", + icon: , value: 1000, type: ItemType.CREDIT, }, @@ -73,8 +73,8 @@ export const MixedPurchase: Story = { type: "onchain", items: [ { - title: "Credits", - icon: , + title: "USDs", + icon: , value: 2000, type: ItemType.CREDIT, }, @@ -99,8 +99,8 @@ export const ClaimedItems: Story = { type: "claimed", items: [ { - title: "Credits", - icon: , + title: "USD", + icon: , value: 500, type: ItemType.CREDIT, }, diff --git a/packages/keychain/src/components/purchase/success.tsx b/packages/keychain/src/components/purchase/success.tsx index 51c6ffceee..f72fcc40ba 100644 --- a/packages/keychain/src/components/purchase/success.tsx +++ b/packages/keychain/src/components/purchase/success.tsx @@ -23,6 +23,7 @@ import { CoinflowPaymentStatus, PurchaseFulfillmentStatus, useCoinflowPaymentQuery, + usePurchaseFulfillmentQuery, } from "@/utils/api"; import { posthog } from "@/components/provider/posthog"; import { captureAnalyticsEvent } from "@/types/analytics"; @@ -31,7 +32,7 @@ export function Success() { const { starterpackDetails, transactionHash, claimItems } = useStarterpackContext(); const { purchaseItems } = useOnchainPurchaseContext(); - const { coinflowIntent } = useCreditPurchaseContext(); + const { coinflowIntent, creditsFulfillment } = useCreditPurchaseContext(); const items = useMemo(() => { if (starterpackDetails?.type === "claimed") { @@ -41,6 +42,16 @@ export function Success() { return purchaseItems; }, [starterpackDetails, claimItems, purchaseItems]); + if (starterpackDetails?.type === "onchain" && creditsFulfillment) { + return ( + + ); + } + if (starterpackDetails?.type === "onchain" && coinflowIntent) { return ( 1 ? `(${quantity})` : ""; + + const { data, error, isLoading, isFetching, refetch } = + usePurchaseFulfillmentQuery( + { id: fulfillmentId }, + { + enabled: true, + retry: false, + }, + ); + + const fulfillment = data?.purchaseFulfillment; + const fulfillmentStatus = fulfillment?.status; + const transactionHash = fulfillment?.transactionHash ?? undefined; + + const isPurchaseComplete = + fulfillmentStatus === PurchaseFulfillmentStatus.Confirmed; + const isFulfillmentFailed = + fulfillmentStatus === PurchaseFulfillmentStatus.Failed; + + const hasCapturedPurchase = useRef(false); + + useEffect(() => { + if (isPurchaseComplete && !hasCapturedPurchase.current) { + hasCapturedPurchase.current = true; + captureAnalyticsEvent(posthog, "purchase_completed", { + method: "credits", + }); + } + }, [isPurchaseComplete]); + + // Refresh the credit balance once the fulfillment settles: on success the + // debit is final, on failure the backend refunded the credits. + const hasRefetchedBalance = useRef(false); + useEffect(() => { + if ( + (isPurchaseComplete || isFulfillmentFailed) && + !hasRefetchedBalance.current + ) { + hasRefetchedBalance.current = true; + void refetchCreditsBalance(); + } + }, [isPurchaseComplete, isFulfillmentFailed, refetchCreditsBalance]); + + useEffect(() => { + if ( + isLoading || + isFetching || + error || + isPurchaseComplete || + isFulfillmentFailed + ) { + return; + } + + const pollTimer = window.setTimeout(() => { + void refetch(); + }, 3000); + + return () => window.clearTimeout(pollTimer); + }, [ + error, + isFetching, + isLoading, + isPurchaseComplete, + isFulfillmentFailed, + fulfillmentStatus, + refetch, + ]); + + const statusError = useMemo(() => { + if (error) { + return error instanceof Error + ? error + : new Error("Unable to load purchase status."); + } + + return undefined; + }, [error]); + + const fulfillmentStage = getFulfillmentStage(fulfillmentStatus); + + return ( + <> + : undefined} + /> + + + + + {isFulfillmentFailed ? ( + + ) : statusError ? ( + + ) : null} +
+ + +
+ +
+ + ); +} + export function PurchaseSuccessInner({ items, type, diff --git a/packages/keychain/src/components/purchase/wallet/wallet.stories.tsx b/packages/keychain/src/components/purchase/wallet/wallet.stories.tsx index 68ce5db2a9..ee63155f75 100644 --- a/packages/keychain/src/components/purchase/wallet/wallet.stories.tsx +++ b/packages/keychain/src/components/purchase/wallet/wallet.stories.tsx @@ -67,12 +67,15 @@ const mockOnchainPurchaseValue: OnchainPurchaseContextType = { waitForDeposit: async () => "0xmocktxhash", isApplePaySelected: false, isCoinflowSelected: false, + isCreditsRailSelected: false, + isCreditsSelected: false, paymentLink: undefined, isCreatingOrder: false, coinbaseQuote: undefined, isFetchingCoinbaseQuote: false, onApplePaySelect: () => {}, onCoinflowSelect: () => {}, + onCreditsSelect: () => {}, onCreateCoinbaseOrder: async () => undefined, openPaymentPopup: () => {}, closePaymentPopup: () => {}, diff --git a/packages/keychain/src/components/slot/crypto-fund.tsx b/packages/keychain/src/components/slot/crypto-fund.tsx index b4e789f1ef..80dffa1653 100644 --- a/packages/keychain/src/components/slot/crypto-fund.tsx +++ b/packages/keychain/src/components/slot/crypto-fund.tsx @@ -43,7 +43,7 @@ import { formatBalance, useFeeToken, } from "@/hooks/tokens"; -import { USDC_ADDRESSES } from "@/utils/ekubo"; +import { USDC_ADDRESSES, USDC_ICON } from "@/utils/ekubo"; import { STRK_CONTRACT_ADDRESS } from "@cartridge/controller-ui/utils"; import { ErrorAlert } from "@/components/ErrorAlert"; import { createStarknetCryptoPayment } from "@/hooks/payments/crypto"; @@ -128,7 +128,7 @@ function SlotCryptoFundInner({ name: "USD Coin", decimals: 6, address: usdcAddress, - icon: "https://static.cartridge.gg/tokens/usdc.svg", + icon: USDC_ICON, defaultAmount: "10", min: 1, max: 2000, @@ -352,7 +352,7 @@ function SlotCryptoFundInner({ }} disabled={isSubmitting} > - + {tokens.map((token) => ( diff --git a/packages/keychain/src/context/arcade.ts b/packages/keychain/src/context/arcade.ts index c42fd5efe4..7a9a4335da 100644 --- a/packages/keychain/src/context/arcade.ts +++ b/packages/keychain/src/context/arcade.ts @@ -5,7 +5,7 @@ import { OrderModel, SaleEvent, } from "@cartridge/arcade"; -import { createContext } from "react"; +import { createContext, useContext } from "react"; /** * Interface defining the shape of the Arcade context. @@ -30,9 +30,18 @@ interface ArcadeContextType { removeOrder: (order: OrderModel) => void; initializable: boolean; setInitializable: (initializable: boolean) => void; + marketplaceAddress: string | undefined; } /** * React context for sharing Arcade-related data throughout the application. */ export const ArcadeContext = createContext(null); + +export const useArcadeContext = () => { + const context = useContext(ArcadeContext); + if (!context) { + throw new Error("useArcadeContext must be used within an ArcadeProvider"); + } + return context; +}; diff --git a/packages/keychain/src/context/starterpack/credit-purchase.tsx b/packages/keychain/src/context/starterpack/credit-purchase.tsx index 9a467823ae..68e4b68a53 100644 --- a/packages/keychain/src/context/starterpack/credit-purchase.tsx +++ b/packages/keychain/src/context/starterpack/credit-purchase.tsx @@ -4,6 +4,7 @@ import { useState, useCallback, useEffect, + useMemo, ReactNode, } from "react"; import { useConnection } from "@/hooks/connection"; @@ -12,7 +13,13 @@ import useCoinflowPayment, { CoinflowStarterpackQuote, useCoinflowStarterpackQuote, } from "@/hooks/payments/coinflow"; -import { USD_AMOUNTS } from "@/components/funding/AmountSelection"; +import useCreditsPayment, { + BundleCreditsQuote, + CreditsBundleFulfillment, + useBundleCreditsQuote, +} from "@/hooks/payments/credits"; +import { useCreditBalance } from "@cartridge/controller-ui/utils"; +import { CREDIT_AMOUNTS } from "@/components/funding/AmountSelection"; import { useStarterpackContext } from "./starterpack"; import { useOnchainPurchaseContext } from "./onchain-purchase"; import { getCurrentReferral } from "@/utils/referral"; @@ -30,8 +37,22 @@ export interface CreditPurchaseContextType { coinflowEnv: "prod" | "sandbox"; isCoinflowLoading: boolean; + // Credits (spend account credit balance) state + creditsQuote: BundleCreditsQuote | undefined; + isCreditsQuoteLoading: boolean; + /** Quote rejection — PermissionDenied when the bundle is not approved for + * credits; display its message and treat credits as unavailable. */ + creditsQuoteError: Error | undefined; + /** Raw credit balance in 1e8-per-USD units (same unit as requiredCredits). */ + creditsBalance: bigint; + refetchCreditsBalance: () => Promise; + hasSufficientCredits: boolean; + isCreditsLoading: boolean; + creditsFulfillment: CreditsBundleFulfillment | undefined; + // Actions onCreditCardPurchase: () => Promise; + onCreditsPurchase: () => Promise; } export const CreditPurchaseContext = createContext< @@ -55,7 +76,7 @@ export const CreditPurchaseProvider = ({ } = useStarterpackContext(); const { quantity } = useOnchainPurchaseContext(); - const [usdAmount, setUsdAmount] = useState(USD_AMOUNTS[0]); + const [usdAmount, setUsdAmount] = useState(CREDIT_AMOUNTS[0]); const [coinflowIntent, setCoinflowIntent] = useState< CoinflowStarterpackIntent | undefined >(); @@ -73,24 +94,60 @@ export const CreditPurchaseProvider = ({ const referralData = getCurrentReferral(origin); const isOnchain = !!starterpackDetails && isOnchainStarterpack(starterpackDetails); + const quoteInput = { + starterpackId: isOnchain + ? (starterpackDetails as { id: number | string }).id.toString() + : undefined, + quantity, + registryAddress, + referral: referralData?.refAddress || referralData?.ref, + referralGroup: referralData?.refGroup, + ...(bundleId !== undefined && { clientPercentage: 0 }), + enabled: isOnchain, + }; const { data: coinflowQuote, isLoading: isCoinflowQuoteLoading } = - useCoinflowStarterpackQuote({ - starterpackId: isOnchain - ? (starterpackDetails as { id: number | string }).id.toString() - : undefined, - quantity, - registryAddress, - referral: referralData?.refAddress || referralData?.ref, - referralGroup: referralData?.refGroup, - ...(bundleId !== undefined && { clientPercentage: 0 }), - enabled: isOnchain, + useCoinflowStarterpackQuote(quoteInput); + + // Credits quote — same inputs, same backend pricing path as the purchase + // mutation, so quote == charge. The backend also gates which bundles may be + // bought with credits: unapproved ones reject with PermissionDenied, which + // we surface (not auto-display) via creditsQuoteError. + const { + data: creditsQuote, + isLoading: isCreditsQuoteLoading, + error: creditsQuoteError, + } = useBundleCreditsQuote(quoteInput); + + // Credit balance — raw 1e8-per-USD units, same unit as the quote's + // requiredCredits, so the sufficiency check needs no conversion. + const { balance: rawCreditBalance, refetch: refetchCreditsBalance } = + useCreditBalance({ + username: controller?.username(), + interval: undefined, }); + const creditsBalance = rawCreditBalance.value; + const hasSufficientCredits = useMemo(() => { + if (!creditsQuote) return false; + return creditsBalance >= BigInt(creditsQuote.requiredCredits); + }, [creditsQuote, creditsBalance]); + + const { + isLoading: isCreditsLoading, + error: creditsError, + purchaseBundle, + } = useCreditsPayment(); + const [creditsFulfillment, setCreditsFulfillment] = useState< + CreditsBundleFulfillment | undefined + >(); useEffect(() => { if (coinflowError) { setDisplayError(coinflowError); } - }, [coinflowError]); // eslint-disable-line react-hooks/exhaustive-deps + if (creditsError) { + setDisplayError(creditsError); + } + }, [coinflowError, creditsError, setDisplayError]); const onCreditCardPurchase = useCallback(async () => { if (!controller || !registryAddress) return; @@ -129,8 +186,45 @@ export const CreditPurchaseProvider = ({ setDisplayError, ]); + // Spend the account's credit balance: the backend debits synchronously and + // returns a PurchaseFulfillment to poll (credits are auto-refunded if + // fulfillment terminally fails). Same input shape as the Coinflow intent. + const onCreditsPurchase = useCallback(async () => { + if (!controller || !registryAddress) return; + if (!starterpackDetails || !isOnchainStarterpack(starterpackDetails)) { + return; + } + + try { + const referralData = getCurrentReferral(origin); + + const fulfillment = await purchaseBundle({ + starterpackId: starterpackDetails.id.toString(), + quantity, + referral: referralData?.refAddress || referralData?.ref, + referralGroup: referralData?.refGroup, + registryAddress, + ...(bundleId !== undefined && { clientPercentage: 0 }), + }); + setCreditsFulfillment(fulfillment); + } catch (e) { + setDisplayError(e as Error); + throw e; + } + }, [ + controller, + origin, + registryAddress, + bundleId, + starterpackDetails, + quantity, + purchaseBundle, + setDisplayError, + ]); + useEffect(() => { setCoinflowIntent(undefined); + setCreditsFulfillment(undefined); }, [starterpackId]); const contextValue: CreditPurchaseContextType = { @@ -141,7 +235,16 @@ export const CreditPurchaseProvider = ({ isCoinflowQuoteLoading, coinflowEnv, isCoinflowLoading, + creditsQuote, + isCreditsQuoteLoading, + creditsQuoteError: (creditsQuoteError as Error) ?? undefined, + creditsBalance, + refetchCreditsBalance, + hasSufficientCredits, + isCreditsLoading, + creditsFulfillment, onCreditCardPurchase, + onCreditsPurchase, }; return ( diff --git a/packages/keychain/src/context/starterpack/onchain-purchase.tsx b/packages/keychain/src/context/starterpack/onchain-purchase.tsx index c96a3f44f2..122a9f3a9a 100644 --- a/packages/keychain/src/context/starterpack/onchain-purchase.tsx +++ b/packages/keychain/src/context/starterpack/onchain-purchase.tsx @@ -42,9 +42,13 @@ import { } from "@/hooks/starterpack"; import { useSocialClaimConnection } from "@/hooks/starterpack/social"; import { Explorer } from "@/hooks/starterpack/layerswap"; +import { CREDITS_TOKEN } from "@/components/purchase/review/cost"; export type { TokenOption } from "@/hooks/starterpack"; +/** Non-wallet payment rails selectable in the checkout. */ +export type PurchaseRail = "apple-pay" | "coinflow" | "credits"; + export interface OnchainPurchaseContextType { // Purchase items purchaseItems: Item[]; @@ -95,6 +99,8 @@ export interface OnchainPurchaseContextType { // Coinbase / Apple Pay state isApplePaySelected: boolean; isCoinflowSelected: boolean; + isCreditsRailSelected: boolean; // credits selected as payment method + isCreditsSelected: boolean; // credits is selected as a fake controller token paymentLink: string | undefined; isCreatingOrder: boolean; coinbaseQuote: CoinbaseQuoteResult | undefined; @@ -124,6 +130,7 @@ export interface OnchainPurchaseContextType { waitForDeposit: (swapId: string) => Promise; onApplePaySelect: () => void; onCoinflowSelect: () => void; + onCreditsSelect: () => void; onCreateCoinbaseOrder: (opts?: { force?: boolean; }) => Promise; @@ -228,8 +235,7 @@ export const OnchainPurchaseProvider = ({ const clearSelectedWallet = useCallback(() => { clearSelectedWalletInternal(); - setIsApplePaySelected(false); - setIsCoinflowSelected(false); + setSelectedRail(null); clearPaymentMethod(); }, [clearSelectedWalletInternal, clearPaymentMethod]); @@ -239,8 +245,7 @@ export const OnchainPurchaseProvider = ({ platform: ExternalPlatform, chainId?: string, ) => { - setIsApplePaySelected(false); - setIsCoinflowSelected(false); + setSelectedRail(null); clearPaymentMethod(); return onExternalConnectInternal(wallet, platform, chainId); }, @@ -290,8 +295,15 @@ export const OnchainPurchaseProvider = ({ onError: setDisplayError, }); - const [isApplePaySelected, setIsApplePaySelected] = useState(false); - const [isCoinflowSelected, setIsCoinflowSelected] = useState(false); + // Single source of truth for the selected non-wallet payment rail. The + // rails are mutually exclusive by construction; wallet payment (controller + // or external) is rail === null. The per-rail booleans are derived so + // consumers keep their existing API. + const [selectedRail, setSelectedRail] = useState(null); + const isApplePaySelected = selectedRail === "apple-pay"; + const isCoinflowSelected = selectedRail === "coinflow"; + const isCreditsRailSelected = selectedRail === "credits"; + const isCreditsSelected = isCreditsRailSelected || !!selectedToken?.isCredits; const [coinbaseLsSwapId, setCoinbaseLsSwapId] = useState< string | undefined >(); @@ -336,7 +348,7 @@ export const OnchainPurchaseProvider = ({ const searchParams = new URLSearchParams(location.search); if (searchParams.get("method") === "apple-pay") { resetCoinbasePurchase(); - setIsApplePaySelected(true); + setSelectedRail("apple-pay"); clearSelectedWalletInternal(); } }, [location.search, clearSelectedWalletInternal, resetCoinbasePurchase]); @@ -395,7 +407,9 @@ export const OnchainPurchaseProvider = ({ // Auto-select USDC when a card-based flow is selected useEffect(() => { - if ( + if (isCreditsSelected) { + setSelectedToken(CREDITS_TOKEN); + } else if ( (isApplePaySelected || isCoinflowSelected) && availableTokens.length > 0 ) { @@ -407,6 +421,7 @@ export const OnchainPurchaseProvider = ({ } } }, [ + isCreditsSelected, isApplePaySelected, isCoinflowSelected, availableTokens, @@ -414,6 +429,11 @@ export const OnchainPurchaseProvider = ({ setSelectedToken, ]); + const listedTokens = useMemo(() => { + if (isApplePaySelected || isCoinflowSelected) return availableTokens; + return [...availableTokens, CREDITS_TOKEN]; + }, [availableTokens, isApplePaySelected, isCoinflowSelected]); + // Wrap onSendDeposit to clear errors before sending const onSendDeposit = useCallback(async () => { setDisplayError(undefined); @@ -484,7 +504,12 @@ export const OnchainPurchaseProvider = ({ // When a swap is required we add a 2% buffer so the on-chain swap — which runs // minutes later against a fresh quote — has enough USDC after price drift. const applePayUsdcAmount = useMemo(() => { - if (!onchainDetails?.quote || !selectedToken || !convertedPrice) { + if ( + !onchainDetails?.quote || + !selectedToken || + !convertedPrice || + selectedToken.isCredits + ) { return undefined; } if (convertedPrice.quantity !== quantity) { @@ -713,20 +738,35 @@ export const OnchainPurchaseProvider = ({ setDisplayError, ]); - const onApplePaySelect = useCallback(() => { - resetCoinbasePurchase(); - setIsApplePaySelected(true); - setIsCoinflowSelected(false); - clearSelectedWalletInternal(); - }, [clearSelectedWalletInternal, resetCoinbasePurchase]); + // Selecting a rail deselects any wallet (and the other rails, since the + // rail is a single state). Apple Pay is intentionally not persisted as the + // last payment method — only coinflow/credits are restored on revisit. + const selectRail = useCallback( + (rail: PurchaseRail, opts?: { persist?: boolean }) => { + resetCoinbasePurchase(); + setSelectedRail(rail); + clearSelectedWalletInternal(); + if (opts?.persist) { + savePaymentMethod(rail); + } + }, + [clearSelectedWalletInternal, savePaymentMethod, resetCoinbasePurchase], + ); - const onCoinflowSelect = useCallback(() => { - resetCoinbasePurchase(); - setIsCoinflowSelected(true); - setIsApplePaySelected(false); - clearSelectedWalletInternal(); - savePaymentMethod("coinflow"); - }, [clearSelectedWalletInternal, savePaymentMethod, resetCoinbasePurchase]); + const onApplePaySelect = useCallback( + () => selectRail("apple-pay"), + [selectRail], + ); + + const onCoinflowSelect = useCallback( + () => selectRail("coinflow", { persist: true }), + [selectRail], + ); + + const onCreditsSelect = useCallback( + () => selectRail("credits", { persist: true }), + [selectRail], + ); const onCreateCoinbaseOrder = useCallback( async (opts?: { force?: boolean }) => { @@ -786,14 +826,17 @@ export const OnchainPurchaseProvider = ({ selectedPlatform, walletAddress, clearSelectedWallet, - availableTokens, + availableTokens: listedTokens, selectedToken, setSelectedToken, convertedPrice, swapQuote, isFetchingConversion, isTokenSelectionLocked: - isTokenSelectionLocked || isApplePaySelected || isCoinflowSelected, + isTokenSelectionLocked || + isApplePaySelected || + isCoinflowSelected || + isCreditsRailSelected, conversionError, usdAmount, layerswapFees, @@ -807,6 +850,8 @@ export const OnchainPurchaseProvider = ({ feeEstimationError, isApplePaySelected, isCoinflowSelected, + isCreditsRailSelected, + isCreditsSelected, paymentLink, isCreatingOrder, coinbaseQuote, @@ -824,6 +869,7 @@ export const OnchainPurchaseProvider = ({ waitForDeposit, onApplePaySelect, onCoinflowSelect, + onCreditsSelect, onCreateCoinbaseOrder, openPaymentPopup, closePaymentPopup, diff --git a/packages/keychain/src/hooks/credits-history.ts b/packages/keychain/src/hooks/credits-history.ts new file mode 100644 index 0000000000..b5b6b35e08 --- /dev/null +++ b/packages/keychain/src/hooks/credits-history.ts @@ -0,0 +1,46 @@ +import { useMemo } from "react"; +import { CreditsHistoryQuery, useCreditsHistoryQuery } from "@/utils/api"; +import { useAccount } from "@/hooks/account"; + +const DEFAULT_LIMIT = 100; + +export type CreditsHistoryItem = NonNullable< + NonNullable< + NonNullable< + NonNullable["creditsHistory"]["edges"] + >[number] + >["node"] +>; + +export type UseCreditsHistoryResponse = { + items: CreditsHistoryItem[]; + status: "idle" | "loading" | "error" | "success"; + refetch: () => Promise; +}; + +export function useCreditsHistory({ + first = DEFAULT_LIMIT, +}: { + first?: number; +} = {}): UseCreditsHistoryResponse { + const account = useAccount(); + const username = account?.username ?? ""; + + const { data, status, refetch } = useCreditsHistoryQuery( + { username, first }, + { + enabled: !!username, + refetchOnWindowFocus: false, + }, + ); + + const items = useMemo( + () => + data?.account?.creditsHistory?.edges?.flatMap((edge) => + edge?.node ? [edge.node] : [], + ) ?? [], + [data], + ); + + return { items, status, refetch }; +} diff --git a/packages/keychain/src/hooks/marketplace.ts b/packages/keychain/src/hooks/marketplace.ts index fa5c767baa..2d95f27ed3 100644 --- a/packages/keychain/src/hooks/marketplace.ts +++ b/packages/keychain/src/hooks/marketplace.ts @@ -1,5 +1,5 @@ -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; -import { ArcadeContext } from "@/context/arcade"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useArcadeContext } from "@/context/arcade"; import { useParams } from "react-router-dom"; import { cairo, getChecksumAddress } from "starknet"; import { OrderModel, StatusType } from "@cartridge/arcade"; @@ -21,14 +21,6 @@ const FEE_ENTRYPOINT = "royalty_info"; * @throws {Error} If used outside of a MarketplaceProvider context */ export const useMarketplace = () => { - const context = useContext(ArcadeContext); - - if (!context) { - throw new Error( - "The `useMarketplace` hook must be used within a `MarketplaceProvider`", - ); - } - const account = useAccount(); const address = account?.address || ""; const { address: contractAddress, tokenId } = useParams(); @@ -43,7 +35,7 @@ export const useMarketplace = () => { sales, book, setInitializable, - } = context; + } = useArcadeContext(); const [amount, setAmount] = useState(0); useEffect(() => { diff --git a/packages/keychain/src/hooks/payments/credits.ts b/packages/keychain/src/hooks/payments/credits.ts new file mode 100644 index 0000000000..406fc8b412 --- /dev/null +++ b/packages/keychain/src/hooks/payments/credits.ts @@ -0,0 +1,131 @@ +import { useCallback, useState } from "react"; +import { useConnection } from "../connection"; +import { + PurchaseBundleWithCreditsInput, + useBundleCreditsQuoteQuery, + usePurchaseBundleWithCreditsMutation, +} from "@/utils/api"; +import type { PurchaseBundleWithCreditsMutation } from "@/utils/api"; + +// Re-export generated types/hooks for convenience so callers can import +// from one place instead of digging through the generated module. +export { + PurchaseFulfillmentStatus, + usePurchaseFulfillmentQuery, +} from "@/utils/api"; +export type { BundleCreditsQuote, PurchaseFulfillment } from "@/utils/api"; + +export type CreditsBundleFulfillment = + PurchaseBundleWithCreditsMutation["purchaseBundleWithCredits"]; + +/** + * Spend the account's credit balance to purchase a starterpack bundle. The + * backend debits the credits synchronously and returns a PurchaseFulfillment + * (QUEUED) to poll via usePurchaseFulfillmentQuery; credits are refunded + * automatically if fulfillment terminally fails. + */ +const useCreditsPayment = () => { + const { controller, isMainnet } = useConnection(); + const [error, setError] = useState(null); + + const { mutateAsync, isLoading } = usePurchaseBundleWithCreditsMutation(); + + const purchaseBundle = useCallback( + async ( + input: Omit, + ): Promise => { + if (!controller) { + throw new Error("Controller not connected"); + } + + try { + setError(null); + + const result = await mutateAsync({ + input: { + ...input, + isMainnet, + }, + }); + + return result.purchaseBundleWithCredits; + } catch (e) { + setError(e as Error); + throw e; + } + }, + [controller, isMainnet, mutateAsync], + ); + + return { + isLoading, + error, + purchaseBundle, + }; +}; + +export default useCreditsPayment; + +// --------------------------------------------------------------------------- +// Quote query +// --------------------------------------------------------------------------- + +export interface UseBundleCreditsQuoteParams { + starterpackId: string | undefined; + quantity: number; + registryAddress: string | undefined; + referral?: string; + referralGroup?: string; + clientPercentage?: number; + enabled?: boolean; +} + +/** + * Quotes a bundle purchase paid from the account's credit balance. Uses the + * exact same backend pricing path as the purchase mutation, so the displayed + * credit cost always matches what gets debited. The backend also gates which + * registries/bundles may be bought with credits: unapproved ones reject with + * PermissionDenied, surfaced here via `error` — display its message and treat + * credits as unavailable for the bundle. + */ +export const useBundleCreditsQuote = ({ + starterpackId, + quantity, + registryAddress, + referral, + referralGroup, + clientPercentage, + enabled = true, +}: UseBundleCreditsQuoteParams) => { + const { controller, isMainnet } = useConnection(); + + const isReady = + enabled && + !!controller && + !!starterpackId && + !!registryAddress && + quantity > 0; + + const result = useBundleCreditsQuoteQuery( + { + input: { + starterpackId: starterpackId ?? "", + quantity, + registryAddress: registryAddress ?? "", + referral, + referralGroup, + clientPercentage, + isMainnet, + }, + }, + { + enabled: isReady, + retry: false, + }, + ); + + return { + ...result, + data: result.data?.bundleCreditsQuote, + }; +}; diff --git a/packages/keychain/src/hooks/payments/crypto.ts b/packages/keychain/src/hooks/payments/crypto.ts index 0222dbfc26..3b63ebb5d5 100644 --- a/packages/keychain/src/hooks/payments/crypto.ts +++ b/packages/keychain/src/hooks/payments/crypto.ts @@ -26,7 +26,8 @@ type CryptoPaymentResult = { export type CreateStarknetCryptoPaymentInput = { tokenAddress: string; tokenAmount: bigint; - teamId: string; + // Omit for account credits; set only when funding a team pool. + teamId?: string; isMainnet: boolean; }; @@ -43,7 +44,7 @@ export async function createStarknetCryptoPayment({ network: "STARKNET", tokenAddress, tokenAmount: tokenAmount.toString(), - teamId, + ...(teamId ? { teamId } : {}), isMainnet, }, }, diff --git a/packages/keychain/src/hooks/starterpack/token-balance.ts b/packages/keychain/src/hooks/starterpack/token-balance.ts index 4290b15a1f..9f3eee56b7 100644 --- a/packages/keychain/src/hooks/starterpack/token-balance.ts +++ b/packages/keychain/src/hooks/starterpack/token-balance.ts @@ -66,7 +66,7 @@ export function useTokenBalance({ // Determine which token to check balance for and required amount const tokenToCheck = useMemo(() => { - if (!quote) return null; + if (!quote || selectedToken?.isCredits) return null; // If a token is selected and it's different from payment token, check selected token if ( @@ -92,7 +92,7 @@ export function useTokenBalance({ // Check if we need token conversion (selected token differs from payment token) const needsConversion = useMemo(() => { - if (!quote || !selectedToken) return false; + if (!quote || !selectedToken || selectedToken.isCredits) return false; return num.toHex(selectedToken.address) !== num.toHex(quote.paymentToken); }, [quote, selectedToken]); diff --git a/packages/keychain/src/hooks/starterpack/token-selection.ts b/packages/keychain/src/hooks/starterpack/token-selection.ts index 7ae90654bf..89db0161c0 100644 --- a/packages/keychain/src/hooks/starterpack/token-selection.ts +++ b/packages/keychain/src/hooks/starterpack/token-selection.ts @@ -9,6 +9,7 @@ import { import { fetchSwapQuote, USDC_ADDRESSES, + USDC_ICON, USDCE_ADDRESSES, isQuoteChain, type SwapQuote, @@ -29,8 +30,9 @@ export interface TokenOption { symbol: string; decimals: number; address: string; - icon: string; + icon: string | React.ReactNode; contract: ERC20Contract; + isCredits?: boolean; } // Minimal token metadata for price display @@ -191,7 +193,7 @@ export function useTokenSelection({ name: "USD Coin", symbol: "USDC", decimals: 6, - icon: "https://static.cartridge.gg/tokens/usdc.svg", + icon: USDC_ICON, }); } if (usdceAddress) { @@ -200,7 +202,7 @@ export function useTokenSelection({ name: "Bridged USDC", symbol: "USDC.e", decimals: 6, - icon: "https://static.cartridge.gg/tokens/usdc.svg", + icon: USDC_ICON, }); } tokens.push(...DEFAULT_TOKENS); @@ -388,7 +390,13 @@ export function useTokenSelection({ // Fetch conversion price when selected token or quote changes useEffect(() => { - if (!controller || !selectedToken || !starterpackDetails) return; + if ( + !controller || + !selectedToken || + !starterpackDetails || + selectedToken.isCredits + ) + return; if (!isOnchainStarterpack(starterpackDetails)) return; const quote = starterpackDetails.quote; diff --git a/packages/keychain/src/hooks/token.mock.ts b/packages/keychain/src/hooks/token.mock.tsx similarity index 82% rename from packages/keychain/src/hooks/token.mock.ts rename to packages/keychain/src/hooks/token.mock.tsx index 9080e96448..079483f1d3 100644 --- a/packages/keychain/src/hooks/token.mock.ts +++ b/packages/keychain/src/hooks/token.mock.tsx @@ -1,6 +1,7 @@ import { tokens } from "@cartridge/controller-ui/utils/mock/data"; import { fn, Mock } from "@storybook/test"; import { UseTokensResponse, UseTokenResponse, Token } from "./token"; +import { UsdColorIcon } from "@cartridge/controller-ui"; export * from "./token"; @@ -12,10 +13,10 @@ export const credits = { }, metadata: { address: "credits", - name: "Credits", - symbol: "CREDITS", - image: "https://static.cartridge.gg/presets/credit/icon.svg", - decimals: 6, + name: "USD", + symbol: "USD", + image: , + decimals: 8, }, }; diff --git a/packages/keychain/src/hooks/token.ts b/packages/keychain/src/hooks/token.tsx similarity index 97% rename from packages/keychain/src/hooks/token.ts rename to packages/keychain/src/hooks/token.tsx index 06e8f8434a..5abab14449 100644 --- a/packages/keychain/src/hooks/token.ts +++ b/packages/keychain/src/hooks/token.tsx @@ -17,6 +17,7 @@ import { constants, getChecksumAddress } from "starknet"; import { useEffect, useMemo, useState } from "react"; import { erc20Metadata } from "@cartridge/presets"; import * as torii from "@dojoengine/torii-wasm"; +import { UsdColorIcon } from "@cartridge/controller-ui"; const CONTRACT_TYPES: torii.ContractType[] = ["ERC20"]; @@ -53,12 +54,13 @@ export type Metadata = { symbol: string; decimals: number; address: string; - image: string | undefined; + image: string | React.ReactNode | undefined; }; export type Token = { balance: Balance; metadata: Metadata; + refetch?: () => Promise; }; export type UseBalanceResponse = { @@ -230,17 +232,18 @@ export function useTokens(accountAddress?: string): UseTokensResponse { const credits: Token = useMemo(() => { return { balance: { - amount: Number(creditBalance.balance.value) / 10 ** 6, - value: 0, + amount: Number(creditBalance.balance.value) / 10 ** 8, + value: Number(creditBalance.balance.value) / 10 ** 8, change: 0, }, metadata: { - name: "Credits", - symbol: "Credits", - decimals: 6, + name: "USD", + symbol: "USD", + decimals: 8, address: "credit", - image: "https://static.cartridge.gg/presets/credit/icon.svg", + image: , }, + refetch: creditBalance.refetch, }; }, [creditBalance]); diff --git a/packages/keychain/src/utils/api/account.graphql b/packages/keychain/src/utils/api/account.graphql index b8a91a555d..1d140fc9d0 100644 --- a/packages/keychain/src/utils/api/account.graphql +++ b/packages/keychain/src/utils/api/account.graphql @@ -30,6 +30,33 @@ query Credit($username: String!) { } } +query CreditsHistory($username: String!, $first: Int, $after: Cursor) { + account(username: $username) { + creditsHistory( + first: $first + after: $after + orderBy: { field: CREATED_AT, direction: DESC } + ) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + amount + transactionType + paymentMethod + transactionHash + comment + createdAt + } + } + } + } +} + query AccountName($address: String!) { accounts(where: { hasControllersWith: { address: $address } }, first: 1) { edges { diff --git a/packages/keychain/src/utils/api/generated.ts b/packages/keychain/src/utils/api/generated.ts index 7ee49dff88..41ffb48fdd 100644 --- a/packages/keychain/src/utils/api/generated.ts +++ b/packages/keychain/src/utils/api/generated.ts @@ -40,6 +40,13 @@ export type Account = Node & { createdAt: Scalars["Time"]; credentials: Credentials; credits: Credits; + /** + * This account's credit ledger — deposits (credits bought or granted) and spends + * (credits used) — most recent first by default. Private: only ever returns rows + * for the authenticated caller's own account; requesting it for any other account + * yields an empty connection. + */ + creditsHistory: CreditsHistoryConnection; creditsPlain: Scalars["Int"]; email?: Maybe; id: Scalars["ID"]; @@ -81,6 +88,15 @@ export type AccountControllersArgs = { where?: InputMaybe; }; +export type AccountCreditsHistoryArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; + where?: InputMaybe; +}; + export type AccountMembershipArgs = { after?: InputMaybe; before?: InputMaybe; @@ -1029,6 +1045,20 @@ export type BroadcastNotificationInput = { title: Scalars["String"]; }; +export type BundleCreditsQuote = { + __typename?: "BundleCreditsQuote"; + /** USDC cost in 6-decimal wei (includes swap slippage for non-USDC bundles). */ + costInUsdc: Scalars["Long"]; + /** True when the bundle is priced in a non-USDC token; the executor re-quotes the swap at execution time, so the final cost may drift slightly from this quote. */ + needsSwap: Scalars["Boolean"]; + /** The registry's payment token address — what the bundle is priced in. */ + paymentToken: Scalars["String"]; + /** Bundle price in the registry's payment token units. */ + paymentTokenAmount: Scalars["Long"]; + /** Credit units that will be debited (1 credit unit = 1e-8 USD; 100 credits = $1). */ + requiredCredits: Scalars["Long"]; +}; + export type CoinbaseAmount = { __typename?: "CoinbaseAmount"; /** The amount value as a string. */ @@ -1692,6 +1722,13 @@ export type CreditsHistory = Node & { comment?: Maybe; createdAt: Scalars["Time"]; id: Scalars["ID"]; + /** + * How the credits in this entry moved: for deposits (transactionType CREDIT) it is + * how they were acquired — CARD, CRYPTO, or FREE; for spends (transactionType DEBIT) + * it is always CREDITS, debited from the balance. The reason for a spend (bundle + * purchase, transaction fee, team transfer) is carried by `comment`. + */ + paymentMethod: CreditsPaymentMethod; /** Transaction hash for debit transactions */ transactionHash?: Maybe; /** Type of transaction: credit or debit */ @@ -1699,6 +1736,26 @@ export type CreditsHistory = Node & { updatedAt: Scalars["Time"]; }; +/** A connection to a list of items. */ +export type CreditsHistoryConnection = { + __typename?: "CreditsHistoryConnection"; + /** A list of edges. */ + edges?: Maybe>>; + /** Information to aid in pagination. */ + pageInfo: PageInfo; + /** Identifies the total count of items in the connection. */ + totalCount: Scalars["Int"]; +}; + +/** An edge in a connection. */ +export type CreditsHistoryEdge = { + __typename?: "CreditsHistoryEdge"; + /** A cursor for use in pagination. */ + cursor: Scalars["Cursor"]; + /** The item at the end of the edge. */ + node?: Maybe; +}; + /** Ordering options for CreditsHistory connections */ export type CreditsHistoryOrder = { /** The ordering direction. */ @@ -1822,6 +1879,17 @@ export type CreditsInput = { decimals: Scalars["Int"]; }; +export enum CreditsPaymentMethod { + /** Deposit paid by card (Stripe). */ + Card = "CARD", + /** Spend debited from the account's credit balance. */ + Credits = "CREDITS", + /** Deposit paid with crypto — onramp (Coinbase/Layerswap) or a direct transfer. */ + Crypto = "CRYPTO", + /** Deposit granted at no cost (e.g. a booster-pack claim). */ + Free = "FREE", +} + export type CryptoPayment = { __typename?: "CryptoPayment"; depositAddress: Scalars["String"]; @@ -3195,6 +3263,16 @@ export type Mutation = { finalizeLogin: Scalars["String"]; finalizeRegistration: Account; increaseBudget: Paymaster; + /** + * Spend the authenticated account's off-chain credit balance to purchase a + * starterpack bundle. Mirrors createCoinflowStarterpackIntent's pricing and + * fulfillment creation, but replaces the external card payment with a synchronous, + * in-transaction credit debit: the bundle is created directly at QUEUED and issued + * by the same operator-paid on-chain flow as the card rails. The credits are debited + * upfront and refunded automatically if fulfillment terminally fails. Returns the + * PurchaseFulfillment for status polling. + */ + purchaseBundleWithCredits: PurchaseFulfillment; register: Account; registerNotificationDevice: NotificationDevice; removeAllPolicies: Scalars["Boolean"]; @@ -3423,6 +3501,10 @@ export type MutationIncreaseBudgetArgs = { unit: FeeUnit; }; +export type MutationPurchaseBundleWithCreditsArgs = { + input: PurchaseBundleWithCreditsInput; +}; + export type MutationRegisterArgs = { chainId: Scalars["String"]; owner: SignerInput; @@ -4750,6 +4832,16 @@ export type Project = { project: Scalars["String"]; }; +export type PurchaseBundleWithCreditsInput = { + clientPercentage?: InputMaybe; + isMainnet?: InputMaybe; + quantity: Scalars["Int"]; + referral?: InputMaybe; + referralGroup?: InputMaybe; + registryAddress: Scalars["String"]; + starterpackId: Scalars["String"]; +}; + export type PurchaseFulfillment = { __typename?: "PurchaseFulfillment"; id: Scalars["ID"]; @@ -4777,6 +4869,13 @@ export type Query = { activities: ActivityResult; balance: Balance; balances: BalanceConnection; + /** + * Quote a bundle purchase paid from the account's credit balance. Reuses the exact + * pricing path as purchaseBundleWithCredits, so the credit cost shown to the user + * matches what will be debited. Call this before purchasing to display the price — the + * bundle's payment token and amount, plus the credit cost — and to pre-check the balance. + */ + bundleCreditsQuote: BundleCreditsQuote; /** * Get the authenticated user's current Coinbase onramp spending limits along * with any available limits-upgrade option. Clients should only show the @@ -4850,6 +4949,16 @@ export type Query = { price: Array; priceByAddresses: Array; pricePeriodByAddresses: Array; + /** + * Fetch a purchase fulfillment by id for status polling (owner only). Every + * starterpack purchase — paid by card, crypto, or credits — creates a + * PurchaseFulfillment that is issued on-chain asynchronously; this lets the client + * re-fetch it to track status, tx hash, and any failure (refund) message until it + * reaches CONFIRMED/FAILED. Card and crypto purchases can also reach their + * fulfillment via stripePayment/coinflowPayment, but credits purchases have no + * payment row, so this is the only path for them. + */ + purchaseFulfillment: PurchaseFulfillment; rpcApiKeys?: Maybe; rpcCorsDomains?: Maybe; rpcLogs?: Maybe; @@ -4904,6 +5013,10 @@ export type QueryBalancesArgs = { projects?: InputMaybe>; }; +export type QueryBundleCreditsQuoteArgs = { + input: PurchaseBundleWithCreditsInput; +}; + export type QueryCoinbaseOnrampOrderArgs = { orderId: Scalars["String"]; }; @@ -5116,6 +5229,10 @@ export type QueryPricePeriodByAddressesArgs = { start: Scalars["Int"]; }; +export type QueryPurchaseFulfillmentArgs = { + id: Scalars["ID"]; +}; + export type QueryRpcApiKeysArgs = { after?: InputMaybe; before?: InputMaybe; @@ -7083,6 +7200,41 @@ export type CreditQuery = { } | null; }; +export type CreditsHistoryQueryVariables = Exact<{ + username: Scalars["String"]; + first?: InputMaybe; + after?: InputMaybe; +}>; + +export type CreditsHistoryQuery = { + __typename?: "Query"; + account?: { + __typename?: "Account"; + creditsHistory: { + __typename?: "CreditsHistoryConnection"; + totalCount: number; + pageInfo: { + __typename?: "PageInfo"; + hasNextPage: boolean; + endCursor?: string | null; + }; + edges?: Array<{ + __typename?: "CreditsHistoryEdge"; + node?: { + __typename?: "CreditsHistory"; + id: string; + amount: number; + transactionType: CreditsHistoryTransactionType; + paymentMethod: CreditsPaymentMethod; + transactionHash?: string | null; + comment?: string | null; + createdAt: string; + } | null; + } | null> | null; + }; + } | null; +}; + export type AccountNameQueryVariables = Exact<{ address: Scalars["String"]; }>; @@ -7274,6 +7426,52 @@ export type CreateCryptoPaymentMutation = { }; }; +export type BundleCreditsQuoteQueryVariables = Exact<{ + input: PurchaseBundleWithCreditsInput; +}>; + +export type BundleCreditsQuoteQuery = { + __typename?: "Query"; + bundleCreditsQuote: { + __typename?: "BundleCreditsQuote"; + requiredCredits: string; + costInUsdc: string; + paymentToken: string; + paymentTokenAmount: string; + needsSwap: boolean; + }; +}; + +export type PurchaseBundleWithCreditsMutationVariables = Exact<{ + input: PurchaseBundleWithCreditsInput; +}>; + +export type PurchaseBundleWithCreditsMutation = { + __typename?: "Mutation"; + purchaseBundleWithCredits: { + __typename?: "PurchaseFulfillment"; + id: string; + status: PurchaseFulfillmentStatus; + transactionHash?: string | null; + lastError?: string | null; + }; +}; + +export type PurchaseFulfillmentQueryVariables = Exact<{ + id: Scalars["ID"]; +}>; + +export type PurchaseFulfillmentQuery = { + __typename?: "Query"; + purchaseFulfillment: { + __typename?: "PurchaseFulfillment"; + id: string; + status: PurchaseFulfillmentStatus; + transactionHash?: string | null; + lastError?: string | null; + }; +}; + export type CreateCoinflowStarterpackIntentMutationVariables = Exact<{ input: CreateCoinflowStarterpackIntentInput; }>; @@ -7857,6 +8055,48 @@ export const useCreditQuery = ( ), options, ); +export const CreditsHistoryDocument = ` + query CreditsHistory($username: String!, $first: Int, $after: Cursor) { + account(username: $username) { + creditsHistory( + first: $first + after: $after + orderBy: {field: CREATED_AT, direction: DESC} + ) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + amount + transactionType + paymentMethod + transactionHash + comment + createdAt + } + } + } + } +} + `; +export const useCreditsHistoryQuery = < + TData = CreditsHistoryQuery, + TError = unknown, +>( + variables: CreditsHistoryQueryVariables, + options?: UseQueryOptions, +) => + useQuery( + ["CreditsHistory", variables], + useFetchData( + CreditsHistoryDocument, + ).bind(null, variables), + options, + ); export const AccountNameDocument = ` query AccountName($address: String!) { accounts(where: {hasControllersWith: {address: $address}}, first: 1) { @@ -8207,6 +8447,89 @@ export const useCreateCryptoPaymentMutation = < >(CreateCryptoPaymentDocument), options, ); +export const BundleCreditsQuoteDocument = ` + query BundleCreditsQuote($input: PurchaseBundleWithCreditsInput!) { + bundleCreditsQuote(input: $input) { + requiredCredits + costInUsdc + paymentToken + paymentTokenAmount + needsSwap + } +} + `; +export const useBundleCreditsQuoteQuery = < + TData = BundleCreditsQuoteQuery, + TError = unknown, +>( + variables: BundleCreditsQuoteQueryVariables, + options?: UseQueryOptions, +) => + useQuery( + ["BundleCreditsQuote", variables], + useFetchData( + BundleCreditsQuoteDocument, + ).bind(null, variables), + options, + ); +export const PurchaseBundleWithCreditsDocument = ` + mutation PurchaseBundleWithCredits($input: PurchaseBundleWithCreditsInput!) { + purchaseBundleWithCredits(input: $input) { + id + status + transactionHash + lastError + } +} + `; +export const usePurchaseBundleWithCreditsMutation = < + TError = unknown, + TContext = unknown, +>( + options?: UseMutationOptions< + PurchaseBundleWithCreditsMutation, + TError, + PurchaseBundleWithCreditsMutationVariables, + TContext + >, +) => + useMutation< + PurchaseBundleWithCreditsMutation, + TError, + PurchaseBundleWithCreditsMutationVariables, + TContext + >( + ["PurchaseBundleWithCredits"], + useFetchData< + PurchaseBundleWithCreditsMutation, + PurchaseBundleWithCreditsMutationVariables + >(PurchaseBundleWithCreditsDocument), + options, + ); +export const PurchaseFulfillmentDocument = ` + query PurchaseFulfillment($id: ID!) { + purchaseFulfillment(id: $id) { + id + status + transactionHash + lastError + } +} + `; +export const usePurchaseFulfillmentQuery = < + TData = PurchaseFulfillmentQuery, + TError = unknown, +>( + variables: PurchaseFulfillmentQueryVariables, + options?: UseQueryOptions, +) => + useQuery( + ["PurchaseFulfillment", variables], + useFetchData( + PurchaseFulfillmentDocument, + ).bind(null, variables), + options, + ); export const CreateCoinflowStarterpackIntentDocument = ` mutation CreateCoinflowStarterpackIntent($input: CreateCoinflowStarterpackIntentInput!) { createCoinflowStarterpackIntent(input: $input) { diff --git a/packages/keychain/src/utils/api/payment.graphql b/packages/keychain/src/utils/api/payment.graphql index 799d599d1e..50c526a2ad 100644 --- a/packages/keychain/src/utils/api/payment.graphql +++ b/packages/keychain/src/utils/api/payment.graphql @@ -28,6 +28,34 @@ mutation CreateCryptoPayment($input: CreateCryptoPaymentInput!) { } } +query BundleCreditsQuote($input: PurchaseBundleWithCreditsInput!) { + bundleCreditsQuote(input: $input) { + requiredCredits + costInUsdc + paymentToken + paymentTokenAmount + needsSwap + } +} + +mutation PurchaseBundleWithCredits($input: PurchaseBundleWithCreditsInput!) { + purchaseBundleWithCredits(input: $input) { + id + status + transactionHash + lastError + } +} + +query PurchaseFulfillment($id: ID!) { + purchaseFulfillment(id: $id) { + id + status + transactionHash + lastError + } +} + mutation CreateCoinflowStarterpackIntent( $input: CreateCoinflowStarterpackIntentInput! ) { diff --git a/packages/keychain/src/utils/credits.ts b/packages/keychain/src/utils/credits.ts new file mode 100644 index 0000000000..0ce3674e01 --- /dev/null +++ b/packages/keychain/src/utils/credits.ts @@ -0,0 +1,63 @@ +// Credit unit helpers — the single source of truth for credit ↔ USD display. +// +// Canonical unit (see internal credits-unification plan): $1 = 1e8 raw credit +// units. The raw value lives in `account.credits.amount` (decimals 6), so +// 1 "plain" credit = 1e6 raw units and 100 plain credits = $1. +// +// Product convention: $1 of credits is always $1, so we surface credits as a +// USD-style figure with two decimals (e.g. "12.34") rather than a bare credit +// count. Route all credit display through `formatCredits` so the conversion and +// rounding can never drift. + +export const CREDIT_UNITS_PER_USD = 100_000_000n; // 1e8 raw units = $1 +export const CREDIT_UNITS_PER_CREDIT = 1_000_000n; // 1e6 raw units = 1 plain credit +export const USDC_DECIMALS = 6; // $1 = 1 USDC = 1e6 USDC wei + +export interface FormattedCredits { + /** Dollar value of the credits. */ + usd: number; + /** Whole "plain" credits (100 = $1). */ + credits: number; + /** USD-style display string with two decimals, e.g. "1234.56". */ + formatted: string; +} + +/** Format a raw credit amount (`account.credits.amount`) for display. */ +export function formatCredits( + rawUnits: bigint | number | string, + minDecimals: number = 2, + maxDecimals: number = 4, +): FormattedCredits { + const units = + typeof rawUnits === "bigint" + ? rawUnits + : BigInt(Math.trunc(Number(rawUnits))); + const usd = Number(units) / 1e8; + // const credits = Number(units) / 1e6; // 1 credit = $0.01 + const credits = Number(units) / 1e8; // 1 credit = $1.00 + let formatted = credits.toFixed(maxDecimals); + while ( + formatted.at(-1) === "0" && + formatted.split(".")[1].length > minDecimals + ) { + formatted = formatted.slice(0, -1); + } + if (formatted.at(-1) === ".") { + formatted = formatted.slice(0, -1); + } + return { + usd, + credits, + formatted, + }; +} + +/** USD dollars → raw credit units (1e8 per $1). */ +export function usdToCreditUnits(usd: number): bigint { + return BigInt(Math.round(usd * 1e8)); +} + +/** USD dollars → USDC wei (USDC has 6 decimals; $1 = 1 USDC). */ +export function usdToUsdcWei(usd: number): bigint { + return BigInt(Math.round(usd * 10 ** USDC_DECIMALS)); +} diff --git a/packages/keychain/src/utils/ekubo/index.ts b/packages/keychain/src/utils/ekubo/index.ts index daa154a230..bfa5bb7b3c 100644 --- a/packages/keychain/src/utils/ekubo/index.ts +++ b/packages/keychain/src/utils/ekubo/index.ts @@ -46,6 +46,8 @@ export const USDCE_ADDRESSES: Record = { "0x053b40a647cedfca6ca84f542a0fe36736031905a9639a7f19a3c1e66bfd5080", }; +export const USDC_ICON = "https://static.cartridge.gg/tokens/usdc.svg"; + /** * Whether the chain has a stablecoin price source (a known USDC market with * Ekubo liquidity) that fiat/USD quoting and token swaps can rely on. diff --git a/packages/keychain/src/wallets/social/turnkey.ts b/packages/keychain/src/wallets/social/turnkey.ts index 856686a8d8..9c24465075 100644 --- a/packages/keychain/src/wallets/social/turnkey.ts +++ b/packages/keychain/src/wallets/social/turnkey.ts @@ -109,7 +109,10 @@ export class TurnkeyWallet { }; } - async connect(isSignup: boolean): Promise { + async connect( + isSignup: boolean, + forceAccountSelection = false, + ): Promise { try { if (!this.socialProvider) { throw new Error("Social provider not set"); @@ -127,8 +130,19 @@ export class TurnkeyWallet { const auth0Client = await this.getAuth0Client(10_000); + if (forceAccountSelection) { + // Drop any cached Auth0 session so the upstream provider shows its + // account chooser and the previously selected account isn't silently + // reused (e.g. after a "Wrong Signer" mismatch). + await auth0Client.logout({ openUrl: false }); + } + // For login flows, try to use cached Auth0 session if available - if (!isSignup && (await auth0Client.isAuthenticated())) { + if ( + !isSignup && + !forceAccountSelection && + (await auth0Client.isAuthenticated()) + ) { const tokenClaims = await auth0Client.getIdTokenClaims(); const cachedNonce = tokenClaims?.tknonce as string | undefined; @@ -192,6 +206,7 @@ export class TurnkeyWallet { redirect_uri: redirectUri, nonce, tknonce: nonce, + ...(forceAccountSelection ? { prompt: "select_account" } : {}), }, appState: { nonce, @@ -235,6 +250,7 @@ export class TurnkeyWallet { nonce, display: "touch", tknonce: nonce, + ...(forceAccountSelection ? { prompt: "select_account" } : {}), }, }, { popup }, diff --git a/packages/ui/package.json b/packages/ui/package.json index ba6e90bf63..c43c8ae6e3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -70,7 +70,7 @@ "test:ci": "vitest --passWithNoTests" }, "dependencies": { - "@cartridge/presets": "github:cartridge-gg/presets#90a5fe0", + "@cartridge/presets": "catalog:", "@radix-ui/react-accordion": "^1.2.6", "@radix-ui/react-alert-dialog": "^1.1.9", "@radix-ui/react-aspect-ratio": "^1.1.4", diff --git a/packages/ui/src/components/icons/directional/arrow-from-bracket.tsx b/packages/ui/src/components/icons/directional/arrow-from-bracket.tsx new file mode 100644 index 0000000000..3447f69c28 --- /dev/null +++ b/packages/ui/src/components/icons/directional/arrow-from-bracket.tsx @@ -0,0 +1,51 @@ +import { forwardRef, memo } from "react"; +import { DirectionalIconProps } from "../types"; +import { iconVariants } from "../utils"; + +export const ArrowFromBracketIcon = memo( + forwardRef( + ({ className, size, variant, ...props }, forwardedRef) => ( + + {(() => { + switch (variant) { + case "up": + return ( + + ); + case "right": + return ( + + ); + case "down": + return ( + + ); + case "left": + return ( + + ); + } + })()} + + ), + ), +); + +ArrowFromBracketIcon.displayName = "ArrowFromBracketIcon"; diff --git a/packages/ui/src/components/icons/directional/arrow-from-line.tsx b/packages/ui/src/components/icons/directional/arrow-from-line.tsx index c7eb7942cd..0cd02a116c 100644 --- a/packages/ui/src/components/icons/directional/arrow-from-line.tsx +++ b/packages/ui/src/components/icons/directional/arrow-from-line.tsx @@ -17,28 +17,32 @@ export const ArrowFromLineIcon = memo( return ( ); case "right": return ( ); case "down": return ( ); case "left": return ( ); } diff --git a/packages/ui/src/components/icons/directional/arrow-to-bracket.tsx b/packages/ui/src/components/icons/directional/arrow-to-bracket.tsx new file mode 100644 index 0000000000..795a62c957 --- /dev/null +++ b/packages/ui/src/components/icons/directional/arrow-to-bracket.tsx @@ -0,0 +1,51 @@ +import { forwardRef, memo } from "react"; +import { DirectionalIconProps } from "../types"; +import { iconVariants } from "../utils"; + +export const ArrowToBracketIcon = memo( + forwardRef( + ({ className, size, variant, ...props }, forwardedRef) => ( + + {(() => { + switch (variant) { + case "up": + return ( + + ); + case "right": + return ( + + ); + case "down": + return ( + + ); + case "left": + return ( + + ); + } + })()} + + ), + ), +); + +ArrowToBracketIcon.displayName = "ArrowToBracketIcon"; diff --git a/packages/ui/src/components/icons/directional/arrow-to-line.tsx b/packages/ui/src/components/icons/directional/arrow-to-line.tsx index 481745f617..02039e7541 100644 --- a/packages/ui/src/components/icons/directional/arrow-to-line.tsx +++ b/packages/ui/src/components/icons/directional/arrow-to-line.tsx @@ -17,28 +17,28 @@ export const ArrowToLineIcon = memo( return ( ); case "right": return ( ); case "down": return ( ); case "left": return ( ); } diff --git a/packages/ui/src/components/icons/directional/index.ts b/packages/ui/src/components/icons/directional/index.ts index f98a07773f..5459a461bb 100644 --- a/packages/ui/src/components/icons/directional/index.ts +++ b/packages/ui/src/components/icons/directional/index.ts @@ -1,5 +1,7 @@ export * from "./arrow"; +export * from "./arrow-from-bracket"; export * from "./arrow-from-line"; +export * from "./arrow-to-bracket"; export * from "./arrow-to-line"; export * from "./carat"; export * from "./wedge"; diff --git a/packages/ui/src/components/icons/utils.tsx b/packages/ui/src/components/icons/utils.tsx index 5ccb26a05b..e54aa74621 100644 --- a/packages/ui/src/components/icons/utils.tsx +++ b/packages/ui/src/components/icons/utils.tsx @@ -13,6 +13,7 @@ export const size = { "3xl": "h-[72px] w-[72px]", "4xl": "h-[96px] w-[96px]", collectible: "h-[24px] w-[24px]", + auto: "h-full w-full aspect-square", }; export const iconVariants = cva(base, { diff --git a/packages/ui/src/components/layout/header.tsx b/packages/ui/src/components/layout/header.tsx index a52c92b6d4..e830a98144 100644 --- a/packages/ui/src/components/layout/header.tsx +++ b/packages/ui/src/components/layout/header.tsx @@ -25,6 +25,8 @@ export type HeaderProps = HeaderInnerProps & { onFollowersClick?: () => void; onFollowingsClick?: () => void; onOpenSettings?: () => void; + onDeposit?: () => void; + onWithdraw?: () => void; onLogout?: () => void; }; @@ -36,6 +38,8 @@ export function LayoutHeader({ hideSettings, onOpenStarterPack, onOpenSettings, + onDeposit, + onWithdraw, ...innerProps }: HeaderProps) { const { @@ -143,6 +147,8 @@ export function LayoutHeader({ onOpenSettings={ onOpenSettings ? onOpenSettings : openSettings } + onDeposit={onDeposit} + onWithdraw={onWithdraw} onLogout={onLogout} /> diff --git a/packages/ui/src/components/modules/activities/card/card.stories.tsx b/packages/ui/src/components/modules/activities/card/card.stories.tsx index 0bc30d73bc..91bb2328ef 100644 --- a/packages/ui/src/components/modules/activities/card/card.stories.tsx +++ b/packages/ui/src/components/modules/activities/card/card.stories.tsx @@ -7,6 +7,7 @@ import { ActivityCollectibleCard, } from "./"; import { ControllerStack } from "@/utils/mock/controller-stack"; +import { UsdColorIcon } from "@/components/icons"; const meta: Meta = { title: "Modules/Activities/Card", @@ -287,6 +288,53 @@ export const Token: Story = { timestamp={seconds_away} loading /> + } + action="deposit" + timestamp={seconds_away} + /> + } + action="claimed" + timestamp={seconds_away} + /> + } + action="spend" + item="Network Fees" + timestamp={seconds_away} + /> + } + action="spend" + item="Bundle" + timestamp={seconds_away} + /> + } + action="withdraw" + timestamp={seconds_away} + /> ), }; diff --git a/packages/ui/src/components/modules/activities/card/collectible-card.tsx b/packages/ui/src/components/modules/activities/card/collectible-card.tsx index 26ff004f8f..2e1d42d09a 100644 --- a/packages/ui/src/components/modules/activities/card/collectible-card.tsx +++ b/packages/ui/src/components/modules/activities/card/collectible-card.tsx @@ -69,17 +69,17 @@ export const ActivityCollectibleCard = ({ const Icon = useMemo(() => { switch (action) { case "send": - return ; + return ; case "receive": - return ; + return ; case "mint": - return ; + return ; case "burn": - return ; + return ; case "list": - return ; + return ; case "sell": - return ; + return ; default: return undefined; } diff --git a/packages/ui/src/components/modules/activities/card/token-card.tsx b/packages/ui/src/components/modules/activities/card/token-card.tsx index 898ee27713..6fab1b72b7 100644 --- a/packages/ui/src/components/modules/activities/card/token-card.tsx +++ b/packages/ui/src/components/modules/activities/card/token-card.tsx @@ -2,10 +2,13 @@ import { useMemo, useState } from "react"; import { AchievementPlayerAvatar, ActivityPreposition, + ArrowFromLineIcon, ArrowIcon, + ArrowToLineIcon, CoinsIcon, CollectibleTag, FireIcon, + MoneyIcon, PaperPlaneIcon, SeedlingIcon, Thumbnail, @@ -23,19 +26,31 @@ export interface ActivityTokenCardProps username?: string; // token owner username amount: string; // token amount value?: string; // usd value - image?: string; // token image + image?: string | React.ReactNode; // token image symbol?: string; // token symbol (used if no image) swappedAmount?: string; swappedImage?: string; swappedSymbol?: string; logo?: string; // game logo - action: "send" | "receive" | "mint" | "burn" | "swap"; + action: + | "send" + | "receive" + | "mint" + | "burn" + | "swap" + | "claimed" + | "deposit" + | "withdraw" + | "spend"; + item?: string; timestamp: number; error?: boolean; loading?: boolean; className?: string; } +export type ActivityTokenCardAction = ActivityTokenCardProps["action"]; + export const ActivityTokenCard = ({ address, username, @@ -48,6 +63,7 @@ export const ActivityTokenCard = ({ swappedSymbol, logo, action: actionProp, + item, timestamp, error, loading, @@ -70,15 +86,22 @@ export const ActivityTokenCard = ({ const Icon = useMemo(() => { switch (action) { case "send": - return ; + return ; case "receive": - return ; + return ; case "mint": - return ; + case "claimed": + return ; case "burn": - return ; + return ; case "swap": - return ; + return ; + case "deposit": + return ; + case "withdraw": + return ; + case "spend": + return ; default: return undefined; } @@ -133,6 +156,10 @@ export const ActivityTokenCard = ({ return ; case "swap": return ; + case "claimed": + return ; + case "spend": + return !!item && ; default: return undefined; } @@ -157,6 +184,17 @@ export const ActivityTokenCard = ({

{formatAddress(address, { size: "xs" })}

); + case "spend": + return ( + !!item && ( + +

{item}

+
+ ) + ); case "swap": return ( diff --git a/packages/ui/src/components/modules/activities/detail/detail.stories.tsx b/packages/ui/src/components/modules/activities/detail/detail.stories.tsx index 3fff9f51e3..d904641f95 100644 --- a/packages/ui/src/components/modules/activities/detail/detail.stories.tsx +++ b/packages/ui/src/components/modules/activities/detail/detail.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ActivityDetail } from "."; -import { CreditIcon, StarknetColorIcon } from "@/components/icons"; +import { UsdColorIcon, StarknetColorIcon } from "@/components/icons"; import { Thumbnail } from "@/index"; const meta: Meta = { @@ -41,7 +41,7 @@ export const Samples: Story = {
} + icon={} size="xs" centered rounded diff --git a/packages/ui/src/components/modules/activities/details/details.stories.tsx b/packages/ui/src/components/modules/activities/details/details.stories.tsx index 8260c28b65..818b401abd 100644 --- a/packages/ui/src/components/modules/activities/details/details.stories.tsx +++ b/packages/ui/src/components/modules/activities/details/details.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ActivityDetails } from "."; import { ActivityDetail, Thumbnail } from "@/index"; -import { CreditIcon, StarknetColorIcon } from "@/components/icons"; +import { UsdColorIcon, StarknetColorIcon } from "@/components/icons"; const meta: Meta = { title: "Modules/Activities/Details", @@ -40,7 +40,7 @@ export const Default: Story = {
} + icon={} size="xs" centered rounded diff --git a/packages/ui/src/components/modules/collectibles/image/image.stories.tsx b/packages/ui/src/components/modules/collectibles/image/image.stories.tsx index 598cc180c6..0577ef3632 100644 --- a/packages/ui/src/components/modules/collectibles/image/image.stories.tsx +++ b/packages/ui/src/components/modules/collectibles/image/image.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { CollectibleImage, CollectibleImageProps } from "."; +import { UsdColorIcon } from "@/components/icons"; const meta: Meta = { title: "Modules/Collectibles/Image", @@ -114,6 +115,16 @@ export const BadUrlWithFallback: Story = { }, }; +export const Icon: Story = { + render: function Render(args: CollectibleImageProps) { + return ( +
+ ]} /> +
+ ); + }, +}; + export const IpfsDirect: Story = { render: function Render(args: CollectibleImageProps) { return ( diff --git a/packages/ui/src/components/modules/collectibles/image/image.tsx b/packages/ui/src/components/modules/collectibles/image/image.tsx index 4dc5556b19..815d3dfc5f 100644 --- a/packages/ui/src/components/modules/collectibles/image/image.tsx +++ b/packages/ui/src/components/modules/collectibles/image/image.tsx @@ -7,7 +7,7 @@ import { cn } from "@/utils"; export interface CollectibleImageProps extends React.HTMLAttributes, VariantProps { - images: string[]; + images: (string | React.ReactNode)[]; loadingSpinner?: boolean; loadingSkeleton?: boolean; onLoaded?: () => void; @@ -35,9 +35,9 @@ export const CollectibleImage = ({ onError, ...props }: CollectibleImageProps) => { - const [displayImage, setDisplayImage] = useState( - undefined, - ); + const [displayImage, setDisplayImage] = useState< + string | React.ReactNode | undefined + >(undefined); useEffect(() => { if (onLoaded && displayImage !== undefined) { onLoaded(); @@ -80,6 +80,12 @@ export const CollectibleImage = ({ loadNextImage(); return; } + // it's a component + if (typeof image !== "string") { + setDisplayImage(image); + return; + } + // image is a url if (image.startsWith("ipfs://")) { image = image.replace("ipfs://", "https://ipfs.io/ipfs/"); } @@ -91,7 +97,7 @@ export const CollectibleImage = ({ }; loader.onerror = () => { // console.warn('CollectibleImage: Error loading image', image); - fixBeastDataUri(image) + fixBeastDataUri(image as string) .then((data) => { if (isMounted) { setDisplayImage(data); @@ -102,7 +108,7 @@ export const CollectibleImage = ({ }); }; // start loader - loader.src = image; + loader.src = image as string; }; loadNextImage(); @@ -126,13 +132,15 @@ export const CollectibleImage = ({ {displayImage === undefined && loadingSkeleton && ( )} - {displayImage !== undefined && ( + {typeof displayImage === "string" ? ( + ) : ( + displayImage )}
); diff --git a/packages/ui/src/components/modules/connection/tooltip-content/content.tsx b/packages/ui/src/components/modules/connection/tooltip-content/content.tsx index 77e37a33b1..bb6f81ffb1 100644 --- a/packages/ui/src/components/modules/connection/tooltip-content/content.tsx +++ b/packages/ui/src/components/modules/connection/tooltip-content/content.tsx @@ -1,5 +1,7 @@ import { AchievementPlayerBadge, + ArrowFromLineIcon, + ArrowToLineIcon, Button, CopyIcon, GearIcon, @@ -47,6 +49,8 @@ export interface ConnectionTooltipContentProps onFollowersClick?: () => void; onFollowingsClick?: () => void; onOpenSettings?: () => void; + onDeposit?: () => void; + onWithdraw?: () => void; onLogout?: () => void; } @@ -62,6 +66,8 @@ export const ConnectionTooltipContent = ({ onFollowersClick, onFollowingsClick, onOpenSettings, + onDeposit, + onWithdraw, onLogout, variant, className, @@ -203,6 +209,26 @@ export const ConnectionTooltipContent = ({
+ {onDeposit && ( + + )} + {onWithdraw && ( + + )} {onOpenSettings && (
diff --git a/packages/ui/src/components/modules/erc20/select/header.stories.tsx b/packages/ui/src/components/modules/erc20/select/header.stories.tsx index 6a0e92f6f6..37e04652e3 100644 --- a/packages/ui/src/components/modules/erc20/select/header.stories.tsx +++ b/packages/ui/src/components/modules/erc20/select/header.stories.tsx @@ -5,23 +5,7 @@ import { TokenSelectRow, TokenSelectHeader, } from "@/index"; - -const mockToken = { - balance: { - amount: 0.000071521921165994, - value: 0.12851233577956853, - change: -0.0003482251426370486, - }, - metadata: { - name: "Ether", - symbol: "ETH", - decimals: 18, - address: - "0x049D36570D4e46f48e99674bd3fcc84644DdD6c96F7C741B1562B82f9e004dC7", - image: - "https://imagedelivery.net/0xPAQaDtnQhBs8IzYRIlNg/e07829b7-0382-4e03-7ecd-a478c5aa9f00/logo", - }, -}; +import { mockTokens } from "./row.stories"; const meta: Meta = { title: "Modules/ERC20/Token Select/Header", @@ -37,15 +21,15 @@ type Story = StoryObj; export const Default: Story = { render: () => { - const currentToken = mockToken; + const currentToken = mockTokens[0]; return ( - {[mockToken].map((token) => ( + {mockTokens.map((token) => ( ) => { +}: React.ComponentProps & { singleToken?: boolean }) => { return ( - + {singleToken ? ( +
+ ) : ( + + )} ); }; diff --git a/packages/ui/src/components/modules/erc20/select/row.stories.tsx b/packages/ui/src/components/modules/erc20/select/row.stories.tsx index 49ee2322ce..758b72379a 100644 --- a/packages/ui/src/components/modules/erc20/select/row.stories.tsx +++ b/packages/ui/src/components/modules/erc20/select/row.stories.tsx @@ -6,7 +6,7 @@ import { TokenSelectHeader, } from "@/index"; -const mockTokens = [ +export const mockTokens = [ { balance: { amount: 0.000071521921165994, diff --git a/packages/ui/src/components/modules/erc20/select/token-select.stories.tsx b/packages/ui/src/components/modules/erc20/select/token-select.stories.tsx index 49e85fcdea..743540789f 100644 --- a/packages/ui/src/components/modules/erc20/select/token-select.stories.tsx +++ b/packages/ui/src/components/modules/erc20/select/token-select.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { TokenSelect } from "./token-select"; +import { UsdColorIcon } from "@/components/icons"; const mockTokens = [ { @@ -114,6 +115,20 @@ const mockTokens = [ "https://imagedelivery.net/0xPAQaDtnQhBs8IzYRIlNg/811f019a-0461-4cff-6c1e-442102863f00/logo", }, }, + { + balance: { + amount: 1234567890, + value: 0, + change: 0, + }, + metadata: { + name: "USD", + symbol: "USD", + decimals: 8, + address: "credits", + image: , + }, + }, ]; const meta: Meta = { @@ -132,3 +147,15 @@ export default meta; type Story = StoryObj; export const Default: Story = {}; + +export const SingleToken: Story = { + args: { + tokens: [mockTokens[0]], + }, +}; + +export const SingleTokenIcon: Story = { + args: { + tokens: [mockTokens.at(-1)!], + }, +}; diff --git a/packages/ui/src/components/modules/erc20/select/token-select.tsx b/packages/ui/src/components/modules/erc20/select/token-select.tsx index 5867aa47b6..b37d1a0597 100644 --- a/packages/ui/src/components/modules/erc20/select/token-select.tsx +++ b/packages/ui/src/components/modules/erc20/select/token-select.tsx @@ -15,7 +15,7 @@ export type ERC20Metadata = { symbol: string; decimals: number; address: string; - image: string | undefined; + image: string | React.ReactNode | undefined; }; export type Token = { @@ -49,6 +49,8 @@ export const TokenSelect = ({ } }; + const singleToken = tokens.length <= 1; + return (