Client Actions Widget
The Client Actions Widget is a comprehensive, self-contained React component that provides users with all available blockchain actions for IFIF projects. It automatically detects the project stage and user's wallet state to display only relevant actions.
Overview
The widget serves as a unified interface for all client-side blockchain interactions, including:
- Purchase Actions: Token purchases during sale stages
- Refund Actions: Investment refunds during failed sales
- Claim Actions: Token and NFT claiming
- NFT Operations: Split, merge, and convert NFT actions
Features
🎯 Smart Action Detection
- Automatically shows/hides actions based on project stage
- Validates user eligibility for each action
- Real-time validation feedback
🔄 Self-Contained Architecture
- Only requires
projectIdas prop - Progressive data fetching
- No external modal dependencies
💰 Contract-First Validation
- UI validation mirrors smart contract requirements
- Prevents invalid transactions before submission
- BigInt precision for all calculations
✅ Approval Management
- Automatic token (ERC20) and NFT (ERC721) approval handling
- Checks current approvals before transactions
- Efficient approval flow with
setApprovalForAllfor NFT operations
🎨 Consistent UI/UX
- Uniform NFT selection interfaces
- Loading states and error handling
- Success/failure feedback
Usage
Basic Implementation
import { ClientActionsWidget } from '@/components/client-actions-widget'
export default function ProjectPage() {
const projectId = 1 // Your project ID
return (
<div className="space-y-6">
{/* Other project content */}
<ClientActionsWidget projectId={projectId} />
</div>
)
}Integration Example
// In project detail page
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
{/* Main project content */}
</div>
<div className="space-y-6">
{/* Quick stats */}
{/* Client Actions Widget */}
<ClientActionsWidget projectId={Number(params.id)} />
{/* Other sidebar content */}
</div>
</div>Available Actions
Purchase Tokens
Available: Private Sale (Stage 2) & Public Sale (Stage 3)
- Input validation for purchase amounts
- Whitelist verification for private sales
- Funding target and deadline checks
- Token allowance approval handling
// Automatic validation includes:
// - Stage verification (2 or 3)
// - Amount > 0
// - Funding target not exceeded
// - Sale deadline not passed
// - Whitelist eligibility (both private and public sales)Request Refund
Available: Sale Failed (Stage 5)
- Refunds remaining investment after failed sales
- Validates user has active investment
- Processes full refund amount
Claim Tokens
Available: Claim Stage (Stage 6)
- Direct token claiming for successful projects
- Available only if user has no NFTs and hasn't claimed tokens yet
- Validates user has unclaimed investment
- Calculates claimable amount using contract formula
Claim Investment NFT
Available: Sale Succeeded (Stage 4)
- Converts investment to NFT representation
- Available only if user has investment but no NFT yet
- NFT contains investment weight information
- Alternative to direct token claiming
Split NFT
Available: Sale Succeeded (Stage 4)
- Splits single NFT into exactly 2 parts
- Weight distribution with 1e18 precision
- Weight validation (min 1 wei per split)
- Simple 2-weight split interface
// Split validation:
// - NFT weight > 1 (splittable)
// - Exactly 2 weight values required
// - Weight distribution sums to original
// - Minimum 1 wei per new NFTMerge NFTs
Available: Sale Succeeded (Stage 4)
- Combines 2-3 NFTs into single NFT
- Uses
setApprovalForAllfor efficient approval - Validates NFT ownership
- Preserves total weight
Convert NFT to Tokens
Available: Claim Stage (Stage 6)
- Converts NFT back to claimable tokens
- Available regardless of previous token claims
- Single NFT selection interface
- Maintains precision with BigInt calculations
Data Flow
1. Progressive Data Fetching
// Widget automatically fetches required data:
const { project, overview } = useProjectDetailData(projectId)
const { investments: userInvestments } = useUserProfileData(address)
const { allocations: nftAllocations } = useProjectNFTData(project?.id || '')2. Action Availability Logic
const availableActions = useMemo(() => {
const actions = []
const currentStage = overview.stage
// Purchase (Stages 2-3)
if (currentStage === 2 || currentStage === 3) {
actions.push(purchaseAction)
}
// Refund (Stage 5)
if (currentStage === 5 && userData.hasInvestment) {
actions.push(refundAction)
}
// Continue for all actions...
return actions
}, [overview, userData])3. Contract Validation
// Each action includes comprehensive validation:
const handlePurchase = async () => {
if (!overview?.projectAddress || !inputs.purchaseAmount) return
// Validate stage is PRIVATE_SALE (2) or PUBLIC_SALE (3) - contract requirement
if (overview.stage !== 2 && overview.stage !== 3) {
console.error('Purchase only allowed during PRIVATE_SALE or PUBLIC_SALE stages')
return
}
// Validate purchase amount > 0 (contract requirement)
const purchaseAmount = Number(inputs.purchaseAmount)
if (purchaseAmount <= 0) {
console.error('Purchase amount must be greater than 0')
return
}
// Validate funding target not exceeded (contract requirement)
if (overview?.fundingTarget && overview?.totalPurchase !== undefined) {
const currentFundingWei = overview.totalPurchase
const fundingTargetWei = overview.fundingTarget
const purchaseAmountWei = parseEther(purchaseAmount.toString())
if (currentFundingWei + purchaseAmountWei > fundingTargetWei) {
console.error('Purchase would exceed funding target')
return
}
}
// Validate sale time and project deadline (contract requirement)
const now = Math.floor(Date.now() / 1000)
// Check activeSaleEndTime (sale period hasn't expired)
if (project?.activeSaleEndTime && now > Number(project.activeSaleEndTime)) {
console.error('Sale period has expired')
return
}
// Check desiredEndEpoch (project deadline hasn't expired)
if (project?.desiredEndEpoch && now > Number(project.desiredEndEpoch)) {
console.error('Project deadline has expired')
return
}
// Validate whitelist eligibility (contract requirement)
if (isPrivateSale && !isEligible) {
console.error('Not whitelisted for private sale')
return
} else if (!isPrivateSale && !isEligible) {
console.error('Not whitelisted for public sale')
return
}
// Continue with transaction...
}NFT Selection Interface
Single Selection (Convert NFT)
<div className="space-y-2">
<Label className="text-xs text-slate-600">Select NFT to Convert (1 NFT)</Label>
<div className="grid gap-2 max-h-32 overflow-y-auto">
{userNFTs.map((nft: any) => (
<label key={nft.tokenId} className="flex items-center gap-2 p-2 border border-slate-200 rounded text-xs cursor-pointer hover:bg-slate-50">
<input
type="radio"
name="convertNFT"
checked={inputs.selectedConvertNFTId === String(nft.tokenId)}
onChange={() => setInputs(prev => ({
...prev,
selectedConvertNFTId: String(nft.tokenId)
}))}
className="text-blue-600"
/>
<div className="flex-1">
<div className="font-medium">NFT #{nft.tokenId}</div>
<div className="text-slate-500">
Weight: {Number(formatEther(nft.weight || 0n)).toFixed(2)}
</div>
</div>
</label>
))}
</div>
{inputs.selectedConvertNFTId && (
<div className="text-xs text-slate-600 bg-blue-50 p-2 rounded">
Selected NFT #{inputs.selectedConvertNFTId} • Weight: {
Number(formatEther(
userNFTs.find((nft: any) => nft.tokenId === Number(inputs.selectedConvertNFTId))?.weight || 0n
)).toFixed(2)
}
</div>
)}
</div>Split NFT Interface (2 Weights)
<div className="space-y-2">
<Label className="text-xs text-slate-600">Split Weights</Label>
<div className="flex gap-2">
<Input
type="number"
step="0.000001"
min="0"
placeholder="Weight 1"
value={inputs.splitWeights[0] || ''}
onChange={(e) => setInputs(prev => ({
...prev,
splitWeights: [e.target.value, prev.splitWeights[1] || '']
}))}
className="flex-1 text-xs"
/>
<Input
type="number"
step="0.000001"
min="0"
placeholder="Weight 2"
value={inputs.splitWeights[1] || ''}
onChange={(e) => setInputs(prev => ({
...prev,
splitWeights: [prev.splitWeights[0] || '', e.target.value]
}))}
className="flex-1 text-xs"
/>
</div>
{/* Total weight validation display */}
<div className="text-xs text-slate-600">
Total: {totalWeight.toFixed(6)} / {selectedNFTWeight.toFixed(6)}
</div>
</div>Multi-Selection (Merge NFTs)
<div className="space-y-2">
<Label className="text-xs text-slate-600">Select NFTs to Merge (2-3 NFTs)</Label>
<div className="grid gap-2 max-h-32 overflow-y-auto">
{userNFTs.map((nft: any) => (
<label key={nft.tokenId} className="flex items-center gap-2 p-2 border border-slate-200 rounded text-xs cursor-pointer hover:bg-slate-50">
<input
type="checkbox"
checked={inputs.selectedMergeNFTIds.includes(String(nft.tokenId))}
onChange={(e) => {
if (e.target.checked) {
if (inputs.selectedMergeNFTIds.length < 3) {
setInputs(prev => ({
...prev,
selectedMergeNFTIds: [...prev.selectedMergeNFTIds, String(nft.tokenId)]
}))
}
} else {
setInputs(prev => ({
...prev,
selectedMergeNFTIds: prev.selectedMergeNFTIds.filter(id => id !== String(nft.tokenId))
}))
}
}}
disabled={!inputs.selectedMergeNFTIds.includes(String(nft.tokenId)) && inputs.selectedMergeNFTIds.length >= 3}
className="rounded"
/>
<div className="flex-1">
<div className="font-medium">NFT #{nft.tokenId}</div>
<div className="text-slate-500">
Weight: {Number(formatEther(nft.weight)).toFixed(2)}
</div>
</div>
</label>
))}
</div>
{inputs.selectedMergeNFTIds.length >= 2 && (
<div className="text-xs text-slate-600">
Selected {inputs.selectedMergeNFTIds.length} NFTs • Combined Weight: {
Number(formatEther(
inputs.selectedMergeNFTIds
.map(id => userNFTs.find((nft: any) => nft.tokenId === Number(id))?.weight || 0n)
.reduce((sum, weight) => sum + weight, 0n)
)).toFixed(2)
}
</div>
)}
</div>Approval Mechanisms
Token Approval (ERC20)
// Check current allowance
const { data: tokenAllowance, refetch: refetchTokenAllowance } = useReadContract({
address: project?.fundToken as `0x${string}`,
abi: erc20Abi,
functionName: 'allowance',
args: [address as `0x${string}`, overview?.projectAddress as `0x${string}`],
query: {
enabled: !!project?.fundToken && !!address && !!overview?.projectAddress
}
})
// Approve if insufficient
const isPurchaseAmountApproved = (amount: string) => {
if (!tokenAllowance || !amount) return false
try {
const amountWei = parseEther(amount)
return tokenAllowance >= amountWei
} catch {
return false
}
}
// Request approval
if (!isPurchaseAmountApproved(inputs.purchaseAmount)) {
setIsApproving(true)
await approveToken({
tokenAddress: project?.fundToken as `0x${string}`,
spenderAddress: overview.projectAddress as `0x${string}`,
amount: parseEther(inputs.purchaseAmount)
})
await refetchTokenAllowance()
setIsApproving(false)
}NFT Approval (ERC721)
// Check operator approval
const { data: isApprovedForAll, refetch: refetchOperatorApproval } = useReadContract({
address: overview?.projectAddress as `0x${string}`,
abi: erc721Abi,
functionName: 'isApprovedForAll',
args: [address as `0x${string}`, overview?.projectAddress as `0x${string}`],
query: {
enabled: !!overview?.projectAddress && !!address
}
})
// Approve all NFTs for operations
if (!hasOperatorApproval) {
setIsApproving(true)
await setApprovalForAll({
nftAddress: overview.projectAddress as `0x${string}`,
operatorAddress: overview.projectAddress as `0x${string}`,
approved: true
})
await refetchOperatorApproval()
setIsApproving(false)
}Error Handling
Validation Errors
// Client-side validation prevents invalid submissions
if (overview.stage !== 6) {
console.error('Convert NFT only allowed in CLAIM stage')
return
}
if (!selectedNFT) {
console.error('No NFT selected for conversion')
return
}Transaction Errors
try {
await convertNFT({
projectAddress: overview.projectAddress as `0x${string}`,
tokenId: BigInt(selectedNFT.tokenId)
})
// Success is handled by the hook's success state
} catch (error) {
console.error('Convert NFT failed:', error)
// Error is handled by the hook's error state
}Approval Errors
try {
setIsApproving(true)
await setApprovalForAll({
nftAddress: overview.projectAddress as `0x${string}`,
operatorAddress: overview.projectAddress as `0x${string}`,
approved: true
})
await refetchOperatorApproval()
setIsApproving(false)
} catch (error) {
setIsApproving(false)
setApprovalError('Failed to approve NFTs. Please try again.')
return
}State Management
Input State
interface TransactionInputs {
purchaseAmount: string
splitWeights: string[]
mergeTokenIds: string[]
selectedSplitNFTId: string
selectedMergeNFTIds: string[]
selectedConvertNFTId: string
}
const [inputs, setInputs] = useState<TransactionInputs>({
purchaseAmount: '',
splitWeights: [],
mergeTokenIds: [],
selectedSplitNFTId: '',
selectedMergeNFTIds: [],
selectedConvertNFTId: ''
})Loading States
const [activeTransaction, setActiveTransaction] = useState<string | null>(null)
const [isApproving, setIsApproving] = useState(false)
// Usage
const isProcessing = activeTransaction === 'purchase' && (isPurchasing || isApproving)Success/Error States
// Auto-dismiss success messages
useEffect(() => {
if (purchaseSuccess || refundSuccess || claimSuccess || claimNFTSuccess || splitSuccess || mergeSuccess) {
const timer = setTimeout(() => {
if (purchaseSuccess) resetPurchase()
if (refundSuccess) resetRefund()
if (claimSuccess) resetClaim()
if (claimNFTSuccess) resetClaimNFT()
if (splitSuccess) resetSplit()
if (mergeSuccess) resetMerge()
}, 5000)
return () => clearTimeout(timer)
}
}, [purchaseSuccess, refundSuccess, claimSuccess, claimNFTSuccess, splitSuccess, mergeSuccess, resetPurchase, resetRefund, resetClaim, resetClaimNFT, resetSplit, resetMerge])Styling & Theming
Component Structure
<div className="bg-white border border-slate-200 space-y-4">
<div className="p-6 pb-0">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Wallet className="h-5 w-5" />
Quick Client Actions Widget
</h2>
<Badge variant="default" className="bg-blue-100 text-blue-800 border-blue-200">
Connected
</Badge>
</div>
</div>
<div className="px-6 pb-6 space-y-3">
{/* Action buttons */}
</div>
</div>Action Button Styling
// Primary actions (most important)
<Button
variant="default"
className="w-full justify-start"
onClick={handleAction}
disabled={!isValid}
>
<Icon className="h-4 w-4 mr-2" />
Action Label
</Button>
// Secondary actions
<Button
variant="outline"
className="w-full justify-start"
onClick={handleAction}
disabled={!isValid}
>
<Icon className="h-4 w-4 mr-2" />
Action Label
</Button>Best Practices
1. Progressive Enhancement
- Start with basic functionality
- Add advanced features incrementally
- Maintain backwards compatibility
2. Error Prevention
- Validate inputs before submission
- Show clear error messages
- Prevent invalid state transitions
3. User Feedback
- Loading states for all async operations
- Success/error notifications
- Progress indicators for multi-step operations
4. Performance
- Memoize expensive calculations
- Use React.memo for stable components
- Efficient state management with useState
5. Accessibility
- Button titles for screen readers
- Standard form inputs with labels
- Clear visual feedback
Integration Checklist
- Import ClientActionsWidget component
- Pass correct projectId prop
- Ensure parent has proper spacing/layout
- Test all available actions for the project
- Verify wallet connection requirements
- Check error handling and user feedback
- Validate responsive design
- Test with different project stages
Troubleshooting
Common Issues
Actions not appearing- Check project stage matches action requirements
- Verify user has necessary permissions/investments
- Ensure wallet is connected
- Check network connectivity
- Verify sufficient gas fees
- Confirm contract addresses are correct
- Validate input parameters
- Check contract state hasn't changed
- Verify user has sufficient balances
- Ensure user owns NFTs for the project
- Check NFT selection state management
- Verify NFT data is properly loaded
API Reference
Props
interface ClientActionsWidgetProps {
projectId: number // Required: The IFIF project ID
}Dependencies
// React hooks
import { useMemo, useState, useEffect } from 'react'
// UI components
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
// Icons
import {
ShoppingCart,
RefreshCw,
Gift,
ArrowRightLeft,
Split,
Merge,
Wallet,
AlertCircle,
CheckCircle,
Loader2
} from 'lucide-react'
// Required hooks
import {
usePurchase,
useRefund,
useClaim,
useClaimNFT
} from '@/lib/ifif-project-management-hooks'
import {
useSplitNFT,
useMergeNFT,
useConvertNFT
} from '@/lib/ifif-nft-management-hooks'
// Required data hooks
import {
useProjectDetailData,
useProjectNFTData,
useCachedIFIFData
} from '@/lib/progressive-ifif-hooks'
import { useUserProfileData } from '@/lib/progressive-user-hooks'
// Required utility hooks
import { useProjectPurchaseProof } from '@/components/whitelist-status-indicator'
// Required wagmi hooks
import { useAccount, useWriteContract, useReadContract } from 'wagmi'
import { formatEther, parseEther } from 'viem'
import { erc20Abi, erc721Abi } from 'viem'The Client Actions Widget provides a complete solution for user blockchain interactions within IFIF projects, combining ease of use with robust functionality and comprehensive error handling.