1681 lines
70 KiB
TypeScript
Executable File
1681 lines
70 KiB
TypeScript
Executable File
import { AlgorandClient, microAlgos } from '@algorandfoundation/algokit-utils'
|
||
import { useWallet } from '@txnlab/use-wallet-react'
|
||
import { sha512_256 } from 'js-sha512'
|
||
import { useSnackbar } from 'notistack'
|
||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { AiOutlineCloudUpload, AiOutlineInfoCircle, AiOutlineLoading3Quarters } from 'react-icons/ai'
|
||
import { BsCoin } from 'react-icons/bs'
|
||
import { getAlgodConfigFromViteEnvironment } from '../utils/network/getAlgoClientConfigs'
|
||
|
||
/**
|
||
* Type for created assets stored in browser localStorage
|
||
* Captures ASA configuration including compliance fields
|
||
*/
|
||
type CreatedAsset = {
|
||
assetId: string
|
||
assetName: string
|
||
unitName: string
|
||
total: string
|
||
decimals: string
|
||
url?: string
|
||
manager?: string
|
||
reserve?: string
|
||
freeze?: string
|
||
clawback?: string
|
||
createdAt: string
|
||
}
|
||
|
||
/**
|
||
* Tri-state for USDC opt-in status
|
||
* - 'loading': blockchain query in progress, UI should show spinner/loading
|
||
* - 'opted-in': confirmed on-chain that user has opted in
|
||
* - 'not-opted-in': confirmed on-chain that user has NOT opted in
|
||
*/
|
||
type UsdcStatus = 'loading' | 'opted-in' | 'not-opted-in'
|
||
|
||
const STORAGE_KEY = 'tokenize_assets'
|
||
const LORA_BASE = 'https://lora.algokit.io/testnet'
|
||
|
||
// Circle USDC on Algorand TestNet (ASA)
|
||
const TESTNET_USDC_ASSET_ID = 10458941
|
||
const USDC_DECIMALS = 6
|
||
const ALGO_DECIMALS = 6
|
||
|
||
type TransferMode = 'manual' | 'algo' | 'usdc'
|
||
|
||
function resolveBackendBase(): string {
|
||
// 1) Respect explicit env (Vercel or custom)
|
||
const env = import.meta.env.VITE_API_URL?.trim()
|
||
if (env) {
|
||
const cleaned = env.replace(/\/$/, '')
|
||
// If someone pastes "my-backend.vercel.app" (no protocol),
|
||
// the browser will treat it as a relative path. Force https.
|
||
return cleaned.startsWith('http://') || cleaned.startsWith('https://') ? cleaned : `https://${cleaned}`
|
||
}
|
||
|
||
// 2) Codespaces: convert current host to port 3001
|
||
// e.g. https://abc-5173.app.github.dev -> https://abc-3001.app.github.dev
|
||
const host = window.location.host
|
||
if (host.endsWith('.app.github.dev')) {
|
||
const base = host.replace(/-\d+\.app\.github\.dev$/, '-3001.app.github.dev')
|
||
return `https://${base}`
|
||
}
|
||
|
||
// 3) Plain local fallback
|
||
return 'http://localhost:3001'
|
||
}
|
||
|
||
/**
|
||
* Load created assets from browser localStorage
|
||
*/
|
||
function loadAssets(): CreatedAsset[] {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY)
|
||
return raw ? (JSON.parse(raw) as CreatedAsset[]) : []
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save a newly created asset to localStorage
|
||
* Returns updated asset list with new asset at the top
|
||
*/
|
||
function persistAsset(asset: CreatedAsset): CreatedAsset[] {
|
||
const existing = loadAssets()
|
||
const next = [asset, ...existing]
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||
return next
|
||
}
|
||
|
||
/**
|
||
* Convert a decimal string (e.g. "1.23") into base units bigint given decimals.
|
||
* - Supports up to `decimals` fractional digits.
|
||
* - Rejects negatives and invalid formats.
|
||
*/
|
||
function decimalToBaseUnits(value: string, decimals: number): bigint {
|
||
const v = value.trim()
|
||
if (!v) throw new Error('Amount is required')
|
||
|
||
// Allow: "1", "1.", "1.0", ".5" ? We'll keep it simple: must start with digit.
|
||
// (Users can type 0.5)
|
||
if (!/^\d+(\.\d+)?$/.test(v)) throw new Error('Invalid amount format')
|
||
|
||
const [wholeRaw, fracRaw = ''] = v.split('.')
|
||
const whole = wholeRaw || '0'
|
||
const frac = fracRaw || ''
|
||
|
||
if (frac.length > decimals) {
|
||
throw new Error(`Too many decimal places (max ${decimals})`)
|
||
}
|
||
|
||
const fracPadded = frac.padEnd(decimals, '0')
|
||
const combined = `${whole}${fracPadded}`.replace(/^0+(?=\d)/, '') // keep at least one digit
|
||
return BigInt(combined || '0')
|
||
}
|
||
|
||
/**
|
||
* TokenizeAsset Component
|
||
* Main form for creating Algorand Standard Assets (ASAs)
|
||
* + NFT minting panel (ASA mint with IPFS metadata)
|
||
* Persists created assets to localStorage for tracking
|
||
*/
|
||
export default function TokenizeAsset() {
|
||
// ===== ASA (original) state =====
|
||
const [assetName, setAssetName] = useState<string>('Tokenized Coffee Membership')
|
||
const [unitName, setUnitName] = useState<string>('COFFEE')
|
||
const [total, setTotal] = useState<string>('1000')
|
||
const [decimals, setDecimals] = useState<string>('0')
|
||
const [url, setUrl] = useState<string>('')
|
||
|
||
const [showAdvanced, setShowAdvanced] = useState<boolean>(false)
|
||
const [manager, setManager] = useState<string>('')
|
||
const [reserve, setReserve] = useState<string>('')
|
||
const [freeze, setFreeze] = useState<string>('')
|
||
const [clawback, setClawback] = useState<string>('')
|
||
|
||
const [loading, setLoading] = useState<boolean>(false)
|
||
const [createdAssets, setCreatedAssets] = useState<CreatedAsset[]>([])
|
||
|
||
// ===== Transfer state =====
|
||
const [transferMode, setTransferMode] = useState<TransferMode>('manual')
|
||
const [transferAssetId, setTransferAssetId] = useState<string>('')
|
||
const [receiverAddress, setReceiverAddress] = useState<string>('')
|
||
const [transferAmount, setTransferAmount] = useState<string>('1')
|
||
const [transferLoading, setTransferLoading] = useState<boolean>(false)
|
||
|
||
// ===== USDC opt-in state =====
|
||
// Uses tri-state ('loading' | 'opted-in' | 'not-opted-in') to prevent infinite re-renders
|
||
// Refs are used to track state without causing callback recreations
|
||
const [usdcStatus, setUsdcStatus] = useState<UsdcStatus>('loading')
|
||
const [usdcBalance, setUsdcBalance] = useState<bigint>(0n)
|
||
const [usdcOptInLoading, setUsdcOptInLoading] = useState<boolean>(false)
|
||
|
||
// Track if we've completed at least one successful blockchain check for this address
|
||
const [hasCheckedUsdcOnChain, setHasCheckedUsdcOnChain] = useState<boolean>(false)
|
||
|
||
// Refs to prevent circular dependencies and duplicate operations
|
||
const hasShownUsdcWarningRef = useRef<boolean>(false) // Prevent duplicate snackbar warnings
|
||
const lastTransferModeRef = useRef<TransferMode>('manual') // Track mode changes
|
||
const isCheckingUsdcRef = useRef<boolean>(false) // Prevent duplicate status checks
|
||
const hasCheckedUsdcOnChainRef = useRef<boolean>(false) // Track checked state (avoids stale closures)
|
||
|
||
// ===== NFT mint state =====
|
||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||
const [previewUrl, setPreviewUrl] = useState<string>('')
|
||
const [nftLoading, setNftLoading] = useState<boolean>(false)
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
// NFT mint configurable fields
|
||
const [nftName, setNftName] = useState<string>('MasterPass Ticket')
|
||
const [nftUnit, setNftUnit] = useState<string>('MTK')
|
||
const [nftSupply, setNftSupply] = useState<string>('1')
|
||
const [nftDecimals, setNftDecimals] = useState<string>('0')
|
||
const [nftDefaultFrozen, setNftDefaultFrozen] = useState<boolean>(false)
|
||
|
||
// NFT advanced (addresses)
|
||
const [nftShowAdvanced, setNftShowAdvanced] = useState<boolean>(false)
|
||
const [nftManager, setNftManager] = useState<string>('')
|
||
const [nftReserve, setNftReserve] = useState<string>('')
|
||
const [nftFreeze, setNftFreeze] = useState<string>('')
|
||
const [nftClawback, setNftClawback] = useState<string>('')
|
||
|
||
// ===== use-wallet (Web3Auth OR WalletConnect) =====
|
||
// Use transactionSigner (not signer) - this is the correct property name from use-wallet
|
||
const { transactionSigner, activeAddress } = useWallet()
|
||
|
||
// Alias for backward compatibility in the code
|
||
const signer = transactionSigner
|
||
|
||
// ===== Notifications =====
|
||
const { enqueueSnackbar } = useSnackbar()
|
||
|
||
// ===== Algorand client =====
|
||
const algodConfig = getAlgodConfigFromViteEnvironment()
|
||
const algorand = useMemo(() => AlgorandClient.fromConfig({ algodConfig }), [algodConfig])
|
||
|
||
// Derived booleans for convenience (only valid when hasCheckedUsdcOnChain is true)
|
||
const usdcOptedIn = usdcStatus === 'opted-in'
|
||
const usdcStatusLoading = usdcStatus === 'loading'
|
||
|
||
/**
|
||
* Fetch USDC opt-in status from blockchain
|
||
* Uses asset-specific API for reliable opt-in detection
|
||
* Falls back to account information API if needed
|
||
*/
|
||
const checkUsdcOptInStatus = useCallback(async () => {
|
||
if (!activeAddress) {
|
||
setUsdcStatus('not-opted-in')
|
||
setUsdcBalance(0n)
|
||
setHasCheckedUsdcOnChain(false)
|
||
hasCheckedUsdcOnChainRef.current = false
|
||
isCheckingUsdcRef.current = false
|
||
return
|
||
}
|
||
|
||
// Prevent duplicate concurrent calls
|
||
if (isCheckingUsdcRef.current) {
|
||
return
|
||
}
|
||
|
||
isCheckingUsdcRef.current = true
|
||
|
||
// Only set loading if we haven't checked yet (preserve existing status during refresh)
|
||
if (!hasCheckedUsdcOnChainRef.current) {
|
||
setUsdcStatus('loading')
|
||
}
|
||
|
||
try {
|
||
// Method 1: Use asset-specific API (most reliable)
|
||
// Returns holding if opted in, throws if not opted in
|
||
let holding: any = null
|
||
let apiCallSucceeded = false
|
||
|
||
try {
|
||
holding = await algorand.asset.getAccountInformation(activeAddress, BigInt(TESTNET_USDC_ASSET_ID))
|
||
apiCallSucceeded = true
|
||
} catch (assetApiError: unknown) {
|
||
// API call failed - account is likely not opted in
|
||
const error = assetApiError as any
|
||
|
||
// Check if it's a 404/not found error (definitely not opted in)
|
||
// vs a network error (should fall through to method 2)
|
||
const isNotFoundError =
|
||
error?.message?.includes('not found') ||
|
||
error?.message?.includes('404') ||
|
||
error?.status === 404 ||
|
||
error?.response?.status === 404
|
||
|
||
if (isNotFoundError) {
|
||
setUsdcStatus('not-opted-in')
|
||
setUsdcBalance(0n)
|
||
setHasCheckedUsdcOnChain(true)
|
||
hasCheckedUsdcOnChainRef.current = true
|
||
return
|
||
}
|
||
// Non-404 error - fall through to method 2 for verification
|
||
}
|
||
|
||
// Process successful API call result
|
||
if (apiCallSucceeded && holding) {
|
||
const holdingAny = holding as any
|
||
const amount = holdingAny?.amount ?? holdingAny?.balance ?? 0
|
||
const balance = typeof amount === 'bigint' ? amount : BigInt(amount ?? 0)
|
||
|
||
setUsdcStatus('opted-in')
|
||
setUsdcBalance(balance)
|
||
setHasCheckedUsdcOnChain(true)
|
||
hasCheckedUsdcOnChainRef.current = true
|
||
return
|
||
}
|
||
|
||
// Method 2: Fallback to account information API
|
||
// Used when method 1 has non-404 errors or for verification
|
||
const info = await algorand.client.algod.accountInformation(activeAddress).do()
|
||
const assets: Array<{ assetId: bigint; amount?: number | bigint }> = info?.assets ?? []
|
||
|
||
const usdcHolding = assets.find((a) => a.assetId === BigInt(TESTNET_USDC_ASSET_ID))
|
||
|
||
if (usdcHolding) {
|
||
const balance = BigInt(usdcHolding.amount ?? 0)
|
||
setUsdcStatus('opted-in')
|
||
setUsdcBalance(balance)
|
||
} else {
|
||
setUsdcStatus('not-opted-in')
|
||
setUsdcBalance(0n)
|
||
}
|
||
|
||
// Mark that we've successfully completed a blockchain check
|
||
setHasCheckedUsdcOnChain(true)
|
||
hasCheckedUsdcOnChainRef.current = true
|
||
} catch (e) {
|
||
// On error, set to not-opted-in but don't mark as checked
|
||
// This allows retry on next render cycle
|
||
setUsdcStatus('not-opted-in')
|
||
setUsdcBalance(0n)
|
||
setHasCheckedUsdcOnChain(false)
|
||
hasCheckedUsdcOnChainRef.current = false
|
||
} finally {
|
||
isCheckingUsdcRef.current = false
|
||
}
|
||
// Note: hasCheckedUsdcOnChain is read from closure, not needed in deps
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [activeAddress, algorand])
|
||
|
||
// Effect: Check USDC status when address changes or on mount
|
||
// Small delay allows wallet state to stabilize after reconnect
|
||
useEffect(() => {
|
||
// Reset state when address changes
|
||
setHasCheckedUsdcOnChain(false)
|
||
hasCheckedUsdcOnChainRef.current = false
|
||
hasShownUsdcWarningRef.current = false
|
||
isCheckingUsdcRef.current = false
|
||
|
||
if (!activeAddress) {
|
||
setUsdcStatus('not-opted-in')
|
||
setUsdcBalance(0n)
|
||
return
|
||
}
|
||
|
||
// Set loading immediately, then check after delay
|
||
setUsdcStatus('loading')
|
||
|
||
const timeoutId = setTimeout(() => {
|
||
checkUsdcOptInStatus()
|
||
}, 500)
|
||
|
||
return () => clearTimeout(timeoutId)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [activeAddress])
|
||
|
||
// Effect: Handle transfer mode changes and show appropriate warnings
|
||
// Only shows warnings after blockchain state is confirmed (not during loading)
|
||
useEffect(() => {
|
||
const prevMode = lastTransferModeRef.current
|
||
const modeChanged = prevMode !== transferMode
|
||
lastTransferModeRef.current = transferMode
|
||
|
||
// Set transfer asset ID based on mode
|
||
if (transferMode === 'algo') {
|
||
setTransferAssetId('ALGO')
|
||
} else if (transferMode === 'usdc') {
|
||
setTransferAssetId(String(TESTNET_USDC_ASSET_ID))
|
||
|
||
// Show warnings only when:
|
||
// 1. Actually switching TO usdc mode (not just re-render)
|
||
// 2. Blockchain check is complete (status confirmed)
|
||
// 3. Warning hasn't been shown already
|
||
if (modeChanged && hasCheckedUsdcOnChain && !hasShownUsdcWarningRef.current && usdcStatus === 'not-opted-in') {
|
||
enqueueSnackbar('You are not opted in to USDC yet. Please opt in before receiving or sending USDC.', {
|
||
variant: 'info',
|
||
})
|
||
hasShownUsdcWarningRef.current = true
|
||
} else if (
|
||
modeChanged &&
|
||
hasCheckedUsdcOnChain &&
|
||
!hasShownUsdcWarningRef.current &&
|
||
usdcStatus === 'opted-in' &&
|
||
usdcBalance === 0n
|
||
) {
|
||
enqueueSnackbar('Heads up: you have 0 USDC to send.', { variant: 'info' })
|
||
hasShownUsdcWarningRef.current = true
|
||
}
|
||
} else {
|
||
// Manual mode - reset asset ID if it was set to ALGO or USDC
|
||
if (transferAssetId === 'ALGO' || transferAssetId === String(TESTNET_USDC_ASSET_ID)) {
|
||
setTransferAssetId('')
|
||
}
|
||
// Prefill with latest created asset if available
|
||
if (!transferAssetId && createdAssets.length > 0) {
|
||
setTransferAssetId(String(createdAssets[0].assetId))
|
||
}
|
||
}
|
||
|
||
// Reset warning flag when leaving USDC mode
|
||
if (prevMode === 'usdc' && transferMode !== 'usdc') {
|
||
hasShownUsdcWarningRef.current = false
|
||
}
|
||
// Note: We intentionally don't include usdcStatus/usdcBalance in deps to avoid re-runs on status changes
|
||
// We only want this to run when transferMode or hasCheckedUsdcOnChain changes
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [transferMode, hasCheckedUsdcOnChain, enqueueSnackbar])
|
||
|
||
/**
|
||
* Opt-in to TestNet USDC
|
||
* Opt-in is an asset transfer of 0 USDC to self
|
||
*/
|
||
const handleOptInUsdc = async () => {
|
||
// Check for activeAddress first (primary indicator of connection)
|
||
// transactionSigner might be available even if not explicitly set
|
||
if (!activeAddress) {
|
||
enqueueSnackbar('Please connect a wallet or continue with Google first.', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
if (!signer) {
|
||
enqueueSnackbar('Wallet signer not available. Please try reconnecting your wallet.', { variant: 'error' })
|
||
return
|
||
}
|
||
|
||
// Prevent duplicate transactions if already opted in
|
||
if (usdcOptedIn) {
|
||
enqueueSnackbar('You are already opted in to USDC ✅', { variant: 'info' })
|
||
return
|
||
}
|
||
|
||
try {
|
||
setUsdcOptInLoading(true)
|
||
enqueueSnackbar('Opting into USDC...', { variant: 'info' })
|
||
|
||
// Opt-in = asset transfer of 0 to self
|
||
const result = await algorand.send.assetTransfer({
|
||
sender: activeAddress,
|
||
signer,
|
||
assetId: BigInt(TESTNET_USDC_ASSET_ID),
|
||
receiver: activeAddress,
|
||
amount: 0n,
|
||
})
|
||
|
||
const txId = (result as { txId?: string }).txId
|
||
|
||
// Optimistically update status immediately after successful transaction
|
||
setUsdcStatus('opted-in')
|
||
setUsdcBalance(0n)
|
||
setHasCheckedUsdcOnChain(true)
|
||
hasCheckedUsdcOnChainRef.current = true
|
||
hasShownUsdcWarningRef.current = true // Prevent warning since we just opted in
|
||
|
||
enqueueSnackbar('✅ USDC opted in!', {
|
||
variant: 'success',
|
||
action: () =>
|
||
txId ? (
|
||
<a
|
||
href={`${LORA_BASE}/transaction/${txId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ textDecoration: 'underline', marginLeft: 8 }}
|
||
>
|
||
View Tx on Lora ↗
|
||
</a>
|
||
) : null,
|
||
})
|
||
|
||
// Verify with blockchain after delay to confirm opt-in
|
||
setTimeout(() => {
|
||
checkUsdcOptInStatus()
|
||
}, 2000)
|
||
} catch (e) {
|
||
enqueueSnackbar('USDC opt-in failed.', { variant: 'error' })
|
||
} finally {
|
||
setUsdcOptInLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
setCreatedAssets(loadAssets())
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (activeAddress && !manager) setManager(activeAddress)
|
||
}, [activeAddress, manager])
|
||
|
||
// NFT: default manager to connected address (same UX as ASA)
|
||
useEffect(() => {
|
||
if (activeAddress && !nftManager) setNftManager(activeAddress)
|
||
}, [activeAddress, nftManager])
|
||
|
||
// Prefill transfer asset id from latest created asset (QoL) — only in manual mode
|
||
useEffect(() => {
|
||
if (transferMode !== 'manual') return
|
||
if (!transferAssetId && createdAssets.length > 0) {
|
||
setTransferAssetId(String(createdAssets[0].assetId))
|
||
}
|
||
}, [createdAssets, transferAssetId, transferMode])
|
||
|
||
const resetDefaults = () => {
|
||
setAssetName('Tokenized Coffee Membership')
|
||
setUnitName('COFFEE')
|
||
setTotal('1000')
|
||
setDecimals('0')
|
||
setUrl('')
|
||
setShowAdvanced(false)
|
||
setManager(activeAddress ?? '')
|
||
setReserve('')
|
||
setFreeze('')
|
||
setClawback('')
|
||
}
|
||
|
||
const resetNftDefaults = () => {
|
||
setSelectedFile(null)
|
||
setPreviewUrl('')
|
||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||
|
||
setNftName('MasterPass Ticket')
|
||
setNftUnit('MTK')
|
||
setNftSupply('1')
|
||
setNftDecimals('0')
|
||
setNftDefaultFrozen(false)
|
||
|
||
setNftShowAdvanced(false)
|
||
setNftManager(activeAddress ?? '')
|
||
setNftReserve('')
|
||
setNftFreeze('')
|
||
setNftClawback('')
|
||
}
|
||
|
||
const isWholeNumber = (v: string) => /^\d+$/.test(v)
|
||
|
||
const copyToClipboard = async (text: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(text)
|
||
enqueueSnackbar('Asset ID copied to clipboard', { variant: 'success' })
|
||
setTransferMode('manual')
|
||
setTransferAssetId(text)
|
||
} catch {
|
||
enqueueSnackbar('Copy failed. Please copy manually.', { variant: 'warning' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle ASA creation with validation and on-chain transaction
|
||
* Adjusts total supply by decimals and saves asset to localStorage
|
||
*/
|
||
const handleTokenize = async () => {
|
||
// Check for activeAddress first (primary indicator of connection)
|
||
if (!activeAddress) {
|
||
enqueueSnackbar('Please connect a wallet or continue with Google first.', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
if (!signer) {
|
||
enqueueSnackbar('Wallet signer not available. Please try reconnecting your wallet.', { variant: 'error' })
|
||
return
|
||
}
|
||
|
||
if (!assetName || !unitName) {
|
||
enqueueSnackbar('Please enter an asset name and symbol.', { variant: 'warning' })
|
||
return
|
||
}
|
||
if (!isWholeNumber(total)) {
|
||
enqueueSnackbar('Total supply must be a whole number.', { variant: 'warning' })
|
||
return
|
||
}
|
||
if (!isWholeNumber(decimals)) {
|
||
enqueueSnackbar('Decimals must be a whole number (0–19).', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
const d = Number(decimals)
|
||
if (Number.isNaN(d) || d < 0 || d > 19) {
|
||
enqueueSnackbar('Decimals must be between 0 and 19.', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
try {
|
||
setLoading(true)
|
||
enqueueSnackbar('Tokenizing asset (creating ASA)...', { variant: 'info' })
|
||
|
||
const onChainTotal = BigInt(total) * 10n ** BigInt(d)
|
||
|
||
const createResult = await algorand.send.assetCreate({
|
||
sender: activeAddress,
|
||
signer,
|
||
total: onChainTotal,
|
||
decimals: d,
|
||
assetName,
|
||
unitName,
|
||
url: url || undefined,
|
||
defaultFrozen: false,
|
||
manager: manager || undefined,
|
||
reserve: reserve || undefined,
|
||
freeze: freeze || undefined,
|
||
clawback: clawback || undefined,
|
||
})
|
||
|
||
const assetId = createResult.assetId
|
||
|
||
const newEntry: CreatedAsset = {
|
||
assetId: String(assetId),
|
||
assetName: String(assetName),
|
||
unitName: String(unitName),
|
||
total: String(total),
|
||
decimals: String(decimals),
|
||
url: url ? String(url) : undefined,
|
||
manager: manager ? String(manager) : undefined,
|
||
reserve: reserve ? String(reserve) : undefined,
|
||
freeze: freeze ? String(freeze) : undefined,
|
||
clawback: clawback ? String(clawback) : undefined,
|
||
createdAt: new Date().toISOString(),
|
||
}
|
||
|
||
const next = persistAsset(newEntry)
|
||
setCreatedAssets(next)
|
||
|
||
enqueueSnackbar(`✅ Success! Asset ID: ${assetId}`, {
|
||
variant: 'success',
|
||
action: () =>
|
||
assetId ? (
|
||
<a
|
||
href={`${LORA_BASE}/asset/${assetId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ textDecoration: 'underline', marginLeft: 8 }}
|
||
>
|
||
View on Lora ↗
|
||
</a>
|
||
) : null,
|
||
})
|
||
|
||
resetDefaults()
|
||
} catch (error: any) {
|
||
console.error('[ASA create] error:', error)
|
||
|
||
const msg = error?.response?.body?.message || error?.response?.text || error?.message || String(error)
|
||
|
||
enqueueSnackbar(`ASA creation failed: ${msg}`, { variant: 'error' })
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Transfer assets (Manual ASA / USDC ASA / ALGO payment)
|
||
* Handles validation, amount conversion, and transaction submission
|
||
*/
|
||
const handleTransferAsset = async () => {
|
||
// Check for activeAddress first (primary indicator of connection)
|
||
if (!activeAddress) {
|
||
enqueueSnackbar('Please connect a wallet or continue with Google first.', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
if (!signer) {
|
||
enqueueSnackbar('Wallet signer not available. Please try reconnecting your wallet.', { variant: 'error' })
|
||
return
|
||
}
|
||
|
||
if (!receiverAddress) {
|
||
enqueueSnackbar('Please enter a recipient address.', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
if (!transferAmount || Number(transferAmount) <= 0) {
|
||
enqueueSnackbar('Please enter an amount greater than 0.', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
// Manual ASA: validate Asset ID and whole-number amount
|
||
if (transferMode === 'manual') {
|
||
if (!transferAssetId || !isWholeNumber(transferAssetId)) {
|
||
enqueueSnackbar('Please enter a valid Asset ID (number).', { variant: 'warning' })
|
||
return
|
||
}
|
||
if (!isWholeNumber(transferAmount)) {
|
||
enqueueSnackbar('Amount must be a whole number for manual ASA transfers.', { variant: 'warning' })
|
||
return
|
||
}
|
||
}
|
||
|
||
// USDC + ALGO: allow decimals up to 6 places
|
||
if (transferMode === 'algo' || transferMode === 'usdc') {
|
||
if (!/^\d+(\.\d+)?$/.test(transferAmount.trim())) {
|
||
enqueueSnackbar('Amount must be a valid number (decimals allowed).', { variant: 'warning' })
|
||
return
|
||
}
|
||
}
|
||
|
||
// USDC: block transfer if not opted in (only if status is confirmed, not during loading)
|
||
if (transferMode === 'usdc' && hasCheckedUsdcOnChain && !usdcOptedIn) {
|
||
enqueueSnackbar('You must opt-in to USDC before you can send/receive it.', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
try {
|
||
setTransferLoading(true)
|
||
|
||
if (transferMode === 'algo') {
|
||
enqueueSnackbar('Sending ALGO...', { variant: 'info' })
|
||
|
||
const result = await algorand.send.payment({
|
||
sender: activeAddress,
|
||
signer,
|
||
receiver: receiverAddress,
|
||
amount: microAlgos(decimalToBaseUnits(transferAmount, ALGO_DECIMALS)),
|
||
})
|
||
|
||
const txId = (result as { txId?: string }).txId
|
||
|
||
enqueueSnackbar('✅ ALGO sent!', {
|
||
variant: 'success',
|
||
action: () =>
|
||
txId ? (
|
||
<a
|
||
href={`${LORA_BASE}/transaction/${txId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ textDecoration: 'underline', marginLeft: 8 }}
|
||
>
|
||
View Tx on Lora ↗
|
||
</a>
|
||
) : null,
|
||
})
|
||
} else if (transferMode === 'usdc') {
|
||
// Double-check opt-in status (in case it changed)
|
||
if (hasCheckedUsdcOnChain && !usdcOptedIn) {
|
||
enqueueSnackbar('You are not opted in to USDC yet. Please opt in first.', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
if (usdcBalance === 0n) {
|
||
enqueueSnackbar('You have 0 USDC to send.', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
enqueueSnackbar('Sending USDC...', { variant: 'info' })
|
||
const usdcAmount = decimalToBaseUnits(transferAmount, USDC_DECIMALS)
|
||
|
||
if (usdcAmount > usdcBalance) {
|
||
enqueueSnackbar('Insufficient USDC balance for this transfer.', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
const result = await algorand.send.assetTransfer({
|
||
sender: activeAddress,
|
||
signer,
|
||
assetId: BigInt(TESTNET_USDC_ASSET_ID),
|
||
receiver: receiverAddress,
|
||
amount: usdcAmount,
|
||
})
|
||
|
||
const txId = (result as { txId?: string }).txId
|
||
|
||
enqueueSnackbar('✅ USDC transfer complete!', {
|
||
variant: 'success',
|
||
action: () =>
|
||
txId ? (
|
||
<a
|
||
href={`${LORA_BASE}/transaction/${txId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ textDecoration: 'underline', marginLeft: 8 }}
|
||
>
|
||
View Tx on Lora ↗
|
||
</a>
|
||
) : null,
|
||
})
|
||
|
||
// Refresh balance after transfer to show updated amount
|
||
setTimeout(() => {
|
||
checkUsdcOptInStatus()
|
||
}, 2000)
|
||
} else {
|
||
// manual ASA
|
||
enqueueSnackbar('Transferring asset...', { variant: 'info' })
|
||
|
||
const result = await algorand.send.assetTransfer({
|
||
sender: activeAddress,
|
||
signer,
|
||
assetId: BigInt(transferAssetId),
|
||
receiver: receiverAddress,
|
||
amount: BigInt(transferAmount),
|
||
})
|
||
|
||
const txId = (result as { txId?: string }).txId
|
||
|
||
enqueueSnackbar('✅ Transfer complete!', {
|
||
variant: 'success',
|
||
action: () =>
|
||
txId ? (
|
||
<a
|
||
href={`${LORA_BASE}/transaction/${txId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ textDecoration: 'underline', marginLeft: 8 }}
|
||
>
|
||
View Tx on Lora ↗
|
||
</a>
|
||
) : null,
|
||
})
|
||
}
|
||
|
||
setReceiverAddress('')
|
||
setTransferAmount('1')
|
||
} catch (error) {
|
||
if (transferMode === 'algo') {
|
||
enqueueSnackbar('ALGO send failed.', { variant: 'error' })
|
||
} else {
|
||
enqueueSnackbar('Transfer failed. If sending an ASA (incl. USDC), make sure the recipient has opted in.', {
|
||
variant: 'error',
|
||
})
|
||
}
|
||
} finally {
|
||
setTransferLoading(false)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* NFT mint helpers
|
||
*/
|
||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0] || null
|
||
setSelectedFile(file)
|
||
setPreviewUrl(file ? URL.createObjectURL(file) : '')
|
||
}
|
||
|
||
const handleDivClick = () => fileInputRef.current?.click()
|
||
|
||
const handleMintNFT = async () => {
|
||
// Check for activeAddress first (primary indicator of connection)
|
||
if (!activeAddress) {
|
||
enqueueSnackbar('Please connect a wallet or continue with Google first.', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
if (!signer) {
|
||
enqueueSnackbar('Wallet signer not available. Please try reconnecting your wallet.', { variant: 'error' })
|
||
return
|
||
}
|
||
|
||
if (!selectedFile) {
|
||
enqueueSnackbar('Please select an image file to mint.', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
// Validate NFT fields
|
||
if (!nftName || !nftUnit) {
|
||
enqueueSnackbar('Please enter an NFT name and unit/symbol.', { variant: 'warning' })
|
||
return
|
||
}
|
||
if (!nftSupply || !isWholeNumber(nftSupply)) {
|
||
enqueueSnackbar('Supply must be a whole number.', { variant: 'warning' })
|
||
return
|
||
}
|
||
if (!nftDecimals || !isWholeNumber(nftDecimals)) {
|
||
enqueueSnackbar('Decimals must be a whole number (0–19).', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
const d = Number(nftDecimals)
|
||
if (Number.isNaN(d) || d < 0 || d > 19) {
|
||
enqueueSnackbar('NFT decimals must be between 0 and 19.', { variant: 'warning' })
|
||
return
|
||
}
|
||
|
||
setNftLoading(true)
|
||
enqueueSnackbar('Uploading and preparing NFT...', { variant: 'info' })
|
||
|
||
let metadataUrl = ''
|
||
try {
|
||
const backendBase = resolveBackendBase()
|
||
const backendApiUrl = `${backendBase.replace(/\/$/, '')}/api/pin-image`
|
||
|
||
const formData = new FormData()
|
||
formData.append('file', selectedFile)
|
||
|
||
const response = await fetch(backendApiUrl, {
|
||
method: 'POST',
|
||
body: formData,
|
||
mode: 'cors',
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text()
|
||
throw new Error(`Backend request failed: ${response.status} - ${errorText}`)
|
||
}
|
||
|
||
const data = await response.json()
|
||
metadataUrl = data.metadataUrl
|
||
if (!metadataUrl) throw new Error('Backend did not return a valid metadata URL')
|
||
} catch (e: any) {
|
||
enqueueSnackbar('Error uploading to backend. If in Codespaces, make port 3001 Public.', { variant: 'error' })
|
||
setNftLoading(false)
|
||
return
|
||
}
|
||
|
||
try {
|
||
enqueueSnackbar('Minting NFT on Algorand...', { variant: 'info' })
|
||
|
||
// Demo shortcut: hash the metadata URL string (ARC-3 would hash JSON bytes)
|
||
const metadataHash = Uint8Array.from(sha512_256.digest(metadataUrl))
|
||
|
||
const onChainTotal = BigInt(nftSupply) * 10n ** BigInt(d)
|
||
|
||
const createNFTResult = await algorand.send.assetCreate({
|
||
sender: activeAddress,
|
||
signer,
|
||
total: onChainTotal,
|
||
decimals: d,
|
||
assetName: nftName,
|
||
unitName: nftUnit,
|
||
url: metadataUrl,
|
||
metadataHash,
|
||
defaultFrozen: nftDefaultFrozen,
|
||
manager: nftManager || undefined,
|
||
reserve: nftReserve || undefined,
|
||
freeze: nftFreeze || undefined,
|
||
clawback: nftClawback || undefined,
|
||
})
|
||
|
||
const assetId = createNFTResult.assetId
|
||
|
||
// ✅ Persist minted NFT into SAME history list (NFTs are ASAs)
|
||
const nftEntry: CreatedAsset = {
|
||
assetId: String(assetId),
|
||
assetName: String(nftName),
|
||
unitName: String(nftUnit),
|
||
total: String(nftSupply),
|
||
decimals: String(nftDecimals),
|
||
url: metadataUrl ? String(metadataUrl) : undefined,
|
||
manager: nftManager ? String(nftManager) : undefined,
|
||
reserve: nftReserve ? String(nftReserve) : undefined,
|
||
freeze: nftFreeze ? String(nftFreeze) : undefined,
|
||
clawback: nftClawback ? String(nftClawback) : undefined,
|
||
createdAt: new Date().toISOString(),
|
||
}
|
||
|
||
const next = persistAsset(nftEntry)
|
||
setCreatedAssets(next)
|
||
|
||
// QoL: switch to manual mode + prefill transfer section with minted asset id
|
||
setTransferMode('manual')
|
||
setTransferAssetId(String(assetId))
|
||
|
||
enqueueSnackbar(`✅ Success! NFT Asset ID: ${assetId}`, {
|
||
variant: 'success',
|
||
action: () =>
|
||
assetId ? (
|
||
<a
|
||
href={`${LORA_BASE}/asset/${assetId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ textDecoration: 'underline', marginLeft: 8 }}
|
||
>
|
||
View on Lora ↗
|
||
</a>
|
||
) : null,
|
||
})
|
||
|
||
// Reset just the file picker + preview (keep fields, so they can mint many quickly)
|
||
setSelectedFile(null)
|
||
setPreviewUrl('')
|
||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||
} catch (e: any) {
|
||
enqueueSnackbar(`Failed to mint NFT: ${e?.message || 'Unknown error'}`, { variant: 'error' })
|
||
} finally {
|
||
setNftLoading(false)
|
||
}
|
||
}
|
||
|
||
const canSubmit = !!assetName && !!unitName && !!total && !loading && !!activeAddress
|
||
|
||
const canMintNft = !!nftName && !!nftUnit && !!nftSupply && !!nftDecimals && !!selectedFile && !!activeAddress && !nftLoading
|
||
|
||
const transferAmountLabel = transferMode === 'algo' ? 'Amount (ALGO)' : transferMode === 'usdc' ? 'Amount (USDC)' : 'Amount'
|
||
|
||
const transferAssetIdLabel = transferMode === 'algo' ? 'Asset (ALGO)' : transferMode === 'usdc' ? 'Asset (USDC)' : 'Asset ID'
|
||
|
||
// Helper to render USDC status text
|
||
const renderUsdcStatusText = () => {
|
||
if (usdcStatusLoading) {
|
||
return <span className="text-slate-500 dark:text-slate-400">Checking status...</span>
|
||
}
|
||
if (usdcOptedIn) {
|
||
return <span className="text-teal-700 dark:text-teal-300">Already opted in ✅</span>
|
||
}
|
||
return <span className="text-slate-600 dark:text-slate-300">Required before you can receive TestNet USDC.</span>
|
||
}
|
||
|
||
// Helper to render opt-in button text
|
||
const renderOptInButtonText = () => {
|
||
if (usdcOptInLoading) {
|
||
return (
|
||
<span className="flex items-center gap-2">
|
||
<AiOutlineLoading3Quarters className="animate-spin" />
|
||
Opting in…
|
||
</span>
|
||
)
|
||
}
|
||
if (usdcStatusLoading) {
|
||
return (
|
||
<span className="flex items-center gap-2">
|
||
<AiOutlineLoading3Quarters className="animate-spin" />
|
||
Checking…
|
||
</span>
|
||
)
|
||
}
|
||
if (usdcOptedIn) {
|
||
return 'USDC opted in ✅'
|
||
}
|
||
return 'Opt in USDC'
|
||
}
|
||
|
||
return (
|
||
<div className="bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-lg p-6 sm:p-8">
|
||
{/* Top header */}
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex items-start gap-3">
|
||
<span className="inline-flex h-12 w-12 items-center justify-center rounded-lg bg-teal-100 dark:bg-teal-900/30">
|
||
<BsCoin className="text-2xl text-teal-600 dark:text-teal-400" />
|
||
</span>
|
||
<div>
|
||
<h2 className="text-xl sm:text-2xl font-bold tracking-tight text-slate-900 dark:text-white">Tokenize on Algorand</h2>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1">Mint standard ASAs or mint an NFT-style ASA on TestNet.</p>
|
||
{/* TestNet funding helper */}
|
||
<div className="mt-4 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 p-4">
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||
<div className="text-sm text-slate-700 dark:text-slate-200">
|
||
Need TestNet ALGO to get started? Use the Algorand TestNet Dispenser.
|
||
<span className="block text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||
Tip: fund the connected address, then refresh your balance.
|
||
</span>
|
||
</div>
|
||
|
||
<a
|
||
href="https://bank.testnet.algorand.network/"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center justify-center px-4 py-2 rounded-lg font-semibold bg-teal-600 hover:bg-teal-700 text-white shadow-md transition"
|
||
>
|
||
Open Dispenser ↗
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ASA loading bar */}
|
||
{loading && (
|
||
<div className="relative h-1 w-full mt-5 overflow-hidden rounded bg-slate-200 dark:bg-slate-700">
|
||
<div className="absolute inset-y-0 left-0 w-1/3 animate-[loading_1.2s_ease-in-out_infinite] bg-teal-600 dark:bg-teal-500" />
|
||
<style>{`
|
||
@keyframes loading {
|
||
0% { transform: translateX(-120%); }
|
||
50% { transform: translateX(60%); }
|
||
100% { transform: translateX(220%); }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
)}
|
||
|
||
{/* MAIN: 2-column panel (ASA left, NFT right) */}
|
||
<div className="mt-6">
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||
{/* ===== LEFT: ASA TOKENIZE FORM ===== */}
|
||
<div className={`${loading ? 'opacity-50 pointer-events-none' : ''}`}>
|
||
<div className="mb-4">
|
||
<h3 className="text-lg font-bold text-slate-900 dark:text-white">Tokenize an Asset (Mint ASA)</h3>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1">Create a standard ASA on TestNet. Perfect for RWA POCs.</p>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-sm p-5 sm:p-6">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Asset Name</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
value={assetName}
|
||
onChange={(e) => setAssetName(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Symbol</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
value={unitName}
|
||
onChange={(e) => setUnitName(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Total Supply</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
value={total}
|
||
onChange={(e) => setTotal(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
|
||
<span>Decimals</span>
|
||
<div className="group relative">
|
||
<AiOutlineInfoCircle className="text-slate-400 cursor-help hover:text-slate-600 dark:hover:text-slate-300" />
|
||
<div className="invisible group-hover:visible bg-slate-900 dark:bg-slate-800 text-white dark:text-slate-200 text-xs rounded px-2 py-1 whitespace-nowrap absolute bottom-full left-0 mb-1 z-10">
|
||
Decimals controls fractional units. 0 = whole units only.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
max={19}
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
value={decimals}
|
||
onChange={(e) => setDecimals(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="md:col-span-2">
|
||
<label className="flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
|
||
<span>Metadata URL (optional)</span>
|
||
<div className="group relative">
|
||
<AiOutlineInfoCircle className="text-slate-400 cursor-help hover:text-slate-600 dark:hover:text-slate-300" />
|
||
<div className="invisible group-hover:visible bg-slate-900 dark:bg-slate-800 text-white dark:text-slate-200 text-xs rounded px-2 py-1 whitespace-nowrap absolute bottom-full left-0 mb-1 z-10">
|
||
A public link describing the asset (JSON, webpage, or doc).
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<input
|
||
type="url"
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
placeholder="https://example.com/metadata.json"
|
||
value={url}
|
||
onChange={(e) => setUrl(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-6">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowAdvanced((s) => !s)}
|
||
className="flex items-center gap-2 text-sm font-medium text-primary hover:underline transition"
|
||
>
|
||
<span>{showAdvanced ? 'Hide advanced options' : 'Show advanced options'}</span>
|
||
<span className={`transition-transform ${showAdvanced ? 'rotate-180' : ''}`}>▾</span>
|
||
</button>
|
||
|
||
{showAdvanced && (
|
||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-5">
|
||
{[
|
||
{
|
||
label: 'Manager',
|
||
tip: 'The manager can update or reconfigure asset settings. Often set to the issuer wallet.',
|
||
value: manager,
|
||
setValue: setManager,
|
||
placeholder: 'Defaults to your wallet address',
|
||
},
|
||
{
|
||
label: 'Reserve',
|
||
tip: 'Reserve may hold non-circulating supply depending on your design. Leave blank to disable.',
|
||
value: reserve,
|
||
setValue: setReserve,
|
||
placeholder: 'Optional address',
|
||
},
|
||
{
|
||
label: 'Freeze',
|
||
tip: 'Freeze can freeze/unfreeze holdings (useful for compliance). Leave blank to disable.',
|
||
value: freeze,
|
||
setValue: setFreeze,
|
||
placeholder: 'Optional address',
|
||
},
|
||
{
|
||
label: 'Clawback',
|
||
tip: 'Clawback can revoke tokens from accounts (recovery/compliance). Leave blank to disable.',
|
||
value: clawback,
|
||
setValue: setClawback,
|
||
placeholder: 'Optional address',
|
||
},
|
||
].map((f) => (
|
||
<div key={f.label}>
|
||
<label className="flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
|
||
<span>{f.label}</span>
|
||
<div className="group relative">
|
||
<AiOutlineInfoCircle className="text-slate-400 cursor-help hover:text-slate-600 dark:hover:text-slate-300" />
|
||
<div className="invisible group-hover:visible bg-slate-900 dark:bg-slate-800 text-white dark:text-slate-200 text-xs rounded px-2 py-1 whitespace-nowrap absolute bottom-full left-0 mb-1 z-10">
|
||
{f.tip}
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
placeholder={f.placeholder}
|
||
value={f.value}
|
||
onChange={(e) => f.setValue(e.target.value)}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-8 flex flex-col sm:flex-row gap-3 sm:justify-end">
|
||
<button
|
||
type="button"
|
||
className={`px-6 py-3 rounded-lg font-semibold transition ${
|
||
canSubmit
|
||
? 'bg-teal-600 hover:bg-teal-700 text-white shadow-md'
|
||
: 'bg-slate-300 text-slate-500 cursor-not-allowed dark:bg-slate-700 dark:text-slate-400'
|
||
}`}
|
||
onClick={handleTokenize}
|
||
disabled={!canSubmit}
|
||
>
|
||
{loading ? (
|
||
<span className="flex items-center gap-2">
|
||
<AiOutlineLoading3Quarters className="animate-spin" />
|
||
Creating…
|
||
</span>
|
||
) : (
|
||
'Tokenize Asset'
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ===== RIGHT: NFT MINT PANEL ===== */}
|
||
<div className={`${nftLoading ? 'opacity-90' : ''}`}>
|
||
<div className="mb-4">
|
||
<h3 className="text-lg font-bold text-slate-900 dark:text-white">Tokenize an NFT (Mint ASA)</h3>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1">
|
||
Upload an image → backend pins to IPFS → mint an ASA with metadata.
|
||
</p>
|
||
</div>
|
||
|
||
<div
|
||
className={`rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-sm p-5 sm:p-6 ${
|
||
nftLoading ? 'pointer-events-none opacity-70' : ''
|
||
}`}
|
||
>
|
||
{/* NFT fields */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Name</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
value={nftName}
|
||
onChange={(e) => setNftName(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Unit / Symbol</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
value={nftUnit}
|
||
onChange={(e) => setNftUnit(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Supply</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
value={nftSupply}
|
||
onChange={(e) => setNftSupply(e.target.value)}
|
||
/>
|
||
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400">For a true 1/1 NFT, set supply = 1.</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
|
||
<span>Decimals</span>
|
||
<div className="group relative">
|
||
<AiOutlineInfoCircle className="text-slate-400 cursor-help hover:text-slate-600 dark:hover:text-slate-300" />
|
||
<div className="invisible group-hover:visible bg-slate-900 dark:bg-slate-800 text-white dark:text-slate-200 text-xs rounded px-2 py-1 whitespace-nowrap absolute bottom-full left-0 mb-1 z-10">
|
||
Decimals controls fractional units. For a typical NFT, use 0.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
max={19}
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
value={nftDecimals}
|
||
onChange={(e) => setNftDecimals(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="md:col-span-2">
|
||
<label className="flex items-center gap-3 text-sm font-semibold text-slate-700 dark:text-slate-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={nftDefaultFrozen}
|
||
onChange={(e) => setNftDefaultFrozen(e.target.checked)}
|
||
className="h-4 w-4 rounded border border-slate-300 dark:border-slate-600"
|
||
/>
|
||
<span>Default Frozen</span>
|
||
<div className="group relative">
|
||
<AiOutlineInfoCircle className="text-slate-400 cursor-help hover:text-slate-600 dark:hover:text-slate-300" />
|
||
<div className="invisible group-hover:visible bg-slate-900 dark:bg-slate-800 text-white dark:text-slate-200 text-xs rounded px-2 py-1 whitespace-nowrap absolute bottom-full left-0 mb-1 z-10">
|
||
If enabled, new holdings start frozen until unfrozen by the Freeze account.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* NFT advanced options toggle */}
|
||
<div className="mt-6">
|
||
<button
|
||
type="button"
|
||
onClick={() => setNftShowAdvanced((s) => !s)}
|
||
className="flex items-center gap-2 text-sm font-medium text-primary hover:underline transition"
|
||
>
|
||
<span>{nftShowAdvanced ? 'Hide advanced options' : 'Show advanced options'}</span>
|
||
<span className={`transition-transform ${nftShowAdvanced ? 'rotate-180' : ''}`}>▾</span>
|
||
</button>
|
||
|
||
{nftShowAdvanced && (
|
||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-5">
|
||
{[
|
||
{
|
||
label: 'Manager',
|
||
tip: 'Manager can update or reconfigure asset settings. Often set to the issuer wallet.',
|
||
value: nftManager,
|
||
setValue: setNftManager,
|
||
placeholder: 'Defaults to your wallet address',
|
||
},
|
||
{
|
||
label: 'Reserve',
|
||
tip: 'Reserve can hold non-circulating supply depending on design. Leave blank to disable.',
|
||
value: nftReserve,
|
||
setValue: setNftReserve,
|
||
placeholder: 'Optional address',
|
||
},
|
||
{
|
||
label: 'Freeze',
|
||
tip: 'Freeze can freeze/unfreeze holdings (useful for compliance). Leave blank to disable.',
|
||
value: nftFreeze,
|
||
setValue: setNftFreeze,
|
||
placeholder: 'Optional address',
|
||
},
|
||
{
|
||
label: 'Clawback',
|
||
tip: 'Clawback can revoke tokens from accounts (recovery/compliance). Leave blank to disable.',
|
||
value: nftClawback,
|
||
setValue: setNftClawback,
|
||
placeholder: 'Optional address',
|
||
},
|
||
].map((f) => (
|
||
<div key={f.label}>
|
||
<label className="flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
|
||
<span>{f.label}</span>
|
||
<div className="group relative">
|
||
<AiOutlineInfoCircle className="text-slate-400 cursor-help hover:text-slate-600 dark:hover:text-slate-300" />
|
||
<div className="invisible group-hover:visible bg-slate-900 dark:bg-slate-800 text-white dark:text-slate-200 text-xs rounded px-2 py-1 whitespace-nowrap absolute bottom-full left-0 mb-1 z-10">
|
||
{f.tip}
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
placeholder={f.placeholder}
|
||
value={f.value}
|
||
onChange={(e) => f.setValue(e.target.value)}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Image upload */}
|
||
<div className="mt-6">
|
||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Select an image</label>
|
||
|
||
<div
|
||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-slate-200 dark:border-slate-700 rounded-xl cursor-pointer bg-slate-50 dark:bg-slate-800/40 hover:border-teal-200 dark:hover:border-teal-700 transition-colors"
|
||
onClick={handleDivClick}
|
||
>
|
||
{previewUrl ? (
|
||
<img
|
||
src={previewUrl}
|
||
alt="NFT preview"
|
||
className="rounded-lg max-h-48 object-contain shadow-sm bg-white dark:bg-slate-900"
|
||
/>
|
||
) : (
|
||
<div className="text-center">
|
||
<AiOutlineCloudUpload className="mx-auto h-12 w-12 text-slate-400" />
|
||
<p className="mt-2 text-sm text-slate-700 dark:text-slate-200">Drag and drop or click to upload</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">PNG, JPG, GIF up to 10MB</p>
|
||
</div>
|
||
)}
|
||
|
||
<input
|
||
type="file"
|
||
ref={fileInputRef}
|
||
className="sr-only"
|
||
onChange={handleFileChange}
|
||
accept="image/png, image/jpeg, image/gif"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Buttons */}
|
||
<div className="mt-6 flex flex-col sm:flex-row gap-3 sm:justify-end">
|
||
<button
|
||
type="button"
|
||
className="px-6 py-3 rounded-lg font-semibold transition bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-700"
|
||
onClick={resetNftDefaults}
|
||
disabled={nftLoading}
|
||
>
|
||
Reset
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleMintNFT}
|
||
disabled={!canMintNft}
|
||
className={`px-6 py-3 rounded-lg font-semibold transition ${
|
||
canMintNft
|
||
? 'bg-teal-600 hover:bg-teal-700 text-white shadow-md'
|
||
: 'bg-slate-300 text-slate-500 cursor-not-allowed dark:bg-slate-700 dark:text-slate-400'
|
||
}`}
|
||
>
|
||
{nftLoading ? (
|
||
<span className="flex items-center gap-2">
|
||
<AiOutlineLoading3Quarters className="animate-spin" />
|
||
Minting…
|
||
</span>
|
||
) : (
|
||
'Mint NFT'
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-2">
|
||
<AiOutlineInfoCircle />
|
||
Uses backend <span className="font-mono">/api/pin-image</span>. In Codespaces, make port 3001 Public.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ===== MY CREATED ASSETS ===== */}
|
||
<div className="mt-10">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-lg font-bold text-slate-900 dark:text-white">My Created Assets</h3>
|
||
<button
|
||
type="button"
|
||
className="px-3 py-1 text-xs bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 border border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300 rounded-lg font-medium transition"
|
||
onClick={() => {
|
||
localStorage.removeItem(STORAGE_KEY)
|
||
setCreatedAssets([])
|
||
}}
|
||
>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
|
||
<div className="overflow-x-auto border border-slate-200 dark:border-slate-700 rounded-lg">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800">
|
||
<th className="text-left px-4 py-3 font-semibold text-slate-900 dark:text-white">Asset ID</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-slate-900 dark:text-white">Name</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-slate-900 dark:text-white">Symbol</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-slate-900 dark:text-white">Supply</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-slate-900 dark:text-white">Decimals</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{createdAssets.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={5} className="text-center px-4 py-6 text-slate-500 dark:text-slate-400">
|
||
No assets created yet. Mint one to see it here.
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
createdAssets.map((a) => (
|
||
<tr
|
||
key={`${a.assetId}-${a.createdAt}`}
|
||
className="border-b border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800/50 cursor-pointer transition"
|
||
onClick={() => window.open(`${LORA_BASE}/asset/${a.assetId}`, '_blank', 'noopener,noreferrer')}
|
||
title="Open in Lora explorer"
|
||
>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-mono text-xs text-slate-700 dark:text-slate-300">{String(a.assetId)}</span>
|
||
<button
|
||
type="button"
|
||
className="px-2 py-1 text-[11px] rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 transition"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
copyToClipboard(String(a.assetId))
|
||
}}
|
||
title="Copy Asset ID"
|
||
>
|
||
Copy
|
||
</button>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-slate-900 dark:text-white">{a.assetName}</td>
|
||
<td className="font-mono px-4 py-3 text-slate-700 dark:text-slate-300">{a.unitName}</td>
|
||
<td className="font-mono px-4 py-3 text-slate-700 dark:text-slate-300">{a.total}</td>
|
||
<td className="font-mono px-4 py-3 text-slate-700 dark:text-slate-300">{a.decimals}</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-2">
|
||
<AiOutlineInfoCircle />
|
||
This list is stored locally in your browser (localStorage) to keep the template simple.
|
||
</p>
|
||
</div>
|
||
|
||
{/* ===== TRANSFER ===== */}
|
||
<div className="mt-12 bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-lg p-6 sm:p-8">
|
||
<h3 className="text-lg font-bold text-slate-900 dark:text-white mb-2">Transfer</h3>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-6">Send ALGO, USDC, or any ASA (including NFTs) to another wallet.</p>
|
||
|
||
{/* USDC Opt-in (only relevant for receiving USDC) */}
|
||
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 p-4">
|
||
<div className="text-sm text-slate-700 dark:text-slate-200">
|
||
<span className="font-semibold">USDC Opt-In:</span> {renderUsdcStatusText()}
|
||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||
Asset ID: <span className="font-mono">{TESTNET_USDC_ASSET_ID}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleOptInUsdc}
|
||
disabled={!activeAddress || usdcOptedIn || usdcOptInLoading || usdcStatusLoading}
|
||
className={`inline-flex items-center justify-center px-4 py-2 rounded-lg font-semibold transition ${
|
||
!activeAddress || usdcOptedIn || usdcOptInLoading || usdcStatusLoading
|
||
? 'bg-slate-300 text-slate-500 cursor-not-allowed dark:bg-slate-700 dark:text-slate-400'
|
||
: 'bg-teal-600 hover:bg-teal-700 text-white shadow-md'
|
||
}`}
|
||
>
|
||
{renderOptInButtonText()}
|
||
</button>
|
||
</div>
|
||
|
||
{/* TestNet USDC helper */}
|
||
<div className="mb-6 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 p-4">
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||
<div className="text-sm text-slate-700 dark:text-slate-200">
|
||
Need TestNet USDC? Use Circle's faucet, then transfer it like any ASA.
|
||
<span className="block text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||
Note: you may need to opt-in to the USDC asset before receiving it.
|
||
</span>
|
||
</div>
|
||
|
||
<a
|
||
href="https://faucet.circle.com/"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center justify-center px-4 py-2 rounded-lg font-semibold bg-teal-600 hover:bg-teal-700 text-white shadow-md transition"
|
||
>
|
||
Open USDC Faucet ↗
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mode selector */}
|
||
<div className="mb-5">
|
||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Transfer type</label>
|
||
<div className="flex flex-col sm:flex-row gap-3">
|
||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||
<input
|
||
type="radio"
|
||
name="transferMode"
|
||
checked={transferMode === 'manual'}
|
||
onChange={() => setTransferMode('manual')}
|
||
className="h-4 w-4"
|
||
/>
|
||
Manual (custom ASA)
|
||
</label>
|
||
|
||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||
<input
|
||
type="radio"
|
||
name="transferMode"
|
||
checked={transferMode === 'algo'}
|
||
onChange={() => setTransferMode('algo')}
|
||
className="h-4 w-4"
|
||
/>
|
||
ALGO
|
||
</label>
|
||
|
||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||
<input
|
||
type="radio"
|
||
name="transferMode"
|
||
checked={transferMode === 'usdc'}
|
||
onChange={() => setTransferMode('usdc')}
|
||
className="h-4 w-4"
|
||
/>
|
||
USDC (TestNet)
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">{transferAssetIdLabel}</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
placeholder="e.g. 123456789"
|
||
value={transferAssetId}
|
||
onChange={(e) => setTransferAssetId(e.target.value)}
|
||
disabled={transferMode === 'algo' || transferMode === 'usdc'}
|
||
/>
|
||
{transferMode === 'usdc' && (
|
||
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400">
|
||
USDC TestNet Asset ID: <span className="font-mono">{TESTNET_USDC_ASSET_ID}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Recipient Address</label>
|
||
<input
|
||
type="text"
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
placeholder="Wallet address"
|
||
value={receiverAddress}
|
||
onChange={(e) => setReceiverAddress(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">{transferAmountLabel}</label>
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
className="w-full rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 border border-slate-300 dark:border-slate-600 focus:outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 dark:focus:ring-teal-900/30 px-4 py-2 transition"
|
||
value={transferAmount}
|
||
onChange={(e) => setTransferAmount(e.target.value)}
|
||
placeholder={transferMode === 'manual' ? 'e.g. 1' : 'e.g. 1.5'}
|
||
/>
|
||
{transferMode === 'manual' && (
|
||
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400">Manual ASA transfers use whole-number amounts.</p>
|
||
)}
|
||
{(transferMode === 'algo' || transferMode === 'usdc') && (
|
||
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400">Decimals allowed (up to 6 places).</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-6 flex flex-col sm:flex-row gap-3 sm:justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={handleTransferAsset}
|
||
disabled={transferLoading || !activeAddress}
|
||
className={`px-6 py-3 rounded-lg font-semibold transition ${
|
||
transferLoading || !activeAddress
|
||
? 'bg-slate-300 text-slate-500 cursor-not-allowed dark:bg-slate-700 dark:text-slate-400'
|
||
: 'bg-teal-600 hover:bg-teal-700 text-white shadow-md'
|
||
}`}
|
||
>
|
||
{transferLoading
|
||
? 'Transferring…'
|
||
: transferMode === 'algo'
|
||
? 'Send ALGO'
|
||
: transferMode === 'usdc'
|
||
? 'Send USDC'
|
||
: 'Transfer Asset'}
|
||
</button>
|
||
</div>
|
||
|
||
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-2">
|
||
<AiOutlineInfoCircle />
|
||
{transferMode === 'algo'
|
||
? 'ALGO payments do not require opt-in.'
|
||
: 'For ASAs (including USDC and NFTs), the recipient must opt-in to the asset before receiving it.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|