diff --git a/components/escrow/FundEscrowButton.tsx b/components/escrow/FundEscrowButton.tsx index 54652343..1ea7f8d1 100644 --- a/components/escrow/FundEscrowButton.tsx +++ b/components/escrow/FundEscrowButton.tsx @@ -15,6 +15,10 @@ import { } from '@trustless-work/escrow'; import { toast } from 'sonner'; import { reportError } from '@/lib/error-reporting'; +import { useWalletProtection } from '@/hooks/use-wallet-protection'; +import WalletRequiredModal from '@/components/wallet/WalletRequiredModal'; +import WalletNotReadyModal from '@/components/wallet/WalletNotReadyModal'; +import { WalletSheet } from '@/components/wallet/WalletSheet'; // Extended type to include balance property that may exist at runtime // Using intersection type to avoid type conflicts with required balance property @@ -34,7 +38,6 @@ import { * Component to fund an existing multi-release escrow using Trustless Work */ export const FundEscrowButton = () => { - const { walletAddress } = useWalletContext(); const { contractId, escrow, updateEscrow } = useEscrowContext(); const { fundEscrow } = useFundEscrow(); const { sendTransaction } = useSendTransaction(); @@ -45,6 +48,22 @@ export const FundEscrowButton = () => { transactionHash?: string; } | null>(null); + // Wallet protection hook + const { + requireWallet, + showWalletModal, + showNotReadyModal, + notReadyReasons, + handleWalletConnected, + closeWalletModal, + closeNotReadyModal, + publicKey: walletAddress, + } = useWalletProtection({ + actionName: 'fund escrow', + }); + + const [isWalletDrawerOpen, setIsWalletDrawerOpen] = useState(false); + // Calculate total amount from all milestones const calculateTotalAmount = (): number => { if (!escrow || !escrow.milestones) { @@ -94,79 +113,86 @@ export const FundEscrowButton = () => { ); } - // Step 1: Prepare the payload according to FundEscrowPayload type - const payload: FundEscrowPayload = { - contractId: contractId, - signer: walletAddress, - amount: totalAmount, - }; - - // Log payload for debugging - // Step 2: Execute function from Trustless Work - const fundResponse: EscrowRequestResponse = await fundEscrow( - payload, - 'multi-release' as EscrowType - ); + const walletReady = await requireWallet(async () => { + // Step 1: Prepare the payload according to FundEscrowPayload type + const payload: FundEscrowPayload = { + contractId: contractId, + signer: walletAddress!, + amount: totalAmount, + }; - // Type guard: Check if response is successful - if ( - fundResponse.status !== ('SUCCESS' as Status) || - !fundResponse.unsignedTransaction - ) { - const errorMessage = - 'message' in fundResponse && typeof fundResponse.message === 'string' - ? fundResponse.message - : 'Failed to fund escrow'; - throw new Error(errorMessage); - } + // Step 2: Execute function from Trustless Work + const fundResponse: EscrowRequestResponse = await fundEscrow( + payload, + 'multi-release' as EscrowType + ); - const { unsignedTransaction } = fundResponse; + // Type guard: Check if response is successful + if ( + fundResponse.status !== ('SUCCESS' as Status) || + !fundResponse.unsignedTransaction + ) { + const errorMessage = + 'message' in fundResponse && + typeof fundResponse.message === 'string' + ? fundResponse.message + : 'Failed to fund escrow'; + throw new Error(errorMessage); + } - // Step 3: Sign transaction with wallet - const signedXdr = await signTransaction({ - unsignedTransaction, - address: walletAddress, - }); + const { unsignedTransaction } = fundResponse; + + // Step 3: Sign transaction with wallet + const signedXdr = await signTransaction({ + unsignedTransaction, + address: walletAddress!, + }); + + // Step 4: Send transaction + const sendResponse = await sendTransaction(signedXdr); + + // Type guard: Check if response is successful + if ( + 'status' in sendResponse && + sendResponse.status !== ('SUCCESS' as Status) + ) { + const errorMessage = + 'message' in sendResponse && + typeof sendResponse.message === 'string' + ? sendResponse.message + : 'Failed to send transaction'; + throw new Error(errorMessage); + } - // Step 4: Send transaction - const sendResponse = await sendTransaction(signedXdr); + // Update escrow balance in context + if (escrow) { + const escrowWithBalance = escrow as MultiReleaseEscrowWithBalance; + const currentBalance = escrowWithBalance.balance || 0; + const updatedEscrow: MultiReleaseEscrowWithBalance = { + ...escrow, + balance: currentBalance + totalAmount, + }; + updateEscrow(updatedEscrow as MultiReleaseEscrow); + } - // Type guard: Check if response is successful - if ( - 'status' in sendResponse && - sendResponse.status !== ('SUCCESS' as Status) - ) { - const errorMessage = + // Display success status + const successMessage = 'message' in sendResponse && typeof sendResponse.message === 'string' ? sendResponse.message - : 'Failed to send transaction'; - throw new Error(errorMessage); - } + : 'Escrow funded successfully!'; - // Update escrow balance in context - if (escrow) { - // Balance may not be in the type, so we use type assertion - const escrowWithBalance = escrow as MultiReleaseEscrowWithBalance; - const currentBalance = escrowWithBalance.balance || 0; - const updatedEscrow: MultiReleaseEscrowWithBalance = { - ...escrow, - balance: currentBalance + totalAmount, - }; - updateEscrow(updatedEscrow as MultiReleaseEscrow); - } + setFundingStatus({ + success: true, + message: successMessage, + }); - // Display success status - const successMessage = - 'message' in sendResponse && typeof sendResponse.message === 'string' - ? sendResponse.message - : 'Escrow funded successfully!'; + toast.success('Escrow funded successfully!'); + }, totalAmount); - setFundingStatus({ - success: true, - message: successMessage, - }); - - toast.success('Escrow funded successfully!'); + if (!walletReady) { + setIsLoading(false); + return; + } } catch (err) { reportError(err, { context: 'escrow-fund' }); setFundingStatus({ @@ -187,147 +213,159 @@ export const FundEscrowButton = () => { const totalAmount = calculateTotalAmount(); - if (fundingStatus) { - return ( - - -
- {fundingStatus.success ? ( - - ) : ( - - )} - + {fundingStatus ? ( + + +
+ {fundingStatus.success ? ( + + ) : ( + + )} + + {fundingStatus.success + ? 'Escrow Funded Successfully!' + : 'Funding Failed'} + +
+ - {fundingStatus.success - ? 'Escrow Funded Successfully!' - : 'Funding Failed'} -
-
- - {fundingStatus.message} - -
- {fundingStatus.success && escrow && ( - -
-
- - Previous Balance: - - - {formatAmount( - ((escrow as MultiReleaseEscrowWithBalance).balance || 0) - - totalAmount - )} - -
-
- - Funded Amount: - - - +{formatAmount(totalAmount)} - -
-
- - New Balance: - - - {formatAmount( - (escrow as MultiReleaseEscrowWithBalance).balance || 0 - )} - + {fundingStatus.message} + + + {fundingStatus.success && escrow && ( + +
+
+ + Previous Balance: + + + {formatAmount( + ((escrow as MultiReleaseEscrowWithBalance).balance || 0) - + totalAmount + )} + +
+
+ + Funded Amount: + + + +{formatAmount(totalAmount)} + +
+
+ + New Balance: + + + {formatAmount( + (escrow as MultiReleaseEscrowWithBalance).balance || 0 + )} + +
+ +
+ )} + + ) : !contractId || !escrow ? ( +
+

+ Please initialize an escrow first before funding. +

+
+ ) : !escrow.milestones || escrow.milestones.length === 0 ? ( +
+

+ Error: Escrow initialized without milestones +

+

+ This escrow was initialized without milestones. Please initialize a + new escrow with milestones. +

+
+ ) : ( + <> +
+
+ + Total Funding Amount: + + + {formatAmount(totalAmount)} +
- - - )} - - ); - } - - if (!contractId || !escrow) { - return ( -
-

- Please initialize an escrow first before funding. -

-
- ); - } - - // Check if escrow has milestones - if (!escrow.milestones || escrow.milestones.length === 0) { - return ( -
-

- Error: Escrow initialized without milestones -

-

- This escrow was initialized without milestones. Please initialize a - new escrow with milestones. -

-
- ); - } +

+ This amount is the sum of all milestone amounts ( + {escrow.milestones.length} milestones) +

+
- return ( -
-
-
- - Total Funding Amount: - - - {formatAmount(totalAmount)} - -
-

- This amount is the sum of all milestone amounts ( - {escrow.milestones.length} milestones) -

-
- - + + + )} + + {/* Wallet Modals */} + + + setIsWalletDrawerOpen(true)} + actionName='fund escrow' + /> + +
); }; diff --git a/components/landing-page/navbar.tsx b/components/landing-page/navbar.tsx index 01fe37b4..930ff85f 100644 --- a/components/landing-page/navbar.tsx +++ b/components/landing-page/navbar.tsx @@ -33,6 +33,8 @@ import WalletRequiredModal from '@/components/wallet/WalletRequiredModal'; import { WalletTrigger } from '../wallet/WalletTrigger'; import { NotificationBell } from '../notifications/NotificationBell'; import CreateProjectModal from '@/features/projects/components/CreateProjectModal'; +import WalletNotReadyModal from '@/components/wallet/WalletNotReadyModal'; +import { useWalletContext } from '../providers/wallet-provider'; const BRAND_COLOR = '#a7f950'; const ACTIONS = { @@ -200,12 +202,16 @@ function AuthenticatedActions() { const { executeProtectedAction, showWalletModal, + showNotReadyModal, + notReadyReasons, closeWalletModal, + closeNotReadyModal, handleWalletConnected, } = useProtectedAction({ actionName: ACTIONS.CREATE_PROJECT, onSuccess: () => setCreateProjectModalOpen(true), }); + const { onOpenWallet } = useWalletContext(); return ( <> @@ -294,17 +300,31 @@ function AuthenticatedActions() { actionName={ACTIONS.CREATE_PROJECT} onWalletConnected={handleWalletConnected} /> + ); } function UnauthenticatedActions() { const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false); - const { showWalletModal, closeWalletModal, handleWalletConnected } = - useProtectedAction({ - actionName: ACTIONS.CREATE_PROJECT, - onSuccess: () => setCreateProjectModalOpen(true), - }); + const { + showWalletModal, + showNotReadyModal, + notReadyReasons, + closeWalletModal, + closeNotReadyModal, + handleWalletConnected, + } = useProtectedAction({ + actionName: ACTIONS.CREATE_PROJECT, + onSuccess: () => setCreateProjectModalOpen(true), + }); + const { onOpenWallet } = useWalletContext(); return ( <> @@ -334,6 +354,13 @@ function UnauthenticatedActions() { actionName={ACTIONS.CREATE_PROJECT} onWalletConnected={handleWalletConnected} /> + ); } diff --git a/components/modals/fund-project/index.tsx b/components/modals/fund-project/index.tsx index e74f2b7b..763645c1 100644 --- a/components/modals/fund-project/index.tsx +++ b/components/modals/fund-project/index.tsx @@ -12,6 +12,9 @@ import { fundCrowdfundingProject } from '@/features/projects/api'; import { useWalletContext } from '@/components/providers/wallet-provider'; import { useWalletProtection } from '@/hooks/use-wallet-protection'; import { signTransaction } from '@/lib/config/wallet-kit'; +import WalletRequiredModal from '@/components/wallet/WalletRequiredModal'; +import WalletNotReadyModal from '@/components/wallet/WalletNotReadyModal'; +import { WalletSheet } from '@/components/wallet/WalletSheet'; import { useFundEscrow, useSendTransaction, @@ -82,9 +85,19 @@ const FundProject = ({ open, setOpen, project }: FundProjectProps) => { // Wallet hooks const { walletAddress } = useWalletContext(); - const { requireWallet } = useWalletProtection({ + const { + requireWallet, + showWalletModal, + showNotReadyModal, + notReadyReasons, + handleWalletConnected, + closeWalletModal, + closeNotReadyModal, + } = useWalletProtection({ actionName: 'fund project', }); + + const [isWalletDrawerOpen, setIsWalletDrawerOpen] = useState(false); // Form data state const [formData, setFormData] = useState({ amount: {}, @@ -292,29 +305,11 @@ const FundProject = ({ open, setOpen, project }: FundProjectProps) => { setError(null); const walletValid = await requireWallet(async () => { - if (!walletAddress) { - setError('Wallet address is required'); - setFlowStep('form'); - setIsLoading(false); - setIsSubmitting(false); - return; - } - - if (!project.contractId) { - setError( - 'This project does not have an escrow contract set up. Please contact the project creator or support if you believe this is an error.' - ); - setFlowStep('form'); - setIsLoading(false); - setIsSubmitting(false); - return; - } - try { // Step 1: Prepare the payload according to FundEscrowPayload type const payload: FundEscrowPayload = { - contractId: project.contractId, - signer: walletAddress, + contractId: project.contractId!, + signer: walletAddress!, amount: fundingAmount, }; @@ -342,7 +337,7 @@ const FundProject = ({ open, setOpen, project }: FundProjectProps) => { // Step 3: Sign transaction with wallet const signedXdr = await signTransaction({ unsignedTransaction, - address: walletAddress, + address: walletAddress!, }); // Extract transaction hash from signed XDR @@ -405,7 +400,7 @@ const FundProject = ({ open, setOpen, project }: FundProjectProps) => { setIsLoading(false); setIsSubmitting(false); } - }); + }, fundingAmount); if (!walletValid) { setFlowStep('form'); @@ -593,6 +588,27 @@ const FundProject = ({ open, setOpen, project }: FundProjectProps) => { isSubmitting={isSubmitting} /> )} + + {/* Wallet Modals */} + + + setIsWalletDrawerOpen(true)} + actionName='fund project' + /> + + ); }; diff --git a/components/providers/wallet-provider.tsx b/components/providers/wallet-provider.tsx index 46586a6a..fa1b918d 100644 --- a/components/providers/wallet-provider.tsx +++ b/components/providers/wallet-provider.tsx @@ -26,6 +26,9 @@ type WalletContextType = { syncWallet: () => Promise; getSupportedTrustlineAssets: () => Promise; addTrustline: (assetCode: string) => Promise; + isWalletOpen: boolean; + onOpenWallet: () => void; + onCloseWallet: () => void; }; const WalletContext = createContext(undefined); @@ -123,6 +126,16 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => { const isLoading = isSessionLoading || walletLoading; const hasWalletFromSession = !!session?.user?.wallet?.address; + const [isWalletOpen, setIsWalletOpen] = useState(false); + + const onOpenWallet = useCallback(() => { + setIsWalletOpen(true); + }, []); + + const onCloseWallet = useCallback(() => { + setIsWalletOpen(false); + }, []); + return ( { syncWallet, getSupportedTrustlineAssets, addTrustline, + isWalletOpen, + onOpenWallet, + onCloseWallet, }} > {children} diff --git a/components/wallet/LandingWalletWrapper.tsx b/components/wallet/LandingWalletWrapper.tsx index 4b827829..8e37ecd6 100644 --- a/components/wallet/LandingWalletWrapper.tsx +++ b/components/wallet/LandingWalletWrapper.tsx @@ -4,11 +4,12 @@ import { useState } from 'react'; import { FamilyWalletButton } from './FamilyWalletButton'; import { FamilyWalletDrawer, DrawerView } from './FamilyWalletDrawer'; import { useAuthStatus } from '@/hooks/use-auth'; +import { useWalletContext } from '@/components/providers/wallet-provider'; export function LandingWalletWrapper() { - const [open, setOpen] = useState(false); const [drawerView, setDrawerView] = useState('main'); const { isAuthenticated, isLoading } = useAuthStatus(); + const { isWalletOpen, onOpenWallet, onCloseWallet } = useWalletContext(); if (isLoading || !isAuthenticated) { return null; @@ -19,13 +20,13 @@ export function LandingWalletWrapper() { { if (view) setDrawerView(view); - setOpen(true); + onOpenWallet(); }} /> ); diff --git a/components/wallet/WalletNotReadyModal.tsx b/components/wallet/WalletNotReadyModal.tsx new file mode 100644 index 00000000..ec1a3976 --- /dev/null +++ b/components/wallet/WalletNotReadyModal.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; +import { Button } from '../ui/button'; +import { + XIcon, + Wallet, + ExternalLink, + Activity, + Coins, + ShieldCheck, + AlertCircle, +} from 'lucide-react'; +import { WalletNotReadyReason } from '@/hooks/use-wallet-readiness'; + +interface WalletNotReadyModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + reasons: WalletNotReadyReason[]; + onOpenWallet: () => void; + actionName: string; +} + +const WalletNotReadyModal: React.FC = ({ + open, + onOpenChange, + reasons, + onOpenWallet, + actionName, +}) => { + const handleOpenWallet = () => { + onOpenChange(false); + onOpenWallet(); + }; + + return ( + + + + + + + + +
+ {/* Header Icon with Glow */} +
+
+
+ +
+
+ + {/* Title & Description */} +
+ + Wallet Not Ready + + + A few quick steps are needed before you can{' '} + {actionName}. + +
+ + {/* Steps Section */} +
+ {reasons.includes('not_activated') && ( + } + title='Activate Stellar Account' + description="Your account isn't on-chain. Send at least 2 XLM to this address to activate it." + /> + )} + {reasons.includes('no_usdc_trustline') && ( + } + title='Add USDC Trustline' + description='Required to hold USDC on Stellar. Open your wallet to add the trustline.' + /> + )} + {reasons.includes('insufficient_xlm') && + !reasons.includes('not_activated') && ( + } + title='Low XLM Balance' + description='Transaction fees and reserve requirements need a small amount of XLM.' + /> + )} + {reasons.includes('insufficient_usdc') && ( + } + title='Insufficient USDC Balance' + description='You need more USDC in your wallet to complete this transaction.' + /> + )} +
+ + {/* Actions */} +
+ + +
+ + + Why is this required? + + +
+ +
+ ); +}; + +const StepItem = ({ + icon, + title, + description, +}: { + icon: React.ReactNode; + title: string; + description: string; +}) => ( +
+
+ {icon} +
+
+

{title}

+

{description}

+
+
+); + +export default WalletNotReadyModal; diff --git a/components/wallet/WalletTrigger.tsx b/components/wallet/WalletTrigger.tsx index db0eff67..31f39591 100644 --- a/components/wallet/WalletTrigger.tsx +++ b/components/wallet/WalletTrigger.tsx @@ -21,7 +21,8 @@ export function WalletTrigger({ className, drawerType = 'sheet', }: WalletTriggerProps) { - const { walletAddress, hasWalletFromSession, isLoading } = useWalletContext(); + const { walletAddress, hasWalletFromSession, isLoading, onOpenWallet } = + useWalletContext(); const [open, setOpen] = useState(false); // Wallet is managed by backend; no "Connect Wallet" flow. Show trigger only when diff --git a/features/projects/components/Initialize.tsx b/features/projects/components/Initialize.tsx index 5460ab3b..18a9c74b 100644 --- a/features/projects/components/Initialize.tsx +++ b/features/projects/components/Initialize.tsx @@ -13,6 +13,8 @@ import ProjectSubmissionSuccess from './ProjectSubmissionSuccess'; import Loading from '@/components/loading/Loading'; import { useWalletProtection } from '@/hooks/use-wallet-protection'; import WalletRequiredModal from '@/components/wallet/WalletRequiredModal'; +import WalletNotReadyModal from '@/components/wallet/WalletNotReadyModal'; +import { WalletSheet } from '@/components/wallet/WalletSheet'; type StepState = 'pending' | 'active' | 'completed'; @@ -47,12 +49,17 @@ const Initialize: React.FC = ({ onSuccess }) => { const { requireWallet, showWalletModal, + showNotReadyModal, + notReadyReasons, handleWalletConnected, closeWalletModal, + closeNotReadyModal, } = useWalletProtection({ actionName: 'initialize project', }); + const [isWalletDrawerOpen, setIsWalletDrawerOpen] = useState(false); + const localSteps: Step[] = [ { title: 'Submit your project Details', @@ -97,7 +104,7 @@ const Initialize: React.FC = ({ onSuccess }) => { const submitInit = async () => { if (!formData) return; - requireWallet(async () => { + await requireWallet(async () => { try { setIsSubmitting(true); toast.loading('Initializing project...'); @@ -203,6 +210,21 @@ const Initialize: React.FC = ({ onSuccess }) => { actionName='initialize project' onWalletConnected={handleWalletConnected} /> + + {/* Wallet Not Ready Modal */} + setIsWalletDrawerOpen(true)} + actionName='initialize project' + /> + + {/* Wallet Sheet */} + ); }; @@ -237,7 +259,7 @@ const MilestonesPhase = ({