Add algo / usdc transfers

This commit is contained in:
SaraJane
2026-01-12 13:42:36 +00:00
parent 660dc742ad
commit 6386a98986

View File

@ -28,6 +28,13 @@ type CreatedAsset = {
const STORAGE_KEY = 'tokenize_assets' const STORAGE_KEY = 'tokenize_assets'
const LORA_BASE = 'https://lora.algokit.io/testnet' 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 { function resolveBackendBase(): string {
// 1) Respect explicit env (Vercel or custom) // 1) Respect explicit env (Vercel or custom)
const env = import.meta.env.VITE_API_URL?.trim() const env = import.meta.env.VITE_API_URL?.trim()
@ -68,6 +75,32 @@ function persistAsset(asset: CreatedAsset): CreatedAsset[] {
return 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 * TokenizeAsset Component
* Main form for creating Algorand Standard Assets (ASAs) * Main form for creating Algorand Standard Assets (ASAs)
@ -92,6 +125,7 @@ export default function TokenizeAsset() {
const [createdAssets, setCreatedAssets] = useState<CreatedAsset[]>([]) const [createdAssets, setCreatedAssets] = useState<CreatedAsset[]>([])
// ===== Transfer state ===== // ===== Transfer state =====
const [transferMode, setTransferMode] = useState<TransferMode>('manual')
const [transferAssetId, setTransferAssetId] = useState<string>('') const [transferAssetId, setTransferAssetId] = useState<string>('')
const [receiverAddress, setReceiverAddress] = useState<string>('') const [receiverAddress, setReceiverAddress] = useState<string>('')
const [transferAmount, setTransferAmount] = useState<string>('1') const [transferAmount, setTransferAmount] = useState<string>('1')
@ -138,12 +172,32 @@ export default function TokenizeAsset() {
if (activeAddress && !nftManager) setNftManager(activeAddress) if (activeAddress && !nftManager) setNftManager(activeAddress)
}, [activeAddress, nftManager]) }, [activeAddress, nftManager])
// Prefill transfer asset id from latest created asset (QoL) // Prefill transfer asset id from latest created asset (QoL) — only in manual mode
useEffect(() => { useEffect(() => {
if (transferMode !== 'manual') return
if (!transferAssetId && createdAssets.length > 0) { if (!transferAssetId && createdAssets.length > 0) {
setTransferAssetId(String(createdAssets[0].assetId)) setTransferAssetId(String(createdAssets[0].assetId))
} }
}, [createdAssets, transferAssetId]) }, [createdAssets, transferAssetId, transferMode])
// When switching transfer mode, set the asset id display/value accordingly
useEffect(() => {
if (transferMode === 'algo') {
setTransferAssetId('ALGO')
} else if (transferMode === 'usdc') {
setTransferAssetId(String(TESTNET_USDC_ASSET_ID))
} else {
// manual: keep whatever the user had, but if it was ALGO then clear
if (transferAssetId === 'ALGO' || transferAssetId === String(TESTNET_USDC_ASSET_ID)) {
setTransferAssetId('')
}
// If we have history, prefill from latest
if (!transferAssetId && createdAssets.length > 0) {
setTransferAssetId(String(createdAssets[0].assetId))
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transferMode])
const resetDefaults = () => { const resetDefaults = () => {
setAssetName('Tokenized Coffee Membership') setAssetName('Tokenized Coffee Membership')
@ -182,6 +236,7 @@ export default function TokenizeAsset() {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
enqueueSnackbar('Asset ID copied to clipboard', { variant: 'success' }) enqueueSnackbar('Asset ID copied to clipboard', { variant: 'success' })
setTransferMode('manual')
setTransferAssetId(text) setTransferAssetId(text)
} catch { } catch {
enqueueSnackbar('Copy failed. Please copy manually.', { variant: 'warning' }) enqueueSnackbar('Copy failed. Please copy manually.', { variant: 'warning' })
@ -282,7 +337,7 @@ export default function TokenizeAsset() {
} }
/** /**
* Transfer ASA (works for NFTs too, since NFTs are ASAs) * Transfer (Manual ASA / USDC ASA / ALGO payment)
*/ */
const handleTransferAsset = async () => { const handleTransferAsset = async () => {
if (!transactionSigner || !activeAddress) { if (!transactionSigner || !activeAddress) {
@ -290,55 +345,135 @@ export default function TokenizeAsset() {
return return
} }
if (!transferAssetId || !isWholeNumber(transferAssetId)) {
enqueueSnackbar('Please enter a valid Asset ID (number).', { variant: 'warning' })
return
}
if (!receiverAddress) { if (!receiverAddress) {
enqueueSnackbar('Please enter a recipient address.', { variant: 'warning' }) enqueueSnackbar('Please enter a recipient address.', { variant: 'warning' })
return return
} }
if (!transferAmount || !isWholeNumber(transferAmount)) { if (!transferAmount || Number(transferAmount) <= 0) {
enqueueSnackbar('Amount must be a whole number.', { variant: 'warning' }) enqueueSnackbar('Please enter an amount greater than 0.', { variant: 'warning' })
return return
} }
// Manual ASA validates Asset ID + 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
if (transferMode === 'algo' || transferMode === 'usdc') {
if (!/^\d+(\.\d+)?$/.test(transferAmount.trim())) {
enqueueSnackbar('Amount must be a valid number (decimals allowed).', { variant: 'warning' })
return
}
}
try { try {
setTransferLoading(true) setTransferLoading(true)
enqueueSnackbar('Transferring asset...', { variant: 'info' })
const result = await algorand.send.assetTransfer({ if (transferMode === 'algo') {
sender: activeAddress, enqueueSnackbar('Sending ALGO...', { variant: 'info' })
signer: transactionSigner,
assetId: Number(transferAssetId),
receiver: receiverAddress,
amount: BigInt(transferAmount),
})
const txId = (result as { txId?: string }).txId const microAlgos = decimalToBaseUnits(transferAmount, ALGO_DECIMALS)
enqueueSnackbar('✅ Transfer complete!', { const result = await algorand.send.payment({
variant: 'success', sender: activeAddress,
action: () => signer: transactionSigner,
txId ? ( receiver: receiverAddress,
<a amount: { microAlgo: Number(microAlgos) },
href={`${LORA_BASE}/transaction/${txId}`} })
target="_blank"
rel="noopener noreferrer" const txId = (result as { txId?: string }).txId
style={{ textDecoration: 'underline', marginLeft: 8 }}
> enqueueSnackbar('✅ ALGO sent!', {
View Tx on Lora variant: 'success',
</a> action: () =>
) : null, 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') {
enqueueSnackbar('Sending USDC...', { variant: 'info' })
const usdcAmount = decimalToBaseUnits(transferAmount, USDC_DECIMALS)
const result = await algorand.send.assetTransfer({
sender: activeAddress,
signer: transactionSigner,
assetId: 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,
})
} else {
// manual ASA
enqueueSnackbar('Transferring asset...', { variant: 'info' })
const result = await algorand.send.assetTransfer({
sender: activeAddress,
signer: transactionSigner,
assetId: Number(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('') setReceiverAddress('')
setTransferAmount('1') setTransferAmount('1')
} catch (error) { } catch (error) {
console.error(error) console.error(error)
enqueueSnackbar('Transfer failed. Make sure the recipient has opted in.', { variant: '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 { } finally {
setTransferLoading(false) setTransferLoading(false)
} }
@ -462,7 +597,8 @@ export default function TokenizeAsset() {
const next = persistAsset(nftEntry) const next = persistAsset(nftEntry)
setCreatedAssets(next) setCreatedAssets(next)
// QoL: prefill transfer section with minted asset id // QoL: switch to manual mode + prefill transfer section with minted asset id
setTransferMode('manual')
setTransferAssetId(String(assetId)) setTransferAssetId(String(assetId))
enqueueSnackbar(`✅ Success! NFT Asset ID: ${assetId}`, { enqueueSnackbar(`✅ Success! NFT Asset ID: ${assetId}`, {
@ -503,6 +639,12 @@ export default function TokenizeAsset() {
!!activeAddress && !!activeAddress &&
!nftLoading !nftLoading
const transferAmountLabel =
transferMode === 'algo' ? 'Amount (ALGO)' : transferMode === 'usdc' ? 'Amount (USDC)' : 'Amount'
const transferAssetIdLabel =
transferMode === 'algo' ? 'Asset (ALGO)' : transferMode === 'usdc' ? 'Asset (USDC)' : 'Asset ID'
return ( 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"> <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 */} {/* Top header */}
@ -543,177 +685,181 @@ export default function TokenizeAsset() {
</div> </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="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 className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div> <div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Asset Name</label> <label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Asset Name</label>
<input <input
type="text" 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" 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} value={assetName}
onChange={(e) => setAssetName(e.target.value)} 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>
<div className="mt-8 flex flex-col sm:flex-row gap-3 sm:justify-end"> <div>
<button <label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Symbol</label>
type="button" <input
className={`px-6 py-3 rounded-lg font-semibold transition ${ type="text"
canSubmit 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"
? 'bg-teal-600 hover:bg-teal-700 text-white shadow-md' value={unitName}
: 'bg-slate-300 text-slate-500 cursor-not-allowed dark:bg-slate-700 dark:text-slate-400' onChange={(e) => setUnitName(e.target.value)}
}`} />
onClick={handleTokenize} </div>
disabled={!canSubmit}
> <div>
{loading ? ( <label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Total Supply</label>
<span className="flex items-center gap-2"> <input
<AiOutlineLoading3Quarters className="animate-spin" /> type="number"
Creating min={1}
</span> 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}
'Tokenize Asset' 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>
)} )}
</button> </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>
</div> </div>
</div>
{/* ===== RIGHT: NFT MINT PANEL ===== */} {/* ===== RIGHT: NFT MINT PANEL ===== */}
<div className={`${nftLoading ? 'opacity-90' : ''}`}> <div className={`${nftLoading ? 'opacity-90' : ''}`}>
<div className="mb-4"> <div className="mb-4">
<h3 className="text-lg font-bold text-slate-900 dark:text-white">Tokenize an NFT (Mint ASA)</h3> <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"> <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 on Algorand TestNet. Upload an image backend pins to IPFS mint an ASA with metadata.
</p> </p>
</div> </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' : ''}`}> <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 */} {/* NFT fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5"> <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div> <div>
@ -897,7 +1043,9 @@ export default function TokenizeAsset() {
onClick={handleMintNFT} onClick={handleMintNFT}
disabled={!canMintNft} disabled={!canMintNft}
className={`px-6 py-3 rounded-lg font-semibold transition ${ 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' 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 ? ( {nftLoading ? (
@ -994,21 +1142,66 @@ export default function TokenizeAsset() {
</p> </p>
</div> </div>
{/* ===== TRANSFER ASSET ===== */} {/* ===== 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"> <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 Asset</h3> <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 units of an Algorand Standard Asset (ASA) to another wallet.</p> <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>
{/* 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 className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Asset ID</label> <label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">{transferAssetIdLabel}</label>
<input <input
type="text" 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" 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" placeholder="e.g. 123456789"
value={transferAssetId} value={transferAssetId}
onChange={(e) => setTransferAssetId(e.target.value)} 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>
<div> <div>
@ -1023,14 +1216,21 @@ export default function TokenizeAsset() {
</div> </div>
<div> <div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Amount</label> <label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">{transferAmountLabel}</label>
<input <input
type="number" type="text"
min={1} 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" 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} value={transferAmount}
onChange={(e) => setTransferAmount(e.target.value)} 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> </div>
@ -1045,13 +1245,15 @@ export default function TokenizeAsset() {
: 'bg-teal-600 hover:bg-teal-700 text-white shadow-md' : 'bg-teal-600 hover:bg-teal-700 text-white shadow-md'
}`} }`}
> >
{transferLoading ? 'Transferring…' : 'Transfer Asset'} {transferLoading ? 'Transferring…' : transferMode === 'algo' ? 'Send ALGO' : transferMode === 'usdc' ? 'Send USDC' : 'Transfer Asset'}
</button> </button>
</div> </div>
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-2"> <p className="mt-3 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-2">
<AiOutlineInfoCircle /> <AiOutlineInfoCircle />
The recipient must opt-in to the asset before receiving it. {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> </p>
</div> </div>
</div> </div>