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:
Serkan Reis 2022-08-09 14:42:55 +03:00 committed by GitHub
parent 986777b73d
commit 0f0e68a285
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 77 additions and 47 deletions

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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',

View File

@ -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'}>

View File

@ -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">

View 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.')
}
}