diff --git a/__tests__/components/manifoldMinting/ManifoldMintingConnect.test.tsx b/__tests__/components/manifoldMinting/ManifoldMintingConnect.test.tsx index 18fb21c596..2959dd1d96 100644 --- a/__tests__/components/manifoldMinting/ManifoldMintingConnect.test.tsx +++ b/__tests__/components/manifoldMinting/ManifoldMintingConnect.test.tsx @@ -20,6 +20,11 @@ jest.mock('@/components/user/utils/UserCICAndLevel', () => ({ UserCICAndLevelSize: { XLARGE: 'XLARGE' }, })); +jest.mock('@/components/nft-transfer/TransferModalPfp', () => ({ + __esModule: true, + default: () =>
, +})); + let mockOnProfileSelect: ((profile: any) => void) | null = null; let mockOnWalletSelect: ((wallet: string | null) => void) | null = null; @@ -85,6 +90,42 @@ describe('ManifoldMintingConnect', () => { expect(onMintFor).toHaveBeenCalledWith(seizeCtx.address); }); + it('lets mint for me switch wallet inside connected profile', async () => { + const { onMintFor } = renderConnected(); + const alternateWallet = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + if (mockOnWalletSelect) { + mockOnWalletSelect(alternateWallet); + } + + await waitFor(() => + expect(onMintFor).toHaveBeenLastCalledWith(alternateWallet) + ); + }); + + it('reselects connected wallet when switching back to mint for me', async () => { + const { onMintFor, seizeCtx } = renderConnected(); + const alternateWallet = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const frenWallet = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + if (mockOnWalletSelect) { + mockOnWalletSelect(alternateWallet); + } + await waitFor(() => + expect(onMintFor).toHaveBeenLastCalledWith(alternateWallet) + ); + + await userEvent.click(screen.getByRole('button', { name: /Mint for fren/i })); + if (mockOnProfileSelect && mockOnWalletSelect) { + mockOnProfileSelect({ handle: 'fren', wallet: frenWallet }); + mockOnWalletSelect(frenWallet); + } + await waitFor(() => expect(onMintFor).toHaveBeenLastCalledWith(frenWallet)); + + await userEvent.click(screen.getByRole('button', { name: /Mint for me/i })); + await waitFor(() => expect(onMintFor).toHaveBeenLastCalledWith(seizeCtx.address)); + }); + it('shows RecipientSelector when mint for fren is clicked', async () => { renderConnected(); await userEvent.click(screen.getByRole('button', { name: /Mint for fren/i })); diff --git a/components/auth/SeizeConnectContext.tsx b/components/auth/SeizeConnectContext.tsx index 6a883b2490..840218c625 100644 --- a/components/auth/SeizeConnectContext.tsx +++ b/components/auth/SeizeConnectContext.tsx @@ -10,6 +10,7 @@ import React, { useState, } from "react"; +import { getNodeEnv, publicEnv } from "@/config/env"; import { getWalletAddress, removeAuthJwt } from "@/services/auth/auth.utils"; import { WalletInitializationError } from "@/src/errors/wallet"; import { SecurityEventType } from "@/src/types/security"; @@ -364,6 +365,23 @@ export const SeizeConnectProvider: React.FC<{ children: React.ReactNode }> = ({ isInitialized, } = useConsolidatedWalletState(); const debounceTimeoutRef = useRef(null); + const nodeEnv = getNodeEnv(); + const isDevLikeEnv = + nodeEnv === "development" || nodeEnv === "test" || nodeEnv === "local"; + const isLocalHost = + globalThis.window !== undefined && + (globalThis.window.location.hostname === "localhost" || + globalThis.window.location.hostname === "127.0.0.1" || + globalThis.window.location.hostname === "::1" || + globalThis.window.location.hostname.endsWith(".local")); + const impersonatedAddress = + isDevLikeEnv && + isLocalHost && + publicEnv.USE_DEV_AUTH === "true" && + publicEnv.DEV_MODE_WALLET_ADDRESS && + isAddress(publicEnv.DEV_MODE_WALLET_ADDRESS) + ? getAddress(publicEnv.DEV_MODE_WALLET_ADDRESS) + : undefined; useEffect(() => { // Wait for initialization to complete before processing account changes @@ -378,6 +396,16 @@ export const SeizeConnectProvider: React.FC<{ children: React.ReactNode }> = ({ // Use debounced state update to prevent race conditions debounceTimeoutRef.current = setTimeout(() => { + if (impersonatedAddress) { + const isAlreadyConnected = + walletState.status === "connected" && + walletState.address === impersonatedAddress; + if (!isAlreadyConnected) { + setConnected(impersonatedAddress); + } + return; + } + if (account.address && account.isConnected) { // Validate and normalize address to checksummed format if (isAddress(account.address)) { @@ -442,6 +470,7 @@ export const SeizeConnectProvider: React.FC<{ children: React.ReactNode }> = ({ setConnected, setDisconnected, setConnecting, + impersonatedAddress, ]); const seizeConnect = useCallback((): void => { @@ -584,7 +613,7 @@ export const SeizeConnectProvider: React.FC<{ children: React.ReactNode }> = ({ const contextValue = useMemo( (): SeizeConnectContextType => ({ - address: connectedAddress, + address: impersonatedAddress ?? connectedAddress, walletName: walletInfo?.name, walletIcon: walletInfo?.icon, isSafeWallet: isSafeWalletInfo(walletInfo), @@ -593,8 +622,8 @@ export const SeizeConnectProvider: React.FC<{ children: React.ReactNode }> = ({ seizeDisconnectAndLogout, seizeAcceptConnection, seizeConnectOpen: state.open, - isConnected: account.isConnected, - isAuthenticated: !!connectedAddress, + isConnected: impersonatedAddress ? true : account.isConnected, + isAuthenticated: !!(impersonatedAddress ?? connectedAddress), connectionState: walletState.status, // Unified state machine walletState, // Expose unified state for advanced consumers hasInitializationError, @@ -602,6 +631,7 @@ export const SeizeConnectProvider: React.FC<{ children: React.ReactNode }> = ({ }), [ connectedAddress, + impersonatedAddress, walletInfo?.name, walletInfo?.icon, walletInfo?.type, diff --git a/components/common/RecipientSelector.tsx b/components/common/RecipientSelector.tsx index 90617eb4c1..1857905d31 100644 --- a/components/common/RecipientSelector.tsx +++ b/components/common/RecipientSelector.tsx @@ -41,21 +41,27 @@ function RecipientSelectedDisplay({ profile, isIdentityLoading, onClear, + allowProfileChange, + showSelectedProfileCard, walletsListRef, walletsHasOverflow, walletsAtEnd, selectedWallet, onWalletSelect, + disableSingleWalletSelection, }: { readonly selectedProfile: CommunityMemberMinimal; readonly profile: ApiIdentity | null; readonly isIdentityLoading: boolean; readonly onClear: () => void; + readonly allowProfileChange: boolean; + readonly showSelectedProfileCard: boolean; readonly walletsListRef: React.RefObject; readonly walletsHasOverflow: boolean; readonly walletsAtEnd: boolean; readonly selectedWallet: string | null; readonly onWalletSelect: (wallet: string) => void; + readonly disableSingleWalletSelection: boolean; }) { const getWallets = () => { if (profile?.wallets && profile.wallets.length > 0) { @@ -89,23 +95,42 @@ function RecipientSelectedDisplay({
); } else { + const hasSingleWallet = wallets.length === 1; + const isWalletSelectionDisabled = + disableSingleWalletSelection && hasSingleWallet; + walletsContent = wallets.map( (w: { wallet: string; display: string | null; tdh: number }) => { const isSel = selectedWallet?.toLowerCase() === w.wallet.toLowerCase(); const hasDisplay = w.display && w.display.toLowerCase() !== w.wallet.toLowerCase(); + const classes = [ + "tw-flex tw-min-h-[58px] tw-w-full tw-flex-col tw-rounded-lg tw-border tw-border-white/10 tw-bg-white/10 tw-p-2", + hasDisplay + ? "tw-items-start tw-justify-between" + : "tw-items-start tw-justify-center", + isSel ? "tw-border-2 tw-border-solid !tw-border-emerald-400" : "", + ].join(" "); + + if (isWalletSelectionDisabled) { + return ( +
+
+ {w.display || w.wallet} +
+ {hasDisplay && ( +
{w.wallet}
+ )} +
+ ); + } + return ( + )} - - + )} -
- {wallets.length > 1 && ( +
+ {(showSelectedProfileCard ? wallets.length > 1 : wallets.length > 0) && (
Choose destination wallet
)}
) : ( undefined; export default function ManifoldMintingConnect( props: Readonly<{ onMintFor: (address: string) => void; }> ) { + const { onMintFor } = props; const account = useSeizeConnectContext(); const { connectedProfile } = useContext(AuthContext); const { isIos } = useCapacitor(); const { country } = useCookieConsent(); const [mintForFren, setMintForFren] = useState(false); - const [selectedProfile, setSelectedProfile] = + const [selectedFrenProfile, setSelectedFrenProfile] = useState(null); - const [selectedWallet, setSelectedWallet] = useState(null); + const [selectedFrenWallet, setSelectedFrenWallet] = useState( + null + ); + const [selectedMintForMeWallet, setSelectedMintForMeWallet] = useState< + string | null + >(null); - function reset() { - setSelectedProfile(null); - setSelectedWallet(null); - } + const connectedRecipientProfile = useMemo(() => { + if (!account.address) { + return null; + } + + return { + profile_id: connectedProfile?.id ?? null, + handle: connectedProfile?.handle ?? null, + normalised_handle: connectedProfile?.normalised_handle ?? null, + primary_wallet: connectedProfile?.primary_wallet ?? account.address, + display: connectedProfile?.display ?? account.address, + tdh: connectedProfile?.tdh ?? 0, + level: connectedProfile?.level ?? 0, + cic_rating: connectedProfile?.cic ?? 0, + wallet: account.address, + pfp: connectedProfile?.pfp ?? null, + }; + }, [connectedProfile, account.address]); + + const resetFren = useCallback(() => { + setSelectedFrenProfile(null); + setSelectedFrenWallet(null); + }, []); useEffect(() => { - reset(); + resetFren(); setMintForFren(false); - }, [account.address]); + }, [account.address, resetFren]); useEffect(() => { - if (mintForFren && selectedWallet) { - props.onMintFor(selectedWallet); - } else { - props.onMintFor(account.address as string); + if (!account.address || mintForFren) { + return; + } + setSelectedMintForMeWallet(account.address); + }, [account.address, mintForFren]); + + useEffect(() => { + if (mintForFren) { + if (!selectedFrenWallet) { + onMintFor(""); + return; + } + + onMintFor(selectedFrenWallet); + return; + } + + const mintDestination = selectedMintForMeWallet ?? account.address; + if (!mintDestination) { + return; } - }, [selectedWallet, mintForFren, account.address, props]); - function printMintFor() { + onMintFor(mintDestination); + }, [ + selectedFrenWallet, + selectedMintForMeWallet, + mintForFren, + account.address, + onMintFor, + ]); + + function printMintForFren() { return (
@@ -64,6 +113,28 @@ export default function ManifoldMintingConnect( ); } + function printMintForMe() { + if (!connectedRecipientProfile) { + return <>; + } + + return ( +
+ +
+ ); + } + function printConnected() { const profileHandle = connectedProfile?.handle ?? connectedProfile?.display ?? account.address; @@ -71,26 +142,33 @@ export default function ManifoldMintingConnect( return (
Connected Profile - - + - - {profileHandle} +
+
+ {profileHandle} +
+
+ TDH: {(connectedProfile?.tdh ?? 0).toLocaleString()} - Level:{" "} + {connectedProfile?.level ?? 0} +
{showAddress && ( - +
{account.address} - +
)} -
- +
+
); } function printContent() { - return <>{mintForFren && printMintFor()}; + return <>{mintForFren ? printMintForFren() : printMintForMe()}; } if (isIos) { @@ -129,7 +207,7 @@ export default function ManifoldMintingConnect( className={`btn ${mintForFren ? "btn-dark" : "btn-light"}`} style={{ width: "50%" }} onClick={() => { - reset(); + resetFren(); setMintForFren(false); }}> Mint for me diff --git a/components/manifoldMinting/ManifoldMintingWidget.tsx b/components/manifoldMinting/ManifoldMintingWidget.tsx index eba9572e1c..6136b68b1c 100644 --- a/components/manifoldMinting/ManifoldMintingWidget.tsx +++ b/components/manifoldMinting/ManifoldMintingWidget.tsx @@ -9,6 +9,7 @@ import { import { Time } from "@/helpers/time"; import type { ManifoldClaim } from "@/hooks/useManifoldClaim"; import { ManifoldClaimStatus, ManifoldPhase } from "@/hooks/useManifoldClaim"; +import { useSearchParams } from "next/navigation"; import { useEffect, useState, type JSX } from "react"; import { Col, Container, Form, Row, Table } from "react-bootstrap"; import { @@ -35,7 +36,9 @@ export default function ManifoldMintingWidget( }> ) { const connectedAddress = useSeizeConnectContext(); + const searchParams = useSearchParams(); const [mintForAddress, setMintForAddress] = useState(""); + const [copyStatus, setCopyStatus] = useState<"" | "copied" | "failed">(""); const [isError, setIsError] = useState(false); const [fetchingMerkle, setFetchingMerkle] = useState(false); @@ -132,8 +135,12 @@ export default function ManifoldMintingWidget( const getSelectedMerkleProofs = () => { const selectedMerkleProofs: ManifoldMerkleProof[] = []; for (let i = 0; i < merkleProofsMints.length; i++) { + const proof = merkleProofs[i]; + if (!proof) { + continue; + } if (!merkleProofsMints[i]) { - selectedMerkleProofs.push(merkleProofs[i]!); + selectedMerkleProofs.push(proof); } if (selectedMerkleProofs.length === mintCount) { break; @@ -195,9 +202,71 @@ export default function ManifoldMintingWidget( } }; + const isMintDebugEnabled = searchParams?.get("mintdebug") === "1"; + + const buildMintDiagnostics = () => { + const connectedWallet = connectedAddress.address ?? ""; + const recipientWallet = mintForAddress; + const isProxy = + !!connectedWallet && + !!recipientWallet && + !areEqualAddresses(connectedWallet, recipientWallet); + const selectedProofs = getSelectedMerkleProofs(); + const argsPreview = + recipientWallet && mintCount > 0 + ? getMintArgs() + : { + functionName: "n/a", + args: [], + }; + + return { + timestamp: new Date().toISOString(), + chainId: MANIFOLD_NETWORK.id, + claimStatus: props.claim.status, + phase: props.claim.phase, + connectedWallet, + recipientWallet, + isProxy, + mintFunction: argsPreview.functionName, + mintCount, + valueWei: getValue().toString(), + feeWei: fee.toString(), + claimCostWei: props.claim.cost.toString(), + availableMerkleProofs: merkleProofs.length, + selectedMerkleProofs: selectedProofs.length, + mintedProofs: merkleProofsMints.filter(Boolean).length, + mintError: mintError || null, + txHash: mintWrite.data ?? null, + txPending: waitMintWritePending, + txSuccess: waitMintWriteSuccess, + }; + }; + + const onCopyMintDiagnostics = async () => { + try { + const diagnostics = buildMintDiagnostics(); + await navigator.clipboard.writeText(JSON.stringify(diagnostics, null, 2)); + setCopyStatus("copied"); + } catch { + setCopyStatus("failed"); + } finally { + setTimeout(() => setCopyStatus(""), 2000); + } + }; + const onMint = () => { setMintError(""); setMintStatus(<>); + + if (props.claim.phase === ManifoldPhase.ALLOWLIST) { + const selectedProofs = getSelectedMerkleProofs(); + if (selectedProofs.length < mintCount) { + setMintError("No allowlist spots in current phase for this address"); + return; + } + } + const value = getValue(); const args = getMintArgs(); mintWrite.writeContract({ @@ -481,6 +550,55 @@ export default function ManifoldMintingWidget( ); } + function printMintDebug() { + if (!isMintDebugEnabled) { + return <>; + } + + const diagnostics = buildMintDiagnostics(); + const getCopyButtonStyle = () => { + if (copyStatus === "copied") { + return "tw-border-emerald-400/60 tw-bg-emerald-500/20"; + } + if (copyStatus === "failed") { + return "tw-border-red-400/60 tw-bg-red-500/20"; + } + return "tw-border-white/20 tw-bg-white/10 hover:tw-bg-white/15"; + }; + const getCopyButtonText = () => { + if (copyStatus === "copied") { + return "Copied!"; + } + if (copyStatus === "failed") { + return "Copy Failed"; + } + return "Copy"; + }; + + return ( + + +
+
+ Mint diagnostics + +
+
+              {JSON.stringify(diagnostics, null, 2)}
+            
+
+ +
+ ); + } + useEffect(() => { props.setMintForAddress(mintForAddress); }, [mintForAddress]); @@ -498,6 +616,7 @@ export default function ManifoldMintingWidget( {printContent()} + {printMintDebug()} ); }