add NFT minting (frontend + Pinata/IPFS backend)
This commit is contained in:
@ -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
|
||||||
134
projects/TokenizeRWATemplate-contracts/NFT_mint_server/app.js
Normal file
134
projects/TokenizeRWATemplate-contracts/NFT_mint_server/app.js
Normal 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
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
import app from './app.js'
|
||||||
|
export default app
|
||||||
1434
projects/TokenizeRWATemplate-contracts/NFT_mint_server/package-lock.json
generated
Normal file
1434
projects/TokenizeRWATemplate-contracts/NFT_mint_server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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}`)
|
||||||
|
})
|
||||||
@ -65,6 +65,7 @@
|
|||||||
"integrity": "sha512-jILpK3PlSJ55crS8+Dl7iIiADEj/VowokTGSVEEneDG41XM3jMmogGVpCNipBil/YAGC2eh2yc4l9iHnfQdT/g==",
|
"integrity": "sha512-jILpK3PlSJ55crS8+Dl7iIiADEj/VowokTGSVEEneDG41XM3jMmogGVpCNipBil/YAGC2eh2yc4l9iHnfQdT/g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer": "^6.0.3"
|
"buffer": "^6.0.3"
|
||||||
},
|
},
|
||||||
@ -1489,6 +1490,7 @@
|
|||||||
"integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
|
"integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.51.0",
|
"@typescript-eslint/scope-manager": "8.51.0",
|
||||||
"@typescript-eslint/types": "8.51.0",
|
"@typescript-eslint/types": "8.51.0",
|
||||||
@ -1876,6 +1878,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@ -1939,6 +1942,7 @@
|
|||||||
"integrity": "sha512-frhGtZl1JvfrLRKmMvUm880wj4OiWsWo2FhbreNWh7pdFsKuWPj60fV682wt/CYefLI70iwHavPOwGBkTVt0VA==",
|
"integrity": "sha512-frhGtZl1JvfrLRKmMvUm880wj4OiWsWo2FhbreNWh7pdFsKuWPj60fV682wt/CYefLI70iwHavPOwGBkTVt0VA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"algorand-msgpack": "^1.1.0",
|
"algorand-msgpack": "^1.1.0",
|
||||||
"hi-base32": "^0.5.1",
|
"hi-base32": "^0.5.1",
|
||||||
@ -2591,6 +2595,7 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@ -3980,6 +3985,7 @@
|
|||||||
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@ -4696,7 +4702,8 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tweetnacl": {
|
"node_modules/tweetnacl": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@ -4724,6 +4731,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -4761,8 +4769,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/upath": {
|
"node_modules/upath": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
@ -4798,6 +4805,7 @@
|
|||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
|
|||||||
@ -1,14 +1,15 @@
|
|||||||
import { AlgorandClient } from '@algorandfoundation/algokit-utils'
|
import { AlgorandClient } from '@algorandfoundation/algokit-utils'
|
||||||
import { useWallet } from '@txnlab/use-wallet-react'
|
import { useWallet } from '@txnlab/use-wallet-react'
|
||||||
|
import { sha512_256 } from 'js-sha512'
|
||||||
import { useSnackbar } from 'notistack'
|
import { useSnackbar } from 'notistack'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { AiOutlineInfoCircle, AiOutlineLoading3Quarters } from 'react-icons/ai'
|
import { AiOutlineCloudUpload, AiOutlineInfoCircle, AiOutlineLoading3Quarters } from 'react-icons/ai'
|
||||||
import { BsCoin } from 'react-icons/bs'
|
import { BsCoin } from 'react-icons/bs'
|
||||||
import { getAlgodConfigFromViteEnvironment } from '../utils/network/getAlgoClientConfigs'
|
import { getAlgodConfigFromViteEnvironment } from '../utils/network/getAlgoClientConfigs'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type for created assets stored in browser localStorage
|
* Type for created assets stored in browser localStorage
|
||||||
* Captures all ASA configuration including compliance fields
|
* Captures ASA configuration including compliance fields
|
||||||
*/
|
*/
|
||||||
type CreatedAsset = {
|
type CreatedAsset = {
|
||||||
assetId: number
|
assetId: number
|
||||||
@ -27,6 +28,23 @@ 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'
|
||||||
|
|
||||||
|
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
|
* Load created assets from browser localStorage
|
||||||
*/
|
*/
|
||||||
@ -53,10 +71,11 @@ function persistAsset(asset: CreatedAsset): CreatedAsset[] {
|
|||||||
/**
|
/**
|
||||||
* TokenizeAsset Component
|
* TokenizeAsset Component
|
||||||
* Main form for creating Algorand Standard Assets (ASAs)
|
* 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
|
* Persists created assets to localStorage for tracking
|
||||||
*/
|
*/
|
||||||
export default function TokenizeAsset() {
|
export default function TokenizeAsset() {
|
||||||
|
// ===== ASA (original) state =====
|
||||||
const [assetName, setAssetName] = useState<string>('Tokenized Coffee Membership')
|
const [assetName, setAssetName] = useState<string>('Tokenized Coffee Membership')
|
||||||
const [unitName, setUnitName] = useState<string>('COFFEE')
|
const [unitName, setUnitName] = useState<string>('COFFEE')
|
||||||
const [total, setTotal] = useState<string>('1000')
|
const [total, setTotal] = useState<string>('1000')
|
||||||
@ -72,15 +91,37 @@ export default function TokenizeAsset() {
|
|||||||
const [loading, setLoading] = useState<boolean>(false)
|
const [loading, setLoading] = useState<boolean>(false)
|
||||||
const [createdAssets, setCreatedAssets] = useState<CreatedAsset[]>([])
|
const [createdAssets, setCreatedAssets] = useState<CreatedAsset[]>([])
|
||||||
|
|
||||||
// NEW: Transfer state (added)
|
// ===== Transfer state =====
|
||||||
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')
|
||||||
const [transferLoading, setTransferLoading] = useState<boolean>(false)
|
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 { transactionSigner, activeAddress } = useWallet()
|
||||||
const { enqueueSnackbar } = useSnackbar()
|
const { enqueueSnackbar } = useSnackbar()
|
||||||
|
|
||||||
|
// ===== Algorand client =====
|
||||||
const algodConfig = getAlgodConfigFromViteEnvironment()
|
const algodConfig = getAlgodConfigFromViteEnvironment()
|
||||||
const algorand = useMemo(() => AlgorandClient.fromConfig({ algodConfig }), [algodConfig])
|
const algorand = useMemo(() => AlgorandClient.fromConfig({ algodConfig }), [algodConfig])
|
||||||
|
|
||||||
@ -92,7 +133,12 @@ export default function TokenizeAsset() {
|
|||||||
if (activeAddress && !manager) setManager(activeAddress)
|
if (activeAddress && !manager) setManager(activeAddress)
|
||||||
}, [activeAddress, manager])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!transferAssetId && createdAssets.length > 0) {
|
if (!transferAssetId && createdAssets.length > 0) {
|
||||||
setTransferAssetId(String(createdAssets[0].assetId))
|
setTransferAssetId(String(createdAssets[0].assetId))
|
||||||
@ -112,20 +158,36 @@ export default function TokenizeAsset() {
|
|||||||
setClawback('')
|
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) => {
|
const copyToClipboard = async (text: string) => {
|
||||||
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' })
|
||||||
// Also fill transfer field to reduce friction
|
|
||||||
setTransferAssetId(text)
|
setTransferAssetId(text)
|
||||||
} catch {
|
} catch {
|
||||||
enqueueSnackbar('Copy failed. Please copy manually.', { variant: 'warning' })
|
enqueueSnackbar('Copy failed. Please copy manually.', { variant: 'warning' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isWholeNumber = (v: string) => /^\d+$/.test(v)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle ASA creation with validation and on-chain transaction
|
* Handle ASA creation with validation and on-chain transaction
|
||||||
* Adjusts total supply by decimals and saves asset to localStorage
|
* 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 () => {
|
const handleTransferAsset = async () => {
|
||||||
if (!transactionSigner || !activeAddress) {
|
if (!transactionSigner || !activeAddress) {
|
||||||
enqueueSnackbar('Please connect your wallet first.', { variant: 'warning' })
|
enqueueSnackbar('Please connect your wallet first.', { variant: 'warning' })
|
||||||
@ -252,7 +317,6 @@ export default function TokenizeAsset() {
|
|||||||
amount: BigInt(transferAmount),
|
amount: BigInt(transferAmount),
|
||||||
})
|
})
|
||||||
|
|
||||||
// AlgoKit commonly returns txId here
|
|
||||||
const txId = (result as { txId?: string }).txId
|
const txId = (result as { txId?: string }).txId
|
||||||
|
|
||||||
enqueueSnackbar('✅ Transfer complete!', {
|
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 (0–19).', { variant: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = Number(nftDecimals)
|
||||||
|
if (Number.isNaN(d) || d < 0 || d > 19) {
|
||||||
|
enqueueSnackbar('NFT decimals must be between 0 and 19.', { variant: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setNftLoading(true)
|
||||||
|
enqueueSnackbar('Uploading and preparing NFT...', { variant: 'info' })
|
||||||
|
|
||||||
|
let metadataUrl = ''
|
||||||
|
try {
|
||||||
|
const backendBase = resolveBackendBase()
|
||||||
|
const backendApiUrl = `${backendBase.replace(/\/$/, '')}/api/pin-image`
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', selectedFile)
|
||||||
|
|
||||||
|
const response = await fetch(backendApiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
mode: 'cors',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(`Backend request failed: ${response.status} - ${errorText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
metadataUrl = data.metadataUrl
|
||||||
|
if (!metadataUrl) throw new Error('Backend did not return a valid metadata URL')
|
||||||
|
} catch (e: any) {
|
||||||
|
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 canSubmit = !!assetName && !!unitName && !!total && !loading && !!activeAddress
|
||||||
|
|
||||||
|
const canMintNft =
|
||||||
|
!!nftName &&
|
||||||
|
!!nftUnit &&
|
||||||
|
!!nftSupply &&
|
||||||
|
!!nftDecimals &&
|
||||||
|
!!selectedFile &&
|
||||||
|
!!activeAddress &&
|
||||||
|
!nftLoading
|
||||||
|
|
||||||
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">
|
||||||
{/* ===== ORIGINAL TOKENIZE FORM (UNCHANGED) ===== */}
|
{/* Top header */}
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-start 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">
|
<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" />
|
<BsCoin className="text-2xl text-teal-600 dark:text-teal-400" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl sm:text-2xl font-bold tracking-tight text-slate-900 dark:text-white">Tokenize an Asset (Mint ASA)</h2>
|
<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">Create a standard ASA on TestNet. Perfect for RWA POCs.</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ASA loading bar */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="relative h-1 w-full mt-5 overflow-hidden rounded bg-slate-200 dark:bg-slate-700">
|
<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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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 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>
|
||||||
@ -470,6 +701,223 @@ export default function TokenizeAsset() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ===== */}
|
{/* ===== MY CREATED ASSETS ===== */}
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
@ -549,9 +997,7 @@ export default function TokenizeAsset() {
|
|||||||
{/* ===== TRANSFER ASSET ===== */}
|
{/* ===== 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">
|
<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 Asset</h3>
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-6">
|
<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>
|
||||||
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 className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user