add NFT minting (frontend + Pinata/IPFS backend)

This commit is contained in:
Sara Jane (SJ)
2026-01-09 22:24:46 +00:00
parent 6aacc68cdc
commit 660dc742ad
8 changed files with 2232 additions and 161 deletions

View File

@ -1,14 +1,15 @@
import { AlgorandClient } from '@algorandfoundation/algokit-utils'
import { useWallet } from '@txnlab/use-wallet-react'
import { sha512_256 } from 'js-sha512'
import { useSnackbar } from 'notistack'
import { useEffect, useMemo, useState } from 'react'
import { AiOutlineInfoCircle, AiOutlineLoading3Quarters } from 'react-icons/ai'
import { ChangeEvent, 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 all ASA configuration including compliance fields
* Captures ASA configuration including compliance fields
*/
type CreatedAsset = {
assetId: number
@ -27,6 +28,23 @@ type CreatedAsset = {
const STORAGE_KEY = 'tokenize_assets'
const LORA_BASE = 'https://lora.algokit.io/testnet'
function resolveBackendBase(): string {
// 1) Respect explicit env (Vercel or custom)
const env = import.meta.env.VITE_API_URL?.trim()
if (env) return env.replace(/\/$/, '')
// 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
*/
@ -53,10 +71,11 @@ function persistAsset(asset: CreatedAsset): CreatedAsset[] {
/**
* TokenizeAsset Component
* Main form for creating Algorand Standard Assets (ASAs)
* Collects basic and advanced compliance metadata
* + 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')
@ -72,15 +91,37 @@ export default function TokenizeAsset() {
const [loading, setLoading] = useState<boolean>(false)
const [createdAssets, setCreatedAssets] = useState<CreatedAsset[]>([])
// NEW: Transfer state (added)
// ===== Transfer state =====
const [transferAssetId, setTransferAssetId] = useState<string>('')
const [receiverAddress, setReceiverAddress] = useState<string>('')
const [transferAmount, setTransferAmount] = useState<string>('1')
const [transferLoading, setTransferLoading] = useState<boolean>(false)
// ===== NFT mint state (new) =====
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>('')
// ===== Wallet + notifications =====
const { transactionSigner, activeAddress } = useWallet()
const { enqueueSnackbar } = useSnackbar()
// ===== Algorand client =====
const algodConfig = getAlgodConfigFromViteEnvironment()
const algorand = useMemo(() => AlgorandClient.fromConfig({ algodConfig }), [algodConfig])
@ -92,7 +133,12 @@ export default function TokenizeAsset() {
if (activeAddress && !manager) setManager(activeAddress)
}, [activeAddress, manager])
// NEW: Prefill transfer asset id from latest created asset (optional QoL)
// 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)
useEffect(() => {
if (!transferAssetId && createdAssets.length > 0) {
setTransferAssetId(String(createdAssets[0].assetId))
@ -112,20 +158,36 @@ export default function TokenizeAsset() {
setClawback('')
}
// NEW: Copy helper (added)
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' })
// Also fill transfer field to reduce friction
setTransferAssetId(text)
} catch {
enqueueSnackbar('Copy failed. Please copy manually.', { variant: 'warning' })
}
}
const isWholeNumber = (v: string) => /^\d+$/.test(v)
/**
* Handle ASA creation with validation and on-chain transaction
* Adjusts total supply by decimals and saves asset to localStorage
@ -219,7 +281,10 @@ export default function TokenizeAsset() {
}
}
const handleTransferAsset = async () => {
/**
* Transfer ASA (works for NFTs too, since NFTs are ASAs)
*/
const handleTransferAsset = async () => {
if (!transactionSigner || !activeAddress) {
enqueueSnackbar('Please connect your wallet first.', { variant: 'warning' })
return
@ -252,7 +317,6 @@ export default function TokenizeAsset() {
amount: BigInt(transferAmount),
})
// AlgoKit commonly returns txId here
const txId = (result as { txId?: string }).txId
enqueueSnackbar('✅ Transfer complete!', {
@ -280,24 +344,181 @@ export default function TokenizeAsset() {
}
}
/**
* 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 () => {
if (!transactionSigner || !activeAddress) {
enqueueSnackbar('Please connect wallet first', { variant: 'warning' })
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) {
console.error(e)
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: transactionSigner,
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: Number(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: prefill transfer section with minted asset id
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) {
console.error(e)
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
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">
{/* ===== ORIGINAL TOKENIZE FORM (UNCHANGED) ===== */}
{/* 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 an Asset (Mint ASA)</h2>
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1">Create a standard ASA on TestNet. Perfect for RWA POCs.</p>
<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>
</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" />
@ -311,167 +532,394 @@ export default function TokenizeAsset() {
</div>
)}
<div className={`mt-6 ${loading ? 'opacity-50' : ''}`}>
<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>
{/* 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>
<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 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>
</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 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>
</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>
<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>
</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>
{/* ===== 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 on Algorand TestNet.
</p>
</div>
{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}>
<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>{f.label}</span>
<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">
{f.tip}
Decimals controls fractional units. For a typical NFT, use 0.
</div>
</div>
</label>
<input
type="text"
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"
placeholder={f.placeholder}
value={f.value}
onChange={(e) => f.setValue(e.target.value)}
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>
<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>
{/* ===== MY CREATED ASSETS ===== */}
{/* ===== 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>
@ -549,9 +997,7 @@ export default function TokenizeAsset() {
{/* ===== TRANSFER ASSET ===== */}
<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>
<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 units of an Algorand Standard Asset (ASA) to another wallet.</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>