Skip to content

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 with V2 soft/hard cap validation
  • Refund Actions: Investment refunds during failed sales
  • Claim Actions: Token and NFT claiming with V2 vesting support
  • NFT Operations: Split, merge, and convert NFT actions
  • V2 Enhancements: Automatic stage transitions, vesting-aware claims, enhanced purchase controls

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 projectId as 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
  • V2-aware validation for soft caps, hard caps, and vesting schedules

Approval Management

  • Automatic token (ERC20) and NFT (ERC721) approval handling
  • Checks current approvals before transactions
  • Efficient approval flow with setApprovalForAll for 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
  • V2 Enhanced: Soft cap (minimum) and hard cap (maximum per user) validation
  • V2 Enhanced: Automatic stage transitions based on timing
// 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)
// 
// V2 Additional Validations:
// - Soft cap: Purchase amount >= purchaseSoftCap
// - Hard cap: Total user purchases <= (fundingTarget * purchaseHardCapPercent / 100)
// - Automatic private sale start at privateSaleStartEpoch

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
  • V2 Enhanced: Vesting-aware claiming with graduated token release
  • V2 Enhanced: Multiple partial claims based on vesting schedule

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 NFT

Merge NFTs

Available: Sale Succeeded (Stage 4)

  • Combines 2-3 NFTs into single NFT
  • Uses setApprovalForAll for 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
  • V2 Enhanced: Vesting-based NFT conversion with graduated release
  • V2 Enhanced: NFT remains active if vesting incomplete (not burned immediately)

V2-Specific Enhancements

Version Detection

The widget automatically detects if a project is V2-enabled and adjusts validation logic accordingly:

import { useProjectVersion } from '@/lib/use-project-version'
import { useProjectV2Configuration } from '@/lib/progressive-ifif-v2-configuration-hooks'
 
function ClientActionsWidget({ projectId }: { projectId: number }) {
  const { isV2 } = useProjectVersion(projectAddress)
  const v2Config = useProjectV2Configuration(projectId)
 
  // V2-aware purchase validation
  const canPurchase = useMemo(() => {
    if (!isV2) {
      return standardValidation()
    }
 
    // Enhanced V2 validation
    const softCapCheck = purchaseAmount >= v2Config.config?.purchaseSoftCap
    const hardCapCheck = userTotalPurchase + purchaseAmount <= (fundingTarget * v2Config.config?.purchaseHardCapPercent / 100)
    
    return standardValidation() && softCapCheck && hardCapCheck
  }, [isV2, v2Config, purchaseAmount, userTotalPurchase])
 
  return (
    <div>
      {isV2 && v2Config.status.hasConfig && (
        <Alert className="border-blue-200 bg-blue-50 mb-4">
          <Info className="h-4 w-4" />
          <AlertDescription>
            V2 Enhanced Features Active
            {v2Config.config && (
              <div className="text-xs mt-1 space-y-1">
                <div>Min Purchase: {formatEther(v2Config.config.purchaseSoftCap)} tokens</div>
                <div>Max Purchase: {v2Config.config.purchaseHardCapPercent}% of funding target</div>
              </div>
            )}
          </AlertDescription>
        </Alert>
      )}
      
      {/* Standard actions with V2-aware validation */}
    </div>
  )
}

Enhanced Purchase Controls

V2 Soft Cap Validation:
// Minimum purchase amount validation
const validateSoftCap = (amount: bigint) => {
  if (!isV2 || !v2Config.config) return true
  
  const softCap = v2Config.config.purchaseSoftCap
  if (amount < softCap) {
    return {
      valid: false,
      error: `Minimum purchase is ${formatEther(softCap)} tokens`
    }
  }
  
  return { valid: true }
}
V2 Hard Cap Validation:
// Maximum per-user purchase validation
const validateHardCap = (newPurchaseAmount: bigint, existingPurchases: bigint) => {
  if (!isV2 || !v2Config.config || !overview?.fundingTarget) return true
  
  const hardCapPercent = v2Config.config.purchaseHardCapPercent
  const maxUserPurchase = (overview.fundingTarget * BigInt(hardCapPercent)) / 100n
  const totalUserPurchase = existingPurchases + newPurchaseAmount
  
  if (totalUserPurchase > maxUserPurchase) {
    return {
      valid: false,
      error: `Maximum per user is ${formatEther(maxUserPurchase)} tokens (${hardCapPercent}% of target)`
    }
  }
  
  return { valid: true }
}

Vesting-Aware Claims

V2 Gradual Token Release:
// Calculate vested token amount
const calculateVestedAmount = (totalAllocation: bigint) => {
  if (!isV2 || !v2Config.claimConfig) {
    // V1: Full allocation immediately
    return totalAllocation
  }
 
  // V2: Gradual vesting based on time
  const now = BigInt(Math.floor(Date.now() / 1000))
  const { startEpoch, endEpoch } = v2Config.claimConfig
  
  if (now < startEpoch) {
    // Vesting hasn't started
    return 0n
  }
  
  if (now >= endEpoch) {
    // Fully vested
    return totalAllocation
  }
  
  // Partially vested (linear)
  const vestingDuration = endEpoch - startEpoch
  const timeElapsed = now - startEpoch
  const vestedAmount = (totalAllocation * timeElapsed) / vestingDuration
  
  return vestedAmount
}
 
// Display vesting progress
{isV2 && v2Config.claimConfig && (
  <div className="text-xs space-y-2 p-3 bg-blue-50 border border-blue-200 rounded">
    <div className="font-medium text-blue-900">Vesting Schedule</div>
    <div className="space-y-1 text-blue-700">
      <div>Start: {new Date(Number(v2Config.claimConfig.startEpoch) * 1000).toLocaleDateString()}</div>
      <div>End: {new Date(Number(v2Config.claimConfig.endEpoch) * 1000).toLocaleDateString()}</div>
      <div>Currently Vested: {vestingPercentage.toFixed(2)}%</div>
    </div>
    <div className="w-full bg-blue-200 rounded-full h-2">
      <div 
        className="bg-blue-600 h-2 rounded-full transition-all"
        style={{ width: `${vestingPercentage}%` }}
      />
    </div>
  </div>
)}

Automatic Stage Transitions

V2 projects transition stages automatically based on timing:

// V2 automatic transitions
const getProjectStageInfo = () => {
  if (!isV2 || !v2Config.config) {
    return {
      stage: overview.stage,
      info: 'Manual stage transitions'
    }
  }
 
  const now = Math.floor(Date.now() / 1000)
  const privateSaleStart = Number(v2Config.config.privateSaleStartEpoch)
  const privateSaleEnd = privateSaleStart + Number(project?.privateSaleTime || 0)
  const publicSaleEnd = privateSaleEnd + Number(project?.publicSaleTime || 0)
 
  if (now < privateSaleStart) {
    return {
      stage: 1, // INIT
      info: `Private sale starts in ${formatTimeRemaining(privateSaleStart - now)}`
    }
  }
 
  if (now >= privateSaleStart && now < privateSaleEnd) {
    return {
      stage: 2, // PRIVATE_SALE
      info: `Private sale ends in ${formatTimeRemaining(privateSaleEnd - now)}`
    }
  }
 
  if (now >= privateSaleEnd && now < publicSaleEnd) {
    return {
      stage: 3, // PUBLIC_SALE
      info: `Public sale ends in ${formatTimeRemaining(publicSaleEnd - now)}`
    }
  }
 
  return {
    stage: overview.stage,
    info: 'Check project status'
  }
}

V2 Configuration Display

Show V2-specific configuration details:

{isV2 && v2Config.status.hasConfig && v2Config.config && (
  <div className="space-y-2 text-xs p-3 bg-slate-50 border border-slate-200 rounded">
    <div className="font-semibold text-slate-900">V2 Configuration</div>
    <div className="grid grid-cols-2 gap-2 text-slate-700">
      <div>
        <span className="text-slate-500">Private Sale Start:</span>
        <div className="font-medium">
          {new Date(Number(v2Config.config.privateSaleStartEpoch) * 1000).toLocaleString()}
        </div>
      </div>
      <div>
        <span className="text-slate-500">Soft Cap:</span>
        <div className="font-medium">
          {formatEther(v2Config.config.purchaseSoftCap)} tokens
        </div>
      </div>
      <div>
        <span className="text-slate-500">Hard Cap:</span>
        <div className="font-medium">
          {v2Config.config.purchaseHardCapPercent}% of target
        </div>
      </div>
      <div>
        <span className="text-slate-500">Min NFT Weight:</span>
        <div className="font-medium">
          {formatEther(v2Config.config.minNFTWeight)}
        </div>
      </div>
    </div>
    
    {v2Config.fundClaimConfig && (
      <div className="pt-2 border-t border-slate-300">
        <div className="text-slate-500">Fund Vesting Period:</div>
        <div className="font-medium">
          {v2Config.fundClaimConfig.periodLength / 86400} days
        </div>
      </div>
    )}
  </div>
)}

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
  • V2: Always check version before applying V2-specific logic

2. Error Prevention

  • Validate inputs before submission
  • Show clear error messages
  • Prevent invalid state transitions
  • V2: Validate soft caps and hard caps before purchase
  • V2: Check vesting schedules before claims

3. User Feedback

  • Loading states for all async operations
  • Success/error notifications
  • Progress indicators for multi-step operations
  • V2: Show vesting progress bars for claims
  • V2: Display time remaining for automatic stage transitions

4. Performance

  • Memoize expensive calculations
  • Use React.memo for stable components
  • Efficient state management with useState
  • V2: Cache V2 configuration data to avoid repeated fetches

5. Accessibility

  • Button titles for screen readers
  • Standard form inputs with labels
  • Clear visual feedback
  • V2: Clear indicators for V2-enhanced features

6. Version Compatibility (V2-Specific)

  • Always detect project version before rendering actions
  • Handle V1 and V2 validation paths separately
  • Provide clear visual indicators for V2 features
  • Fall back gracefully for V1 projects

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
Approval failures
  • Check network connectivity
  • Verify sufficient gas fees
  • Confirm contract addresses are correct
Transaction failures
  • Validate input parameters
  • Check contract state hasn't changed
  • Verify user has sufficient balances
NFT selection issues
  • 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'
 
// V2-specific hooks
import { useProjectVersion } from '@/lib/use-project-version'
import { useProjectV2Configuration } from '@/lib/progressive-ifif-v2-configuration-hooks'
import { useCachedIFIFV2Data } from '@/lib/progressive-ifif-v2-hooks'
 
// Required wagmi hooks
import { useAccount, useWriteContract, useReadContract } from 'wagmi'
import { formatEther, parseEther } from 'viem'
import { erc20Abi, erc721Abi } from 'viem'

V2 Migration Guide

Upgrading from V1 to V2-Aware Widget

If you have an existing V1 widget implementation, here's how to add V2 support:

1. Add Version Detection:
import { useProjectVersion } from '@/lib/use-project-version'
 
const { version, isV2 } = useProjectVersion(projectAddress)
2. Add V2 Configuration Fetching:
import { useProjectV2Configuration } from '@/lib/progressive-ifif-v2-configuration-hooks'
 
const v2Config = useProjectV2Configuration(projectId)
3. Enhance Purchase Validation:
// Before (V1 only)
const canPurchase = amount > 0 && amount <= remainingFunding
 
// After (V1 + V2)
const canPurchase = useMemo(() => {
  if (!amount || amount <= 0) return false
  if (amount > remainingFunding) return false
  
  if (isV2 && v2Config.config) {
    // Soft cap check
    if (amount < v2Config.config.purchaseSoftCap) return false
    
    // Hard cap check
    const maxUserPurchase = (fundingTarget * BigInt(v2Config.config.purchaseHardCapPercent)) / 100n
    if (userTotalPurchases + amount > maxUserPurchase) return false
  }
  
  return true
}, [amount, remainingFunding, isV2, v2Config, userTotalPurchases])
4. Add Vesting Support to Claims:
// Before (V1 only)
const claimableAmount = userInvestment
 
// After (V1 + V2)
const claimableAmount = useMemo(() => {
  if (!isV2 || !v2Config.claimConfig) {
    return userInvestment // V1: Full amount immediately
  }
  
  // V2: Calculate vested amount
  return calculateVestedAmount(userInvestment, v2Config.claimConfig)
}, [isV2, v2Config, userInvestment])
5. Display V2 Features:
{isV2 && v2Config.status.hasConfig && (
  <Badge variant="default" className="bg-blue-100 text-blue-800">
    V2 Enhanced
  </Badge>
)}

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. With V2 support, it seamlessly handles enhanced features like vesting schedules, purchase caps, and automatic stage transitions while maintaining full backwards compatibility with V1 projects.