Files
TokenizeRWATemplate/projects/TokenizeRWATemplate-frontend/src/components/TokenizeAsset.tsx
Raghav d62f2fd1ca
Some checks failed
Release / Run TokenizeRWATemplate-contracts release (push) Has been cancelled
Release / Run TokenizeRWATemplate-frontend release (push) Has been cancelled
Update project templates and workspace configuration files
Co-Authored-By: Oz <oz-agent@warp.dev>
2026-04-16 10:38:17 +05:30

1681 lines
70 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (019).', { 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 (019).', { 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&apos;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>
)
}