From 0398864e339114fe23b4e0ffd0b68a059599adb7 Mon Sep 17 00:00:00 2001 From: Agbeleshe Date: Sun, 8 Mar 2026 22:13:52 +0100 Subject: [PATCH 1/2] feat: implement Wallet Not Ready modal and readiness checks --- components/escrow/FundEscrowButton.tsx | 434 +++++++++++--------- components/modals/fund-project/index.tsx | 62 +-- components/wallet/WalletNotReadyModal.tsx | 139 +++++++ features/projects/components/Initialize.tsx | 24 +- hooks/use-wallet-protection.ts | 31 +- hooks/use-wallet-readiness.ts | 63 +++ 6 files changed, 528 insertions(+), 225 deletions(-) create mode 100644 components/wallet/WalletNotReadyModal.tsx create mode 100644 hooks/use-wallet-readiness.ts diff --git a/components/escrow/FundEscrowButton.tsx b/components/escrow/FundEscrowButton.tsx index 54652343..1b2e6c4c 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,84 @@ 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 +211,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/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/wallet/WalletNotReadyModal.tsx b/components/wallet/WalletNotReadyModal.tsx new file mode 100644 index 00000000..b315a8eb --- /dev/null +++ b/components/wallet/WalletNotReadyModal.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; +import { Button } from '../ui/button'; +import { XIcon, AlertCircle, CheckCircle2, Wallet, ExternalLink } from 'lucide-react'; +import Image from 'next/image'; +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 ( + + + + + + + + +
+
+
+ +
+ +
+ + Wallet Not Ready + + + Your wallet needs a few steps before you can{' '} + {actionName}. + +
+ +
+ {reasons.includes('not_activated') && ( + + )} + {reasons.includes('no_usdc_trustline') && ( + + )} + {(reasons.includes('insufficient_xlm') && !reasons.includes('not_activated')) && ( + + )} + {reasons.includes('insufficient_usdc') && ( + + )} +
+ +
+ + +
+ + + Learn more about Stellar account requirements + + +
+ +
+ ); +}; + +const StepItem = ({ title, description }: { title: string; description: string }) => ( +
+
+
+
+
+
+
+

{title}

+

{description}

+
+
+); + +export default WalletNotReadyModal; diff --git a/features/projects/components/Initialize.tsx b/features/projects/components/Initialize.tsx index 5460ab3b..264a3700 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', @@ -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 = ({
- - Learn more about Stellar account requirements - + Why is this required? +
@@ -122,16 +140,22 @@ const WalletNotReadyModal: React.FC = ({ ); }; -const StepItem = ({ title, description }: { title: string; description: string }) => ( -
-
-
-
-
+const StepItem = ({ + icon, + title, + description, +}: { + icon: React.ReactNode; + title: string; + description: string; +}) => ( +
+
+ {icon}
-

{title}

-

{description}

+

{title}

+

{description}

); 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 264a3700..18a9c74b 100644 --- a/features/projects/components/Initialize.tsx +++ b/features/projects/components/Initialize.tsx @@ -104,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...'); diff --git a/hooks/use-protected-action.ts b/hooks/use-protected-action.ts index 4bfc8f53..60b69b70 100644 --- a/hooks/use-protected-action.ts +++ b/hooks/use-protected-action.ts @@ -18,8 +18,11 @@ export function useProtectedAction({ const { requireWallet, showWalletModal, + showNotReadyModal, + notReadyReasons, handleWalletConnected, closeWalletModal, + closeNotReadyModal, } = useWalletProtection({ actionName, showModal: true, @@ -70,7 +73,10 @@ export function useProtectedAction({ return { executeProtectedAction, showWalletModal, + showNotReadyModal, + notReadyReasons, closeWalletModal, + closeNotReadyModal, handleWalletConnected: handleWalletConnectedWithRedirect, clearPendingAction, hasPendingAction: !!pendingAction, diff --git a/hooks/use-wallet-protection.ts b/hooks/use-wallet-protection.ts index bb7de3bc..2b04a3e4 100644 --- a/hooks/use-wallet-protection.ts +++ b/hooks/use-wallet-protection.ts @@ -1,6 +1,9 @@ import { useState, useCallback } from 'react'; import { useWalletContext } from '@/components/providers/wallet-provider'; -import { useWalletReadiness, WalletNotReadyReason } from './use-wallet-readiness'; +import { + useWalletReadiness, + WalletNotReadyReason, +} from './use-wallet-readiness'; import { toast } from 'sonner'; interface UseWalletProtectionOptions { @@ -12,14 +15,19 @@ export function useWalletProtection(options: UseWalletProtectionOptions = {}) { const { actionName = 'perform this action', showModal = true } = options; const { walletAddress } = useWalletContext(); const { checkReadiness } = useWalletReadiness(); - + const [showWalletModal, setShowWalletModal] = useState(false); const [showNotReadyModal, setShowNotReadyModal] = useState(false); - const [notReadyReasons, setNotReadyReasons] = useState([]); - + const [notReadyReasons, setNotReadyReasons] = useState< + WalletNotReadyReason[] + >([]); + const isConnected = Boolean(walletAddress); - const requireWallet = async (callback?: () => void, requiredUsdcAmount: number = 0) => { + const requireWallet = async ( + callback?: () => void, + requiredUsdcAmount: number = 0 + ) => { // 1. Check if wallet is connected at all if (!isConnected || !walletAddress) { if (showModal) { @@ -37,14 +45,16 @@ export function useWalletProtection(options: UseWalletProtectionOptions = {}) { setNotReadyReasons(result.reasons); setShowNotReadyModal(true); } else { - toast.error(`Wallet not ready to ${actionName}. Please check your activation and balances.`); + toast.error( + `Wallet not ready to ${actionName}. Please check your activation and balances.` + ); } return false; } // 3. All good, proceed if (callback) { - callback(); + await callback(); } return true; diff --git a/hooks/use-wallet-readiness.ts b/hooks/use-wallet-readiness.ts index 3630fc0f..7499a2f7 100644 --- a/hooks/use-wallet-readiness.ts +++ b/hooks/use-wallet-readiness.ts @@ -2,10 +2,10 @@ import { useMemo } from 'react'; import { useWalletContext } from '@/components/providers/wallet-provider'; import { WalletBalance } from '@/types/wallet'; -export type WalletNotReadyReason = - | 'not_activated' - | 'no_usdc_trustline' - | 'insufficient_xlm' +export type WalletNotReadyReason = + | 'not_activated' + | 'no_usdc_trustline' + | 'insufficient_xlm' | 'insufficient_usdc'; export interface WalletReadinessResult { @@ -25,10 +25,10 @@ export function useWalletReadiness() { } const reasons: WalletNotReadyReason[] = []; - - const nativeBalance = balances.find(b => b.asset_type === 'native'); - const xlmAmount = nativeBalance ? parseFloat(nativeBalance.balance) : 0; - + + const xlmBalance = balances.find(b => b.asset_type === 'native'); + const xlmAmount = xlmBalance ? parseFloat(xlmBalance.balance) : 0; + // 1. Check activation (needs at least 1 XLM to exist) if (xlmAmount < 1) { reasons.push('not_activated'); @@ -53,11 +53,11 @@ export function useWalletReadiness() { return { isReady: reasons.length === 0, reasons, - hasWallet: true + hasWallet: true, }; }; return { - checkReadiness + checkReadiness, }; }