Fixes and improvements (#28)
* Update collection creation error messages * Update minimum unit price * Update .env.example * Fix double ustars conversion for whitelist & minter unit_price * Add minimum unit price for whitelisted addresses * Fix: Invalid baseTokenURI error during collection instantiation * Collection cover image URI update * Minimum unit price update - 2 * Fix: nonfunctional existing whitelist option * Check matching asset and metadata file arrays before creating a collection * Mark minting detail inputs as required * Fix: collection creation with the specified royalty preference * Fix: whitelistType change problem * Fix creation logic * Automate number of tokens input & check per address limit * Automate number of tokens input & check per address limit - 2 * Metadata files should have .json extensions * Check royalty percentage * Upload service related changes now trigger state updates Co-authored-by: findolor <anakisci@gmail.com>
This commit is contained in:
parent
986777b73d
commit
0f0e68a285
@ -1,8 +1,9 @@
|
||||
APP_VERSION=0.1.0
|
||||
|
||||
NEXT_PUBLIC_SG721_CODE_ID=5
|
||||
|
||||
NEXT_PUBLIC_PINATA_ENDPOINT_URL=https://api.pinata.cloud/pinning/pinFileToIPFS
|
||||
NEXT_PUBLIC_WHITELIST_CODE_ID=3
|
||||
NEXT_PUBLIC_MINTER_CODE_ID=2
|
||||
NEXT_PUBLIC_SG721_CODE_ID=1
|
||||
|
||||
NEXT_PUBLIC_API_URL=https://
|
||||
NEXT_PUBLIC_BLOCK_EXPLORER_URL=https://testnet-explorer.publicawesome.dev/stargaze
|
||||
@ -13,4 +14,4 @@ NEXT_PUBLIC_S3_BUCKET= # TODO
|
||||
NEXT_PUBLIC_S3_ENDPOINT= # TODO
|
||||
NEXT_PUBLIC_S3_KEY= # TODO
|
||||
NEXT_PUBLIC_S3_REGION= # TODO
|
||||
NEXT_PUBLIC_S3_SECRET= # TODO
|
||||
NEXT_PUBLIC_S3_SECRET= # TODO
|
@ -8,6 +8,7 @@ import { NumberInput } from '../../forms/FormInput'
|
||||
|
||||
interface MintingDetailsProps {
|
||||
onChange: (data: MintingDetailsDataProps) => void
|
||||
numberOfTokens: number | undefined
|
||||
}
|
||||
|
||||
export interface MintingDetailsDataProps {
|
||||
@ -17,7 +18,7 @@ export interface MintingDetailsDataProps {
|
||||
startTime: string
|
||||
}
|
||||
|
||||
export const MintingDetails = ({ onChange }: MintingDetailsProps) => {
|
||||
export const MintingDetails = ({ onChange, numberOfTokens }: MintingDetailsProps) => {
|
||||
const [timestamp, setTimestamp] = useState<Date | undefined>()
|
||||
|
||||
const numberOfTokensState = useNumberInputState({
|
||||
@ -45,6 +46,7 @@ export const MintingDetails = ({ onChange }: MintingDetailsProps) => {
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (numberOfTokens) numberOfTokensState.onChange(numberOfTokens)
|
||||
const data: MintingDetailsDataProps = {
|
||||
numTokens: numberOfTokensState.value,
|
||||
unitPrice: unitPriceState.value ? (Number(unitPriceState.value) * 1_000_000).toString() : '',
|
||||
@ -53,14 +55,14 @@ export const MintingDetails = ({ onChange }: MintingDetailsProps) => {
|
||||
}
|
||||
onChange(data)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [numberOfTokensState.value, unitPriceState.value, perAddressLimitState.value, timestamp])
|
||||
}, [numberOfTokens, numberOfTokensState.value, unitPriceState.value, perAddressLimitState.value, timestamp])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormGroup subtitle="Information about your minting settings" title="Minting Details">
|
||||
<NumberInput {...numberOfTokensState} />
|
||||
<NumberInput {...unitPriceState} />
|
||||
<NumberInput {...perAddressLimitState} />
|
||||
<NumberInput {...numberOfTokensState} disabled isRequired value={numberOfTokens} />
|
||||
<NumberInput {...unitPriceState} isRequired />
|
||||
<NumberInput {...perAddressLimitState} isRequired />
|
||||
<FormControl htmlId="timestamp" isRequired subtitle="Start time for the minting" title="Start Time">
|
||||
<InputDateTime minDate={new Date()} onChange={(date) => setTimestamp(date)} value={timestamp} />
|
||||
</FormControl>
|
||||
|
@ -44,7 +44,7 @@ export const RoyaltyDetails = ({ onChange }: RoyaltyDetailsProps) => {
|
||||
}
|
||||
onChange(data)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [royaltyPaymentAddressState.value, royaltyShareState.value])
|
||||
}, [royaltyState, royaltyPaymentAddressState.value, royaltyShareState.value])
|
||||
|
||||
return (
|
||||
<div className="py-3 px-8 rounded border-2 border-white/20">
|
||||
@ -78,7 +78,7 @@ export const RoyaltyDetails = ({ onChange }: RoyaltyDetailsProps) => {
|
||||
value="Existing"
|
||||
/>
|
||||
<label className="inline-block text-white cursor-pointer form-check-label" htmlFor="royaltyRadio2">
|
||||
New royalty
|
||||
Configure royalty details
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -98,11 +98,9 @@ export const UploadDetails = ({ onChange }: UploadDetailsProps) => {
|
||||
if (event.target.files === null) return toast.error('No files selected.')
|
||||
for (let i = 0; i < event.target.files.length; i++) {
|
||||
reader = new FileReader()
|
||||
reader.onload = async (e) => {
|
||||
reader.onload = (e) => {
|
||||
if (!e.target?.result) return toast.error('Error parsing file.')
|
||||
if (!event.target.files) return toast.error('No files selected.')
|
||||
if (!JSON.parse(await event.target.files[i].text()).attributes)
|
||||
return toast.error(`The file with name '${event.target.files[i].name}' doesn't have an attributes list!`)
|
||||
const metadataFile = new File([e.target.result], event.target.files[i].name, { type: 'application/json' })
|
||||
setMetadataFilesArray((prev) => [...prev, metadataFile])
|
||||
}
|
||||
@ -126,16 +124,6 @@ export const UploadDetails = ({ onChange }: UploadDetailsProps) => {
|
||||
console.log(JSON.parse(await metadataFilesArray[metadataFileArrayIndex]?.text()))
|
||||
}
|
||||
|
||||
const checkAssetMetadataMatch = () => {
|
||||
const metadataFileNames = metadataFilesArray.map((file) => file.name)
|
||||
const assetFileNames = assetFilesArray.map((file) => file.name.substring(0, file.name.lastIndexOf('.')))
|
||||
// Compare the two arrays to make sure they are the same
|
||||
const areArraysEqual = metadataFileNames.every((val, index) => val === assetFileNames[index])
|
||||
if (!areArraysEqual) {
|
||||
throw new Error('Asset and metadata file names do not match.')
|
||||
}
|
||||
}
|
||||
|
||||
const videoPreviewElements = useMemo(() => {
|
||||
const tempArray: JSX.Element[] = []
|
||||
assetFilesArray.forEach((assetFile) => {
|
||||
@ -163,7 +151,6 @@ export const UploadDetails = ({ onChange }: UploadDetailsProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
checkAssetMetadataMatch()
|
||||
const data: UploadDetailsDataProps = {
|
||||
assetFiles: assetFilesArray,
|
||||
metadataFiles: metadataFilesArray,
|
||||
@ -176,7 +163,14 @@ export const UploadDetails = ({ onChange }: UploadDetailsProps) => {
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
}, [assetFilesArray, metadataFilesArray])
|
||||
}, [
|
||||
assetFilesArray,
|
||||
metadataFilesArray,
|
||||
uploadService,
|
||||
nftStorageApiKeyState.value,
|
||||
pinataApiKeyState.value,
|
||||
pinataSecretKeyState.value,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
setAssetFilesArray([])
|
||||
@ -383,7 +377,7 @@ export const UploadDetails = ({ onChange }: UploadDetailsProps) => {
|
||||
)}
|
||||
>
|
||||
<input
|
||||
accept=""
|
||||
accept="application/json"
|
||||
className={clsx(
|
||||
'file:py-2 file:px-4 file:mr-4 file:bg-plumbus-light file:rounded file:border-0 cursor-pointer',
|
||||
'before:absolute before:inset-0 before:hover:bg-white/5 before:transition',
|
||||
|
@ -36,6 +36,7 @@ export const WhitelistDetails = ({ onChange }: WhitelistDetailsProps) => {
|
||||
id: 'whitelist-address',
|
||||
name: 'whitelistAddress',
|
||||
title: 'Whitelist Address',
|
||||
defaultValue: '',
|
||||
})
|
||||
|
||||
const uniPriceState = useNumberInputState({
|
||||
@ -79,7 +80,16 @@ export const WhitelistDetails = ({ onChange }: WhitelistDetailsProps) => {
|
||||
}
|
||||
onChange(data)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uniPriceState.value, memberLimitState.value, perAddressLimitState.value, startDate, endDate, whitelistArray])
|
||||
}, [
|
||||
whitelistAddressState.value,
|
||||
uniPriceState.value,
|
||||
memberLimitState.value,
|
||||
perAddressLimitState.value,
|
||||
startDate,
|
||||
endDate,
|
||||
whitelistArray,
|
||||
whitelistState,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="py-3 px-8 rounded border-2 border-white/20">
|
||||
@ -135,7 +145,11 @@ export const WhitelistDetails = ({ onChange }: WhitelistDetailsProps) => {
|
||||
</div>
|
||||
|
||||
<Conditional test={whitelistState === 'existing'}>
|
||||
<AddressInput {...whitelistAddressState} className="pb-5" />
|
||||
<AddressInput
|
||||
{...whitelistAddressState}
|
||||
className="pb-5"
|
||||
onChange={(e) => whitelistAddressState.onChange(e.target.value)}
|
||||
/>
|
||||
</Conditional>
|
||||
|
||||
<Conditional test={whitelistState === 'new'}>
|
||||
|
@ -26,6 +26,7 @@ import useCollapse from 'react-collapsed'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import type { UploadServiceType } from 'services/upload'
|
||||
import { upload } from 'services/upload'
|
||||
import { compareFileArrays } from 'utils/compareFileArrays'
|
||||
import { MINTER_CODE_ID, SG721_CODE_ID, WHITELIST_CODE_ID } from 'utils/constants'
|
||||
import { withMetadata } from 'utils/layout'
|
||||
import { links } from 'utils/links'
|
||||
@ -58,6 +59,7 @@ const CollectionCreationPage: NextPage = () => {
|
||||
checkRoyaltyDetails()
|
||||
|
||||
const baseUri = await uploadFiles()
|
||||
//upload coverImageUri and append the file name
|
||||
const coverImageUri = await upload(
|
||||
collectionDetails?.imageFile as File[],
|
||||
uploadDetails?.uploadService as UploadServiceType,
|
||||
@ -71,7 +73,7 @@ const CollectionCreationPage: NextPage = () => {
|
||||
if (whitelistDetails?.whitelistType === 'existing') whitelist = whitelistDetails.contractAddress
|
||||
else if (whitelistDetails?.whitelistType === 'new') whitelist = await instantiateWhitelist()
|
||||
|
||||
await instantate(baseUri, coverImageUri, whitelist)
|
||||
await instantiate(baseUri, coverImageUri, whitelist)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
@ -87,7 +89,7 @@ const CollectionCreationPage: NextPage = () => {
|
||||
members: whitelistDetails?.members,
|
||||
start_time: whitelistDetails?.startTime,
|
||||
end_time: whitelistDetails?.endTime,
|
||||
unit_price: coin(String(Number(whitelistDetails?.unitPrice) * 1000000), 'ustars'),
|
||||
unit_price: coin(String(Number(whitelistDetails?.unitPrice)), 'ustars'),
|
||||
per_address_limit: whitelistDetails?.perAddressLimit,
|
||||
member_limit: whitelistDetails?.memberLimit,
|
||||
}
|
||||
@ -102,20 +104,20 @@ const CollectionCreationPage: NextPage = () => {
|
||||
return data.contractAddress
|
||||
}
|
||||
|
||||
const instantate = async (baseUri: string, coverImageUri: string, whitelist?: string) => {
|
||||
const instantiate = async (baseUri: string, coverImageUri: string, whitelist?: string) => {
|
||||
if (!wallet.initialized) throw new Error('Wallet not connected')
|
||||
if (!minterContract) throw new Error('Contract not found')
|
||||
|
||||
let royaltyInfo = null
|
||||
if (royaltyDetails?.royaltyType === 'new') {
|
||||
royaltyInfo = {
|
||||
paymentAddress: royaltyDetails.paymentAddress,
|
||||
share: royaltyDetails.share,
|
||||
payment_address: royaltyDetails.paymentAddress,
|
||||
share: (Number(royaltyDetails.share) / 100).toString(),
|
||||
}
|
||||
}
|
||||
|
||||
const msg = {
|
||||
base_token_uri: baseUri,
|
||||
base_token_uri: `ipfs://${baseUri}/`,
|
||||
num_tokens: mintingDetails?.numTokens,
|
||||
sg721_code_id: SG721_CODE_ID,
|
||||
sg721_instantiate_msg: {
|
||||
@ -125,19 +127,18 @@ const CollectionCreationPage: NextPage = () => {
|
||||
collection_info: {
|
||||
creator: wallet.address,
|
||||
description: collectionDetails?.description,
|
||||
image: coverImageUri,
|
||||
external_link: collectionDetails?.externalLink,
|
||||
image: `ipfs://${coverImageUri}/${collectionDetails?.imageFile[0].name as string}`,
|
||||
external_link: collectionDetails?.externalLink === '' ? null : collectionDetails?.externalLink,
|
||||
royalty_info: royaltyInfo,
|
||||
},
|
||||
},
|
||||
per_address_limit: mintingDetails?.perAddressLimit,
|
||||
unit_price: coin(String(Number(mintingDetails?.unitPrice) * 1000000), 'ustars'),
|
||||
whitelist_address: whitelist,
|
||||
unit_price: coin(String(Number(mintingDetails?.unitPrice)), 'ustars'),
|
||||
whitelist,
|
||||
start_time: mintingDetails?.startTime,
|
||||
}
|
||||
|
||||
const data = await minterContract.instantiate(MINTER_CODE_ID, msg, 'Stargaze Minter Contract', wallet.address)
|
||||
|
||||
setTransactionHash(data.transactionHash)
|
||||
setContractAddress(data.contractAddress)
|
||||
}
|
||||
@ -206,6 +207,7 @@ const CollectionCreationPage: NextPage = () => {
|
||||
if (uploadDetails.metadataFiles.length === 0) {
|
||||
throw new Error('Please upload metadatas')
|
||||
}
|
||||
compareFileArrays(uploadDetails.assetFiles, uploadDetails.metadataFiles)
|
||||
if (uploadDetails.uploadService === 'nft-storage') {
|
||||
if (uploadDetails.nftStorageApiKey === '') {
|
||||
throw new Error('Please enter NFT Storage api key')
|
||||
@ -217,17 +219,22 @@ const CollectionCreationPage: NextPage = () => {
|
||||
|
||||
const checkCollectionDetails = () => {
|
||||
if (!collectionDetails) throw new Error('Please fill out the collection details')
|
||||
if (collectionDetails.name === '') throw new Error('Name is required')
|
||||
if (collectionDetails.description === '') throw new Error('Description is required')
|
||||
if (collectionDetails.imageFile.length === 0) throw new Error('Cover image is required')
|
||||
if (collectionDetails.name === '') throw new Error('Collection name is required')
|
||||
if (collectionDetails.description === '') throw new Error('Collection description is required')
|
||||
if (collectionDetails.imageFile.length === 0) throw new Error('Collection cover image is required')
|
||||
}
|
||||
|
||||
const checkMintingDetails = () => {
|
||||
if (!mintingDetails) throw new Error('Please fill out the minting details')
|
||||
if (mintingDetails.numTokens < 1 || mintingDetails.numTokens > 10000) throw new Error('Invalid number of tokens')
|
||||
if (Number(mintingDetails.unitPrice) < 500) throw new Error('Invalid unit price')
|
||||
if (mintingDetails.perAddressLimit < 1 || mintingDetails.perAddressLimit > 50)
|
||||
throw new Error('Per address limit is required')
|
||||
if (Number(mintingDetails.unitPrice) < 50000000)
|
||||
throw new Error('Invalid unit price: The minimum unit price is 50 STARS')
|
||||
if (
|
||||
mintingDetails.perAddressLimit < 1 ||
|
||||
mintingDetails.perAddressLimit > 50 ||
|
||||
mintingDetails.perAddressLimit > mintingDetails.numTokens
|
||||
)
|
||||
throw new Error('Invalid limit for tokens per address')
|
||||
if (mintingDetails.startTime === '') throw new Error('Start time is required')
|
||||
}
|
||||
|
||||
@ -235,9 +242,11 @@ const CollectionCreationPage: NextPage = () => {
|
||||
if (!whitelistDetails) throw new Error('Please fill out the whitelist details')
|
||||
if (whitelistDetails.whitelistType === 'existing') {
|
||||
if (whitelistDetails.contractAddress === '') throw new Error('Whitelist contract address is required')
|
||||
} else {
|
||||
} else if (whitelistDetails.whitelistType === 'new') {
|
||||
if (whitelistDetails.members?.length === 0) throw new Error('Whitelist member list cannot be empty')
|
||||
if (whitelistDetails.unitPrice === '') throw new Error('Whitelist unit price is required')
|
||||
if (Number(whitelistDetails.unitPrice) < 25000000)
|
||||
throw new Error('Invalid unit price: The minimum unit price for whitelisted addresses is 25 STARS')
|
||||
if (whitelistDetails.startTime === '') throw new Error('Start time is required')
|
||||
if (whitelistDetails.endTime === '') throw new Error('End time is required')
|
||||
if (whitelistDetails.perAddressLimit === 0) throw new Error('Per address limit is required')
|
||||
@ -249,6 +258,7 @@ const CollectionCreationPage: NextPage = () => {
|
||||
if (!royaltyDetails) throw new Error('Please fill out the royalty details')
|
||||
if (royaltyDetails.royaltyType === 'new') {
|
||||
if (royaltyDetails.share === 0) throw new Error('Royalty share is required')
|
||||
if (royaltyDetails.share > 100 || royaltyDetails.share < 0) throw new Error('Invalid royalty share')
|
||||
if (royaltyDetails.paymentAddress === '') throw new Error('Royalty payment address is required')
|
||||
}
|
||||
}
|
||||
@ -274,7 +284,7 @@ const CollectionCreationPage: NextPage = () => {
|
||||
|
||||
<div className="flex justify-between py-3 px-8 rounded border-2 border-white/20 grid-col-2">
|
||||
<CollectionDetails onChange={setCollectionDetails} />
|
||||
<MintingDetails onChange={setMintingDetails} />
|
||||
<MintingDetails numberOfTokens={uploadDetails?.assetFiles.length} onChange={setMintingDetails} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between my-6">
|
||||
|
9
utils/compareFileArrays.ts
Normal file
9
utils/compareFileArrays.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export const compareFileArrays = (assetFilesArray: File[], metadataFilesArray: File[]) => {
|
||||
const metadataFileNames = metadataFilesArray.map((file) => file.name.substring(0, file.name.lastIndexOf('.')))
|
||||
const assetFileNames = assetFilesArray.map((file) => file.name.substring(0, file.name.lastIndexOf('.')))
|
||||
// Compare the two arrays to make sure they are the same
|
||||
const areArraysEqual = metadataFileNames.every((val, index) => val === assetFileNames[index])
|
||||
if (!areArraysEqual) {
|
||||
throw new Error('Asset and metadata file names do not match.')
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user