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

@ -0,0 +1,23 @@
# =========================
# Environment Setup
# =========================
# 1. In your project, go to the `nft_mint_server` folder.
# 2. Create a new file in this folder and name it exactly: `.env`
# (right-click → New File → type `.env`).
# 3. Open `.env.template` (this file) and copy all its contents.
# 4. Paste the contents into your new `.env` file.
# =========================
# Pinata Credentials
# =========================
# 5. Go to https://app.pinata.cloud/developers/api-keys
# 6. Click "New Key", toggle to **Admin**, and click "Create".
# 7. Copy the JWT Token and paste them into your `.env` below.
PINATA_API_KEY=YOUR_PINATA_API_KEY_GOES_HERE
PINATA_API_SECRET=YOUR_PINATA_API_SECRET_GOES_HERE
# =========================
# Server Config
# =========================
PORT=3001

View File

@ -0,0 +1,134 @@
import express from 'express'
import cors from 'cors'
import multer from 'multer'
import pinataSDK from '@pinata/sdk'
import dotenv from 'dotenv'
import { Readable } from 'stream'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// Load local .env for dev. In Vercel, env vars come from platform.
dotenv.config({ path: path.resolve(__dirname, '.env') })
const app = express()
// Allow local + prod (comma-separated in env), or * by default for dev
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '*')
.split(',')
.map((o) => o.trim())
app.use(
cors({
origin: (origin, cb) => {
if (!origin) return cb(null, true) // same-origin, curl, Postman
if (allowedOrigins.includes('*')) return cb(null, true)
if (allowedOrigins.includes(origin)) return cb(null, true)
// Allow any frontend on vercel.app (great for forks)
try {
const host = new URL(origin).hostname
if (host.endsWith('.vercel.app')) return cb(null, true)
} catch (_) {}
return cb(null, false)
},
credentials: false,
})
)
// IMPORTANT: for multipart/form-data, multer populates req.body
// but JSON parsing is still useful for other endpoints
app.use(express.json())
// Pinata client
const pinata = process.env.PINATA_JWT
? new pinataSDK({ pinataJWTKey: process.env.PINATA_JWT })
: new pinataSDK(process.env.PINATA_API_KEY, process.env.PINATA_API_SECRET)
// Optional: test credentials at cold start
;(async () => {
try {
const auth = await pinata.testAuthentication?.()
console.log('Pinata auth OK:', auth || 'ok')
} catch (e) {
console.error('Pinata authentication FAILED. Check env vars.', e?.message || e)
}
})()
// health
app.get('/health', (_req, res) => {
res.set('Cache-Control', 'no-store')
res.json({ ok: true, ts: Date.now() })
})
// uploads
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB safety limit (matches frontend hint)
},
})
// Small helpers
function safeTrim(v) {
return typeof v === 'string' ? v.trim() : ''
}
function safeJsonParse(v, fallback) {
try {
if (typeof v !== 'string' || !v.trim()) return fallback
return JSON.parse(v)
} catch {
return fallback
}
}
app.post('/api/pin-image', upload.single('file'), async (req, res) => {
try {
const file = req.file
if (!file) return res.status(400).json({ error: 'No file uploaded' })
// NEW: read optional fields from multipart form-data (sent by frontend)
const metaName = safeTrim(req.body?.metaName) || 'NFT Example'
const metaDescription = safeTrim(req.body?.metaDescription) || 'This is an unchangeable NFT'
const properties = safeJsonParse(req.body?.properties, {})
// Pin image
const stream = Readable.from(file.buffer)
// Pinata SDK expects a path/filename sometimes
// @ts-ignore
stream.path = file.originalname || 'upload'
const imageOptions = {
pinataMetadata: { name: file.originalname || `${metaName} Image` },
}
const imageResult = await pinata.pinFileToIPFS(stream, imageOptions)
const imageUrl = `ipfs://${imageResult.IpfsHash}`
// Pin metadata JSON
const metadata = {
name: metaName,
description: metaDescription,
image: imageUrl,
properties,
}
const jsonOptions = { pinataMetadata: { name: `${metaName} Metadata` } }
const jsonResult = await pinata.pinJSONToIPFS(metadata, jsonOptions)
const metadataUrl = `ipfs://${jsonResult.IpfsHash}`
res.status(200).json({ metadataUrl })
} catch (error) {
const msg =
error?.response?.data?.error ||
error?.response?.data ||
error?.message ||
'Failed to pin to IPFS.'
res.status(500).json({ error: msg })
}
})
export default app

View File

@ -0,0 +1,2 @@
import app from './app.js'
export default app

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
{
"name": "nft_mint_server",
"private": true,
"type": "module",
"engines": { "node": ">=18 <21" },
"scripts": {
"dev": "node server.js",
"start": "node server.js"
},
"dependencies": {
"@pinata/sdk": "^2.1.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^2.0.2"
}
}

View File

@ -0,0 +1,7 @@
import app from './app.js'
const port = process.env.PORT || 3001
app.listen(port, '0.0.0.0', () => {
console.log(`✅ Backend listening at http://localhost:${port}`)
})

View File

@ -65,6 +65,7 @@
"integrity": "sha512-jILpK3PlSJ55crS8+Dl7iIiADEj/VowokTGSVEEneDG41XM3jMmogGVpCNipBil/YAGC2eh2yc4l9iHnfQdT/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"buffer": "^6.0.3"
},
@ -1489,6 +1490,7 @@
"integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/types": "8.51.0",
@ -1876,6 +1878,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -1939,6 +1942,7 @@
"integrity": "sha512-frhGtZl1JvfrLRKmMvUm880wj4OiWsWo2FhbreNWh7pdFsKuWPj60fV682wt/CYefLI70iwHavPOwGBkTVt0VA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"algorand-msgpack": "^1.1.0",
"hi-base32": "^0.5.1",
@ -2591,6 +2595,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -3980,6 +3985,7 @@
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -4696,7 +4702,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tweetnacl": {
"version": "1.0.3",
@ -4724,6 +4731,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -4761,8 +4769,7 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/upath": {
"version": "2.0.1",
@ -4798,6 +4805,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",

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,6 +281,9 @@ export default function TokenizeAsset() {
}
}
/**
* Transfer ASA (works for NFTs too, since NFTs are ASAs)
*/
const handleTransferAsset = async () => {
if (!transactionSigner || !activeAddress) {
enqueueSnackbar('Please connect your wallet first.', { variant: 'warning' })
@ -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,7 +532,17 @@ export default function TokenizeAsset() {
</div>
)}
<div className={`mt-6 ${loading ? 'opacity-50' : ''}`}>
{/* 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>
@ -470,6 +701,223 @@ export default function TokenizeAsset() {
)}
</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 on Algorand TestNet.
</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">
@ -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>