diff --git a/components/Layout/Header/index.js b/components/Layout/Header/index.js index fafbaceef..111467ec3 100644 --- a/components/Layout/Header/index.js +++ b/components/Layout/Header/index.js @@ -192,7 +192,7 @@ export default function Header({ hoverStates={hoverStates} > {t('menu.developers.faucet')} - {xahauNetwork && {t('menu.services.nft-mint')}} + {t('menu.services.nft-mint')} {t('menu.usernames')} {t('menu.services.tax-reports')} {t('menu.project-registration')} @@ -239,7 +239,7 @@ export default function Header({ {t('menu.nft.offers')} {t('menu.nft.distribution')} {t('menu.nft.statistics')} - {xahauNetwork && {t('menu.services.nft-mint')}} + {t('menu.services.nft-mint')} {/* Hide AMM for XAHAU */} diff --git a/components/Services/NftMint/NFTokenMint.js b/components/Services/NftMint/NFTokenMint.js new file mode 100644 index 000000000..32df8bda5 --- /dev/null +++ b/components/Services/NftMint/NFTokenMint.js @@ -0,0 +1,398 @@ +import { useState, useEffect } from 'react' +import Link from 'next/link' +import { encode, server, network } from '../../../utils' +import { isValidTaxon } from '../../../utils/nft' +import CheckBox from '../../UI/CheckBox' +import AddressInput from '../../UI/AddressInput' +import ExpirationSelect from '../../UI/ExpirationSelect' + +export default function NFTokenMint({ setSignRequest }) { + const [uri, setUri] = useState('') + const [agreeToSiteTerms, setAgreeToSiteTerms] = useState(false) + const [agreeToPrivacyPolicy, setAgreeToPrivacyPolicy] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const [minted, setMinted] = useState('') + const [taxon, setTaxon] = useState('0') + const [flags, setFlags] = useState({ + tfBurnable: false, + tfOnlyXRP: false, + tfTransferable: true, + tfMutable: false + }) + + const [issuer, setIssuer] = useState('') + const [transferFee, setTransferFee] = useState('') + const [destination, setDestination] = useState('') + const [amount, setAmount] = useState('') + const [expiration, setExpiration] = useState(0) + const [mintForOtherAccount, setMintForOtherAccount] = useState(false) + const [createSellOffer, setCreateSellOffer] = useState(false) + + let uriRef + let taxonRef + + useEffect(() => { + if (agreeToSiteTerms || agreeToPrivacyPolicy) { + setErrorMessage('') + } + }, [agreeToSiteTerms, agreeToPrivacyPolicy]) + + const onUriChange = (e) => { + let uri = e.target.value + setUri(uri) + } + + const onTaxonChange = (e) => { + const value = e.target.value.replace(/[^\d]/g, '') + setTaxon(value) + } + + const onTransferFeeChange = (e) => { + let value = e.target.value.replace(/[^\d\.]/g, '') + const decimalPoints = value.split('.').length - 1 + if (decimalPoints > 1) { + const parts = value.split('.') + value = parts[0] + '.' + parts.slice(1).join('') + } + if (value === '' || parseFloat(value) <= 50) { + setTransferFee(value) + } + } + + const onAmountChange = (e) => { + let value = e.target.value.replace(/[^\d\.]/g, '') + const decimalPoints = value.split('.').length - 1 + if (decimalPoints > 1) { + const parts = value.split('.') + value = parts[0] + '.' + parts.slice(1).join('') + } + setAmount(value) + } + + const onIssuerChange = (value) => { + if (typeof value === 'object' && value.address) { + setIssuer(value.address) + } else if (typeof value === 'string') { + setIssuer(value) + } + } + + const onDestinationChange = (value) => { + if (typeof value === 'object' && value.address) { + setDestination(value.address) + } else if (typeof value === 'string') { + setDestination(value) + } + } + + const onExpirationChange = (days) => { + setExpiration(days) + } + + const onSubmit = async () => { + if (!uri) { + setErrorMessage('Please enter URI') + uriRef?.focus() + return + } + + if (!agreeToSiteTerms) { + setErrorMessage('Please agree to the Terms and conditions') + return + } + + if (!agreeToPrivacyPolicy) { + setErrorMessage('Please agree to the Privacy policy') + return + } + + if (!isValidTaxon(taxon)) { + setErrorMessage('Please enter a valid Taxon value') + taxonRef?.focus() + return + } + + if (mintForOtherAccount && (!issuer || !issuer.trim())) { + setErrorMessage('Please enter an Issuer address when minting for another account.') + return + } + + setErrorMessage('') + + let nftFlags = 0 + if (flags.tfBurnable) nftFlags |= 1 + if (flags.tfOnlyXRP) nftFlags |= 2 + if (flags.tfTransferable) nftFlags |= 8 + if (flags.tfMutable) nftFlags |= 16 + + let request = { + TransactionType: 'NFTokenMint', + NFTokenTaxon: parseInt(taxon), + Flags: nftFlags + } + + if (uri && uri.trim()) { + request.URI = encode(uri) + } + + if (issuer && issuer.trim()) { + request.Issuer = issuer.trim() + } + + if (transferFee && transferFee.trim()) { + const feeValue = parseFloat(transferFee.trim()) + if (!isNaN(feeValue) && feeValue >= 0 && feeValue <= 50) { + request.TransferFee = Math.round(feeValue * 1000) + } + } + + if (createSellOffer && amount !== '' && !isNaN(parseFloat(amount)) && parseFloat(amount) >= 0) { + request.Amount = String(Math.round(parseFloat(amount) * 1000000)) + if (destination && destination.trim()) { + request.Destination = destination.trim() + } + if (expiration > 0) { + request.Expiration = Math.floor(Date.now() / 1000) + expiration * 24 * 60 * 60 - 946684800 + } + } + + setSignRequest({ + redirect: 'nft', + request, + callback: (id) => setMinted(id) + }) + } + + const handleFlagChange = (flag) => { + setFlags((prev) => ({ ...prev, [flag]: !prev[flag] })) + } + + return ( + <> +
+ {!minted && ( + <> + {/* URI */} +

URI that points to the data or metadata associated with the NFT:

+
+ { + uriRef = node + }} + spellCheck="false" + maxLength="256" + name="uri" + /> +
+ + {/* NFT Taxon */} +

NFT Taxon (collection identifier, leave as 0 for the issuer's first collection):

+
+ { + taxonRef = node + }} + spellCheck="false" + name="taxon" + /> +
+ + {/* Transferable */} +
+ { + // If disabling and royalty is set, do nothing + if (flags.tfTransferable && transferFee && parseFloat(transferFee) > 0) { + return + } + handleFlagChange('tfTransferable') + }} + name="transferable" + > + Transferable (can be transferred to others) + +
+ + {/* Royalty (Transfer Fee) - only show if Transferable is checked */} + {flags.tfTransferable && ( + <> +

Royalty (paid to the issuer, 0-50%):

+
+ +
+ + )} + + {/* Mutable */} + {network === 'devnet' && ( +
+ handleFlagChange('tfMutable')} name="mutable"> + Mutable (URI can be updated) + +
+ )} + + {/* Only XRP */} +
+ handleFlagChange('tfOnlyXRP')} name="only-xrp"> + Only XRP (can only be sold for XRP) + +
+ + {/* Burnable */} +
+ handleFlagChange('tfBurnable')} name="burnable"> + Burnable (can be destroyed by the issuer) + +
+ + {/* Create Sell Offer */} +
+ setCreateSellOffer(!createSellOffer)} + name="create-sell-offer" + > + Create a Sell offer + +
+ + {/* Sell Offer Fields */} + {createSellOffer && ( + <> +

Initial listing price in XRP (Amount):

+
+ +
+ +

Destination (optional - account to receive the NFT):

+
+ +
+ +

Offer expiration:

+
+ +
+ + )} + + {/* Mint on behalf of another account */} +
+ { + if (mintForOtherAccount) { + setIssuer('') + } + setMintForOtherAccount(!mintForOtherAccount) + }} + name="mint-for-other" + > + Mint on behalf of another account + +
+ + {mintForOtherAccount && ( + <> +

Issuer (account you're minting for):

+
+ +
+

+ Note: You must be authorized as a minter for this account, or the transaction will fail. +

+ + )} + +
+ + {/* Terms and Privacy */} +
+ + I agree with the{' '} + + Terms and conditions + + . + +
+ +
+ + I agree with the{' '} + + Privacy policy + + . + +
+ +

+ +

+ + )} + + {minted && ( + <> +

The NFT was successfully minted:

+

+ + {server}/nft/{minted} + +

+
+ +
+ + )} + +

+

+ + ) +} diff --git a/components/Services/NftMint/URITokenMint.js b/components/Services/NftMint/URITokenMint.js new file mode 100644 index 000000000..da76dd861 --- /dev/null +++ b/components/Services/NftMint/URITokenMint.js @@ -0,0 +1,362 @@ +import { useState, useEffect } from 'react' +import { useTranslation } from 'next-i18next' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { sha512 } from 'crypto-hash' +import axios from 'axios' +import { addAndRemoveQueryParams, encode, isIdValid, isValidJson, server, xahauNetwork } from '../../../utils' +const checkmark = '/images/checkmark.svg' +import CheckBox from '../../UI/CheckBox' + +let interval +let startTime + +export default function URITokenMint({ setSignRequest, uriQuery, digestQuery }) { + const { i18n } = useTranslation() + const router = useRouter() + + const [uri, setUri] = useState(uriQuery) + const [digest, setDigest] = useState(digestQuery) + const [agreeToSiteTerms, setAgreeToSiteTerms] = useState(false) + const [agreeToPrivacyPolicy, setAgreeToPrivacyPolicy] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const [metadataError, setMetadataError] = useState('') + const [calculateDigest, setCalculateDigest] = useState(false) + const [metadata, setMetadata] = useState('') + const [metadataStatus, setMetadataStatus] = useState('') + const [metaLoadedFromUri, setMetaLoadedFromUri] = useState(false) + const [update, setUpdate] = useState(false) + const [minted, setMinted] = useState('') + const [uriValidDigest, setUriValidDigest] = useState(isIdValid(digestQuery)) + + let uriRef + let digestRef + + useEffect(() => { + //on component unmount + return () => { + setUpdate(false) + clearInterval(interval) + } + }, []) + + useEffect(() => { + if (update) { + interval = setInterval(() => getMetadata(), 5000) //5 seconds + } else { + clearInterval(interval) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [update]) + + useEffect(() => { + setErrorMessage('') + }, [i18n.language]) + + const onUriChange = (e) => { + let uri = e.target.value + setUri(uri) + setMetaLoadedFromUri(false) + setMetadata('') + setDigest('') + setMetadataError('') + setUriValidDigest(false) + } + + const getMetadata = async () => { + setMetadataStatus('Trying to load the metadata from URI...') + const nftType = xahauNetwork ? 'xls35' : 'xls20' + const response = await axios + .get('v2/metadata?url=' + encodeURIComponent(uri) + '&type=' + nftType) + .catch((error) => { + console.log(error) + setMetadataStatus('error') + }) + if (response?.data) { + if (response.data?.metadata) { + setMetaLoadedFromUri(true) + setMetadata(JSON.stringify(response.data.metadata, undefined, 4)) + checkDigest(response.data.metadata) + setMetadataStatus('') + setUpdate(false) + } else if (response.data?.message) { + setMetadataStatus(response.data.message) + setUpdate(false) + } else { + if (Date.now() - startTime < 120000) { + // 2 minutes + setUpdate(true) + setMetadataStatus( + 'Trying to load the metadata from URI... (' + + Math.ceil((Date.now() - startTime) / 1000 / 5) + + '/24 attempts)' + ) + } else { + setUpdate(false) + setMetadataStatus('Load failed') + } + } + } + } + + const loadMetadata = async () => { + if (uri) { + setMetaLoadedFromUri(false) + getMetadata() + startTime = Date.now() + } else { + setMetadataStatus('Please enter URI :)') + uriRef?.focus() + } + } + + const onDigestChange = (e) => { + let digest = e.target.value + setDigest(digest) + } + + const onSubmit = async () => { + if (!uri) { + setErrorMessage('Please enter URI') + uriRef?.focus() + return + } + + if (digest && !isIdValid(digest)) { + setErrorMessage('Please enter a valid Digest') + digestRef?.focus() + return + } + + if (!agreeToSiteTerms) { + setErrorMessage('Please agree to the Terms and conditions') + return + } + + if (!agreeToPrivacyPolicy) { + setErrorMessage('Please agree to the Privacy policy') + return + } + + setErrorMessage('') + + let request = { + TransactionType: 'URITokenMint' + } + + if (uri) { + request.URI = encode(uri) + } + + if (digest) { + request.Digest = digest + } + + setSignRequest({ + redirect: 'nft', + request, + callback: afterSubmit + }) + } + + const afterSubmit = (id) => { + setMinted(id) + } + + useEffect(() => { + if (agreeToSiteTerms || agreeToPrivacyPolicy) { + setErrorMessage('') + } + }, [agreeToSiteTerms, agreeToPrivacyPolicy]) + + useEffect(() => { + if (calculateDigest) { + setDigest('') + } + }, [calculateDigest]) + + useEffect(() => { + let queryAddList = [] + let queryRemoveList = [] + if (digest) { + queryAddList.push({ + name: 'digest', + value: digest + }) + setErrorMessage('') + } else { + queryRemoveList.push('digest') + } + if (uri) { + queryAddList.push({ + name: 'uri', + value: uri + }) + setErrorMessage('') + } else { + queryRemoveList.push('uri') + } + addAndRemoveQueryParams(router, queryAddList, queryRemoveList) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [digest, uri]) + + const onMetadataChange = (e) => { + setDigest('') + setMetadataError('') + let metadata = e.target.value + setMetadata(metadata) + if (!metaLoadedFromUri) { + if (metadata && isValidJson(metadata)) { + checkDigest(metadata) + } else { + setMetadataError('Please enter valid JSON') + } + } + } + + const checkDigest = async (metadata) => { + if (!metadata) return + if (typeof metadata === 'string') { + metadata = JSON.parse(metadata) + } + let ourDigest = await sha512(JSON.stringify(metadata)?.trim()) + ourDigest = ourDigest.toString().slice(0, 64) + setDigest(ourDigest.toUpperCase()) + } + + return ( + <> +
+ {!minted && ( + <> +

URI that points to the data or metadata associated with the NFT:

+
+ { + uriRef = node + }} + spellCheck="false" + maxLength="256" + name="uri" + /> +
+ + {!uriValidDigest && ( + <> + + Add Digest (recommended) + + + {calculateDigest && ( + <> +

+ The digest is calculated from the metadata. It is used to verify that the URI and the metadata + have not been tampered with. +

+ + + + + {metadataStatus} + + +

+ Metadata: {metadataError} +

+