Merge pull request #171 from public-awesome/open-edition-minter

Open Edition minter integration
This commit is contained in:
Serkan Reis 2023-06-18 06:36:32 +03:00 committed by GitHub
commit 381c55dab3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 4573 additions and 81 deletions

View File

@ -1,4 +1,4 @@
APP_VERSION=0.6.4 APP_VERSION=0.6.5
NEXT_PUBLIC_PINATA_ENDPOINT_URL=https://api.pinata.cloud/pinning/pinFileToIPFS NEXT_PUBLIC_PINATA_ENDPOINT_URL=https://api.pinata.cloud/pinning/pinFileToIPFS
NEXT_PUBLIC_SG721_CODE_ID=2092 NEXT_PUBLIC_SG721_CODE_ID=2092
@ -11,6 +11,9 @@ NEXT_PUBLIC_VENDING_FACTORY_UPDATABLE_ADDRESS="stars1fnfywcnzzwledr93at65qm8gf95
NEXT_PUBLIC_VENDING_FACTORY_FLEX_ADDRESS="stars1gy6hr9sq9fzrykzw0emmehnjy27agreuepjrfnjnlwlugg29l2qqt0yu2j" NEXT_PUBLIC_VENDING_FACTORY_FLEX_ADDRESS="stars1gy6hr9sq9fzrykzw0emmehnjy27agreuepjrfnjnlwlugg29l2qqt0yu2j"
NEXT_PUBLIC_BASE_FACTORY_ADDRESS="stars18kzfpdgx36m95mszchegnk7car4sq03uvg25zeph2j7xg3rk03cs007sxr" NEXT_PUBLIC_BASE_FACTORY_ADDRESS="stars18kzfpdgx36m95mszchegnk7car4sq03uvg25zeph2j7xg3rk03cs007sxr"
NEXT_PUBLIC_BASE_FACTORY_UPDATABLE_ADDRESS="stars13pw8r33dsnghlxfj2upaywf38z2fc6npuw9maq9e5cpet4v285sscgzjp2" NEXT_PUBLIC_BASE_FACTORY_UPDATABLE_ADDRESS="stars13pw8r33dsnghlxfj2upaywf38z2fc6npuw9maq9e5cpet4v285sscgzjp2"
NEXT_PUBLIC_OPEN_EDITION_FACTORY_ADDRESS="stars1m4fjq0qf3mj6zhplng7yxngp9hq98hd4tj90gjp759l5e0fvf3qqkqzqsf"
NEXT_PUBLIC_OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS="stars1ss39vpz3wfv76nkxpls48srvf58lk57980yatwcjrvvygv9nt8tq5rgslt"
NEXT_PUBLIC_OPEN_EDITION_MINTER_CODE_ID=2570
NEXT_PUBLIC_SG721_NAME_ADDRESS="stars1fx74nkqkw2748av8j7ew7r3xt9cgjqduwn8m0ur5lhe49uhlsasszc5fhr" NEXT_PUBLIC_SG721_NAME_ADDRESS="stars1fx74nkqkw2748av8j7ew7r3xt9cgjqduwn8m0ur5lhe49uhlsasszc5fhr"
NEXT_PUBLIC_WHITELIST_CODE_ID=2093 NEXT_PUBLIC_WHITELIST_CODE_ID=2093
NEXT_PUBLIC_WHITELIST_FLEX_CODE_ID=2005 NEXT_PUBLIC_WHITELIST_FLEX_CODE_ID=2005

View File

@ -41,6 +41,24 @@ export const vendingMinterLinkTabs: LinkTabProps[] = [
}, },
] ]
export const openEditionMinterLinkTabs: LinkTabProps[] = [
{
title: 'Query',
description: `Dispatch queries for your Open Edition Minter contract`,
href: '/contracts/openEditionMinter/query',
},
{
title: 'Execute',
description: `Execute Open Edition Minter contract actions`,
href: '/contracts/openEditionMinter/execute',
},
{
title: 'Migrate',
description: `Migrate Open Edition Minter contract`,
href: '/contracts/openEditionMinter/migrate',
},
]
export const baseMinterLinkTabs: LinkTabProps[] = [ export const baseMinterLinkTabs: LinkTabProps[] = [
{ {
title: 'Instantiate', title: 'Instantiate',

View File

@ -175,9 +175,9 @@ export const MetadataInput = (props: MetadataInputProps) => {
return ( return (
<div> <div>
<div className="grid grid-cols-2 mx-4 mt-4 w-full max-w-6xl max-h-full no-scrollbar"> <div className="grid grid-cols-2 mt-4 mr-4 ml-8 w-full max-w-6xl max-h-full no-scrollbar">
<div className="mr-4"> <div className="mr-4">
<div className="mb-7 text-xl font-bold underline underline-offset-2">NFT Metadata</div> <div className="mb-7 text-xl font-bold underline underline-offset-4">NFT Metadata</div>
<TextInput {...nameState} /> <TextInput {...nameState} />
<TextInput className="mt-2" {...descriptionState} /> <TextInput className="mt-2" {...descriptionState} />
<TextInput className="mt-2" {...externalUrlState} /> <TextInput className="mt-2" {...externalUrlState} />

View File

@ -11,7 +11,7 @@ import { useEffect } from 'react'
// import BrandText from 'public/brand/brand-text.svg' // import BrandText from 'public/brand/brand-text.svg'
import { footerLinks, socialsLinks } from 'utils/links' import { footerLinks, socialsLinks } from 'utils/links'
import { BADGE_HUB_ADDRESS, BASE_FACTORY_ADDRESS, NETWORK } from '../utils/constants' import { BADGE_HUB_ADDRESS, BASE_FACTORY_ADDRESS, NETWORK, OPEN_EDITION_FACTORY_ADDRESS } from '../utils/constants'
import { Conditional } from './Conditional' import { Conditional } from './Conditional'
import { IncomeDashboardDisclaimer } from './IncomeDashboardDisclaimer' import { IncomeDashboardDisclaimer } from './IncomeDashboardDisclaimer'
import { LogModal } from './LogModal' import { LogModal } from './LogModal'
@ -175,6 +175,17 @@ export const Sidebar = () => {
> >
<Link href="/contracts/vendingMinter/">Vending Minter Contract</Link> <Link href="/contracts/vendingMinter/">Vending Minter Contract</Link>
</li> </li>
<Conditional test={OPEN_EDITION_FACTORY_ADDRESS !== undefined}>
<li
className={clsx(
'text-lg font-bold hover:text-white hover:bg-stargaze-80 rounded',
router.asPath.includes('/contracts/openEditionMinter/') ? 'text-white' : 'text-gray',
)}
tabIndex={-1}
>
<Link href="/contracts/openEditionMinter/">Open Edition Minter Contract</Link>
</li>
</Conditional>
<li <li
className={clsx( className={clsx(
'text-lg font-bold hover:text-white hover:bg-stargaze-80 rounded', 'text-lg font-bold hover:text-white hover:bg-stargaze-80 rounded',

View File

@ -15,6 +15,7 @@ import { JsonPreview } from 'components/JsonPreview'
import { TransactionHash } from 'components/TransactionHash' import { TransactionHash } from 'components/TransactionHash'
import { useWallet } from 'contexts/wallet' import { useWallet } from 'contexts/wallet'
import type { BaseMinterInstance } from 'contracts/baseMinter' import type { BaseMinterInstance } from 'contracts/baseMinter'
import type { OpenEditionMinterInstance } from 'contracts/openEditionMinter'
import type { SG721Instance } from 'contracts/sg721' import type { SG721Instance } from 'contracts/sg721'
import type { VendingMinterInstance } from 'contracts/vendingMinter' import type { VendingMinterInstance } from 'contracts/vendingMinter'
import type { FormEvent } from 'react' import type { FormEvent } from 'react'
@ -35,6 +36,7 @@ interface CollectionActionsProps {
sg721Messages: SG721Instance | undefined sg721Messages: SG721Instance | undefined
vendingMinterMessages: VendingMinterInstance | undefined vendingMinterMessages: VendingMinterInstance | undefined
baseMinterMessages: BaseMinterInstance | undefined baseMinterMessages: BaseMinterInstance | undefined
openEditionMinterMessages: OpenEditionMinterInstance | undefined
minterType: MinterType minterType: MinterType
sg721Type: Sg721Type sg721Type: Sg721Type
} }
@ -47,6 +49,7 @@ export const CollectionActions = ({
minterContractAddress, minterContractAddress,
vendingMinterMessages, vendingMinterMessages,
baseMinterMessages, baseMinterMessages,
openEditionMinterMessages,
minterType, minterType,
sg721Type, sg721Type,
}: CollectionActionsProps) => { }: CollectionActionsProps) => {
@ -54,6 +57,7 @@ export const CollectionActions = ({
const [lastTx, setLastTx] = useState('') const [lastTx, setLastTx] = useState('')
const [timestamp, setTimestamp] = useState<Date | undefined>(undefined) const [timestamp, setTimestamp] = useState<Date | undefined>(undefined)
const [endTimestamp, setEndTimestamp] = useState<Date | undefined>(undefined)
const [airdropAllocationArray, setAirdropAllocationArray] = useState<AirdropAllocation[]>([]) const [airdropAllocationArray, setAirdropAllocationArray] = useState<AirdropAllocation[]>([])
const [airdropArray, setAirdropArray] = useState<string[]>([]) const [airdropArray, setAirdropArray] = useState<string[]>([])
const [collectionInfo, setCollectionInfo] = useState<CollectionInfo>() const [collectionInfo, setCollectionInfo] = useState<CollectionInfo>()
@ -168,6 +172,7 @@ export const CollectionActions = ({
const showTokenUriField = isEitherType(type, ['mint_token_uri', 'update_token_metadata']) const showTokenUriField = isEitherType(type, ['mint_token_uri', 'update_token_metadata'])
const showWhitelistField = type === 'set_whitelist' const showWhitelistField = type === 'set_whitelist'
const showDateField = isEitherType(type, ['update_start_time', 'update_start_trading_time']) const showDateField = isEitherType(type, ['update_start_time', 'update_start_trading_time'])
const showEndDateField = type === 'update_end_time'
const showLimitField = type === 'update_per_address_limit' const showLimitField = type === 'update_per_address_limit'
const showTokenIdField = isEitherType(type, ['transfer', 'mint_for', 'burn', 'update_token_metadata']) const showTokenIdField = isEitherType(type, ['transfer', 'mint_for', 'burn', 'update_token_metadata'])
const showNumberOfTokensField = type === 'batch_mint' const showNumberOfTokensField = type === 'batch_mint'
@ -197,6 +202,7 @@ export const CollectionActions = ({
const payload: DispatchExecuteArgs = { const payload: DispatchExecuteArgs = {
whitelist: whitelistState.value, whitelist: whitelistState.value,
startTime: timestamp ? (timestamp.getTime() * 1_000_000).toString() : '', startTime: timestamp ? (timestamp.getTime() * 1_000_000).toString() : '',
endTime: endTimestamp ? (endTimestamp.getTime() * 1_000_000).toString() : '',
limit: limitState.value, limit: limitState.value,
minterContract: minterContractAddress, minterContract: minterContractAddress,
sg721Contract: sg721ContractAddress, sg721Contract: sg721ContractAddress,
@ -208,6 +214,7 @@ export const CollectionActions = ({
batchNumber: batchNumberState.value, batchNumber: batchNumberState.value,
vendingMinterMessages, vendingMinterMessages,
baseMinterMessages, baseMinterMessages,
openEditionMinterMessages,
sg721Messages, sg721Messages,
recipient: resolvedRecipientAddress, recipient: resolvedRecipientAddress,
recipients: airdropArray, recipients: airdropArray,
@ -493,6 +500,11 @@ export const CollectionActions = ({
<InputDateTime minDate={new Date()} onChange={(date) => setTimestamp(date)} value={timestamp} /> <InputDateTime minDate={new Date()} onChange={(date) => setTimestamp(date)} value={timestamp} />
</FormControl> </FormControl>
</Conditional> </Conditional>
<Conditional test={showEndDateField}>
<FormControl className="mt-2" htmlId="end-date" title="End Time">
<InputDateTime minDate={new Date()} onChange={(date) => setEndTimestamp(date)} value={endTimestamp} />
</FormControl>
</Conditional>
</div> </div>
<div className="-mt-6"> <div className="-mt-6">
<div className="relative mb-2"> <div className="relative mb-2">

View File

@ -6,9 +6,9 @@ import { Fragment, useEffect, useState } from 'react'
import { FaChevronDown, FaInfoCircle } from 'react-icons/fa' import { FaChevronDown, FaInfoCircle } from 'react-icons/fa'
import type { ActionListItem } from './actions' import type { ActionListItem } from './actions'
import { BASE_ACTION_LIST, SG721_UPDATABLE_ACTION_LIST, VENDING_ACTION_LIST } from './actions' import { BASE_ACTION_LIST, OPEN_EDITION_ACTION_LIST, SG721_UPDATABLE_ACTION_LIST, VENDING_ACTION_LIST } from './actions'
export type MinterType = 'base' | 'vending' export type MinterType = 'base' | 'vending' | 'openEdition'
export type Sg721Type = 'updatable' | 'base' export type Sg721Type = 'updatable' | 'base'
export interface ActionsComboboxProps { export interface ActionsComboboxProps {
@ -29,6 +29,9 @@ export const ActionsCombobox = ({ value, onChange, minterType, sg721Type }: Acti
} else if (minterType === 'vending') { } else if (minterType === 'vending') {
if (sg721Type === 'updatable') SET_ACTION_LIST(VENDING_ACTION_LIST.concat(SG721_UPDATABLE_ACTION_LIST)) if (sg721Type === 'updatable') SET_ACTION_LIST(VENDING_ACTION_LIST.concat(SG721_UPDATABLE_ACTION_LIST))
else SET_ACTION_LIST(VENDING_ACTION_LIST) else SET_ACTION_LIST(VENDING_ACTION_LIST)
} else if (minterType === 'openEdition') {
if (sg721Type === 'updatable') SET_ACTION_LIST(OPEN_EDITION_ACTION_LIST.concat(SG721_UPDATABLE_ACTION_LIST))
else SET_ACTION_LIST(OPEN_EDITION_ACTION_LIST)
} else SET_ACTION_LIST(VENDING_ACTION_LIST.concat(SG721_UPDATABLE_ACTION_LIST)) } else SET_ACTION_LIST(VENDING_ACTION_LIST.concat(SG721_UPDATABLE_ACTION_LIST))
}, [minterType, sg721Type]) }, [minterType, sg721Type])

View File

@ -1,6 +1,6 @@
/* eslint-disable eslint-comments/disable-enable-pair */ /* eslint-disable eslint-comments/disable-enable-pair */
import { useBaseMinterContract } from 'contracts/baseMinter' import { useBaseMinterContract } from 'contracts/baseMinter'
import { useOpenEditionMinterContract } from 'contracts/openEditionMinter'
import type { CollectionInfo, SG721Instance } from 'contracts/sg721' import type { CollectionInfo, SG721Instance } from 'contracts/sg721'
import { useSG721Contract } from 'contracts/sg721' import { useSG721Contract } from 'contracts/sg721'
import type { VendingMinterInstance } from 'contracts/vendingMinter' import type { VendingMinterInstance } from 'contracts/vendingMinter'
@ -8,6 +8,7 @@ import { useVendingMinterContract } from 'contracts/vendingMinter'
import type { AirdropAllocation } from 'utils/isValidAccountsFile' import type { AirdropAllocation } from 'utils/isValidAccountsFile'
import type { BaseMinterInstance } from '../../../contracts/baseMinter/contract' import type { BaseMinterInstance } from '../../../contracts/baseMinter/contract'
import type { OpenEditionMinterInstance } from '../../../contracts/openEditionMinter/contract'
export type ActionType = typeof ACTION_TYPES[number] export type ActionType = typeof ACTION_TYPES[number]
@ -21,6 +22,7 @@ export const ACTION_TYPES = [
'batch_mint', 'batch_mint',
'set_whitelist', 'set_whitelist',
'update_start_time', 'update_start_time',
'update_end_time',
'update_start_trading_time', 'update_start_trading_time',
'update_per_address_limit', 'update_per_address_limit',
'update_collection_info', 'update_collection_info',
@ -197,6 +199,79 @@ export const VENDING_ACTION_LIST: ActionListItem[] = [
}, },
] ]
export const OPEN_EDITION_ACTION_LIST: ActionListItem[] = [
{
id: 'update_mint_price',
name: 'Update Mint Price',
description: `Update mint price`,
},
{
id: 'mint_to',
name: 'Mint To',
description: `Mint a token to a user`,
},
{
id: 'batch_mint',
name: 'Batch Mint To',
description: `Mint multiple tokens to a user`,
},
{
id: 'update_start_time',
name: 'Update Minting Start Time',
description: `Update the start time for minting`,
},
{
id: 'update_end_time',
name: 'Update Minting End Time',
description: `Update the end time for minting`,
},
{
id: 'update_start_trading_time',
name: 'Update Trading Start Time',
description: `Update start time for trading`,
},
{
id: 'update_per_address_limit',
name: 'Update Tokens Per Address Limit',
description: `Update token per address limit`,
},
{
id: 'update_collection_info',
name: 'Update Collection Info',
description: `Update Collection Info`,
},
{
id: 'freeze_collection_info',
name: 'Freeze Collection Info',
description: `Freeze collection info to prevent further updates`,
},
{
id: 'transfer',
name: 'Transfer Tokens',
description: `Transfer tokens from one address to another`,
},
{
id: 'batch_transfer',
name: 'Batch Transfer Tokens',
description: `Transfer a list of tokens to a recipient`,
},
{
id: 'burn',
name: 'Burn Token',
description: `Burn a specified token from the collection`,
},
{
id: 'batch_burn',
name: 'Batch Burn Tokens',
description: `Burn a list of tokens from the collection`,
},
{
id: 'airdrop',
name: 'Airdrop Tokens',
description: 'Airdrop tokens to given addresses',
},
]
export const SG721_UPDATABLE_ACTION_LIST: ActionListItem[] = [ export const SG721_UPDATABLE_ACTION_LIST: ActionListItem[] = [
{ {
id: 'update_token_metadata', id: 'update_token_metadata',
@ -231,6 +306,7 @@ export interface DispatchExecuteArgs {
sg721Contract: string sg721Contract: string
vendingMinterMessages?: VendingMinterInstance vendingMinterMessages?: VendingMinterInstance
baseMinterMessages?: BaseMinterInstance baseMinterMessages?: BaseMinterInstance
openEditionMinterMessages?: OpenEditionMinterInstance
sg721Messages?: SG721Instance sg721Messages?: SG721Instance
txSigner: string txSigner: string
type: string | undefined type: string | undefined
@ -241,6 +317,7 @@ export interface DispatchExecuteArgs {
batchNumber: number batchNumber: number
whitelist: string whitelist: string
startTime: string | undefined startTime: string | undefined
endTime: string | undefined
limit: number limit: number
tokenIds: string tokenIds: string
recipients: string[] recipients: string[]
@ -250,8 +327,8 @@ export interface DispatchExecuteArgs {
} }
export const dispatchExecute = async (args: DispatchExecuteArgs) => { export const dispatchExecute = async (args: DispatchExecuteArgs) => {
const { vendingMinterMessages, baseMinterMessages, sg721Messages, txSigner } = args const { vendingMinterMessages, baseMinterMessages, openEditionMinterMessages, sg721Messages, txSigner } = args
if (!vendingMinterMessages || !baseMinterMessages || !sg721Messages) { if (!vendingMinterMessages || !baseMinterMessages || !openEditionMinterMessages || !sg721Messages) {
throw new Error('Cannot execute actions') throw new Error('Cannot execute actions')
} }
switch (args.type) { switch (args.type) {
@ -282,6 +359,9 @@ export const dispatchExecute = async (args: DispatchExecuteArgs) => {
case 'update_start_time': { case 'update_start_time': {
return vendingMinterMessages.updateStartTime(txSigner, args.startTime as string) return vendingMinterMessages.updateStartTime(txSigner, args.startTime as string)
} }
case 'update_end_time': {
return openEditionMinterMessages.updateEndTime(txSigner, args.endTime as string)
}
case 'update_start_trading_time': { case 'update_start_trading_time': {
return vendingMinterMessages.updateStartTradingTime(txSigner, args.startTime) return vendingMinterMessages.updateStartTradingTime(txSigner, args.startTime)
} }
@ -346,6 +426,8 @@ export const previewExecutePayload = (args: DispatchExecuteArgs) => {
const { messages: sg721Messages } = useSG721Contract() const { messages: sg721Messages } = useSG721Contract()
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const { messages: baseMinterMessages } = useBaseMinterContract() const { messages: baseMinterMessages } = useBaseMinterContract()
// eslint-disable-next-line react-hooks/rules-of-hooks
const { messages: openEditionMinterMessages } = useOpenEditionMinterContract()
const { minterContract, sg721Contract } = args const { minterContract, sg721Contract } = args
switch (args.type) { switch (args.type) {
case 'mint_token_uri': { case 'mint_token_uri': {
@ -375,6 +457,9 @@ export const previewExecutePayload = (args: DispatchExecuteArgs) => {
case 'update_start_time': { case 'update_start_time': {
return vendingMinterMessages(minterContract)?.updateStartTime(args.startTime as string) return vendingMinterMessages(minterContract)?.updateStartTime(args.startTime as string)
} }
case 'update_end_time': {
return openEditionMinterMessages(minterContract)?.updateEndTime(args.endTime as string)
}
case 'update_start_trading_time': { case 'update_start_trading_time': {
return vendingMinterMessages(minterContract)?.updateStartTradingTime(args.startTime as string) return vendingMinterMessages(minterContract)?.updateStartTradingTime(args.startTime as string)
} }

View File

@ -7,7 +7,7 @@ import { FaChevronDown, FaInfoCircle } from 'react-icons/fa'
import type { MinterType } from '../actions/Combobox' import type { MinterType } from '../actions/Combobox'
import type { QueryListItem } from './query' import type { QueryListItem } from './query'
import { BASE_QUERY_LIST, VENDING_QUERY_LIST } from './query' import { BASE_QUERY_LIST, OPEN_EDITION_QUERY_LIST, VENDING_QUERY_LIST } from './query'
export interface QueryComboboxProps { export interface QueryComboboxProps {
value: QueryListItem | null value: QueryListItem | null
@ -22,6 +22,8 @@ export const QueryCombobox = ({ value, onChange, minterType }: QueryComboboxProp
useEffect(() => { useEffect(() => {
if (minterType === 'base') { if (minterType === 'base') {
SET_QUERY_LIST(BASE_QUERY_LIST) SET_QUERY_LIST(BASE_QUERY_LIST)
} else if (minterType === 'openEdition') {
SET_QUERY_LIST(OPEN_EDITION_QUERY_LIST)
} else { } else {
SET_QUERY_LIST(VENDING_QUERY_LIST) SET_QUERY_LIST(VENDING_QUERY_LIST)
} }

View File

@ -6,6 +6,7 @@ import { AddressInput, TextInput } from 'components/forms/FormInput'
import { useInputState } from 'components/forms/FormInput.hooks' import { useInputState } from 'components/forms/FormInput.hooks'
import { JsonPreview } from 'components/JsonPreview' import { JsonPreview } from 'components/JsonPreview'
import type { BaseMinterInstance } from 'contracts/baseMinter' import type { BaseMinterInstance } from 'contracts/baseMinter'
import type { OpenEditionMinterInstance } from 'contracts/openEditionMinter'
import type { SG721Instance } from 'contracts/sg721' import type { SG721Instance } from 'contracts/sg721'
import type { VendingMinterInstance } from 'contracts/vendingMinter' import type { VendingMinterInstance } from 'contracts/vendingMinter'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
@ -21,6 +22,7 @@ interface CollectionQueriesProps {
sg721Messages: SG721Instance | undefined sg721Messages: SG721Instance | undefined
vendingMinterMessages: VendingMinterInstance | undefined vendingMinterMessages: VendingMinterInstance | undefined
baseMinterMessages: BaseMinterInstance | undefined baseMinterMessages: BaseMinterInstance | undefined
openEditionMinterMessages: OpenEditionMinterInstance | undefined
minterType: MinterType minterType: MinterType
} }
export const CollectionQueries = ({ export const CollectionQueries = ({
@ -28,6 +30,7 @@ export const CollectionQueries = ({
sg721Messages, sg721Messages,
minterContractAddress, minterContractAddress,
vendingMinterMessages, vendingMinterMessages,
openEditionMinterMessages,
baseMinterMessages, baseMinterMessages,
minterType, minterType,
}: CollectionQueriesProps) => { }: CollectionQueriesProps) => {
@ -57,9 +60,25 @@ export const CollectionQueries = ({
const showAddressField = type === 'tokens_minted_to_user' || type === 'tokens' const showAddressField = type === 'tokens_minted_to_user' || type === 'tokens'
const { data: response } = useQuery( const { data: response } = useQuery(
[sg721Messages, baseMinterMessages, vendingMinterMessages, type, tokenId, address] as const, [
sg721Messages,
baseMinterMessages,
vendingMinterMessages,
openEditionMinterMessages,
type,
tokenId,
address,
] as const,
async ({ queryKey }) => { async ({ queryKey }) => {
const [_sg721Messages, _baseMinterMessages_, _vendingMinterMessages, _type, _tokenId, _address] = queryKey const [
_sg721Messages,
_baseMinterMessages_,
_vendingMinterMessages,
_openEditionMinterMessages,
_type,
_tokenId,
_address,
] = queryKey
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const res = await resolveAddress(_address, wallet).then(async (resolvedAddress) => { const res = await resolveAddress(_address, wallet).then(async (resolvedAddress) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@ -67,6 +86,7 @@ export const CollectionQueries = ({
tokenId: _tokenId, tokenId: _tokenId,
vendingMinterMessages: _vendingMinterMessages, vendingMinterMessages: _vendingMinterMessages,
baseMinterMessages: _baseMinterMessages_, baseMinterMessages: _baseMinterMessages_,
openEditionMinterMessages: _openEditionMinterMessages,
sg721Messages: _sg721Messages, sg721Messages: _sg721Messages,
address: resolvedAddress, address: resolvedAddress,
type: _type, type: _type,

View File

@ -1,4 +1,5 @@
import type { BaseMinterInstance } from 'contracts/baseMinter' import type { BaseMinterInstance } from 'contracts/baseMinter'
import type { OpenEditionMinterInstance } from 'contracts/openEditionMinter/contract'
import type { SG721Instance } from 'contracts/sg721' import type { SG721Instance } from 'contracts/sg721'
import type { VendingMinterInstance } from 'contracts/vendingMinter' import type { VendingMinterInstance } from 'contracts/vendingMinter'
@ -9,6 +10,7 @@ export const QUERY_TYPES = [
'mint_price', 'mint_price',
'num_tokens', 'num_tokens',
'tokens_minted_to_user', 'tokens_minted_to_user',
'total_mint_count',
'tokens', 'tokens',
// 'token_owners', // 'token_owners',
'token_info', 'token_info',
@ -91,6 +93,48 @@ export const BASE_QUERY_LIST: QueryListItem[] = [
description: `Query Minter Status`, description: `Query Minter Status`,
}, },
] ]
export const OPEN_EDITION_QUERY_LIST: QueryListItem[] = [
{
id: 'collection_info',
name: 'Collection Info',
description: `Get information about the collection.`,
},
{
id: 'mint_price',
name: 'Mint Price',
description: `Get the price of minting a token.`,
},
{
id: 'tokens_minted_to_user',
name: 'Tokens Minted to User',
description: `Get the number of tokens minted in the collection to a user.`,
},
{
id: 'total_mint_count',
name: 'Total Mint Count',
description: `Get the total number of tokens minted for the collection.`,
},
// {
// id: 'token_owners',
// name: 'Token Owners',
// description: `Get the list of users who own tokens in the collection.`,
// },
{
id: 'token_info',
name: 'Token Info',
description: `Get information about a token in the collection.`,
},
{
id: 'config',
name: 'Minter Config',
description: `Query Minter Config`,
},
{
id: 'status',
name: 'Minter Status',
description: `Query Minter Status`,
},
]
export interface DispatchExecuteProps { export interface DispatchExecuteProps {
type: QueryType type: QueryType
@ -102,6 +146,7 @@ type Select<T extends QueryType> = T
export type DispatchQueryArgs = { export type DispatchQueryArgs = {
baseMinterMessages?: BaseMinterInstance baseMinterMessages?: BaseMinterInstance
vendingMinterMessages?: VendingMinterInstance vendingMinterMessages?: VendingMinterInstance
openEditionMinterMessages?: OpenEditionMinterInstance
sg721Messages?: SG721Instance sg721Messages?: SG721Instance
} & ( } & (
| { type: undefined } | { type: undefined }
@ -109,6 +154,7 @@ export type DispatchQueryArgs = {
| { type: Select<'mint_price'> } | { type: Select<'mint_price'> }
| { type: Select<'num_tokens'> } | { type: Select<'num_tokens'> }
| { type: Select<'tokens_minted_to_user'>; address: string } | { type: Select<'tokens_minted_to_user'>; address: string }
| { type: Select<'total_mint_count'> }
| { type: Select<'tokens'>; address: string } | { type: Select<'tokens'>; address: string }
// | { type: Select<'token_owners'> } // | { type: Select<'token_owners'> }
| { type: Select<'token_info'>; tokenId: string } | { type: Select<'token_info'>; tokenId: string }
@ -117,8 +163,8 @@ export type DispatchQueryArgs = {
) )
export const dispatchQuery = async (args: DispatchQueryArgs) => { export const dispatchQuery = async (args: DispatchQueryArgs) => {
const { baseMinterMessages, vendingMinterMessages, sg721Messages } = args const { baseMinterMessages, vendingMinterMessages, openEditionMinterMessages, sg721Messages } = args
if (!baseMinterMessages || !vendingMinterMessages || !sg721Messages) { if (!baseMinterMessages || !vendingMinterMessages || !openEditionMinterMessages || !sg721Messages) {
throw new Error('Cannot execute actions') throw new Error('Cannot execute actions')
} }
switch (args.type) { switch (args.type) {
@ -134,6 +180,9 @@ export const dispatchQuery = async (args: DispatchQueryArgs) => {
case 'tokens_minted_to_user': { case 'tokens_minted_to_user': {
return vendingMinterMessages.getMintCount(args.address) return vendingMinterMessages.getMintCount(args.address)
} }
case 'total_mint_count': {
return openEditionMinterMessages.getTotalMintCount()
}
case 'tokens': { case 'tokens': {
return sg721Messages.tokens(args.address) return sg721Messages.tokens(args.address)
} }

View File

@ -0,0 +1,7 @@
import type { ExecuteListItem } from 'contracts/openEditionMinter/messages/execute'
import { useState } from 'react'
export const useExecuteComboboxState = () => {
const [value, setValue] = useState<ExecuteListItem | null>(null)
return { value, onChange: (item: ExecuteListItem) => setValue(item) }
}

View File

@ -0,0 +1,92 @@
import { Combobox, Transition } from '@headlessui/react'
import clsx from 'clsx'
import { FormControl } from 'components/FormControl'
import type { ExecuteListItem } from 'contracts/openEditionMinter/messages/execute'
import { EXECUTE_LIST } from 'contracts/openEditionMinter/messages/execute'
import { matchSorter } from 'match-sorter'
import { Fragment, useState } from 'react'
import { FaChevronDown, FaInfoCircle } from 'react-icons/fa'
export interface ExecuteComboboxProps {
value: ExecuteListItem | null
onChange: (item: ExecuteListItem) => void
}
export const ExecuteCombobox = ({ value, onChange }: ExecuteComboboxProps) => {
const [search, setSearch] = useState('')
const filtered =
search === '' ? EXECUTE_LIST : matchSorter(EXECUTE_LIST, search, { keys: ['id', 'name', 'description'] })
return (
<Combobox
as={FormControl}
htmlId="message-type"
labelAs={Combobox.Label}
onChange={onChange}
subtitle="Contract execute message type"
title="Message Type"
value={value}
>
<div className="relative">
<Combobox.Input
className={clsx(
'w-full bg-white/10 rounded border-2 border-white/20 form-input',
'placeholder:text-white/50',
'focus:ring focus:ring-plumbus-20',
)}
displayValue={(val?: ExecuteListItem) => val?.name ?? ''}
id="message-type"
onChange={(event) => setSearch(event.target.value)}
placeholder="Select message type"
/>
<Combobox.Button
className={clsx(
'flex absolute inset-y-0 right-0 items-center p-4',
'opacity-50 hover:opacity-100 active:opacity-100',
)}
>
{({ open }) => <FaChevronDown aria-hidden="true" className={clsx('w-4 h-4', { 'rotate-180': open })} />}
</Combobox.Button>
<Transition afterLeave={() => setSearch('')} as={Fragment}>
<Combobox.Options
className={clsx(
'overflow-auto absolute z-10 mt-2 w-full max-h-[30vh]',
'bg-stone-800/80 rounded shadow-lg backdrop-blur-sm',
'divide-y divide-stone-500/50',
)}
>
{filtered.length < 1 && (
<span className="flex flex-col justify-center items-center p-4 text-sm text-center text-white/50">
Message type not found.
</span>
)}
{filtered.map((entry) => (
<Combobox.Option
key={entry.id}
className={({ active }) =>
clsx('flex relative flex-col py-2 px-4 space-y-1 cursor-pointer', { 'bg-stargaze-80': active })
}
value={entry}
>
<span className="font-bold">{entry.name}</span>
<span className="max-w-md text-sm">{entry.description}</span>
</Combobox.Option>
))}
</Combobox.Options>
</Transition>
</div>
{value && (
<div className="flex space-x-2 text-white/50">
<div className="mt-1">
<FaInfoCircle className="w-3 h-3" />
</div>
<span className="text-sm">{value.description}</span>
</div>
)}
</Combobox>
)
}

View File

@ -0,0 +1,293 @@
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import clsx from 'clsx'
import { Conditional } from 'components/Conditional'
import { FormControl } from 'components/FormControl'
import { FormGroup } from 'components/FormGroup'
import { useInputState } from 'components/forms/FormInput.hooks'
import { InputDateTime } from 'components/InputDateTime'
import { Tooltip } from 'components/Tooltip'
import { addLogItem } from 'contexts/log'
import type { ChangeEvent } from 'react'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'react-hot-toast'
import { OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS, SG721_UPDATABLE_CODE_ID } from 'utils/constants'
import { uid } from 'utils/random'
import { TextInput } from '../forms/FormInput'
import type { UploadMethod } from './OffChainMetadataUploadDetails'
import type { MetadataStorageMethod } from './OpenEditionMinterCreator'
interface CollectionDetailsProps {
onChange: (data: CollectionDetailsDataProps) => void
uploadMethod: UploadMethod
coverImageUrl: string
metadataStorageMethod: MetadataStorageMethod
}
export interface CollectionDetailsDataProps {
name: string
description: string
symbol: string
imageFile: File[]
externalLink?: string
startTradingTime?: string
explicit: boolean
updatable: boolean
}
export const CollectionDetails = ({
onChange,
uploadMethod,
metadataStorageMethod,
coverImageUrl,
}: CollectionDetailsProps) => {
const [coverImage, setCoverImage] = useState<File | null>(null)
const [timestamp, setTimestamp] = useState<Date | undefined>()
const [explicit, setExplicit] = useState<boolean>(false)
const [updatable, setUpdatable] = useState<boolean>(false)
const initialRender = useRef(true)
const coverImageInputRef = useRef<HTMLInputElement>(null)
const nameState = useInputState({
id: 'name',
name: 'name',
title: 'Name',
placeholder: 'My Awesome Collection',
})
const descriptionState = useInputState({
id: 'description',
name: 'description',
title: 'Description',
placeholder: 'My Awesome Collection Description',
})
const symbolState = useInputState({
id: 'symbol',
name: 'symbol',
title: 'Symbol',
placeholder: 'SYMBOL',
})
const externalLinkState = useInputState({
id: 'external-link',
name: 'externalLink',
title: 'External Link (optional)',
placeholder: 'https://my-collection...',
})
useEffect(() => {
try {
const data: CollectionDetailsDataProps = {
name: nameState.value,
description: descriptionState.value,
symbol: symbolState.value,
imageFile: coverImage ? [coverImage] : [],
externalLink: externalLinkState.value || undefined,
startTradingTime: timestamp ? (timestamp.getTime() * 1_000_000).toString() : '',
explicit,
updatable,
}
onChange(data)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
toast.error(error.message, { style: { maxWidth: 'none' } })
addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
nameState.value,
descriptionState.value,
symbolState.value,
externalLinkState.value,
coverImage,
timestamp,
explicit,
updatable,
])
const selectCoverImage = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.files === null) return toast.error('Error selecting cover image')
if (event.target.files.length === 0) {
setCoverImage(null)
return toast.error('No files selected.')
}
const reader = new FileReader()
reader.onload = (e) => {
if (!e.target?.result) return toast.error('Error parsing file.')
if (!event.target.files) return toast.error('No files selected.')
const imageFile = new File([e.target.result], event.target.files[0].name, { type: 'image/jpg' })
setCoverImage(imageFile)
}
reader.readAsArrayBuffer(event.target.files[0])
}
useEffect(() => {
setCoverImage(null)
// empty the element so that the same file can be selected again
if (coverImageInputRef.current) coverImageInputRef.current.value = ''
}, [metadataStorageMethod])
useEffect(() => {
if (initialRender.current) {
initialRender.current = false
} else if (updatable) {
toast.success('Token metadata will be updatable upon collection creation.', {
style: { maxWidth: 'none' },
icon: '✅📝',
})
} else {
toast.error('Token metadata will not be updatable upon collection creation.', {
style: { maxWidth: 'none' },
icon: '⛔🔏',
})
}
}, [updatable])
return (
<div>
<FormGroup subtitle="Information about your collection" title="Collection Details">
<div className={clsx('')}>
<div className="">
<TextInput {...nameState} isRequired />
<TextInput className="mt-2" {...descriptionState} isRequired />
<TextInput className="mt-2" {...symbolState} isRequired />
</div>
<div className={clsx('')}>
<TextInput className={clsx('mt-2')} {...externalLinkState} />
{/* Currently trading starts immediately for 1/1 Collections */}
<FormControl
className={clsx('mt-2')}
htmlId="timestamp"
subtitle="Trading start time offset will be set as 2 weeks by default."
title="Trading Start Time (optional)"
>
<InputDateTime minDate={new Date()} onChange={(date) => setTimestamp(date)} value={timestamp} />
</FormControl>
</div>
</div>
<FormControl className={clsx('')} isRequired={uploadMethod === 'new'} title="Cover Image">
{uploadMethod === 'new' && (
<input
accept="image/*"
className={clsx(
'w-full',
'p-[13px] rounded border-2 border-white/20 border-dashed cursor-pointer h-18',
'file:py-2 file:px-4 file:mr-4 file:bg-plumbus-light file:rounded file:border-0 cursor-pointer',
'before:hover:bg-white/5 before:transition',
)}
id="cover-image"
onChange={selectCoverImage}
ref={coverImageInputRef}
type="file"
/>
)}
{coverImage !== null && uploadMethod === 'new' && (
<div className="max-w-[200px] max-h-[200px] rounded border-2">
<img alt="no-preview-available" src={URL.createObjectURL(coverImage)} />
</div>
)}
{uploadMethod === 'existing' && coverImageUrl?.includes('ipfs://') && (
<div className="max-w-[200px] max-h-[200px] rounded border-2">
<img
alt="no-preview-available"
src={`https://ipfs-gw.stargaze-apis.com/ipfs/${coverImageUrl.substring(
coverImageUrl.lastIndexOf('ipfs://') + 7,
)}`}
/>
</div>
)}
{uploadMethod === 'existing' && coverImageUrl && !coverImageUrl?.includes('ipfs://') && (
<div className="max-w-[200px] max-h-[200px] rounded border-2">
<img alt="no-preview-available" src={coverImageUrl} />
</div>
)}
{uploadMethod === 'existing' && !coverImageUrl && (
<span className="italic font-light ">Waiting for cover image URL to be specified.</span>
)}
</FormControl>
<div className={clsx('flex flex-col space-y-2')}>
<div>
<div className="flex mt-4">
<span className="mt-1 ml-[2px] text-sm first-letter:capitalize">
Does the collection contain explicit content?
</span>
<div className="ml-2 font-bold form-check form-check-inline">
<input
checked={explicit}
className="peer sr-only"
id="explicitRadio1"
name="explicitRadioOptions1"
onClick={() => {
setExplicit(true)
}}
type="radio"
/>
<label
className="inline-block py-1 px-2 text-sm text-gray peer-checked:text-white hover:text-white peer-checked:bg-black hover:rounded-sm peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="explicitRadio1"
>
YES
</label>
</div>
<div className="ml-2 font-bold form-check form-check-inline">
<input
checked={!explicit}
className="peer sr-only"
id="explicitRadio2"
name="explicitRadioOptions2"
onClick={() => {
setExplicit(false)
}}
type="radio"
/>
<label
className="inline-block py-1 px-2 text-sm text-gray peer-checked:text-white hover:text-white peer-checked:bg-black hover:rounded-sm peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="explicitRadio2"
>
NO
</label>
</div>
</div>
</div>
</div>
<Conditional test={SG721_UPDATABLE_CODE_ID > 0 && OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS !== undefined}>
<Tooltip
backgroundColor="bg-blue-500"
label={
<div className="grid grid-flow-row">
<span>
When enabled, the metadata for tokens can be updated after the collection is created until the
collection is frozen by the creator.
</span>
</div>
}
placement="bottom"
>
<div className={clsx('flex flex-col space-y-2 w-3/4 form-control')}>
<label className="justify-start cursor-pointer label">
<span className="mr-4 font-bold">Updatable Token Metadata</span>
<input
checked={updatable}
className={`toggle ${updatable ? `bg-stargaze` : `bg-gray-600`}`}
onClick={() => setUpdatable(!updatable)}
type="checkbox"
/>
</label>
</div>
</Tooltip>
</Conditional>
</FormGroup>
</div>
)
}

View File

@ -0,0 +1,349 @@
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable jsx-a11y/media-has-caption */
/* eslint-disable no-misleading-character-class */
/* eslint-disable no-control-regex */
import clsx from 'clsx'
import { Anchor } from 'components/Anchor'
import { Conditional } from 'components/Conditional'
import { TextInput } from 'components/forms/FormInput'
import { useInputState } from 'components/forms/FormInput.hooks'
import { SingleAssetPreview } from 'components/SingleAssetPreview'
import type { ChangeEvent } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'react-hot-toast'
import type { UploadServiceType } from 'services/upload'
import { getAssetType } from 'utils/getAssetType'
export type UploadMethod = 'new' | 'existing'
interface ImageUploadDetailsProps {
onChange: (value: ImageUploadDetailsDataProps) => void
}
export interface ImageUploadDetailsDataProps {
assetFile: File | undefined
uploadService: UploadServiceType
nftStorageApiKey?: string
pinataApiKey?: string
pinataSecretKey?: string
uploadMethod: UploadMethod
imageUrl?: string
coverImageUrl?: string
}
export const ImageUploadDetails = ({ onChange }: ImageUploadDetailsProps) => {
const [assetFile, setAssetFile] = useState<File>()
const [uploadMethod, setUploadMethod] = useState<UploadMethod>('new')
const [uploadService, setUploadService] = useState<UploadServiceType>('nft-storage')
const assetFileRef = useRef<HTMLInputElement | null>(null)
const nftStorageApiKeyState = useInputState({
id: 'nft-storage-api-key',
name: 'nftStorageApiKey',
title: 'NFT.Storage API Key',
placeholder: 'Enter NFT.Storage API Key',
defaultValue: '',
})
const pinataApiKeyState = useInputState({
id: 'pinata-api-key',
name: 'pinataApiKey',
title: 'Pinata API Key',
placeholder: 'Enter Pinata API Key',
defaultValue: '',
})
const pinataSecretKeyState = useInputState({
id: 'pinata-secret-key',
name: 'pinataSecretKey',
title: 'Pinata Secret Key',
placeholder: 'Enter Pinata Secret Key',
defaultValue: '',
})
const imageUrlState = useInputState({
id: 'imageUrl',
name: 'imageUrl',
title: 'Asset URL',
placeholder: 'ipfs://',
defaultValue: '',
})
const coverImageUrlState = useInputState({
id: 'coverImageUrl',
name: 'coverImageUrl',
title: 'Cover Image URL',
placeholder: 'ipfs://',
defaultValue: '',
})
const selectAsset = (event: ChangeEvent<HTMLInputElement>) => {
setAssetFile(undefined)
if (event.target.files === null) return
let selectedFile: File
const reader = new FileReader()
reader.onload = (e) => {
if (!event.target.files) return toast.error('No file selected.')
if (!e.target?.result) return toast.error('Error parsing file.')
selectedFile = new File([e.target.result], event.target.files[0].name, { type: 'image/jpg' })
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (event.target.files[0]) reader.readAsArrayBuffer(event.target.files[0])
else return toast.error('No file selected.')
reader.onloadend = () => {
if (!event.target.files) return toast.error('No file selected.')
setAssetFile(selectedFile)
}
}
const regex =
/[\0-\x1F\x7F-\x9F\xAD\u0378\u0379\u037F-\u0383\u038B\u038D\u03A2\u0528-\u0530\u0557\u0558\u0560\u0588\u058B-\u058E\u0590\u05C8-\u05CF\u05EB-\u05EF\u05F5-\u0605\u061C\u061D\u06DD\u070E\u070F\u074B\u074C\u07B2-\u07BF\u07FB-\u07FF\u082E\u082F\u083F\u085C\u085D\u085F-\u089F\u08A1\u08AD-\u08E3\u08FF\u0978\u0980\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09FC-\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF2-\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B55\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B78-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BFB-\u0C00\u0C04\u0C0D\u0C11\u0C29\u0C34\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5A-\u0C5F\u0C64\u0C65\u0C70-\u0C77\u0C80\u0C81\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0D01\u0D04\u0D0D\u0D11\u0D3B\u0D3C\u0D45\u0D49\u0D4F-\u0D56\u0D58-\u0D5F\u0D64\u0D65\u0D76-\u0D78\u0D80\u0D81\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DF1\u0DF5-\u0E00\u0E3B-\u0E3E\u0E5C-\u0E80\u0E83\u0E85\u0E86\u0E89\u0E8B\u0E8C\u0E8E-\u0E93\u0E98\u0EA0\u0EA4\u0EA6\u0EA8\u0EA9\u0EAC\u0EBA\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F48\u0F6D-\u0F70\u0F98\u0FBD\u0FCD\u0FDB-\u0FFF\u10C6\u10C8-\u10CC\u10CE\u10CF\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u137D-\u137F\u139A-\u139F\u13F5-\u13FF\u169D-\u169F\u16F1-\u16FF\u170D\u1715-\u171F\u1737-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17DE\u17DF\u17EA-\u17EF\u17FA-\u17FF\u180F\u181A-\u181F\u1878-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191D-\u191F\u192C-\u192F\u193C-\u193F\u1941-\u1943\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DB-\u19DD\u1A1C\u1A1D\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1A9F\u1AAE-\u1AFF\u1B4C-\u1B4F\u1B7D-\u1B7F\u1BF4-\u1BFB\u1C38-\u1C3A\u1C4A-\u1C4C\u1C80-\u1CBF\u1CC8-\u1CCF\u1CF7-\u1CFF\u1DE7-\u1DFB\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FC5\u1FD4\u1FD5\u1FDC\u1FF0\u1FF1\u1FF5\u1FFF\u200B-\u200F\u2020-\u202E\u2060-\u206F\u2072\u2073\u208F\u209D-\u209F\u20BB-\u20CF\u20F1-\u20FF\u218A-\u218F\u23F4-\u23FF\u2427-\u243F\u244B-\u245F\u2700\u2B4D-\u2B4F\u2B5A-\u2BFF\u2C2F\u2C5F\u2CF4-\u2CF8\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D71-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E3C-\u2E7F\u2E9A\u2EF4-\u2EFF\u2FD6-\u2FEF\u2FFC-\u2FFF\u3040\u3097\u3098\u3100-\u3104\u312E-\u3130\u318F\u31BB-\u31BF\u31E4-\u31EF\u321F\u32FF\u4DB6-\u4DBF\u9FCD-\u9FFF\uA48D-\uA48F\uA4C7-\uA4CF\uA62C-\uA63F\uA698-\uA69E\uA6F8-\uA6FF\uA78F\uA794-\uA79F\uA7AB-\uA7F7\uA82C-\uA82F\uA83A-\uA83F\uA878-\uA87F\uA8C5-\uA8CD\uA8DA-\uA8DF\uA8FC-\uA8FF\uA954-\uA95E\uA97D-\uA97F\uA9CE\uA9DA-\uA9DD\uA9E0-\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A\uAA5B\uAA7C-\uAA7F\uAAC3-\uAADA\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F-\uABBF\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBC2-\uFBD2\uFD40-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFE\uFDFF\uFE1A-\uFE1F\uFE27-\uFE2F\uFE53\uFE67\uFE6C-\uFE6F\uFE75\uFEFD-\uFF00\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFDF\uFFE7\uFFEF-\uFFFB\uFFFE\uFFFF]/g
useEffect(() => {
try {
const data: ImageUploadDetailsDataProps = {
assetFile,
uploadService,
nftStorageApiKey: nftStorageApiKeyState.value,
pinataApiKey: pinataApiKeyState.value,
pinataSecretKey: pinataSecretKeyState.value,
uploadMethod,
imageUrl: imageUrlState.value
.replace('IPFS://', 'ipfs://')
.replace(/,/g, '')
.replace(/"/g, '')
.replace(/'/g, '')
.replace(/ /g, '')
.replace(regex, ''),
coverImageUrl: coverImageUrlState.value.trim(),
}
onChange(data)
} catch (error: any) {
toast.error(error.message, { style: { maxWidth: 'none' } })
}
}, [
assetFile,
uploadService,
nftStorageApiKeyState.value,
pinataApiKeyState.value,
pinataSecretKeyState.value,
uploadMethod,
imageUrlState.value,
coverImageUrlState.value,
])
useEffect(() => {
if (assetFileRef.current) assetFileRef.current.value = ''
setAssetFile(undefined)
imageUrlState.onChange('')
}, [uploadMethod])
const previewUrl = imageUrlState.value.toLowerCase().trim().startsWith('ipfs://')
? `https://ipfs-gw.stargaze-apis.com/ipfs/${imageUrlState.value.substring(7)}`
: imageUrlState.value
const audioPreview = useMemo(
() => (
<audio
controls
id="audio"
onMouseEnter={(e) => e.currentTarget.play()}
onMouseLeave={(e) => e.currentTarget.pause()}
src={imageUrlState.value ? previewUrl : ''}
/>
),
[imageUrlState.value],
)
const videoPreview = useMemo(
() => (
<video
controls
id="video"
onMouseEnter={(e) => e.currentTarget.play()}
onMouseLeave={(e) => e.currentTarget.pause()}
src={imageUrlState.value ? previewUrl : ''}
/>
),
[imageUrlState.value],
)
return (
<div className="justify-items-start mb-3 rounded border-2 border-white/20 flex-column">
<div className="flex justify-center">
<div className="mt-3 ml-4 font-bold form-check form-check-inline">
<input
checked={uploadMethod === 'new'}
className="peer sr-only"
id="inlineRadio2"
name="inlineRadioOptions2"
onClick={() => {
setUploadMethod('new')
}}
type="radio"
value="New"
/>
<label
className="inline-block py-1 px-2 text-gray peer-checked:text-white hover:text-white peer-checked:bg-black peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="inlineRadio2"
>
Upload New Asset
</label>
</div>
<div className="mt-3 ml-2 font-bold form-check form-check-inline">
<input
checked={uploadMethod === 'existing'}
className="peer sr-only"
id="inlineRadio1"
name="inlineRadioOptions1"
onClick={() => {
setUploadMethod('existing')
}}
type="radio"
value="Existing"
/>
<label
className="inline-block py-1 px-2 text-gray peer-checked:text-white hover:text-white peer-checked:bg-black peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="inlineRadio1"
>
Use an existing Asset URL
</label>
</div>
</div>
<div className="p-3 py-5 pb-4">
<Conditional test={uploadMethod === 'existing'}>
<div className="ml-3 flex-column">
<p className="mb-5 ml-5">
Though the Open Edition contracts allow for off-chain asset storage, it is recommended to use a
decentralized storage solution, such as IPFS. <br /> You may head over to{' '}
<Anchor className="font-bold text-plumbus hover:underline" href="https://nft.storage">
NFT.Storage
</Anchor>{' '}
or{' '}
<Anchor className="font-bold text-plumbus hover:underline" href="https://www.pinata.cloud/">
Pinata
</Anchor>{' '}
and upload your asset manually to get an asset URL for your NFT.
</p>
<div className="flex flex-row w-full">
<div className="flex flex-col w-3/5">
<TextInput {...imageUrlState} className="mt-2 ml-6" />
<TextInput {...coverImageUrlState} className="mt-4 ml-6" />
</div>
<Conditional test={imageUrlState.value !== ''}>
<div className="flex mt-2 ml-8 w-1/3 border-2 border-dashed">
{getAssetType(imageUrlState.value) === 'audio' && audioPreview}
{getAssetType(imageUrlState.value) === 'video' && videoPreview}
{getAssetType(imageUrlState.value) === 'image' && <img alt="asset-preview" src={previewUrl} />}
</div>
</Conditional>
</div>
</div>
</Conditional>
<Conditional test={uploadMethod === 'new'}>
<div>
<div className="flex flex-col items-center px-8 w-full">
<div className="flex justify-items-start mb-5 w-full font-bold">
<div className="form-check form-check-inline">
<input
checked={uploadService === 'nft-storage'}
className="peer sr-only"
id="inlineRadio3"
name="inlineRadioOptions3"
onClick={() => {
setUploadService('nft-storage')
}}
type="radio"
value="nft-storage"
/>
<label
className="inline-block py-1 px-2 text-gray peer-checked:text-white hover:text-white peer-checked:bg-black hover:rounded-sm peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="inlineRadio3"
>
Upload using NFT.Storage
</label>
</div>
<div className="ml-2 form-check form-check-inline">
<input
checked={uploadService === 'pinata'}
className="peer sr-only"
id="inlineRadio4"
name="inlineRadioOptions4"
onClick={() => {
setUploadService('pinata')
}}
type="radio"
value="pinata"
/>
<label
className="inline-block py-1 px-2 text-gray peer-checked:text-white hover:text-white peer-checked:bg-black hover:rounded-sm peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="inlineRadio4"
>
Upload using Pinata
</label>
</div>
</div>
<div className="flex w-full">
<Conditional test={uploadService === 'nft-storage'}>
<TextInput {...nftStorageApiKeyState} className="w-full" />
</Conditional>
<Conditional test={uploadService === 'pinata'}>
<TextInput {...pinataApiKeyState} className="w-full" />
<div className="w-[20px]" />
<TextInput {...pinataSecretKeyState} className="w-full" />
</Conditional>
</div>
</div>
<div className="mt-6">
<div className="grid grid-cols-2">
<div>
<div className="w-full">
<div>
<label
className="block mt-5 mr-1 mb-1 ml-8 w-full font-bold text-white dark:text-gray-300"
htmlFor="assetFile"
>
Asset Selection
</label>
<div
className={clsx(
'flex relative justify-center items-center mx-8 mt-2 space-y-4 w-full h-32',
'rounded border-2 border-white/20 border-dashed',
)}
>
<input
accept="image/*, audio/*, video/*, .html"
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',
)}
id="assetFile"
onChange={selectAsset}
ref={assetFileRef}
type="file"
/>
</div>
</div>
</div>
</div>
<Conditional test={assetFile !== undefined}>
<SingleAssetPreview
relatedAsset={assetFile}
subtitle={`Asset filename: ${assetFile?.name as string}`}
/>
</Conditional>
</div>
</div>
</div>
</Conditional>
</div>
</div>
)
}

View File

@ -0,0 +1,99 @@
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable no-nested-ternary */
import { FormControl } from 'components/FormControl'
import { FormGroup } from 'components/FormGroup'
import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks'
import { InputDateTime } from 'components/InputDateTime'
import React, { useEffect, useState } from 'react'
import { resolveAddress } from 'utils/resolveAddress'
import { useWallet } from '../../contexts/wallet'
import { NumberInput, TextInput } from '../forms/FormInput'
import type { UploadMethod } from './OffChainMetadataUploadDetails'
interface MintingDetailsProps {
onChange: (data: MintingDetailsDataProps) => void
uploadMethod: UploadMethod
minimumMintPrice: number
}
export interface MintingDetailsDataProps {
unitPrice: string
perAddressLimit: number
startTime: string
endTime: string
paymentAddress?: string
}
export const MintingDetails = ({ onChange, uploadMethod, minimumMintPrice }: MintingDetailsProps) => {
const wallet = useWallet()
const [timestamp, setTimestamp] = useState<Date | undefined>()
const [endTimestamp, setEndTimestamp] = useState<Date | undefined>()
const unitPriceState = useNumberInputState({
id: 'unitPrice',
name: 'unitPrice',
title: 'Unit Price',
subtitle: `Price of each token (min. ${minimumMintPrice} STARS)`,
placeholder: '50',
})
const perAddressLimitState = useNumberInputState({
id: 'peraddresslimit',
name: 'peraddresslimit',
title: 'Per Address Limit',
subtitle: '',
placeholder: '1',
})
const paymentAddressState = useInputState({
id: 'payment-address',
name: 'paymentAddress',
title: 'Payment Address (optional)',
subtitle: 'Address to receive minting revenues (defaults to current wallet address)',
placeholder: 'stars1234567890abcdefghijklmnopqrstuvwxyz...',
})
const resolvePaymentAddress = async () => {
await resolveAddress(paymentAddressState.value.trim(), wallet).then((resolvedAddress) => {
paymentAddressState.onChange(resolvedAddress)
})
}
useEffect(() => {
void resolvePaymentAddress()
}, [paymentAddressState.value])
useEffect(() => {
const data: MintingDetailsDataProps = {
unitPrice: unitPriceState.value
? (Number(unitPriceState.value) * 1_000_000).toString()
: unitPriceState.value === 0
? '0'
: '',
perAddressLimit: perAddressLimitState.value,
startTime: timestamp ? (timestamp.getTime() * 1_000_000).toString() : '',
endTime: endTimestamp ? (endTimestamp.getTime() * 1_000_000).toString() : '',
paymentAddress: paymentAddressState.value.trim(),
}
onChange(data)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [unitPriceState.value, perAddressLimitState.value, timestamp, endTimestamp, paymentAddressState.value])
return (
<div className="border-l-[1px] border-gray-500 border-opacity-20">
<FormGroup subtitle="Information about your minting settings" title="Minting Details">
<NumberInput {...unitPriceState} isRequired />
<NumberInput {...perAddressLimitState} isRequired />
<FormControl htmlId="timestamp" isRequired subtitle="Minting start time (local)" title="Start Time">
<InputDateTime minDate={new Date()} onChange={(date) => setTimestamp(date)} value={timestamp} />
</FormControl>
<FormControl htmlId="endTimestamp" isRequired subtitle="Minting end time (local)" title="End Time">
<InputDateTime minDate={new Date()} onChange={(date) => setEndTimestamp(date)} value={endTimestamp} />
</FormControl>
</FormGroup>
<TextInput className="pr-4 pl-4 mt-3" {...paymentAddressState} />
</div>
)
}

View File

@ -0,0 +1,452 @@
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable no-misleading-character-class */
/* eslint-disable no-control-regex */
/* eslint-disable @typescript-eslint/no-loop-func */
import clsx from 'clsx'
import { Alert } from 'components/Alert'
import { Anchor } from 'components/Anchor'
import { Conditional } from 'components/Conditional'
import { TextInput } from 'components/forms/FormInput'
import { useInputState } from 'components/forms/FormInput.hooks'
import { MetadataInput } from 'components/MetadataInput'
import { MetadataModal } from 'components/MetadataModal'
import { SingleAssetPreview } from 'components/SingleAssetPreview'
import { addLogItem } from 'contexts/log'
import type { ChangeEvent } from 'react'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'react-hot-toast'
import type { UploadServiceType } from 'services/upload'
import { uid } from 'utils/random'
import { naturalCompare } from 'utils/sort'
import type { MetadataStorageMethod } from './OpenEditionMinterCreator'
export type UploadMethod = 'new' | 'existing'
interface OffChainMetadataUploadDetailsProps {
onChange: (value: OffChainMetadataUploadDetailsDataProps) => void
metadataStorageMethod?: MetadataStorageMethod
}
export interface OffChainMetadataUploadDetailsDataProps {
assetFiles: File[]
metadataFiles: File[]
uploadService: UploadServiceType
nftStorageApiKey?: string
pinataApiKey?: string
pinataSecretKey?: string
uploadMethod: UploadMethod
tokenURI?: string
imageUrl?: string
openEditionMinterMetadataFile?: File
}
export const OffChainMetadataUploadDetails = ({
onChange,
metadataStorageMethod,
}: OffChainMetadataUploadDetailsProps) => {
const [assetFilesArray, setAssetFilesArray] = useState<File[]>([])
const [metadataFilesArray, setMetadataFilesArray] = useState<File[]>([])
const [uploadMethod, setUploadMethod] = useState<UploadMethod>('new')
const [uploadService, setUploadService] = useState<UploadServiceType>('nft-storage')
const [metadataFileArrayIndex, setMetadataFileArrayIndex] = useState(0)
const [refreshMetadata, setRefreshMetadata] = useState(false)
const [openEditionMinterMetadataFile, setOpenEditionMinterMetadataFile] = useState<File | undefined>()
const assetFilesRef = useRef<HTMLInputElement | null>(null)
const metadataFilesRef = useRef<HTMLInputElement | null>(null)
const nftStorageApiKeyState = useInputState({
id: 'nft-storage-api-key',
name: 'nftStorageApiKey',
title: 'NFT.Storage API Key',
placeholder: 'Enter NFT.Storage API Key',
defaultValue: '',
})
const pinataApiKeyState = useInputState({
id: 'pinata-api-key',
name: 'pinataApiKey',
title: 'Pinata API Key',
placeholder: 'Enter Pinata API Key',
defaultValue: '',
})
const pinataSecretKeyState = useInputState({
id: 'pinata-secret-key',
name: 'pinataSecretKey',
title: 'Pinata Secret Key',
placeholder: 'Enter Pinata Secret Key',
defaultValue: '',
})
const tokenUriState = useInputState({
id: 'tokenUri',
name: 'tokenUri',
title: 'Token URI',
placeholder: 'ipfs://',
defaultValue: '',
})
const coverImageUrlState = useInputState({
id: 'coverImageUrl',
name: 'coverImageUrl',
title: 'Cover Image URL',
placeholder: 'ipfs://',
defaultValue: '',
})
const selectAssets = (event: ChangeEvent<HTMLInputElement>) => {
setAssetFilesArray([])
setMetadataFilesArray([])
if (event.target.files === null) return
let loadedFileCount = 0
const files: File[] = []
let reader: FileReader
for (let i = 0; i < event.target.files.length; i++) {
reader = new FileReader()
reader.onload = (e) => {
if (!e.target?.result) return toast.error('Error parsing file.')
if (!event.target.files) return toast.error('No files selected.')
const assetFile = new File([e.target.result], event.target.files[i].name, { type: 'image/jpg' })
files.push(assetFile)
}
reader.readAsArrayBuffer(event.target.files[i])
reader.onloadend = () => {
if (!event.target.files) return toast.error('No file selected.')
loadedFileCount++
if (loadedFileCount === event.target.files.length) {
setAssetFilesArray(files.sort((a, b) => naturalCompare(a.name, b.name)))
}
}
}
}
const selectMetadata = (event: ChangeEvent<HTMLInputElement>) => {
setMetadataFilesArray([])
if (event.target.files === null) return toast.error('No files selected.')
let loadedFileCount = 0
const files: File[] = []
let reader: FileReader
for (let i = 0; i < event.target.files.length; i++) {
reader = new FileReader()
reader.onload = async (e) => {
if (!e.target?.result) return toast.error('Error parsing file.')
if (!event.target.files) return toast.error('No files selected.')
const metadataFile = new File([e.target.result], event.target.files[i].name, { type: 'application/json' })
files.push(metadataFile)
try {
const parsedMetadata = JSON.parse(await metadataFile.text())
if (!parsedMetadata || typeof parsedMetadata !== 'object') {
event.target.value = ''
setMetadataFilesArray([])
return toast.error(`Invalid metadata file: ${metadataFile.name}`)
}
} catch (error: any) {
event.target.value = ''
setMetadataFilesArray([])
addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() })
return toast.error(`Invalid metadata file: ${metadataFile.name}`)
}
}
reader.readAsText(event.target.files[i], 'utf8')
reader.onloadend = () => {
if (!event.target.files) return toast.error('No file selected.')
loadedFileCount++
if (loadedFileCount === event.target.files.length) {
setMetadataFilesArray(files.sort((a, b) => naturalCompare(a.name, b.name)))
}
}
}
}
const updateMetadataFileIndex = (index: number) => {
setMetadataFileArrayIndex(index)
setRefreshMetadata((prev) => !prev)
}
const updateMetadataFileArray = (updatedMetadataFile: File) => {
metadataFilesArray[metadataFileArrayIndex] = updatedMetadataFile
}
const updateOpenEditionMinterMetadataFile = (updatedMetadataFile: File) => {
setOpenEditionMinterMetadataFile(updatedMetadataFile)
console.log('Updated Open Edition Minter Metadata File:')
console.log(openEditionMinterMetadataFile)
}
const regex =
/[\0-\x1F\x7F-\x9F\xAD\u0378\u0379\u037F-\u0383\u038B\u038D\u03A2\u0528-\u0530\u0557\u0558\u0560\u0588\u058B-\u058E\u0590\u05C8-\u05CF\u05EB-\u05EF\u05F5-\u0605\u061C\u061D\u06DD\u070E\u070F\u074B\u074C\u07B2-\u07BF\u07FB-\u07FF\u082E\u082F\u083F\u085C\u085D\u085F-\u089F\u08A1\u08AD-\u08E3\u08FF\u0978\u0980\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09FC-\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF2-\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B55\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B78-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BFB-\u0C00\u0C04\u0C0D\u0C11\u0C29\u0C34\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5A-\u0C5F\u0C64\u0C65\u0C70-\u0C77\u0C80\u0C81\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0D01\u0D04\u0D0D\u0D11\u0D3B\u0D3C\u0D45\u0D49\u0D4F-\u0D56\u0D58-\u0D5F\u0D64\u0D65\u0D76-\u0D78\u0D80\u0D81\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DF1\u0DF5-\u0E00\u0E3B-\u0E3E\u0E5C-\u0E80\u0E83\u0E85\u0E86\u0E89\u0E8B\u0E8C\u0E8E-\u0E93\u0E98\u0EA0\u0EA4\u0EA6\u0EA8\u0EA9\u0EAC\u0EBA\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F48\u0F6D-\u0F70\u0F98\u0FBD\u0FCD\u0FDB-\u0FFF\u10C6\u10C8-\u10CC\u10CE\u10CF\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u137D-\u137F\u139A-\u139F\u13F5-\u13FF\u169D-\u169F\u16F1-\u16FF\u170D\u1715-\u171F\u1737-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17DE\u17DF\u17EA-\u17EF\u17FA-\u17FF\u180F\u181A-\u181F\u1878-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191D-\u191F\u192C-\u192F\u193C-\u193F\u1941-\u1943\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DB-\u19DD\u1A1C\u1A1D\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1A9F\u1AAE-\u1AFF\u1B4C-\u1B4F\u1B7D-\u1B7F\u1BF4-\u1BFB\u1C38-\u1C3A\u1C4A-\u1C4C\u1C80-\u1CBF\u1CC8-\u1CCF\u1CF7-\u1CFF\u1DE7-\u1DFB\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FC5\u1FD4\u1FD5\u1FDC\u1FF0\u1FF1\u1FF5\u1FFF\u200B-\u200F\u2020-\u202E\u2060-\u206F\u2072\u2073\u208F\u209D-\u209F\u20BB-\u20CF\u20F1-\u20FF\u218A-\u218F\u23F4-\u23FF\u2427-\u243F\u244B-\u245F\u2700\u2B4D-\u2B4F\u2B5A-\u2BFF\u2C2F\u2C5F\u2CF4-\u2CF8\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D71-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E3C-\u2E7F\u2E9A\u2EF4-\u2EFF\u2FD6-\u2FEF\u2FFC-\u2FFF\u3040\u3097\u3098\u3100-\u3104\u312E-\u3130\u318F\u31BB-\u31BF\u31E4-\u31EF\u321F\u32FF\u4DB6-\u4DBF\u9FCD-\u9FFF\uA48D-\uA48F\uA4C7-\uA4CF\uA62C-\uA63F\uA698-\uA69E\uA6F8-\uA6FF\uA78F\uA794-\uA79F\uA7AB-\uA7F7\uA82C-\uA82F\uA83A-\uA83F\uA878-\uA87F\uA8C5-\uA8CD\uA8DA-\uA8DF\uA8FC-\uA8FF\uA954-\uA95E\uA97D-\uA97F\uA9CE\uA9DA-\uA9DD\uA9E0-\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A\uAA5B\uAA7C-\uAA7F\uAAC3-\uAADA\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F-\uABBF\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBC2-\uFBD2\uFD40-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFE\uFDFF\uFE1A-\uFE1F\uFE27-\uFE2F\uFE53\uFE67\uFE6C-\uFE6F\uFE75\uFEFD-\uFF00\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFDF\uFFE7\uFFEF-\uFFFB\uFFFE\uFFFF]/g
useEffect(() => {
try {
const data: OffChainMetadataUploadDetailsDataProps = {
assetFiles: assetFilesArray,
metadataFiles: metadataFilesArray,
uploadService,
nftStorageApiKey: nftStorageApiKeyState.value,
pinataApiKey: pinataApiKeyState.value,
pinataSecretKey: pinataSecretKeyState.value,
uploadMethod,
tokenURI: tokenUriState.value
.replace('IPFS://', 'ipfs://')
.replace(/,/g, '')
.replace(/"/g, '')
.replace(/'/g, '')
.replace(/ /g, '')
.replace(regex, ''),
imageUrl: coverImageUrlState.value
.replace('IPFS://', 'ipfs://')
.replace(/,/g, '')
.replace(/"/g, '')
.replace(/'/g, '')
.replace(/ /g, '')
.replace(regex, ''),
openEditionMinterMetadataFile,
}
onChange(data)
} catch (error: any) {
toast.error(error.message, { style: { maxWidth: 'none' } })
addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() })
}
}, [
assetFilesArray,
metadataFilesArray,
uploadService,
nftStorageApiKeyState.value,
pinataApiKeyState.value,
pinataSecretKeyState.value,
uploadMethod,
tokenUriState.value,
coverImageUrlState.value,
refreshMetadata,
openEditionMinterMetadataFile,
])
useEffect(() => {
if (metadataFilesRef.current) metadataFilesRef.current.value = ''
setMetadataFilesArray([])
if (assetFilesRef.current) assetFilesRef.current.value = ''
setAssetFilesArray([])
tokenUriState.onChange('')
coverImageUrlState.onChange('')
}, [uploadMethod, metadataStorageMethod])
return (
<div className="justify-items-start mb-3 rounded border-2 border-white/20 flex-column">
<div className="flex justify-center">
<div className="mt-3 ml-4 font-bold form-check form-check-inline">
<input
checked={uploadMethod === 'new'}
className="peer sr-only"
id="inlineRadio2"
name="inlineRadioOptions2"
onClick={() => {
setUploadMethod('new')
}}
type="radio"
value="New"
/>
<label
className="inline-block py-1 px-2 text-gray peer-checked:text-white hover:text-white peer-checked:bg-black peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="inlineRadio2"
>
Upload asset & metadata
</label>
</div>
<div className="mt-3 ml-2 font-bold form-check form-check-inline">
<input
checked={uploadMethod === 'existing'}
className="peer sr-only"
id="inlineRadio1"
name="inlineRadioOptions1"
onClick={() => {
setUploadMethod('existing')
}}
type="radio"
value="Existing"
/>
<label
className="inline-block py-1 px-2 text-gray peer-checked:text-white hover:text-white peer-checked:bg-black peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="inlineRadio1"
>
Use an existing Token URI
</label>
</div>
</div>
<div className="p-3 py-5 pb-8">
<Conditional test={uploadMethod === 'existing'}>
<div className="ml-3 flex-column">
<p className="mb-5 ml-5">
Though Stargaze&apos;s sg721 contract allows for off-chain metadata storage, it is recommended to use a
decentralized storage solution, such as IPFS. <br /> You may head over to{' '}
<Anchor className="font-bold text-plumbus hover:underline" href="https://nft.storage">
NFT.Storage
</Anchor>{' '}
or{' '}
<Anchor className="font-bold text-plumbus hover:underline" href="https://www.pinata.cloud/">
Pinata
</Anchor>{' '}
and upload your asset & metadata manually to get a URI for your token before minting.
</p>
<div>
<TextInput {...tokenUriState} className="ml-4 w-1/2" />
<TextInput {...coverImageUrlState} className="mt-2 ml-4 w-1/2" />
</div>
</div>
</Conditional>
<Conditional test={uploadMethod === 'new'}>
<div>
<div className="flex flex-col items-center px-8 w-full">
<div className="flex justify-items-start mb-5 w-full font-bold">
<div className="form-check form-check-inline">
<input
checked={uploadService === 'nft-storage'}
className="peer sr-only"
id="inlineRadio3"
name="inlineRadioOptions3"
onClick={() => {
setUploadService('nft-storage')
}}
type="radio"
value="nft-storage"
/>
<label
className="inline-block py-1 px-2 text-gray peer-checked:text-white hover:text-white peer-checked:bg-black hover:rounded-sm peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="inlineRadio3"
>
Upload using NFT.Storage
</label>
</div>
<div className="ml-2 form-check form-check-inline">
<input
checked={uploadService === 'pinata'}
className="peer sr-only"
id="inlineRadio4"
name="inlineRadioOptions4"
onClick={() => {
setUploadService('pinata')
}}
type="radio"
value="pinata"
/>
<label
className="inline-block py-1 px-2 text-gray peer-checked:text-white hover:text-white peer-checked:bg-black hover:rounded-sm peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="inlineRadio4"
>
Upload using Pinata
</label>
</div>
</div>
<div className="flex w-full">
<Conditional test={uploadService === 'nft-storage'}>
<TextInput {...nftStorageApiKeyState} className="w-full" />
</Conditional>
<Conditional test={uploadService === 'pinata'}>
<TextInput {...pinataApiKeyState} className="w-full" />
<div className="w-[20px]" />
<TextInput {...pinataSecretKeyState} className="w-full" />
</Conditional>
</div>
</div>
<div className="mt-6">
<div className="grid grid-cols-2">
<div className="w-full">
<Conditional
test={
assetFilesArray.length > 0 &&
metadataFilesArray.length > 0 &&
assetFilesArray.length !== metadataFilesArray.length
}
>
<Alert className="mt-4 ml-8 w-3/4" type="warning">
The number of assets and metadata files should match.
</Alert>
</Conditional>
<div>
<label
className="block mt-5 mr-1 mb-1 ml-8 w-full font-bold text-white dark:text-gray-300"
htmlFor="assetFiles"
>
Asset Selection
</label>
<div
className={clsx(
'flex relative justify-center items-center mx-8 mt-2 space-y-4 w-full h-32',
'rounded border-2 border-white/20 border-dashed',
)}
>
<input
accept="image/*, audio/*, video/*, .html"
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',
)}
id="assetFiles"
onChange={selectAssets}
ref={assetFilesRef}
type="file"
/>
</div>
</div>
{assetFilesArray.length > 0 && (
<div>
<label
className="block mt-5 mr-1 mb-1 ml-8 w-full font-bold text-white dark:text-gray-300"
htmlFor="metadataFiles"
>
Metadata Selection (optional)
</label>
<div
className={clsx(
'flex relative justify-center items-center mx-8 mt-2 space-y-4 w-full h-32',
'rounded border-2 border-white/20 border-dashed',
)}
>
<input
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',
)}
id="metadataFiles"
onChange={selectMetadata}
ref={metadataFilesRef}
type="file"
/>
</div>
</div>
)}
<Conditional test={assetFilesArray.length >= 1}>
<MetadataModal
assetFile={assetFilesArray[metadataFileArrayIndex]}
metadataFile={metadataFilesArray[metadataFileArrayIndex]}
refresher={refreshMetadata}
updateMetadata={updateMetadataFileArray}
/>
</Conditional>
</div>
<SingleAssetPreview
relatedAsset={assetFilesArray[0]}
subtitle={`Asset filename: ${assetFilesArray[0]?.name}`}
updateMetadataFileIndex={updateMetadataFileIndex}
/>
</div>
<MetadataInput
selectedAssetFile={assetFilesArray[0]}
selectedMetadataFile={metadataFilesArray[0]}
updateMetadataToUpload={updateOpenEditionMinterMetadataFile}
/>
</div>
</div>
</Conditional>
</div>
</div>
)
}

View File

@ -0,0 +1,260 @@
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import clsx from 'clsx'
import { Conditional } from 'components/Conditional'
import { useInputState } from 'components/forms/FormInput.hooks'
import { useMetadataAttributesState } from 'components/forms/MetadataAttributes.hooks'
import { useWallet } from 'contexts/wallet'
import type { Trait } from 'contracts/badgeHub'
import type { ChangeEvent } from 'react'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'react-hot-toast'
import { TextInput } from '../forms/FormInput'
import { MetadataAttributes } from '../forms/MetadataAttributes'
import { Tooltip } from '../Tooltip'
import type { UploadMethod } from './ImageUploadDetails'
interface OnChainMetadataInputDetailsProps {
onChange: (data: OnChainMetadataInputDetailsDataProps) => void
uploadMethod: UploadMethod | undefined
}
export interface OnChainMetadataInputDetailsDataProps {
name?: string
description?: string
attributes?: Trait[]
image_data?: string
external_url?: string
background_color?: string
animation_url?: string
youtube_url?: string
}
export const OnChainMetadataInputDetails = ({ onChange, uploadMethod }: OnChainMetadataInputDetailsProps) => {
const wallet = useWallet()
const [timestamp, setTimestamp] = useState<Date | undefined>(undefined)
const [metadataFile, setMetadataFile] = useState<File>()
const [metadataFeeRate, setMetadataFeeRate] = useState<number>(0)
const metadataFileRef = useRef<HTMLInputElement | null>(null)
const nameState = useInputState({
id: 'name',
name: 'name',
title: 'Name',
placeholder: 'My Awesome Collection',
})
const descriptionState = useInputState({
id: 'description',
name: 'description',
title: 'Description',
placeholder: 'My Awesome Collection Description',
})
const imageDataState = useInputState({
id: 'metadata-image-data',
name: 'metadata-image-data',
title: 'Image Data',
subtitle: 'Raw SVG image data',
})
const externalUrlState = useInputState({
id: 'metadata-external-url',
name: 'metadata-external-url',
title: 'External URL',
subtitle: 'External URL for the token',
placeholder: 'https://',
})
const attributesState = useMetadataAttributesState()
const animationUrlState = useInputState({
id: 'metadata-animation-url',
name: 'metadata-animation-url',
title: 'Animation URL',
subtitle: 'Animation URL for the token',
placeholder: 'https://',
})
const youtubeUrlState = useInputState({
id: 'metadata-youtube-url',
name: 'metadata-youtube-url',
title: 'YouTube URL',
subtitle: 'YouTube URL for the token',
placeholder: 'https://',
})
const parseMetadata = async () => {
try {
let parsedMetadata: any
if (metadataFile) {
attributesState.reset()
parsedMetadata = JSON.parse(await metadataFile.text())
if (!parsedMetadata.attributes || parsedMetadata.attributes.length === 0) {
attributesState.add({
trait_type: '',
value: '',
})
} else {
for (let i = 0; i < parsedMetadata.attributes.length; i++) {
attributesState.add({
trait_type: parsedMetadata.attributes[i].trait_type,
value: parsedMetadata.attributes[i].value,
})
}
}
nameState.onChange(parsedMetadata.name ? parsedMetadata.name : '')
descriptionState.onChange(parsedMetadata.description ? parsedMetadata.description : '')
externalUrlState.onChange(parsedMetadata.external_url ? parsedMetadata.external_url : '')
youtubeUrlState.onChange(parsedMetadata.youtube_url ? parsedMetadata.youtube_url : '')
animationUrlState.onChange(parsedMetadata.animation_url ? parsedMetadata.animation_url : '')
imageDataState.onChange(parsedMetadata.image_data ? parsedMetadata.image_data : '')
} else {
attributesState.reset()
nameState.onChange('')
descriptionState.onChange('')
externalUrlState.onChange('')
youtubeUrlState.onChange('')
animationUrlState.onChange('')
imageDataState.onChange('')
}
} catch (error) {
toast.error('Error parsing metadata file: Invalid JSON format.')
if (metadataFileRef.current) metadataFileRef.current.value = ''
setMetadataFile(undefined)
}
}
const selectMetadata = (event: ChangeEvent<HTMLInputElement>) => {
setMetadataFile(undefined)
if (event.target.files === null) return
let selectedFile: File
const reader = new FileReader()
reader.onload = (e) => {
if (!event.target.files) return toast.error('No file selected.')
if (!e.target?.result) return toast.error('Error parsing file.')
selectedFile = new File([e.target.result], event.target.files[0].name, { type: 'application/json' })
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (event.target.files[0]) reader.readAsArrayBuffer(event.target.files[0])
else return toast.error('No file selected.')
reader.onloadend = () => {
if (!event.target.files) return toast.error('No file selected.')
setMetadataFile(selectedFile)
}
}
useEffect(() => {
void parseMetadata()
if (!metadataFile)
attributesState.add({
trait_type: '',
value: '',
})
}, [metadataFile])
useEffect(() => {
try {
const data: OnChainMetadataInputDetailsDataProps = {
name: nameState.value || undefined,
description: descriptionState.value || undefined,
attributes:
attributesState.values[0]?.trait_type && attributesState.values[0]?.value
? attributesState.values
.map((attr) => ({
trait_type: attr.trait_type,
value: attr.value,
}))
.filter((attr) => attr.trait_type && attr.value)
: undefined,
image_data: imageDataState.value || undefined,
external_url: externalUrlState.value || undefined,
animation_url: animationUrlState.value.trim() || undefined,
youtube_url: youtubeUrlState.value || undefined,
}
onChange(data)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
toast.error(error.message, { style: { maxWidth: 'none' } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
nameState.value,
descriptionState.value,
timestamp,
imageDataState.value,
externalUrlState.value,
attributesState.values,
animationUrlState.value,
youtubeUrlState.value,
])
return (
<div className="py-3 px-8 rounded border-2 border-white/20">
<span className="ml-4 text-xl font-bold underline underline-offset-4">NFT Metadata</span>
<div className={clsx('grid grid-cols-2 mt-4 mb-2 ml-4 max-w-6xl')}>
<div className={clsx('mt-6')}>
<TextInput className="mt-2" {...nameState} />
<TextInput className="mt-2" {...descriptionState} />
<TextInput className="mt-2" {...externalUrlState} />
<Conditional test={uploadMethod === 'existing'}>
<TextInput className="mt-2" {...animationUrlState} />
</Conditional>
<TextInput className="mt-2" {...youtubeUrlState} />
</div>
<div className={clsx('ml-10')}>
<div>
<MetadataAttributes
attributes={attributesState.entries}
onAdd={attributesState.add}
onChange={attributesState.update}
onRemove={attributesState.remove}
title="Traits"
/>
</div>
<div className="w-full">
<Tooltip
backgroundColor="bg-blue-500"
label="A metadata file can be selected to automatically fill in the related fields."
placement="bottom"
>
<div>
<label
className="block mt-2 mr-1 mb-1 w-full font-bold text-white dark:text-gray-300"
htmlFor="assetFile"
>
Metadata File Selection (optional)
</label>
<div
className={clsx(
'flex relative justify-center items-center mt-2 space-y-4 w-full h-32',
'rounded border-2 border-white/20 border-dashed',
)}
>
<input
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',
)}
id="metadataFile"
onChange={selectMetadata}
ref={metadataFileRef}
type="file"
/>
</div>
</div>
</Tooltip>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,682 @@
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable no-nested-ternary */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { toUtf8 } from '@cosmjs/encoding'
import { coin } from '@cosmjs/proto-signing'
import clsx from 'clsx'
import { Button } from 'components/Button'
import type { MinterType } from 'components/collections/actions/Combobox'
import { Conditional } from 'components/Conditional'
import { ConfirmationModal } from 'components/ConfirmationModal'
import { LoadingModal } from 'components/LoadingModal'
import { useContracts } from 'contexts/contracts'
import { addLogItem } from 'contexts/log'
import { useWallet } from 'contexts/wallet'
import type { DispatchExecuteArgs as OpenEditionFactoryDispatchExecuteArgs } from 'contracts/openEditionFactory/messages/execute'
import { dispatchExecute as openEditionFactoryDispatchExecute } from 'contracts/openEditionFactory/messages/execute'
import React, { useEffect, useMemo, useState } from 'react'
import { toast } from 'react-hot-toast'
import { upload } from 'services/upload'
import {
OPEN_EDITION_FACTORY_ADDRESS,
OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS,
SG721_CODE_ID,
SG721_UPDATABLE_CODE_ID,
} from 'utils/constants'
import { getAssetType } from 'utils/getAssetType'
import { isValidAddress } from 'utils/isValidAddress'
import { uid } from 'utils/random'
import { type CollectionDetailsDataProps, CollectionDetails } from './CollectionDetails'
import type { ImageUploadDetailsDataProps } from './ImageUploadDetails'
import { ImageUploadDetails } from './ImageUploadDetails'
import type { MintingDetailsDataProps } from './MintingDetails'
import { MintingDetails } from './MintingDetails'
import type { UploadMethod } from './OffChainMetadataUploadDetails'
import {
type OffChainMetadataUploadDetailsDataProps,
OffChainMetadataUploadDetails,
} from './OffChainMetadataUploadDetails'
import type { OnChainMetadataInputDetailsDataProps } from './OnChainMetadataInputDetails'
import { OnChainMetadataInputDetails } from './OnChainMetadataInputDetails'
import { type RoyaltyDetailsDataProps, RoyaltyDetails } from './RoyaltyDetails'
export type MetadataStorageMethod = 'off-chain' | 'on-chain'
interface OpenEditionMinterCreatorProps {
onChange: (data: OpenEditionMinterCreatorDataProps) => void
openEditionMinterUpdatableCreationFee?: string
openEditionMinterCreationFee?: string
minimumMintPrice?: string
minimumUpdatableMintPrice?: string
minterType?: MinterType
}
export interface OpenEditionMinterCreatorDataProps {
metadataStorageMethod: MetadataStorageMethod
openEditionMinterContractAddress: string | null
sg721ContractAddress: string | null
transactionHash: string | null
}
export const OpenEditionMinterCreator = ({
onChange,
openEditionMinterCreationFee,
openEditionMinterUpdatableCreationFee,
minimumMintPrice,
minimumUpdatableMintPrice,
minterType,
}: OpenEditionMinterCreatorProps) => {
const wallet = useWallet()
const { openEditionMinter: openEditionMinterContract, openEditionFactory: openEditionFactoryContract } =
useContracts()
const openEditionFactoryMessages = useMemo(
() => openEditionFactoryContract?.use(OPEN_EDITION_FACTORY_ADDRESS),
[openEditionFactoryContract, wallet.address],
)
const [metadataStorageMethod, setMetadataStorageMethod] = useState<MetadataStorageMethod>('off-chain')
const [imageUploadDetails, setImageUploadDetails] = useState<ImageUploadDetailsDataProps | null>(null)
const [collectionDetails, setCollectionDetails] = useState<CollectionDetailsDataProps | null>(null)
const [royaltyDetails, setRoyaltyDetails] = useState<RoyaltyDetailsDataProps | null>(null)
const [onChainMetadataInputDetails, setOnChainMetadataInputDetails] =
useState<OnChainMetadataInputDetailsDataProps | null>(null)
const [offChainMetadataUploadDetails, setOffChainMetadataUploadDetails] =
useState<OffChainMetadataUploadDetailsDataProps | null>(null)
const [mintingDetails, setMintingDetails] = useState<MintingDetailsDataProps | null>(null)
const [creationInProgress, setCreationInProgress] = useState(false)
const [readyToCreate, setReadyToCreate] = useState(false)
const [uploading, setUploading] = useState(false)
const [tokenUri, setTokenUri] = useState<string | null>(null)
const [tokenImageUri, setTokenImageUri] = useState<string | null>(null)
const [coverImageUrl, setCoverImageUrl] = useState<string | null>(null)
const [openEditionMinterContractAddress, setOpenEditionMinterContractAddress] = useState<string | null>(null)
const [sg721ContractAddress, setSg721ContractAddress] = useState<string | null>(null)
const [transactionHash, setTransactionHash] = useState<string | null>(null)
const performOpenEditionMinterChecks = () => {
try {
setReadyToCreate(false)
checkUploadDetails()
checkCollectionDetails()
checkMintingDetails()
void checkRoyaltyDetails()
.then(() => {
void checkwalletBalance()
.then(() => {
setReadyToCreate(true)
})
.catch((error: any) => {
toast.error(`Error in Wallet Balance: ${error.message}`, { style: { maxWidth: 'none' } })
addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() })
setReadyToCreate(false)
})
})
.catch((error: any) => {
toast.error(`Error in Royalty Details: ${error.message}`, { style: { maxWidth: 'none' } })
addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() })
setReadyToCreate(false)
})
} catch (error: any) {
toast.error(error.message, { style: { maxWidth: 'none' } })
addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() })
setUploading(false)
setReadyToCreate(false)
}
}
const checkUploadDetails = () => {
if (!wallet.initialized) throw new Error('Wallet not connected.')
if (
(metadataStorageMethod === 'off-chain' && !offChainMetadataUploadDetails) ||
(metadataStorageMethod === 'on-chain' && !imageUploadDetails)
) {
throw new Error('Please select assets and metadata')
}
if (
metadataStorageMethod === 'off-chain' &&
offChainMetadataUploadDetails?.uploadMethod === 'new' &&
offChainMetadataUploadDetails.assetFiles.length === 0
) {
throw new Error('Please select the asset file')
}
if (
metadataStorageMethod === 'on-chain' &&
imageUploadDetails?.uploadMethod === 'new' &&
imageUploadDetails.assetFile === undefined
) {
throw new Error('Please select the asset file')
}
if (metadataStorageMethod === 'off-chain' && offChainMetadataUploadDetails?.uploadMethod === 'new') {
if (
offChainMetadataUploadDetails.uploadService === 'nft-storage' &&
offChainMetadataUploadDetails.nftStorageApiKey === ''
) {
throw new Error('Please enter a valid NFT.Storage API key')
} else if (
offChainMetadataUploadDetails.uploadService === 'pinata' &&
(offChainMetadataUploadDetails.pinataApiKey === '' || offChainMetadataUploadDetails.pinataSecretKey === '')
) {
throw new Error('Please enter valid Pinata API and secret keys')
}
}
if (metadataStorageMethod === 'on-chain' && imageUploadDetails?.uploadMethod === 'new') {
if (imageUploadDetails.uploadService === 'nft-storage' && imageUploadDetails.nftStorageApiKey === '') {
throw new Error('Please enter a valid NFT.Storage API key')
} else if (
imageUploadDetails.uploadService === 'pinata' &&
(imageUploadDetails.pinataApiKey === '' || imageUploadDetails.pinataSecretKey === '')
) {
throw new Error('Please enter valid Pinata API and secret keys')
}
}
if (metadataStorageMethod === 'off-chain' && offChainMetadataUploadDetails?.uploadMethod === 'existing') {
if (
offChainMetadataUploadDetails.tokenURI === '' ||
!(offChainMetadataUploadDetails.tokenURI as string).includes('ipfs://')
) {
throw new Error('Please enter a valid token URI')
}
if (
offChainMetadataUploadDetails.imageUrl === '' ||
!(offChainMetadataUploadDetails.imageUrl as string).includes('ipfs://')
) {
throw new Error('Please enter a valid image URI')
}
}
if (metadataStorageMethod === 'on-chain' && imageUploadDetails?.uploadMethod === 'existing') {
if (imageUploadDetails.imageUrl === '' || !(imageUploadDetails.imageUrl as string).includes('ipfs://')) {
throw new Error('Please enter a valid asset URI')
}
if (
imageUploadDetails.coverImageUrl === '' ||
!(imageUploadDetails.coverImageUrl as string).includes('ipfs://')
) {
throw new Error('Please enter a valid cover image URL')
}
}
}
const checkCollectionDetails = () => {
if (!collectionDetails) throw new Error('Please fill out the collection details')
if (collectionDetails.name === '') throw new Error('Collection name is required')
if (collectionDetails.description === '') throw new Error('Collection description is required')
if (collectionDetails.symbol === '') throw new Error('Collection symbol is required')
if (collectionDetails.description.length > 512)
throw new Error('Collection description cannot exceed 512 characters')
if (
metadataStorageMethod === 'off-chain' &&
offChainMetadataUploadDetails?.uploadMethod === 'new' &&
collectionDetails.imageFile.length === 0
)
throw new Error('Collection cover image is required')
if (
metadataStorageMethod === 'on-chain' &&
imageUploadDetails?.uploadMethod === 'new' &&
collectionDetails.imageFile.length === 0
)
throw new Error('Collection cover image is required')
if (
collectionDetails.startTradingTime &&
Number(collectionDetails.startTradingTime) < new Date().getTime() * 1000000
)
throw new Error('Invalid trading start time')
if (
collectionDetails.startTradingTime &&
Number(collectionDetails.startTradingTime) < Number(mintingDetails?.startTime)
)
throw new Error('Trading start time must be after minting start time')
if (collectionDetails.externalLink) {
try {
const url = new URL(collectionDetails.externalLink)
} catch (e: any) {
throw new Error(`Invalid external link: Make sure to include the protocol (e.g. https://)`)
}
}
}
const checkMintingDetails = () => {
if (!mintingDetails) throw new Error('Please fill out the minting details')
if (mintingDetails.unitPrice === '') throw new Error('Mint price is required')
if (collectionDetails?.updatable) {
if (Number(mintingDetails.unitPrice) < Number(minimumUpdatableMintPrice))
throw new Error(
`Invalid mint price: The minimum mint price is ${Number(minimumUpdatableMintPrice) / 1000000} STARS`,
)
} else if (Number(mintingDetails.unitPrice) < Number(minimumMintPrice))
throw new Error(`Invalid mint price: The minimum mint price is ${Number(minimumMintPrice) / 1000000} STARS`)
if (!mintingDetails.perAddressLimit || mintingDetails.perAddressLimit < 1 || mintingDetails.perAddressLimit > 50)
throw new Error('Invalid limit for tokens per address')
if (mintingDetails.startTime === '') throw new Error('Start time is required')
if (Number(mintingDetails.startTime) < new Date().getTime() * 1000000) throw new Error('Invalid start time')
if (
mintingDetails.paymentAddress &&
(!isValidAddress(mintingDetails.paymentAddress) || !mintingDetails.paymentAddress.startsWith('stars1'))
)
throw new Error('Invalid payment address')
}
const checkRoyaltyDetails = async () => {
if (!royaltyDetails) throw new Error('Please fill out the royalty details')
if (royaltyDetails.royaltyType === 'new') {
if (royaltyDetails.share === 0) throw new Error('Royalty share percentage is required')
if (royaltyDetails.share > 100 || royaltyDetails.share < 0) throw new Error('Invalid royalty share percentage')
if (royaltyDetails.paymentAddress === '') throw new Error('Royalty payment address is required')
if (!isValidAddress(royaltyDetails.paymentAddress.trim())) {
if (royaltyDetails.paymentAddress.trim().endsWith('.stars')) {
throw new Error('Royalty payment address could not be resolved')
}
throw new Error('Invalid royalty payment address')
}
const contractInfoResponse = await wallet.client
?.queryContractRaw(
royaltyDetails.paymentAddress.trim(),
toUtf8(Buffer.from(Buffer.from('contract_info').toString('hex'), 'hex').toString()),
)
.catch((e) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
if (e.message.includes('bech32')) throw new Error('Invalid royalty payment address.')
console.log(e.message)
})
if (contractInfoResponse !== undefined) {
const contractInfo = JSON.parse(new TextDecoder().decode(contractInfoResponse as Uint8Array))
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
if (contractInfo && !contractInfo.contract.includes('splits'))
throw new Error('The provided royalty payment address does not belong to a splits contract.')
else console.log(contractInfo)
}
}
}
const checkwalletBalance = async () => {
if (!wallet.initialized) throw new Error('Wallet not connected.')
const amountNeeded = collectionDetails?.updatable
? Number(openEditionMinterUpdatableCreationFee)
: Number(openEditionMinterCreationFee)
await wallet.client?.getBalance(wallet.address, 'ustars').then((balance) => {
if (amountNeeded >= Number(balance.amount))
throw new Error(
`Insufficient wallet balance to instantiate the required contracts. Needed amount: ${(
amountNeeded / 1000000
).toString()} STARS`,
)
})
}
const createOpenEditionMinter = async () => {
try {
setCreationInProgress(true)
setTokenUri(null)
setCoverImageUrl(null)
setTokenImageUri(null)
setOpenEditionMinterContractAddress(null)
setSg721ContractAddress(null)
setTransactionHash(null)
if (metadataStorageMethod === 'off-chain') {
if (offChainMetadataUploadDetails?.uploadMethod === 'new') {
setUploading(true)
const metadataUri = await uploadForOffChainStorage()
const coverImageUri = await upload(
collectionDetails?.imageFile as File[],
offChainMetadataUploadDetails.uploadService,
'cover',
offChainMetadataUploadDetails.nftStorageApiKey as string,
offChainMetadataUploadDetails.pinataApiKey as string,
offChainMetadataUploadDetails.pinataSecretKey as string,
)
const metadataUriWithBase = `ipfs://${metadataUri}/${(
offChainMetadataUploadDetails.openEditionMinterMetadataFile as File
).name.substring(
0,
(offChainMetadataUploadDetails.openEditionMinterMetadataFile as File).name.lastIndexOf('.'),
)}`
const coverImageUriWithBase = `ipfs://${coverImageUri}/${(collectionDetails?.imageFile as File[])[0].name}`
setTokenUri(metadataUriWithBase)
setCoverImageUrl(coverImageUriWithBase)
setUploading(false)
await instantiateOpenEditionMinter(metadataUriWithBase, coverImageUriWithBase)
} else {
setTokenUri(offChainMetadataUploadDetails?.tokenURI as string)
setCoverImageUrl(offChainMetadataUploadDetails?.imageUrl as string)
await instantiateOpenEditionMinter(
offChainMetadataUploadDetails?.tokenURI as string,
offChainMetadataUploadDetails?.imageUrl as string,
)
}
} else if (metadataStorageMethod === 'on-chain') {
if (imageUploadDetails?.uploadMethod === 'new') {
setUploading(true)
const imageUri = await upload(
[imageUploadDetails.assetFile as File],
imageUploadDetails.uploadService,
'cover',
imageUploadDetails.nftStorageApiKey as string,
imageUploadDetails.pinataApiKey as string,
imageUploadDetails.pinataSecretKey as string,
)
const imageUriWithBase = `ipfs://${imageUri}/${(imageUploadDetails.assetFile as File).name}`
setTokenImageUri(imageUriWithBase)
const coverImageUri = await upload(
collectionDetails?.imageFile as File[],
imageUploadDetails.uploadService,
'cover',
imageUploadDetails.nftStorageApiKey as string,
imageUploadDetails.pinataApiKey as string,
imageUploadDetails.pinataSecretKey as string,
)
const coverImageUriWithBase = `ipfs://${coverImageUri}/${(collectionDetails?.imageFile as File[])[0].name}`
setCoverImageUrl(coverImageUriWithBase)
setUploading(false)
await instantiateOpenEditionMinter(imageUriWithBase, coverImageUriWithBase)
} else if (imageUploadDetails?.uploadMethod === 'existing') {
setTokenImageUri(imageUploadDetails.imageUrl as string)
setCoverImageUrl(imageUploadDetails.coverImageUrl as string)
await instantiateOpenEditionMinter(
imageUploadDetails.imageUrl as string,
imageUploadDetails.coverImageUrl as string,
)
}
}
setCreationInProgress(false)
setReadyToCreate(false)
} catch (error: any) {
toast.error(error.message, { style: { maxWidth: 'none' }, duration: 10000 })
addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() })
setReadyToCreate(false)
setCreationInProgress(false)
setUploading(false)
}
}
const uploadForOffChainStorage = async (): Promise<string> => {
if (!offChainMetadataUploadDetails) throw new Error('Please select the asset and fill in the metadata')
return new Promise((resolve, reject) => {
upload(
offChainMetadataUploadDetails.assetFiles,
offChainMetadataUploadDetails.uploadService,
'assets',
offChainMetadataUploadDetails.nftStorageApiKey as string,
offChainMetadataUploadDetails.pinataApiKey as string,
offChainMetadataUploadDetails.pinataSecretKey as string,
)
.then((assetUri: string) => {
const fileArray: File[] = []
const reader: FileReader = new FileReader()
reader.onload = (e) => {
const data: any = JSON.parse(e.target?.result as string)
if (
getAssetType(offChainMetadataUploadDetails.assetFiles[0].name) === 'audio' ||
getAssetType(offChainMetadataUploadDetails.assetFiles[0].name) === 'video'
) {
data.animation_url = `ipfs://${assetUri}/${offChainMetadataUploadDetails.assetFiles[0].name}`
}
data.image = `ipfs://${assetUri}/${offChainMetadataUploadDetails.assetFiles[0].name}`
const metadataFileBlob = new Blob([JSON.stringify(data)], {
type: 'application/json',
})
console.log('Name: ', (offChainMetadataUploadDetails.openEditionMinterMetadataFile as File).name)
const updatedMetadataFile = new File(
[metadataFileBlob],
(offChainMetadataUploadDetails.openEditionMinterMetadataFile as File).name.substring(
0,
(offChainMetadataUploadDetails.openEditionMinterMetadataFile as File).name.lastIndexOf('.'),
),
{
type: 'application/json',
},
)
fileArray.push(updatedMetadataFile)
}
reader.onloadend = () => {
upload(
fileArray,
offChainMetadataUploadDetails.uploadService,
'metadata',
offChainMetadataUploadDetails.nftStorageApiKey as string,
offChainMetadataUploadDetails.pinataApiKey as string,
offChainMetadataUploadDetails.pinataSecretKey as string,
)
.then(resolve)
.catch(reject)
}
console.log('File: ', offChainMetadataUploadDetails.openEditionMinterMetadataFile)
reader.readAsText(offChainMetadataUploadDetails.openEditionMinterMetadataFile as File, 'utf8')
})
.catch(reject)
})
}
const instantiateOpenEditionMinter = async (uri: string, coverImageUri: string) => {
if (!wallet.initialized) throw new Error('Wallet not connected')
if (!openEditionFactoryContract) throw new Error('Contract not found')
if (!openEditionMinterContract) throw new Error('Contract not found')
let royaltyInfo = null
if (royaltyDetails?.royaltyType === 'new') {
royaltyInfo = {
payment_address: royaltyDetails.paymentAddress.trim(),
share: (Number(royaltyDetails.share) / 100).toString(),
}
}
const msg = {
create_minter: {
init_msg: {
nft_data: {
nft_data_type: metadataStorageMethod === 'off-chain' ? 'off_chain_metadata' : 'on_chain_metadata',
token_uri: metadataStorageMethod === 'off-chain' ? uri : null,
extension:
metadataStorageMethod === 'on-chain'
? {
image: uri,
name: onChainMetadataInputDetails?.name,
description: onChainMetadataInputDetails?.description,
attributes: onChainMetadataInputDetails?.attributes,
external_url: onChainMetadataInputDetails?.external_url,
animation_url:
imageUploadDetails?.uploadMethod === 'existing'
? onChainMetadataInputDetails?.animation_url
: getAssetType(imageUploadDetails?.assetFile?.name as string) === 'video'
? uri
: undefined,
youtube_url: onChainMetadataInputDetails?.youtube_url,
}
: null,
},
start_time: mintingDetails?.startTime,
end_time: mintingDetails?.endTime,
mint_price: {
amount: (Number(mintingDetails?.unitPrice) * 1000000).toString(),
denom: 'ustars',
},
per_address_limit: mintingDetails?.perAddressLimit,
payment_address: mintingDetails?.paymentAddress || null,
},
collection_params: {
code_id: collectionDetails?.updatable ? SG721_UPDATABLE_CODE_ID : SG721_CODE_ID,
name: collectionDetails?.name,
symbol: collectionDetails?.symbol,
info: {
creator: wallet.address,
description: collectionDetails?.description,
image: coverImageUri,
explicit_content: collectionDetails?.explicit || false,
royalty_info: royaltyInfo,
start_trading_time: collectionDetails?.startTradingTime || null,
},
},
},
}
console.log('msg: ', msg)
const payload: OpenEditionFactoryDispatchExecuteArgs = {
contract: collectionDetails?.updatable ? OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS : OPEN_EDITION_FACTORY_ADDRESS,
messages: openEditionFactoryMessages,
txSigner: wallet.address,
msg,
funds: [
coin(
collectionDetails?.updatable
? (openEditionMinterUpdatableCreationFee as string)
: (openEditionMinterCreationFee as string),
'ustars',
),
],
updatable: collectionDetails?.updatable,
}
await openEditionFactoryDispatchExecute(payload)
.then((data) => {
setTransactionHash(data.transactionHash)
setOpenEditionMinterContractAddress(data.openEditionMinterAddress)
setSg721ContractAddress(data.sg721Address)
})
.catch((error) => {
toast.error(error.message, { style: { maxWidth: 'none' } })
addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() })
setUploading(false)
setCreationInProgress(false)
})
}
useEffect(() => {
if (minterType !== 'openEdition') {
setTransactionHash(null)
setOpenEditionMinterContractAddress(null)
setSg721ContractAddress(null)
setCreationInProgress(false)
setUploading(false)
}
}, [minterType])
useEffect(() => {
const data: OpenEditionMinterCreatorDataProps = {
metadataStorageMethod,
openEditionMinterContractAddress,
sg721ContractAddress,
transactionHash,
}
onChange(data)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [metadataStorageMethod, openEditionMinterContractAddress, sg721ContractAddress, transactionHash])
return (
<div>
<div className="mx-10 mb-4 rounded border-2 border-white/20">
<div className="flex justify-center mb-2">
<div className="mt-3 ml-4 font-bold form-check form-check-inline">
<input
checked={metadataStorageMethod === 'off-chain'}
className="peer sr-only"
id="inlineRadio9"
name="inlineRadioOptions9"
onClick={() => {
setMetadataStorageMethod('off-chain')
}}
type="radio"
value="Off Chain"
/>
<label
className="inline-block py-1 px-2 text-gray peer-checked:text-white hover:text-white peer-checked:bg-black peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="inlineRadio9"
>
Off-Chain Metadata
</label>
</div>
<div className="mt-3 ml-2 font-bold form-check form-check-inline">
<input
checked={metadataStorageMethod === 'on-chain'}
className="peer sr-only"
id="inlineRadio10"
name="inlineRadioOptions10"
onClick={() => {
setMetadataStorageMethod('on-chain')
}}
type="radio"
value="On Chain"
/>
<label
className="inline-block py-1 px-2 text-gray peer-checked:text-white hover:text-white peer-checked:bg-black peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="inlineRadio10"
>
On-Chain Metadata
</label>
</div>
</div>
</div>
<div className={clsx('my-4 mx-10')}>
<Conditional test={metadataStorageMethod === 'off-chain'}>
<div>
<OffChainMetadataUploadDetails onChange={setOffChainMetadataUploadDetails} />
</div>
</Conditional>
<Conditional test={metadataStorageMethod === 'on-chain'}>
<div>
<ImageUploadDetails onChange={setImageUploadDetails} />
<OnChainMetadataInputDetails
onChange={setOnChainMetadataInputDetails}
uploadMethod={imageUploadDetails?.uploadMethod}
/>
</div>
</Conditional>
</div>
<div className="flex justify-between py-3 px-8 mx-10 rounded border-2 border-white/20 grid-col-2">
<CollectionDetails
coverImageUrl={
metadataStorageMethod === 'off-chain'
? (offChainMetadataUploadDetails?.imageUrl as string)
: (imageUploadDetails?.coverImageUrl as string)
}
metadataStorageMethod={metadataStorageMethod}
onChange={setCollectionDetails}
uploadMethod={
metadataStorageMethod === 'off-chain'
? (offChainMetadataUploadDetails?.uploadMethod as UploadMethod)
: (imageUploadDetails?.uploadMethod as UploadMethod)
}
/>
<MintingDetails
minimumMintPrice={
collectionDetails?.updatable
? Number(minimumUpdatableMintPrice) / 1000000
: Number(minimumMintPrice) / 1000000
}
onChange={setMintingDetails}
uploadMethod={offChainMetadataUploadDetails?.uploadMethod as UploadMethod}
/>
</div>
<div className="my-6">
<RoyaltyDetails onChange={setRoyaltyDetails} />
</div>
<div className="flex justify-end w-full">
<Button
className="relative justify-center p-2 mr-12 mb-6 max-h-12 text-white bg-plumbus hover:bg-plumbus-light border-0"
isLoading={creationInProgress}
onClick={performOpenEditionMinterChecks}
variant="solid"
>
Create Collection
</Button>
</div>
<Conditional test={uploading}>
<LoadingModal />
</Conditional>
<Conditional test={readyToCreate}>
<ConfirmationModal confirm={createOpenEditionMinter} />
</Conditional>
</div>
)
}

View File

@ -0,0 +1,113 @@
import { Conditional } from 'components/Conditional'
import { FormGroup } from 'components/FormGroup'
import { useInputState } from 'components/forms/FormInput.hooks'
import { useWallet } from 'contexts/wallet'
import React, { useEffect, useState } from 'react'
import { resolveAddress } from 'utils/resolveAddress'
import { NumberInput, TextInput } from '../forms/FormInput'
interface RoyaltyDetailsProps {
onChange: (data: RoyaltyDetailsDataProps) => void
}
export interface RoyaltyDetailsDataProps {
royaltyType: RoyaltyState
paymentAddress: string
share: number
}
type RoyaltyState = 'none' | 'new'
export const RoyaltyDetails = ({ onChange }: RoyaltyDetailsProps) => {
const wallet = useWallet()
const [royaltyState, setRoyaltyState] = useState<RoyaltyState>('none')
const royaltyPaymentAddressState = useInputState({
id: 'royalty-payment-address',
name: 'royaltyPaymentAddress',
title: 'Payment Address',
subtitle: 'Address to receive royalties',
placeholder: 'stars1234567890abcdefghijklmnopqrstuvwxyz...',
})
const royaltyShareState = useInputState({
id: 'royalty-share',
name: 'royaltyShare',
title: 'Share Percentage',
subtitle: 'Percentage of royalties to be paid',
placeholder: '5%',
})
useEffect(() => {
void resolveAddress(
royaltyPaymentAddressState.value
.toLowerCase()
.replace(/,/g, '')
.replace(/"/g, '')
.replace(/'/g, '')
.replace(/ /g, ''),
wallet,
).then((royaltyPaymentAddress) => {
royaltyPaymentAddressState.onChange(royaltyPaymentAddress)
const data: RoyaltyDetailsDataProps = {
royaltyType: royaltyState,
paymentAddress: royaltyPaymentAddressState.value,
share: Number(royaltyShareState.value),
}
onChange(data)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [royaltyState, royaltyPaymentAddressState.value, royaltyShareState.value])
return (
<div className="py-3 px-8 mx-10 rounded border-2 border-white/20">
<div className="flex justify-center">
<div className="ml-4 font-bold form-check form-check-inline">
<input
checked={royaltyState === 'none'}
className="peer sr-only"
id="royaltyRadio1"
name="royaltyRadioOptions1"
onClick={() => {
setRoyaltyState('none')
}}
type="radio"
value="None"
/>
<label
className="inline-block py-1 px-2 text-gray peer-checked:text-white hover:text-white peer-checked:bg-black hover:rounded-sm peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="royaltyRadio1"
>
No royalty
</label>
</div>
<div className="ml-4 font-bold form-check form-check-inline">
<input
checked={royaltyState === 'new'}
className="peer sr-only"
id="royaltyRadio2"
name="royaltyRadioOptions2"
onClick={() => {
setRoyaltyState('new')
}}
type="radio"
value="Existing"
/>
<label
className="inline-block py-1 px-2 text-gray peer-checked:text-white hover:text-white peer-checked:bg-black hover:rounded-sm peer-checked:border-b-2 hover:border-b-2 peer-checked:border-plumbus hover:border-plumbus cursor-pointer form-check-label"
htmlFor="royaltyRadio2"
>
Configure royalty details
</label>
</div>
</div>
<Conditional test={royaltyState === 'new'}>
<FormGroup subtitle="Information about royalty" title="Royalty Details">
<TextInput {...royaltyPaymentAddressState} isRequired />
<NumberInput {...royaltyShareState} isRequired />
</FormGroup>
</Conditional>
</div>
)
}

View File

@ -4,6 +4,8 @@ import type { UseBaseFactoryContractProps } from 'contracts/baseFactory'
import { useBaseFactoryContract } from 'contracts/baseFactory' import { useBaseFactoryContract } from 'contracts/baseFactory'
import type { UseBaseMinterContractProps } from 'contracts/baseMinter' import type { UseBaseMinterContractProps } from 'contracts/baseMinter'
import { useBaseMinterContract } from 'contracts/baseMinter' import { useBaseMinterContract } from 'contracts/baseMinter'
import { type UseOpenEditionFactoryContractProps, useOpenEditionFactoryContract } from 'contracts/openEditionFactory'
import { type UseOpenEditionMinterContractProps, useOpenEditionMinterContract } from 'contracts/openEditionMinter'
import type { UseSG721ContractProps } from 'contracts/sg721' import type { UseSG721ContractProps } from 'contracts/sg721'
import { useSG721Contract } from 'contracts/sg721' import { useSG721Contract } from 'contracts/sg721'
import type { UseVendingFactoryContractProps } from 'contracts/vendingFactory' import type { UseVendingFactoryContractProps } from 'contracts/vendingFactory'
@ -27,9 +29,11 @@ export interface ContractsStore extends State {
sg721: UseSG721ContractProps | null sg721: UseSG721ContractProps | null
vendingMinter: UseVendingMinterContractProps | null vendingMinter: UseVendingMinterContractProps | null
baseMinter: UseBaseMinterContractProps | null baseMinter: UseBaseMinterContractProps | null
openEditionMinter: UseOpenEditionMinterContractProps | null
whitelist: UseWhiteListContractProps | null whitelist: UseWhiteListContractProps | null
vendingFactory: UseVendingFactoryContractProps | null vendingFactory: UseVendingFactoryContractProps | null
baseFactory: UseBaseFactoryContractProps | null baseFactory: UseBaseFactoryContractProps | null
openEditionFactory: UseOpenEditionFactoryContractProps | null
badgeHub: UseBadgeHubContractProps | null badgeHub: UseBadgeHubContractProps | null
splits: UseSplitsContractProps | null splits: UseSplitsContractProps | null
} }
@ -41,9 +45,11 @@ export const defaultValues: ContractsStore = {
sg721: null, sg721: null,
vendingMinter: null, vendingMinter: null,
baseMinter: null, baseMinter: null,
openEditionMinter: null,
whitelist: null, whitelist: null,
vendingFactory: null, vendingFactory: null,
baseFactory: null, baseFactory: null,
openEditionFactory: null,
badgeHub: null, badgeHub: null,
splits: null, splits: null,
} }
@ -72,9 +78,11 @@ const ContractsSubscription: VFC = () => {
const sg721 = useSG721Contract() const sg721 = useSG721Contract()
const vendingMinter = useVendingMinterContract() const vendingMinter = useVendingMinterContract()
const baseMinter = useBaseMinterContract() const baseMinter = useBaseMinterContract()
const openEditionMinter = useOpenEditionMinterContract()
const whitelist = useWhiteListContract() const whitelist = useWhiteListContract()
const vendingFactory = useVendingFactoryContract() const vendingFactory = useVendingFactoryContract()
const baseFactory = useBaseFactoryContract() const baseFactory = useBaseFactoryContract()
const openEditionFactory = useOpenEditionFactoryContract()
const badgeHub = useBadgeHubContract() const badgeHub = useBadgeHubContract()
const splits = useSplitsContract() const splits = useSplitsContract()
@ -83,9 +91,11 @@ const ContractsSubscription: VFC = () => {
sg721, sg721,
vendingMinter, vendingMinter,
baseMinter, baseMinter,
openEditionMinter,
whitelist, whitelist,
vendingFactory, vendingFactory,
baseFactory, baseFactory,
openEditionFactory,
badgeHub, badgeHub,
splits, splits,
}) })

View File

@ -0,0 +1,104 @@
/* eslint-disable eslint-comments/disable-enable-pair */
import type { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate'
import type { Coin } from '@cosmjs/proto-signing'
import type { logs } from '@cosmjs/stargate'
import { OPEN_EDITION_FACTORY_ADDRESS, OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS } from 'utils/constants'
export interface CreateOpenEditionMinterResponse {
readonly openEditionMinterAddress: string
readonly sg721Address: string
readonly transactionHash: string
readonly logs: readonly logs.Log[]
}
export interface OpenEditionFactoryInstance {
readonly contractAddress: string
//Query
//Execute
createOpenEditionMinter: (
senderAddress: string,
msg: Record<string, unknown>,
funds: Coin[],
updatable?: boolean,
) => Promise<CreateOpenEditionMinterResponse>
}
export interface OpenEditionFactoryMessages {
createOpenEditionMinter: (
msg: Record<string, unknown>,
funds: Coin[],
updatable?: boolean,
) => CreateOpenEditionMinterMessage
}
export interface CreateOpenEditionMinterMessage {
sender: string
contract: string
msg: Record<string, unknown>
funds: Coin[]
}
export interface OpenEditionFactoryContract {
use: (contractAddress: string) => OpenEditionFactoryInstance
messages: (contractAddress: string) => OpenEditionFactoryMessages
}
export const openEditionFactory = (client: SigningCosmWasmClient, txSigner: string): OpenEditionFactoryContract => {
const use = (contractAddress: string): OpenEditionFactoryInstance => {
//Query
//Execute
const createOpenEditionMinter = async (
senderAddress: string,
msg: Record<string, unknown>,
funds: Coin[],
updatable?: boolean,
): Promise<CreateOpenEditionMinterResponse> => {
const result = await client.execute(
senderAddress,
updatable ? OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS : OPEN_EDITION_FACTORY_ADDRESS,
msg,
'auto',
'',
funds,
)
return {
openEditionMinterAddress: result.logs[0].events[5].attributes[0].value,
sg721Address: result.logs[0].events[5].attributes[2].value,
transactionHash: result.transactionHash,
logs: result.logs,
}
}
return {
contractAddress,
createOpenEditionMinter,
}
}
const messages = (contractAddress: string) => {
const createOpenEditionMinter = (
msg: Record<string, unknown>,
funds: Coin[],
updatable?: boolean,
): CreateOpenEditionMinterMessage => {
return {
sender: txSigner,
contract: contractAddress,
msg,
funds,
}
}
return {
createOpenEditionMinter,
}
}
return { use, messages }
}

View File

@ -0,0 +1,2 @@
export * from './contract'
export * from './useContract'

View File

@ -0,0 +1,31 @@
/* eslint-disable eslint-comments/disable-enable-pair */
import type { Coin } from '@cosmjs/proto-signing'
import type { OpenEditionFactoryInstance } from '../index'
import { useOpenEditionFactoryContract } from '../index'
/** @see {@link OpenEditionFactoryInstance} */
export interface DispatchExecuteArgs {
contract: string
messages?: OpenEditionFactoryInstance
txSigner: string
msg: Record<string, unknown>
funds: Coin[]
updatable?: boolean
}
export const dispatchExecute = async (args: DispatchExecuteArgs) => {
const { messages, txSigner } = args
if (!messages) {
throw new Error('cannot dispatch execute, messages is not defined')
}
return messages.createOpenEditionMinter(txSigner, args.msg, args.funds, args.updatable)
}
export const previewExecutePayload = (args: DispatchExecuteArgs) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { messages } = useOpenEditionFactoryContract()
const { contract } = args
return messages(contract)?.createOpenEditionMinter(args.msg, args.funds, args.updatable)
}

View File

@ -0,0 +1,76 @@
import type { logs } from '@cosmjs/stargate'
import { useWallet } from 'contexts/wallet'
import { useCallback, useEffect, useState } from 'react'
import type { OpenEditionFactoryContract, OpenEditionFactoryInstance, OpenEditionFactoryMessages } from './contract'
import { openEditionFactory as initContract } from './contract'
/*export interface InstantiateResponse {
/** The address of the newly instantiated contract *-/
readonly contractAddress: string
readonly logs: readonly logs.Log[]
/** Block height in which the transaction is included *-/
readonly height: number
/** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex *-/
readonly transactionHash: string
readonly gasWanted: number
readonly gasUsed: number
}*/
interface InstantiateResponse {
readonly contractAddress: string
readonly transactionHash: string
readonly logs: readonly logs.Log[]
}
export interface UseOpenEditionFactoryContractProps {
use: (customAddress: string) => OpenEditionFactoryInstance | undefined
updateContractAddress: (contractAddress: string) => void
getContractAddress: () => string | undefined
messages: (contractAddress: string) => OpenEditionFactoryMessages | undefined
}
export function useOpenEditionFactoryContract(): UseOpenEditionFactoryContractProps {
const wallet = useWallet()
const [address, setAddress] = useState<string>('')
const [openEditionFactory, setOpenEditionFactory] = useState<OpenEditionFactoryContract>()
useEffect(() => {
setAddress(localStorage.getItem('contract_address') || '')
}, [])
useEffect(() => {
const OpenEditionFactoryBaseContract = initContract(wallet.getClient(), wallet.address)
setOpenEditionFactory(OpenEditionFactoryBaseContract)
}, [wallet])
const updateContractAddress = (contractAddress: string) => {
setAddress(contractAddress)
}
const use = useCallback(
(customAddress = ''): OpenEditionFactoryInstance | undefined => {
return openEditionFactory?.use(address || customAddress)
},
[openEditionFactory, address],
)
const getContractAddress = (): string | undefined => {
return address
}
const messages = useCallback(
(customAddress = ''): OpenEditionFactoryMessages | undefined => {
return openEditionFactory?.messages(address || customAddress)
},
[openEditionFactory, address],
)
return {
use,
updateContractAddress,
getContractAddress,
messages,
}
}

View File

@ -0,0 +1,588 @@
import type { MsgExecuteContractEncodeObject, SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate'
import { toUtf8 } from '@cosmjs/encoding'
import type { Coin } from '@cosmjs/proto-signing'
import { coin } from '@cosmjs/proto-signing'
import type { logs } from '@cosmjs/stargate'
import type { Timestamp } from '@stargazezone/types/contracts/minter/shared-types'
import { MsgExecuteContract } from 'cosmjs-types/cosmwasm/wasm/v1/tx'
export interface InstantiateResponse {
readonly contractAddress: string
readonly transactionHash: string
readonly logs: readonly logs.Log[]
}
export interface MigrateResponse {
readonly transactionHash: string
readonly logs: readonly logs.Log[]
}
export interface RoyaltyInfo {
payment_address: string
share: string
}
export interface OpenEditionMinterInstance {
readonly contractAddress: string
//Query
getConfig: () => Promise<any>
getStartTime: () => Promise<any>
getEndTime: () => Promise<any>
getMintPrice: () => Promise<any>
getMintCount: (address: string) => Promise<any>
getTotalMintCount: () => Promise<any>
getStatus: () => Promise<any>
//Execute
mint: (senderAddress: string) => Promise<string>
purge: (senderAddress: string) => Promise<string>
updateMintPrice: (senderAddress: string, price: string) => Promise<string>
updateStartTime: (senderAddress: string, time: Timestamp) => Promise<string>
updateEndTime: (senderAddress: string, time: Timestamp) => Promise<string>
updateStartTradingTime: (senderAddress: string, time?: Timestamp) => Promise<string>
updatePerAddressLimit: (senderAddress: string, perAddressLimit: number) => Promise<string>
mintTo: (senderAddress: string, recipient: string) => Promise<string>
batchMint: (senderAddress: string, recipient: string, batchNumber: number) => Promise<string>
airdrop: (senderAddress: string, recipients: string[]) => Promise<string>
}
export interface OpenEditionMinterMessages {
mint: () => MintMessage
purge: () => PurgeMessage
updateMintPrice: (price: string) => UpdateMintPriceMessage
updateStartTime: (time: Timestamp) => UpdateStartTimeMessage
updateEndTime: (time: Timestamp) => UpdateEndTimeMessage
updateStartTradingTime: (time: Timestamp) => UpdateStartTradingTimeMessage
updatePerAddressLimit: (perAddressLimit: number) => UpdatePerAddressLimitMessage
mintTo: (recipient: string) => MintToMessage
batchMint: (recipient: string, batchNumber: number) => CustomMessage
airdrop: (recipients: string[]) => CustomMessage
}
export interface MintMessage {
sender: string
contract: string
msg: {
mint: Record<string, never>
}
funds: Coin[]
}
export interface PurgeMessage {
sender: string
contract: string
msg: {
purge: Record<string, never>
}
funds: Coin[]
}
export interface UpdateStartTradingTimeMessage {
sender: string
contract: string
msg: {
update_start_trading_time: string
}
funds: Coin[]
}
export interface UpdateStartTimeMessage {
sender: string
contract: string
msg: {
update_start_time: string
}
funds: Coin[]
}
export interface UpdateEndTimeMessage {
sender: string
contract: string
msg: {
update_end_time: string
}
funds: Coin[]
}
export interface UpdateMintPriceMessage {
sender: string
contract: string
msg: {
update_mint_price: {
price: string
}
}
funds: Coin[]
}
export interface UpdatePerAddressLimitMessage {
sender: string
contract: string
msg: {
update_per_address_limit: {
per_address_limit: number
}
}
funds: Coin[]
}
export interface MintToMessage {
sender: string
contract: string
msg: {
mint_to: {
recipient: string
}
}
funds: Coin[]
}
export interface CustomMessage {
sender: string
contract: string
msg: Record<string, unknown>[]
funds: Coin[]
}
export interface MintPriceMessage {
public_price: {
denom: string
amount: string
}
airdrop_price: {
denom: string
amount: string
}
current_price: {
denom: string
amount: string
}
}
export interface OpenEditionMinterContract {
instantiate: (
senderAddress: string,
codeId: number,
initMsg: Record<string, unknown>,
label: string,
admin?: string,
funds?: Coin[],
) => Promise<InstantiateResponse>
migrate: (
senderAddress: string,
contractAddress: string,
codeId: number,
migrateMsg: Record<string, unknown>,
) => Promise<MigrateResponse>
use: (contractAddress: string) => OpenEditionMinterInstance
messages: (contractAddress: string) => OpenEditionMinterMessages
}
export const openEditionMinter = (client: SigningCosmWasmClient, txSigner: string): OpenEditionMinterContract => {
const use = (contractAddress: string): OpenEditionMinterInstance => {
//Query
const getConfig = async (): Promise<any> => {
const res = await client.queryContractSmart(contractAddress, {
config: {},
})
return res
}
const getStartTime = async (): Promise<any> => {
const res = await client.queryContractSmart(contractAddress, {
start_time: {},
})
return res
}
const getEndTime = async (): Promise<any> => {
const res = await client.queryContractSmart(contractAddress, {
end_time: {},
})
return res
}
const getMintPrice = async (): Promise<MintPriceMessage> => {
const res = await client.queryContractSmart(contractAddress, {
mint_price: {},
})
return res
}
const getMintCount = async (address: string): Promise<any> => {
const res = await client.queryContractSmart(contractAddress, {
mint_count: { address },
})
return res
}
const getTotalMintCount = async (): Promise<any> => {
const res = await client.queryContractSmart(contractAddress, {
total_mint_count: {},
})
return res
}
const getStatus = async (): Promise<any> => {
const res = await client.queryContractSmart(contractAddress, {
status: {},
})
return res
}
//Execute
const mint = async (senderAddress: string): Promise<string> => {
const price = (await getMintPrice()).public_price.amount
const res = await client.execute(
senderAddress,
contractAddress,
{
mint: {},
},
'auto',
'',
[coin(price, 'ustars')],
)
return res.transactionHash
}
const purge = async (senderAddress: string): Promise<string> => {
const res = await client.execute(
senderAddress,
contractAddress,
{
purge: {},
},
'auto',
'',
)
return res.transactionHash
}
const updateStartTradingTime = async (senderAddress: string, time?: Timestamp): Promise<string> => {
const res = await client.execute(
senderAddress,
contractAddress,
{
update_start_trading_time: time || null,
},
'auto',
'',
)
return res.transactionHash
}
const updateStartTime = async (senderAddress: string, time: Timestamp): Promise<string> => {
const res = await client.execute(
senderAddress,
contractAddress,
{
update_start_time: time,
},
'auto',
'',
)
return res.transactionHash
}
const updateEndTime = async (senderAddress: string, time: Timestamp): Promise<string> => {
const res = await client.execute(
senderAddress,
contractAddress,
{
update_end_time: time,
},
'auto',
'',
)
return res.transactionHash
}
const updateMintPrice = async (senderAddress: string, price: string): Promise<string> => {
const res = await client.execute(
senderAddress,
contractAddress,
{
update_mint_price: {
price: (Number(price) * 1000000).toString(),
},
},
'auto',
'',
)
return res.transactionHash
}
const updatePerAddressLimit = async (senderAddress: string, perAddressLimit: number): Promise<string> => {
const res = await client.execute(
senderAddress,
contractAddress,
{
update_per_address_limit: { per_address_limit: perAddressLimit },
},
'auto',
'',
)
return res.transactionHash
}
const mintTo = async (senderAddress: string, recipient: string): Promise<string> => {
const res = await client.execute(
senderAddress,
contractAddress,
{
mint_to: { recipient },
},
'auto',
'',
)
return res.transactionHash
}
const batchMint = async (senderAddress: string, recipient: string, batchNumber: number): Promise<string> => {
const executeContractMsgs: MsgExecuteContractEncodeObject[] = []
for (let i = 0; i < batchNumber; i++) {
const msg = {
mint_to: { recipient },
}
const executeContractMsg: MsgExecuteContractEncodeObject = {
typeUrl: '/cosmwasm.wasm.v1.MsgExecuteContract',
value: MsgExecuteContract.fromPartial({
sender: senderAddress,
contract: contractAddress,
msg: toUtf8(JSON.stringify(msg)),
}),
}
executeContractMsgs.push(executeContractMsg)
}
const res = await client.signAndBroadcast(senderAddress, executeContractMsgs, 'auto', 'batch mint')
return res.transactionHash
}
const airdrop = async (senderAddress: string, recipients: string[]): Promise<string> => {
const executeContractMsgs: MsgExecuteContractEncodeObject[] = []
for (let i = 0; i < recipients.length; i++) {
const msg = {
mint_to: { recipient: recipients[i] },
}
const executeContractMsg: MsgExecuteContractEncodeObject = {
typeUrl: '/cosmwasm.wasm.v1.MsgExecuteContract',
value: MsgExecuteContract.fromPartial({
sender: senderAddress,
contract: contractAddress,
msg: toUtf8(JSON.stringify(msg)),
}),
}
executeContractMsgs.push(executeContractMsg)
}
const res = await client.signAndBroadcast(senderAddress, executeContractMsgs, 'auto', 'airdrop')
return res.transactionHash
}
return {
contractAddress,
getConfig,
getStartTime,
getEndTime,
getMintPrice,
getMintCount,
getTotalMintCount,
getStatus,
mint,
purge,
updateStartTradingTime,
updateStartTime,
updateEndTime,
updateMintPrice,
updatePerAddressLimit,
mintTo,
batchMint,
airdrop,
}
}
const migrate = async (
senderAddress: string,
contractAddress: string,
codeId: number,
migrateMsg: Record<string, unknown>,
): Promise<MigrateResponse> => {
const result = await client.migrate(senderAddress, contractAddress, codeId, migrateMsg, 'auto')
return {
transactionHash: result.transactionHash,
logs: result.logs,
}
}
const instantiate = async (
senderAddress: string,
codeId: number,
initMsg: Record<string, unknown>,
label: string,
): Promise<InstantiateResponse> => {
const result = await client.instantiate(senderAddress, codeId, initMsg, label, 'auto', {
funds: [coin('1000000000', 'ustars')],
})
return {
contractAddress: result.contractAddress,
transactionHash: result.transactionHash,
logs: result.logs,
}
}
const messages = (contractAddress: string) => {
const mint = (): MintMessage => {
return {
sender: txSigner,
contract: contractAddress,
msg: {
mint: {},
},
funds: [],
}
}
const purge = (): PurgeMessage => {
return {
sender: txSigner,
contract: contractAddress,
msg: {
purge: {},
},
funds: [],
}
}
const updateStartTradingTime = (startTime: string): UpdateStartTradingTimeMessage => {
return {
sender: txSigner,
contract: contractAddress,
msg: {
update_start_trading_time: startTime,
},
funds: [],
}
}
const updateStartTime = (startTime: string): UpdateStartTimeMessage => {
return {
sender: txSigner,
contract: contractAddress,
msg: {
update_start_time: startTime,
},
funds: [],
}
}
const updateEndTime = (endTime: string): UpdateEndTimeMessage => {
return {
sender: txSigner,
contract: contractAddress,
msg: {
update_end_time: endTime,
},
funds: [],
}
}
const updateMintPrice = (price: string): UpdateMintPriceMessage => {
return {
sender: txSigner,
contract: contractAddress,
msg: {
update_mint_price: {
price: (Number(price) * 1000000).toString(),
},
},
funds: [],
}
}
const updatePerAddressLimit = (limit: number): UpdatePerAddressLimitMessage => {
return {
sender: txSigner,
contract: contractAddress,
msg: {
update_per_address_limit: {
per_address_limit: limit,
},
},
funds: [],
}
}
const mintTo = (recipient: string): MintToMessage => {
return {
sender: txSigner,
contract: contractAddress,
msg: {
mint_to: {
recipient,
},
},
funds: [],
}
}
const batchMint = (recipient: string, batchNumber: number): CustomMessage => {
const msg: Record<string, unknown>[] = []
for (let i = 0; i < batchNumber; i++) {
msg.push({ mint_to: { recipient } })
}
return {
sender: txSigner,
contract: contractAddress,
msg,
funds: [],
}
}
const airdrop = (recipients: string[]): CustomMessage => {
const msg: Record<string, unknown>[] = []
for (let i = 0; i < recipients.length; i++) {
msg.push({ mint_to: { recipient: recipients[i] } })
}
return {
sender: txSigner,
contract: contractAddress,
msg,
funds: [],
}
}
return {
mint,
purge,
updateStartTradingTime,
updateStartTime,
updateEndTime,
updateMintPrice,
updatePerAddressLimit,
mintTo,
batchMint,
airdrop,
}
}
return { use, instantiate, migrate, messages }
}

View File

@ -0,0 +1,2 @@
export * from './contract'
export * from './useContract'

View File

@ -0,0 +1,163 @@
import type { OpenEditionMinterInstance } from '../index'
import { useOpenEditionMinterContract } from '../index'
export type ExecuteType = typeof EXECUTE_TYPES[number]
export const EXECUTE_TYPES = [
'mint',
'update_start_time',
'update_end_time',
'update_mint_price',
'update_start_trading_time',
'update_per_address_limit',
'mint_to',
'purge',
] as const
export interface ExecuteListItem {
id: ExecuteType
name: string
description?: string
}
export const EXECUTE_LIST: ExecuteListItem[] = [
{
id: 'mint',
name: 'Mint',
description: `Mint a new token`,
},
{
id: 'update_mint_price',
name: 'Update Mint Price',
description: `Update the mint price per token`,
},
{
id: 'update_start_time',
name: 'Update Start Time',
description: `Update the start time for minting`,
},
{
id: 'update_end_time',
name: 'Update End Time',
description: `Update the end time for minting`,
},
{
id: 'update_start_trading_time',
name: 'Update Start Trading Time',
description: `Update start trading time for minting`,
},
{
id: 'update_per_address_limit',
name: 'Update Per Address Limit',
description: `Update token per address limit`,
},
{
id: 'mint_to',
name: 'Mint To',
description: `Mint tokens to a given address`,
},
{
id: 'purge',
name: 'Purge',
description: `Purge`,
},
]
export interface DispatchExecuteProps {
type: ExecuteType
[k: string]: unknown
}
type Select<T extends ExecuteType> = T
/** @see {@link OpenEditionMinterInstance} */
export type DispatchExecuteArgs = {
contract: string
messages?: OpenEditionMinterInstance
txSigner: string
} & (
| { type: undefined }
| { type: Select<'mint'> }
| { type: Select<'purge'> }
| { type: Select<'update_start_time'>; startTime: string }
| { type: Select<'update_end_time'>; endTime: string }
| { type: Select<'update_mint_price'>; price: string }
| { type: Select<'update_start_trading_time'>; startTime?: string }
| { type: Select<'update_per_address_limit'>; limit: number }
| { type: Select<'mint_to'>; recipient: string }
)
export const dispatchExecute = async (args: DispatchExecuteArgs) => {
const { messages, txSigner } = args
if (!messages) {
throw new Error('cannot dispatch execute, messages is not defined')
}
switch (args.type) {
case 'mint': {
return messages.mint(txSigner)
}
case 'purge': {
return messages.purge(txSigner)
}
case 'update_start_time': {
return messages.updateStartTime(txSigner, args.startTime)
}
case 'update_end_time': {
return messages.updateEndTime(txSigner, args.endTime)
}
case 'update_mint_price': {
return messages.updateMintPrice(txSigner, args.price)
}
case 'update_start_trading_time': {
return messages.updateStartTradingTime(txSigner, args.startTime)
}
case 'update_per_address_limit': {
return messages.updatePerAddressLimit(txSigner, args.limit)
}
case 'mint_to': {
return messages.mintTo(txSigner, args.recipient)
}
default: {
throw new Error('unknown execute type')
}
}
}
export const previewExecutePayload = (args: DispatchExecuteArgs) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { messages } = useOpenEditionMinterContract()
const { contract } = args
switch (args.type) {
case 'mint': {
return messages(contract)?.mint()
}
case 'purge': {
return messages(contract)?.purge()
}
case 'update_start_time': {
return messages(contract)?.updateStartTime(args.startTime)
}
case 'update_end_time': {
return messages(contract)?.updateEndTime(args.endTime)
}
case 'update_mint_price': {
return messages(contract)?.updateMintPrice(args.price)
}
case 'update_start_trading_time': {
return messages(contract)?.updateStartTradingTime(args.startTime as string)
}
case 'update_per_address_limit': {
return messages(contract)?.updatePerAddressLimit(args.limit)
}
case 'mint_to': {
return messages(contract)?.mintTo(args.recipient)
}
default: {
return {}
}
}
}
export const isEitherType = <T extends ExecuteType>(type: unknown, arr: T[]): type is T => {
return arr.some((val) => type === val)
}

View File

@ -0,0 +1,73 @@
import type { OpenEditionMinterInstance } from '../contract'
export type QueryType = typeof QUERY_TYPES[number]
export const QUERY_TYPES = [
'config',
'start_time',
'end_time',
'mint_price',
'mint_count',
'total_mint_count',
'status',
] as const
export interface QueryListItem {
id: QueryType
name: string
description?: string
}
export const QUERY_LIST: QueryListItem[] = [
{ id: 'config', name: 'Config', description: 'View current config' },
{ id: 'start_time', name: 'Start Time', description: 'View the start time for minting' },
{ id: 'end_time', name: 'End Time', description: 'View the end time for minting' },
{ id: 'mint_price', name: 'Mint Price', description: 'View the mint price' },
{
id: 'mint_count',
name: 'Mint Count for Address',
description: 'View the total amount of minted tokens for an address',
},
{
id: 'total_mint_count',
name: 'Total Mint Count',
description: 'View the total amount of minted tokens',
},
{ id: 'status', name: 'Status', description: 'View contract status' },
]
export interface DispatchQueryProps {
address: string
messages: OpenEditionMinterInstance | undefined
type: QueryType
}
export const dispatchQuery = (props: DispatchQueryProps) => {
const { address, messages, type } = props
switch (type) {
case 'config': {
return messages?.getConfig()
}
case 'status': {
return messages?.getStatus()
}
case 'start_time': {
return messages?.getStartTime()
}
case 'end_time': {
return messages?.getEndTime()
}
case 'mint_price': {
return messages?.getMintPrice()
}
case 'mint_count': {
return messages?.getMintCount(address)
}
case 'total_mint_count': {
return messages?.getTotalMintCount()
}
default: {
throw new Error('unknown query type')
}
}
}

View File

@ -0,0 +1,119 @@
import type { Coin } from '@cosmjs/proto-signing'
import type { logs } from '@cosmjs/stargate'
import { useWallet } from 'contexts/wallet'
import { useCallback, useEffect, useState } from 'react'
import type {
MigrateResponse,
OpenEditionMinterContract,
OpenEditionMinterInstance,
OpenEditionMinterMessages,
} from './contract'
import { openEditionMinter as initContract } from './contract'
/*export interface InstantiateResponse {
/** The address of the newly instantiated contract *-/
readonly contractAddress: string
readonly logs: readonly logs.Log[]
/** Block height in which the transaction is included *-/
readonly height: number
/** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex *-/
readonly transactionHash: string
readonly gasWanted: number
readonly gasUsed: number
}*/
interface InstantiateResponse {
readonly contractAddress: string
readonly transactionHash: string
readonly logs: readonly logs.Log[]
}
export interface UseOpenEditionMinterContractProps {
instantiate: (
codeId: number,
initMsg: Record<string, unknown>,
label: string,
admin?: string,
funds?: Coin[],
) => Promise<InstantiateResponse>
migrate: (contractAddress: string, codeId: number, migrateMsg: Record<string, unknown>) => Promise<MigrateResponse>
use: (customAddress: string) => OpenEditionMinterInstance | undefined
updateContractAddress: (contractAddress: string) => void
getContractAddress: () => string | undefined
messages: (contractAddress: string) => OpenEditionMinterMessages | undefined
}
export function useOpenEditionMinterContract(): UseOpenEditionMinterContractProps {
const wallet = useWallet()
const [address, setAddress] = useState<string>('')
const [openEditionMinter, setOpenEditionMinter] = useState<OpenEditionMinterContract>()
useEffect(() => {
setAddress(localStorage.getItem('contract_address') || '')
}, [])
useEffect(() => {
const OpenEditionMinterBaseContract = initContract(wallet.getClient(), wallet.address)
setOpenEditionMinter(OpenEditionMinterBaseContract)
}, [wallet])
const updateContractAddress = (contractAddress: string) => {
setAddress(contractAddress)
}
const instantiate = useCallback(
(codeId: number, initMsg: Record<string, unknown>, label: string, admin?: string): Promise<InstantiateResponse> => {
return new Promise((resolve, reject) => {
if (!openEditionMinter) {
reject(new Error('Contract is not initialized.'))
return
}
openEditionMinter.instantiate(wallet.address, codeId, initMsg, label, admin).then(resolve).catch(reject)
})
},
[openEditionMinter, wallet],
)
const migrate = useCallback(
(contractAddress: string, codeId: number, migrateMsg: Record<string, unknown>): Promise<MigrateResponse> => {
return new Promise((resolve, reject) => {
if (!openEditionMinter) {
reject(new Error('Contract is not initialized.'))
return
}
console.log(wallet.address, contractAddress, codeId)
openEditionMinter.migrate(wallet.address, contractAddress, codeId, migrateMsg).then(resolve).catch(reject)
})
},
[openEditionMinter, wallet],
)
const use = useCallback(
(customAddress = ''): OpenEditionMinterInstance | undefined => {
return openEditionMinter?.use(address || customAddress)
},
[openEditionMinter, address],
)
const getContractAddress = (): string | undefined => {
return address
}
const messages = useCallback(
(customAddress = ''): OpenEditionMinterMessages | undefined => {
return openEditionMinter?.messages(address || customAddress)
},
[openEditionMinter, address],
)
return {
instantiate,
use,
updateContractAddress,
getContractAddress,
messages,
migrate,
}
}

3
env.d.ts vendored
View File

@ -21,6 +21,9 @@ declare namespace NodeJS {
readonly NEXT_PUBLIC_VENDING_MINTER_CODE_ID: string readonly NEXT_PUBLIC_VENDING_MINTER_CODE_ID: string
readonly NEXT_PUBLIC_VENDING_MINTER_FLEX_CODE_ID: string readonly NEXT_PUBLIC_VENDING_MINTER_FLEX_CODE_ID: string
readonly NEXT_PUBLIC_VENDING_FACTORY_ADDRESS: string readonly NEXT_PUBLIC_VENDING_FACTORY_ADDRESS: string
readonly NEXT_PUBLIC_OPEN_EDITION_FACTORY_ADDRESS: string
readonly NEXT_PUBLIC_OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS: string
readonly NEXT_PUBLIC_OPEN_EDITION_MINTER_CODE_ID: string
readonly NEXT_PUBLIC_VENDING_FACTORY_UPDATABLE_ADDRESS: string readonly NEXT_PUBLIC_VENDING_FACTORY_UPDATABLE_ADDRESS: string
readonly NEXT_PUBLIC_VENDING_FACTORY_FLEX_ADDRESS: string readonly NEXT_PUBLIC_VENDING_FACTORY_FLEX_ADDRESS: string
readonly NEXT_PUBLIC_BASE_FACTORY_ADDRESS: string readonly NEXT_PUBLIC_BASE_FACTORY_ADDRESS: string

View File

@ -1,6 +1,6 @@
{ {
"name": "stargaze-studio", "name": "stargaze-studio",
"version": "0.6.4", "version": "0.6.5",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],

View File

@ -19,7 +19,12 @@ import { links } from 'utils/links'
import type { MinterType, Sg721Type } from '../../components/collections/actions/Combobox' import type { MinterType, Sg721Type } from '../../components/collections/actions/Combobox'
const CollectionActionsPage: NextPage = () => { const CollectionActionsPage: NextPage = () => {
const { baseMinter: baseMinterContract, vendingMinter: vendingMinterContract, sg721: sg721Contract } = useContracts() const {
baseMinter: baseMinterContract,
vendingMinter: vendingMinterContract,
openEditionMinter: openEditionMinterContract,
sg721: sg721Contract,
} = useContracts()
const wallet = useWallet() const wallet = useWallet()
const [action, setAction] = useState<boolean>(false) const [action, setAction] = useState<boolean>(false)
@ -51,6 +56,11 @@ const CollectionActionsPage: NextPage = () => {
() => baseMinterContract?.use(minterContractState.value), () => baseMinterContract?.use(minterContractState.value),
[baseMinterContract, minterContractState.value], [baseMinterContract, minterContractState.value],
) )
const openEditionMinterMessages = useMemo(
() => openEditionMinterContract?.use(minterContractState.value),
[openEditionMinterContract, minterContractState.value],
)
const sg721Messages = useMemo( const sg721Messages = useMemo(
() => sg721Contract?.use(sg721ContractState.value), () => sg721Contract?.use(sg721ContractState.value),
[sg721Contract, sg721ContractState.value], [sg721Contract, sg721ContractState.value],
@ -105,6 +115,8 @@ const CollectionActionsPage: NextPage = () => {
.then((contract) => { .then((contract) => {
if (contract?.includes('sg-base-minter')) { if (contract?.includes('sg-base-minter')) {
setMinterType('base') setMinterType('base')
} else if (contract?.includes('open-edition')) {
setMinterType('openEdition')
} else { } else {
setMinterType('vending') setMinterType('vending')
} }
@ -214,6 +226,7 @@ const CollectionActionsPage: NextPage = () => {
baseMinterMessages={baseMinterMessages} baseMinterMessages={baseMinterMessages}
minterContractAddress={minterContractState.value} minterContractAddress={minterContractState.value}
minterType={minterType} minterType={minterType}
openEditionMinterMessages={openEditionMinterMessages}
sg721ContractAddress={sg721ContractState.value} sg721ContractAddress={sg721ContractState.value}
sg721Messages={sg721Messages} sg721Messages={sg721Messages}
sg721Type={sg721Type} sg721Type={sg721Type}
@ -224,6 +237,7 @@ const CollectionActionsPage: NextPage = () => {
baseMinterMessages={baseMinterMessages} baseMinterMessages={baseMinterMessages}
minterContractAddress={minterContractState.value} minterContractAddress={minterContractState.value}
minterType={minterType} minterType={minterType}
openEditionMinterMessages={openEditionMinterMessages}
sg721ContractAddress={sg721ContractState.value} sg721ContractAddress={sg721ContractState.value}
sg721Messages={sg721Messages} sg721Messages={sg721Messages}
vendingMinterMessages={vendingMinterMessages} vendingMinterMessages={vendingMinterMessages}

View File

@ -30,6 +30,8 @@ import type { UploadDetailsDataProps } from 'components/collections/creation/Upl
import type { WhitelistDetailsDataProps } from 'components/collections/creation/WhitelistDetails' import type { WhitelistDetailsDataProps } from 'components/collections/creation/WhitelistDetails'
import { Conditional } from 'components/Conditional' import { Conditional } from 'components/Conditional'
import { LoadingModal } from 'components/LoadingModal' import { LoadingModal } from 'components/LoadingModal'
import type { OpenEditionMinterCreatorDataProps } from 'components/openEdition/OpenEditionMinterCreator'
import { OpenEditionMinterCreator } from 'components/openEdition/OpenEditionMinterCreator'
import { useContracts } from 'contexts/contracts' import { useContracts } from 'contexts/contracts'
import { addLogItem } from 'contexts/log' import { addLogItem } from 'contexts/log'
import { useWallet } from 'contexts/wallet' import { useWallet } from 'contexts/wallet'
@ -48,6 +50,8 @@ import {
BASE_FACTORY_UPDATABLE_ADDRESS, BASE_FACTORY_UPDATABLE_ADDRESS,
BLOCK_EXPLORER_URL, BLOCK_EXPLORER_URL,
NETWORK, NETWORK,
OPEN_EDITION_FACTORY_ADDRESS,
OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS,
SG721_CODE_ID, SG721_CODE_ID,
SG721_UPDATABLE_CODE_ID, SG721_UPDATABLE_CODE_ID,
STARGAZE_URL, STARGAZE_URL,
@ -91,6 +95,9 @@ const CollectionCreationPage: NextPage = () => {
const [uploadDetails, setUploadDetails] = useState<UploadDetailsDataProps | null>(null) const [uploadDetails, setUploadDetails] = useState<UploadDetailsDataProps | null>(null)
const [collectionDetails, setCollectionDetails] = useState<CollectionDetailsDataProps | null>(null) const [collectionDetails, setCollectionDetails] = useState<CollectionDetailsDataProps | null>(null)
const [baseMinterDetails, setBaseMinterDetails] = useState<BaseMinterDetailsDataProps | null>(null) const [baseMinterDetails, setBaseMinterDetails] = useState<BaseMinterDetailsDataProps | null>(null)
const [openEditionMinterDetails, setOpenEditionMinterDetails] = useState<OpenEditionMinterCreatorDataProps | null>(
null,
)
const [mintingDetails, setMintingDetails] = useState<MintingDetailsDataProps | null>(null) const [mintingDetails, setMintingDetails] = useState<MintingDetailsDataProps | null>(null)
const [whitelistDetails, setWhitelistDetails] = useState<WhitelistDetailsDataProps | null>(null) const [whitelistDetails, setWhitelistDetails] = useState<WhitelistDetailsDataProps | null>(null)
const [royaltyDetails, setRoyaltyDetails] = useState<RoyaltyDetailsDataProps | null>(null) const [royaltyDetails, setRoyaltyDetails] = useState<RoyaltyDetailsDataProps | null>(null)
@ -99,10 +106,16 @@ const CollectionCreationPage: NextPage = () => {
const [vendingMinterCreationFee, setVendingMinterCreationFee] = useState<string | null>(null) const [vendingMinterCreationFee, setVendingMinterCreationFee] = useState<string | null>(null)
const [baseMinterCreationFee, setBaseMinterCreationFee] = useState<string | null>(null) const [baseMinterCreationFee, setBaseMinterCreationFee] = useState<string | null>(null)
const [vendingMinterUpdatableCreationFee, setVendingMinterUpdatableCreationFee] = useState<string | null>(null) const [vendingMinterUpdatableCreationFee, setVendingMinterUpdatableCreationFee] = useState<string | null>(null)
const [openEditionMinterCreationFee, setOpenEditionMinterCreationFee] = useState<string | null>(null)
const [openEditionMinterUpdatableCreationFee, setOpenEditionMinterUpdatableCreationFee] = useState<string | null>(
null,
)
const [vendingMinterFlexCreationFee, setVendingMinterFlexCreationFee] = useState<string | null>(null) const [vendingMinterFlexCreationFee, setVendingMinterFlexCreationFee] = useState<string | null>(null)
const [baseMinterUpdatableCreationFee, setBaseMinterUpdatableCreationFee] = useState<string | null>(null) const [baseMinterUpdatableCreationFee, setBaseMinterUpdatableCreationFee] = useState<string | null>(null)
const [minimumMintPrice, setMinimumMintPrice] = useState<string | null>('0') const [minimumMintPrice, setMinimumMintPrice] = useState<string | null>('0')
const [minimumUpdatableMintPrice, setMinimumUpdatableMintPrice] = useState<string | null>('0') const [minimumUpdatableMintPrice, setMinimumUpdatableMintPrice] = useState<string | null>('0')
const [minimumOpenEditionMintPrice, setMinimumOpenEditionMintPrice] = useState<string | null>('0')
const [minimumOpenEditionUpdatableMintPrice, setMinimumOpenEditionUpdatableMintPrice] = useState<string | null>('0')
const [minimumFlexMintPrice, setMinimumFlexMintPrice] = useState<string | null>('0') const [minimumFlexMintPrice, setMinimumFlexMintPrice] = useState<string | null>('0')
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
@ -1031,6 +1044,26 @@ const CollectionCreationPage: NextPage = () => {
setVendingMinterFlexCreationFee(vendingFactoryFlexParameters?.params?.creation_fee?.amount) setVendingMinterFlexCreationFee(vendingFactoryFlexParameters?.params?.creation_fee?.amount)
setMinimumFlexMintPrice(vendingFactoryFlexParameters?.params?.min_mint_price?.amount) setMinimumFlexMintPrice(vendingFactoryFlexParameters?.params?.min_mint_price?.amount)
} }
if (OPEN_EDITION_FACTORY_ADDRESS) {
const openEditionFactoryParameters = await client
.queryContractSmart(OPEN_EDITION_FACTORY_ADDRESS, { params: {} })
.catch((error) => {
toast.error(`${error.message}`, { style: { maxWidth: 'none' } })
addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() })
})
setOpenEditionMinterCreationFee(openEditionFactoryParameters?.params?.creation_fee?.amount)
setMinimumOpenEditionMintPrice(openEditionFactoryParameters?.params?.min_mint_price?.amount)
}
if (OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS) {
const openEditionUpdatableFactoryParameters = await client
.queryContractSmart(OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS, { params: {} })
.catch((error) => {
toast.error(`${error.message}`, { style: { maxWidth: 'none' } })
addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() })
})
setOpenEditionMinterUpdatableCreationFee(openEditionUpdatableFactoryParameters?.params?.creation_fee?.amount)
setMinimumOpenEditionMintPrice(openEditionUpdatableFactoryParameters?.params?.min_mint_price?.amount)
}
} }
const checkwalletBalance = () => { const checkwalletBalance = () => {
@ -1069,9 +1102,13 @@ const CollectionCreationPage: NextPage = () => {
} }
} }
useEffect(() => { useEffect(() => {
if (vendingMinterContractAddress !== null || isMintingComplete) if (
vendingMinterContractAddress !== null ||
openEditionMinterDetails?.openEditionMinterContractAddress ||
isMintingComplete
)
scrollRef.current?.scrollIntoView({ behavior: 'smooth' }) scrollRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [vendingMinterContractAddress, isMintingComplete]) }, [vendingMinterContractAddress, openEditionMinterDetails?.openEditionMinterContractAddress, isMintingComplete])
useEffect(() => { useEffect(() => {
setBaseTokenUri(uploadDetails?.baseTokenURI as string) setBaseTokenUri(uploadDetails?.baseTokenURI as string)
@ -1118,6 +1155,67 @@ const CollectionCreationPage: NextPage = () => {
</p> </p>
</div> </div>
<div className="mx-10" ref={scrollRef}> <div className="mx-10" ref={scrollRef}>
<Conditional
test={minterType === 'openEdition' && openEditionMinterDetails?.openEditionMinterContractAddress !== null}
>
<Alert className="mt-5" type="info">
<div>
Open Edition Minter Contract Address:{' '}
<Anchor
className="text-stargaze hover:underline"
external
href={`/contracts/openEditionMinter/query/?contractAddress=${
openEditionMinterDetails?.openEditionMinterContractAddress as string
}`}
>
{openEditionMinterDetails?.openEditionMinterContractAddress as string}
</Anchor>
<br />
SG721 Contract Address:{' '}
<Anchor
className="text-stargaze hover:underline"
external
href={`/contracts/sg721/query/?contractAddress=${
openEditionMinterDetails?.sg721ContractAddress as string
}`}
>
{openEditionMinterDetails?.sg721ContractAddress as string}
</Anchor>
<br />
Transaction Hash: {' '}
<Conditional test={NETWORK === 'testnet'}>
<Anchor
className="text-stargaze hover:underline"
external
href={`${BLOCK_EXPLORER_URL}/tx/${openEditionMinterDetails?.transactionHash as string}`}
>
{openEditionMinterDetails?.transactionHash}
</Anchor>
</Conditional>
<Conditional test={NETWORK === 'mainnet'}>
<Anchor
className="text-stargaze hover:underline"
external
href={`${BLOCK_EXPLORER_URL}/txs/${openEditionMinterDetails?.transactionHash as string}`}
>
{openEditionMinterDetails?.transactionHash}
</Anchor>
</Conditional>
<br />
<Button className="mt-2">
<Anchor
className="text-white"
external
href={`${STARGAZE_URL}/launchpad/${
openEditionMinterDetails?.openEditionMinterContractAddress as string
}`}
>
View on Launchpad
</Anchor>
</Button>
</div>
</Alert>
</Conditional>
<Conditional test={vendingMinterContractAddress !== null || isMintingComplete}> <Conditional test={vendingMinterContractAddress !== null || isMintingComplete}>
<Alert className="mt-5" type="info"> <Alert className="mt-5" type="info">
<div> <div>
@ -1289,80 +1387,108 @@ const CollectionCreationPage: NextPage = () => {
</Conditional> </Conditional>
</div> </div>
{/* To be removed */} <div>
<Conditional test={BASE_FACTORY_ADDRESS === undefined}> <div
<div className="mx-10 mt-5" /> className={clsx(
</Conditional> 'mx-10 mt-5',
<Conditional test={BASE_FACTORY_ADDRESS !== undefined}> 'grid before:absolute relative grid-cols-3 grid-flow-col items-stretch rounded',
{/* /To be removed */} 'before:inset-x-0 before:bottom-0 before:border-white/25',
<div> )}
>
<div <div
className={clsx( className={clsx(
'mx-10 mt-5', 'isolate space-y-1 border-2',
'grid before:absolute relative grid-cols-2 grid-flow-col items-stretch rounded', 'first-of-type:rounded-tl-md last-of-type:rounded-tr-md',
'before:inset-x-0 before:bottom-0 before:border-white/25', minterType === 'vending' ? 'border-stargaze' : 'border-transparent',
minterType !== 'vending' ? 'bg-stargaze/5 hover:bg-stargaze/80' : 'hover:bg-white/5',
)} )}
> >
<div <button
className={clsx( className="p-4 w-full h-full text-left bg-transparent"
'isolate space-y-1 border-2', onClick={() => {
'first-of-type:rounded-tl-md last-of-type:rounded-tr-md', setMinterType('vending')
minterType === 'vending' ? 'border-stargaze' : 'border-transparent', resetReadyFlags()
minterType !== 'vending' ? 'bg-stargaze/5 hover:bg-stargaze/80' : 'hover:bg-white/5', }}
)} type="button"
> >
<button <h4 className="font-bold">Standard Collection</h4>
className="p-4 w-full h-full text-left bg-transparent" <span className="text-sm text-white/80 line-clamp-2">
onClick={() => { A non-appendable collection that facilitates primary market vending machine style minting
setMinterType('vending') </span>
resetReadyFlags() </button>
}} </div>
type="button" <div
> className={clsx(
<h4 className="font-bold">Standard Collection</h4> 'isolate space-y-1 border-2',
<span className="text-sm text-white/80 line-clamp-2"> 'first-of-type:rounded-tl-md last-of-type:rounded-tr-md',
A non-appendable collection that facilitates primary market vending machine style minting minterType === 'base' ? 'border-stargaze' : 'border-transparent',
</span> minterType !== 'base' ? 'bg-stargaze/5 hover:bg-stargaze/80' : 'hover:bg-white/5',
</button> )}
</div> >
<div <button
className={clsx( className="p-4 w-full h-full text-left bg-transparent"
'isolate space-y-1 border-2', onClick={() => {
'first-of-type:rounded-tl-md last-of-type:rounded-tr-md', setMinterType('base')
minterType === 'base' ? 'border-stargaze' : 'border-transparent', resetReadyFlags()
minterType !== 'base' ? 'bg-stargaze/5 hover:bg-stargaze/80' : 'hover:bg-white/5', }}
)} type="button"
> >
<button <h4 className="font-bold">1/1 Collection</h4>
className="p-4 w-full h-full text-left bg-transparent" <span className="text-sm text-white/80 line-clamp-2">
onClick={() => { An appendable collection that only allows for direct secondary market listing of tokens
setMinterType('base') </span>
resetReadyFlags() </button>
}} </div>
type="button" <div
> className={clsx(
<h4 className="font-bold">1/1 Collection</h4> 'isolate space-y-1 border-2',
<span className="text-sm text-white/80 line-clamp-2"> 'first-of-type:rounded-tl-md last-of-type:rounded-tr-md',
An appendable collection that only allows for direct secondary market listing of tokens minterType === 'openEdition' ? 'border-stargaze' : 'border-transparent',
</span> minterType !== 'openEdition' ? 'bg-stargaze/5 hover:bg-stargaze/80' : 'hover:bg-white/5',
</button> OPEN_EDITION_FACTORY_ADDRESS === undefined ? 'hover:bg-zinc-500 opacity-50 hover:opacity-70' : '',
</div> )}
>
<button
className="p-4 w-full h-full text-left bg-transparent"
disabled={OPEN_EDITION_FACTORY_ADDRESS === undefined}
onClick={() => {
setMinterType('openEdition')
resetReadyFlags()
}}
type="button"
>
<h4 className="font-bold">Open Edition Collection</h4>
<span className="text-sm text-white/80 line-clamp-2">
Allows multiple copies of a single NFT to be minted for a given time interval
</span>
</button>
</div> </div>
</div> </div>
</Conditional> </div>
{minterType === 'base' && ( {minterType === 'base' && (
<div> <div>
<BaseMinterDetails minterType={minterType} onChange={setBaseMinterDetails} /> <BaseMinterDetails minterType={minterType} onChange={setBaseMinterDetails} />
</div> </div>
)} )}
<Conditional test={minterType === 'openEdition'}>
<div className="mx-10"> <OpenEditionMinterCreator
<UploadDetails minimumMintPrice={minimumOpenEditionMintPrice as string}
baseMinterAcquisitionMethod={baseMinterDetails?.baseMinterAcquisitionMethod} minimumUpdatableMintPrice={minimumOpenEditionUpdatableMintPrice as string}
minterType={minterType} minterType={minterType}
onChange={setUploadDetails} onChange={setOpenEditionMinterDetails}
openEditionMinterCreationFee={openEditionMinterCreationFee as string}
openEditionMinterUpdatableCreationFee={openEditionMinterUpdatableCreationFee as string}
/> />
</Conditional>
<div className="mx-10">
<Conditional test={minterType === 'vending' || minterType === 'base'}>
<UploadDetails
baseMinterAcquisitionMethod={baseMinterDetails?.baseMinterAcquisitionMethod}
minterType={minterType}
onChange={setUploadDetails}
/>
</Conditional>
<Conditional <Conditional
test={ test={

View File

@ -27,6 +27,7 @@ const CollectionList: NextPage = () => {
const [myCollections, setMyCollections] = useState<any[]>([]) const [myCollections, setMyCollections] = useState<any[]>([])
const [myOneOfOneCollections, setMyOneOfOneCollections] = useState<any[]>([]) const [myOneOfOneCollections, setMyOneOfOneCollections] = useState<any[]>([])
const [myStandardCollections, setMyStandardCollections] = useState<any[]>([]) const [myStandardCollections, setMyStandardCollections] = useState<any[]>([])
const [myOpenEditionCollections, setMyOpenEditionCollections] = useState<any[]>([])
async function getMinterContractType(minterContractAddress: string) { async function getMinterContractType(minterContractAddress: string) {
if (wallet.client && minterContractAddress.length > 0) { if (wallet.client && minterContractAddress.length > 0) {
@ -43,6 +44,7 @@ const CollectionList: NextPage = () => {
const filterMyCollections = () => { const filterMyCollections = () => {
setMyOneOfOneCollections([]) setMyOneOfOneCollections([])
setMyStandardCollections([]) setMyStandardCollections([])
setMyOpenEditionCollections([])
if (myCollections.length > 0) { if (myCollections.length > 0) {
myCollections.map(async (collection: any) => { myCollections.map(async (collection: any) => {
await getMinterContractType(collection.minter) await getMinterContractType(collection.minter)
@ -51,6 +53,8 @@ const CollectionList: NextPage = () => {
setMyOneOfOneCollections((prevState) => [...prevState, collection]) setMyOneOfOneCollections((prevState) => [...prevState, collection])
} else if (contractType?.includes('sg-minter') || contractType?.includes('flex')) { } else if (contractType?.includes('sg-minter') || contractType?.includes('flex')) {
setMyStandardCollections((prevState) => [...prevState, collection]) setMyStandardCollections((prevState) => [...prevState, collection])
} else if (contractType?.includes('open-edition')) {
setMyOpenEditionCollections((prevState) => [...prevState, collection])
} }
}) })
.catch((err) => { .catch((err) => {
@ -302,11 +306,120 @@ const CollectionList: NextPage = () => {
</table> </table>
</div> </div>
)} )}
{myOpenEditionCollections.length > 0 && (
<div className="bg-transparent">
<span className="ml-6 text-2xl font-bold text-blue-300 underline underline-offset-4">
Open Edition Collections
</span>
<table className="table w-full">
<thead>
<tr>
<th className="pl-36 text-lg font-bold text-left bg-transparent">Collection Name</th>
<th className="text-lg font-bold bg-transparent">Contract Address</th>
<th className="bg-transparent" />
</tr>
</thead>
<tbody>
{myOpenEditionCollections.map((collection: any, index: any) => {
return (
<tr key={index}>
<td className="w-[40%] bg-black">
<div className="flex items-center space-x-3">
<div className="avatar">
<div className="w-28 h-28 mask mask-squircle">
<img
alt="Cover"
src={
(collection?.image as string).startsWith('ipfs')
? `https://ipfs-gw.stargaze-apis.com/ipfs/${(
collection?.image as string
).substring(7)}`
: collection?.image
}
/>
</div>
</div>
<div className="pl-2">
<p className="overflow-auto font-bold lg:max-w-[160px] xl:max-w-[220px] 2xl:max-w-xs no-scrollbar ">
{collection.name}
</p>
<p className="text-sm truncate opacity-50 lg:max-w-[160px] xl:max-w-[220px] 2xl:max-w-xs">
{collection.description}
</p>
</div>
</div>
</td>
<td className="w-[50%] bg-black">
<div className="flex flex-row items-center space-x-3">
Minter:
<span className="ml-2">
<Tooltip
backgroundColor="bg-blue-500"
label="Click to copy the Vending Minter contract address"
>
<button
className="group flex space-x-2 font-mono text-base text-white/80 hover:underline"
onClick={() => void copy(collection.minter as string)}
type="button"
>
<span>
{truncateMiddle(collection.minter ? (collection.minter as string) : '', 36)}
</span>
<FaCopy className="opacity-0 group-hover:opacity-100" />
</button>
</Tooltip>
</span>
</div>
<div className="flex flex-row items-center space-x-3">
SG721:
<span className="ml-2">
<Tooltip backgroundColor="bg-blue-500" label="Click to copy the SG721 contract address">
<button
className="group flex space-x-2 font-mono text-base text-white/80 hover:underline"
onClick={() => void copy(collection.contractAddress as string)}
type="button"
>
<span>
{truncateMiddle(
collection.contractAddress ? (collection.contractAddress as string) : '',
36,
)}
</span>
<FaCopy className="opacity-0 group-hover:opacity-100" />
</button>
</Tooltip>
</span>
</div>
</td>
<th className="bg-black">
<div className="flex items-center space-x-8">
<Anchor
className="text-xl text-plumbus"
href={`/collections/actions?sg721ContractAddress=${collection.contractAddress}&minterContractAddress=${collection.minter}`}
>
<FaSlidersH />
</Anchor>
<Anchor
className="text-xl text-plumbus"
external
href={`${STARGAZE_URL}/launchpad/${collection.minter}`}
>
<FaRocket />
</Anchor>
</div>
</th>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div> </div>
)} )}
</div> </div>
) )
}, [myCollections, myStandardCollections, myOneOfOneCollections, wallet.address]) }, [myCollections, myStandardCollections, myOneOfOneCollections, myOpenEditionCollections, wallet.address])
return ( return (
<section className="py-6 px-12 space-y-4"> <section className="py-6 px-12 space-y-4">

View File

@ -100,7 +100,7 @@ const BaseMinterQueryPage: NextPage = () => {
onChange={(e) => setType(e.target.value as QueryType)} onChange={(e) => setType(e.target.value as QueryType)}
> >
{QUERY_LIST.map(({ id, name }) => ( {QUERY_LIST.map(({ id, name }) => (
<option key={`query-${id}`} value={id}> <option key={`query-${id}`} className="mt-2 text-lg bg-[#1A1A1A]" value={id}>
{name} {name}
</option> </option>
))} ))}

View File

@ -4,7 +4,7 @@ import type { NextPage } from 'next'
// import Brand from 'public/brand/brand.svg' // import Brand from 'public/brand/brand.svg'
import { withMetadata } from 'utils/layout' import { withMetadata } from 'utils/layout'
import { BADGE_HUB_ADDRESS, BASE_FACTORY_ADDRESS } from '../../utils/constants' import { BADGE_HUB_ADDRESS, BASE_FACTORY_ADDRESS, OPEN_EDITION_FACTORY_ADDRESS } from '../../utils/constants'
const HomePage: NextPage = () => { const HomePage: NextPage = () => {
return ( return (
@ -42,6 +42,15 @@ const HomePage: NextPage = () => {
<HomeCard className="p-4 -m-4 hover:bg-gray-500/10 rounded" link="/contracts/sg721" title="Sg721 Contract"> <HomeCard className="p-4 -m-4 hover:bg-gray-500/10 rounded" link="/contracts/sg721" title="Sg721 Contract">
Execute messages and run queries on Stargaze&apos;s SG721 contract. Execute messages and run queries on Stargaze&apos;s SG721 contract.
</HomeCard> </HomeCard>
<Conditional test={OPEN_EDITION_FACTORY_ADDRESS !== undefined}>
<HomeCard
className="p-4 -m-4 hover:bg-gray-500/10 rounded"
link="/contracts/openEditionMinter"
title="Open Edition Minter Contract"
>
Execute messages and run queries on Stargaze&apos;s Open Edition Minter contract.
</HomeCard>
</Conditional>
<HomeCard <HomeCard
className="p-4 -m-4 hover:bg-gray-500/10 rounded" className="p-4 -m-4 hover:bg-gray-500/10 rounded"
link="/contracts/whitelist" link="/contracts/whitelist"

View File

@ -0,0 +1,249 @@
import { Button } from 'components/Button'
import { Conditional } from 'components/Conditional'
import { ContractPageHeader } from 'components/ContractPageHeader'
import { ExecuteCombobox } from 'components/contracts/openEditionMinter/ExecuteCombobox'
import { useExecuteComboboxState } from 'components/contracts/openEditionMinter/ExecuteCombobox.hooks'
import { FormControl } from 'components/FormControl'
import { AddressInput, NumberInput } from 'components/forms/FormInput'
import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks'
import { InputDateTime } from 'components/InputDateTime'
import { JsonPreview } from 'components/JsonPreview'
import { LinkTabs } from 'components/LinkTabs'
import { openEditionMinterLinkTabs } from 'components/LinkTabs.data'
import { TransactionHash } from 'components/TransactionHash'
import { useContracts } from 'contexts/contracts'
import { useWallet } from 'contexts/wallet'
import type { DispatchExecuteArgs } from 'contracts/openEditionMinter/messages/execute'
import { dispatchExecute, isEitherType, previewExecutePayload } from 'contracts/openEditionMinter/messages/execute'
import type { NextPage } from 'next'
import { useRouter } from 'next/router'
import { NextSeo } from 'next-seo'
import type { FormEvent } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'react-hot-toast'
import { FaArrowRight } from 'react-icons/fa'
import { useMutation } from 'react-query'
import { withMetadata } from 'utils/layout'
import { links } from 'utils/links'
import { resolveAddress } from 'utils/resolveAddress'
const OpenEditionMinterExecutePage: NextPage = () => {
const { openEditionMinter: contract } = useContracts()
const wallet = useWallet()
const [lastTx, setLastTx] = useState('')
const [timestamp, setTimestamp] = useState<Date | undefined>(undefined)
const [endTimestamp, setEndTimestamp] = useState<Date | undefined>(undefined)
const [resolvedRecipientAddress, setResolvedRecipientAddress] = useState<string>('')
const comboboxState = useExecuteComboboxState()
const type = comboboxState.value?.id
const limitState = useNumberInputState({
id: 'per-address-limit',
name: 'perAddressLimit',
title: 'Per Address Limit',
subtitle: 'Enter the per address limit',
})
const tokenIdState = useNumberInputState({
id: 'token-id',
name: 'tokenId',
title: 'Token ID',
subtitle: 'Enter the token ID',
})
const priceState = useNumberInputState({
id: 'price',
name: 'price',
title: 'Price',
subtitle: 'Enter the price for each token',
})
const contractState = useInputState({
id: 'contract-address',
name: 'contract-address',
title: 'Open Edition Minter Address',
subtitle: 'Address of the Open Edition Minter contract',
})
const contractAddress = contractState.value
const recipientState = useInputState({
id: 'recipient-address',
name: 'recipient',
title: 'Recipient Address',
subtitle: 'Address of the recipient',
})
const showDateField = isEitherType(type, ['update_start_time', 'update_start_trading_time'])
const showEndDateField = type === 'update_end_time'
const showLimitField = type === 'update_per_address_limit'
const showRecipientField = isEitherType(type, ['mint_to'])
const showPriceField = isEitherType(type, ['update_mint_price'])
const messages = useMemo(() => contract?.use(contractState.value), [contract, wallet.address, contractState.value])
const payload: DispatchExecuteArgs = {
startTime: timestamp ? (timestamp.getTime() * 1_000_000).toString() : '',
endTime: endTimestamp ? (endTimestamp.getTime() * 1_000_000).toString() : '',
limit: limitState.value,
contract: contractState.value,
messages,
recipient: resolvedRecipientAddress,
txSigner: wallet.address,
price: priceState.value ? priceState.value.toString() : '0',
type,
}
const { isLoading, mutate } = useMutation(
async (event: FormEvent) => {
event.preventDefault()
if (!type) {
throw new Error('Please select message type!')
}
if (!wallet.initialized) {
throw new Error('Please connect your wallet.')
}
if (contractState.value === '') {
throw new Error('Please enter the contract address.')
}
if (wallet.client && type === 'update_mint_price') {
const contractConfig = wallet.client.queryContractSmart(contractState.value, {
config: {},
})
await toast
.promise(
wallet.client.queryContractSmart(contractState.value, {
mint_price: {},
}),
{
error: `Querying mint price failed!`,
loading: 'Querying current mint price...',
success: (price) => {
console.log(price)
return `Current mint price is ${Number(price.public_price.amount) / 1000000} STARS`
},
},
)
.then(async (price) => {
if (Number(price.public_price.amount) / 1000000 <= priceState.value) {
await contractConfig
.then((config) => {
console.log(config.start_time, Date.now() * 1000000)
if (Number(config.start_time) < Date.now() * 1000000) {
throw new Error(
`Minting has already started on ${new Date(
Number(config.start_time) / 1000000,
).toLocaleString()}. Updated mint price cannot be higher than the current price of ${
Number(price.public_price.amount) / 1000000
} STARS`,
)
}
})
.catch((error) => {
throw new Error(String(error).substring(String(error).lastIndexOf('Error:') + 7))
})
} else {
await contractConfig.then(async (config) => {
const factoryParameters = await wallet.client?.queryContractSmart(config.factory, {
params: {},
})
if (
factoryParameters.params.min_mint_price.amount &&
priceState.value < Number(factoryParameters.params.min_mint_price.amount) / 1000000
) {
throw new Error(
`Updated mint price cannot be lower than the minimum mint price of ${
Number(factoryParameters.params.min_mint_price.amount) / 1000000
} STARS`,
)
}
})
}
})
}
const txHash = await toast.promise(dispatchExecute(payload), {
error: `${type.charAt(0).toUpperCase() + type.slice(1)} execute failed!`,
loading: 'Executing message...',
success: (tx) => `Transaction ${tx} success!`,
})
if (txHash) {
setLastTx(txHash)
}
},
{
onError: (error) => {
toast.error(String(error), { style: { maxWidth: 'none' } })
},
},
)
const router = useRouter()
useEffect(() => {
if (contractAddress.length > 0) {
void router.replace({ query: { contractAddress } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contractAddress])
useEffect(() => {
const initial = new URL(document.URL).searchParams.get('contractAddress')
if (initial && initial.length > 0) contractState.onChange(initial)
}, [])
const resolveRecipientAddress = async () => {
await resolveAddress(recipientState.value.trim(), wallet).then((resolvedAddress) => {
setResolvedRecipientAddress(resolvedAddress)
})
}
useEffect(() => {
void resolveRecipientAddress()
}, [recipientState.value])
return (
<section className="py-6 px-12 space-y-4">
<NextSeo title="Execute Open Edition Minter Contract" />
<ContractPageHeader
description="Open Edition Minter contract allows multiple copies of a single NFT to be minted in a specified time interval."
link={links.Documentation}
title="Open Edition Minter Contract"
/>
<LinkTabs activeIndex={1} data={openEditionMinterLinkTabs} />
<form className="grid grid-cols-2 p-4 space-x-8" onSubmit={mutate}>
<div className="space-y-8">
<AddressInput {...contractState} />
<ExecuteCombobox {...comboboxState} />
{showRecipientField && <AddressInput {...recipientState} />}
{showLimitField && <NumberInput {...limitState} />}
{showPriceField && <NumberInput {...priceState} />}
{/* TODO: Fix address execute message */}
<Conditional test={showDateField}>
<FormControl htmlId="start-date" subtitle="Start time for the minting" title="Start Time">
<InputDateTime minDate={new Date()} onChange={(date) => setTimestamp(date)} value={timestamp} />
</FormControl>
</Conditional>
<Conditional test={showEndDateField}>
<FormControl htmlId="end-date" subtitle="End time for the minting" title="End Time">
<InputDateTime minDate={new Date()} onChange={(date) => setEndTimestamp(date)} value={endTimestamp} />
</FormControl>
</Conditional>
</div>
<div className="space-y-8">
<div className="relative">
<Button className="absolute top-0 right-0" isLoading={isLoading} rightIcon={<FaArrowRight />} type="submit">
Execute
</Button>
<FormControl subtitle="View execution transaction hash" title="Transaction Hash">
<TransactionHash hash={lastTx} />
</FormControl>
</div>
<FormControl subtitle="View current message to be sent" title="Payload Preview">
<JsonPreview content={previewExecutePayload(payload)} isCopyable />
</FormControl>
</div>
</form>
</section>
)
}
export default withMetadata(OpenEditionMinterExecutePage, { center: false })

View File

@ -0,0 +1 @@
export { default } from './query'

View File

@ -0,0 +1,132 @@
import { Button } from 'components/Button'
import { ContractPageHeader } from 'components/ContractPageHeader'
import { useExecuteComboboxState } from 'components/contracts/openEditionMinter/ExecuteCombobox.hooks'
import { FormControl } from 'components/FormControl'
import { AddressInput, NumberInput } from 'components/forms/FormInput'
import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks'
import { JsonPreview } from 'components/JsonPreview'
import { LinkTabs } from 'components/LinkTabs'
import { openEditionMinterLinkTabs } from 'components/LinkTabs.data'
import { TransactionHash } from 'components/TransactionHash'
import { useContracts } from 'contexts/contracts'
import { useWallet } from 'contexts/wallet'
import type { MigrateResponse } from 'contracts/openEditionMinter'
import type { NextPage } from 'next'
import { useRouter } from 'next/router'
import { NextSeo } from 'next-seo'
import type { FormEvent } from 'react'
import { useEffect, useState } from 'react'
import { toast } from 'react-hot-toast'
import { FaArrowRight } from 'react-icons/fa'
import { useMutation } from 'react-query'
import { withMetadata } from 'utils/layout'
import { links } from 'utils/links'
const OpenEditionMinterMigratePage: NextPage = () => {
const { openEditionMinter: contract } = useContracts()
const wallet = useWallet()
const [lastTx, setLastTx] = useState('')
const comboboxState = useExecuteComboboxState()
const type = comboboxState.value?.id
const codeIdState = useNumberInputState({
id: 'code-id',
name: 'code-id',
title: 'Code ID',
subtitle: 'Code ID of the New Open Edition Minter',
placeholder: '1',
})
const contractState = useInputState({
id: 'contract-address',
name: 'contract-address',
title: 'Open Edition Minter Address',
subtitle: 'Address of the Open Edition Minter contract',
})
const contractAddress = contractState.value
const { data, isLoading, mutate } = useMutation(
async (event: FormEvent): Promise<MigrateResponse | null> => {
event.preventDefault()
if (!contract) {
throw new Error('Smart contract connection failed')
}
if (!wallet.initialized) {
throw new Error('Please connect your wallet.')
}
const migrateMsg = {}
return toast.promise(contract.migrate(contractAddress, codeIdState.value, migrateMsg), {
error: `Migration failed!`,
loading: 'Executing message...',
success: (tx) => {
if (tx) {
setLastTx(tx.transactionHash)
}
return `Transaction success!`
},
})
},
{
onError: (error) => {
toast.error(String(error), { style: { maxWidth: 'none' } })
},
},
)
const router = useRouter()
useEffect(() => {
if (contractAddress.length > 0) {
void router.replace({ query: { contractAddress } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contractAddress])
useEffect(() => {
const initial = new URL(document.URL).searchParams.get('contractAddress')
if (initial && initial.length > 0) contractState.onChange(initial)
}, [])
return (
<section className="py-6 px-12 space-y-4">
<NextSeo title="Migrate Open Edition Minter Contract" />
<ContractPageHeader
description="Open Edition Minter contract allows multiple copies of a single NFT to be minted in a specified time interval."
link={links.Documentation}
title="Open Edition Minter Contract"
/>
<LinkTabs activeIndex={2} data={openEditionMinterLinkTabs} />
<form className="grid grid-cols-2 p-4 space-x-8" onSubmit={mutate}>
<div className="space-y-8">
<AddressInput {...contractState} />
<NumberInput isRequired {...codeIdState} />
</div>
<div className="space-y-8">
<div className="relative">
<Button className="absolute top-0 right-0" isLoading={isLoading} rightIcon={<FaArrowRight />} type="submit">
Execute
</Button>
<FormControl subtitle="View execution transaction hash" title="Transaction Hash">
<TransactionHash hash={lastTx} />
</FormControl>
</div>
<FormControl subtitle="View current message to be sent" title="Payload Preview">
<JsonPreview
content={{
sender: wallet.address,
contract: contractAddress,
code_id: codeIdState.value,
msg: {},
}}
isCopyable
/>
</FormControl>
</div>
</form>
</section>
)
}
export default withMetadata(OpenEditionMinterMigratePage, { center: false })

View File

@ -0,0 +1,124 @@
import clsx from 'clsx'
import { Conditional } from 'components/Conditional'
import { ContractPageHeader } from 'components/ContractPageHeader'
import { FormControl } from 'components/FormControl'
import { AddressInput } from 'components/forms/FormInput'
import { useInputState } from 'components/forms/FormInput.hooks'
import { JsonPreview } from 'components/JsonPreview'
import { LinkTabs } from 'components/LinkTabs'
import { openEditionMinterLinkTabs } from 'components/LinkTabs.data'
import { useContracts } from 'contexts/contracts'
import { useWallet } from 'contexts/wallet'
import type { QueryType } from 'contracts/openEditionMinter/messages/query'
import { dispatchQuery, QUERY_LIST } from 'contracts/openEditionMinter/messages/query'
import type { NextPage } from 'next'
import { useRouter } from 'next/router'
import { NextSeo } from 'next-seo'
import { useEffect, useState } from 'react'
import { toast } from 'react-hot-toast'
import { useQuery } from 'react-query'
import { withMetadata } from 'utils/layout'
import { links } from 'utils/links'
import { resolveAddress } from 'utils/resolveAddress'
const OpenEditionMinterQueryPage: NextPage = () => {
const { openEditionMinter: contract } = useContracts()
const wallet = useWallet()
const contractState = useInputState({
id: 'contract-address',
name: 'contract-address',
title: 'Open Edition Minter Address',
subtitle: 'Address of the Open Edition Minter contract',
})
const contractAddress = contractState.value
const addressState = useInputState({
id: 'address',
name: 'address',
title: 'Address',
subtitle: 'Address of the user - defaults to current address',
})
const address = addressState.value
const [type, setType] = useState<QueryType>('config')
const { data: response } = useQuery(
[contractAddress, type, contract, wallet, address] as const,
async ({ queryKey }) => {
const [_contractAddress, _type, _contract, _wallet] = queryKey
const messages = contract?.use(_contractAddress)
const res = await resolveAddress(address, wallet).then(async (resolvedAddress) => {
const result = await dispatchQuery({
address: resolvedAddress,
messages,
type,
})
return result
})
return res
},
{
placeholderData: null,
onError: (error: any) => {
toast.error(error.message, { style: { maxWidth: 'none' } })
},
enabled: Boolean(contractAddress && contract && wallet),
},
)
const router = useRouter()
useEffect(() => {
if (contractAddress.length > 0) {
void router.replace({ query: { contractAddress } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contractAddress])
useEffect(() => {
const initial = new URL(document.URL).searchParams.get('contractAddress')
if (initial && initial.length > 0) contractState.onChange(initial)
}, [])
return (
<section className="py-6 px-12 space-y-4">
<NextSeo title="Query Open Edition Minter Contract" />
<ContractPageHeader
description="Open Edition Minter contract allows multiple copies of a single NFT to be minted in a specified time interval."
link={links.Documentation}
title="Open Edition Minter Contract"
/>
<LinkTabs activeIndex={0} data={openEditionMinterLinkTabs} />
<div className="grid grid-cols-2 p-4 space-x-8">
<div className="space-y-8">
<AddressInput {...contractState} />
<FormControl htmlId="contract-query-type" subtitle="Type of query to be dispatched" title="Query Type">
<select
className={clsx(
'bg-white/10 rounded border-2 border-white/20 form-select',
'placeholder:text-white/50',
'focus:ring focus:ring-plumbus-20',
)}
id="contract-query-type"
name="query-type"
onChange={(e) => setType(e.target.value as QueryType)}
>
{QUERY_LIST.map(({ id, name }) => (
<option key={`query-${id}`} className="mt-2 text-lg bg-[#1A1A1A]" value={id}>
{name}
</option>
))}
</select>
</FormControl>
<Conditional test={type === 'mint_count'}>
<AddressInput {...addressState} />
</Conditional>
</div>
<JsonPreview content={contractAddress ? { type, response } : null} title="Query Response" />
</div>
</section>
)
}
export default withMetadata(OpenEditionMinterQueryPage, { center: false })

View File

@ -118,7 +118,7 @@ const Sg721QueryPage: NextPage = () => {
onChange={(e) => setType(e.target.value as QueryType)} onChange={(e) => setType(e.target.value as QueryType)}
> >
{QUERY_LIST.map(({ id, name }) => ( {QUERY_LIST.map(({ id, name }) => (
<option key={`query-${id}`} value={id}> <option key={`query-${id}`} className="mt-2 text-lg bg-[#1A1A1A]" value={id}>
{name} {name}
</option> </option>
))} ))}

View File

@ -105,7 +105,7 @@ const VendingMinterQueryPage: NextPage = () => {
onChange={(e) => setType(e.target.value as QueryType)} onChange={(e) => setType(e.target.value as QueryType)}
> >
{QUERY_LIST.map(({ id, name }) => ( {QUERY_LIST.map(({ id, name }) => (
<option key={`query-${id}`} value={id}> <option key={`query-${id}`} className="mt-2 text-lg bg-[#1A1A1A]" value={id}>
{name} {name}
</option> </option>
))} ))}

View File

@ -9,6 +9,9 @@ export const VENDING_FACTORY_UPDATABLE_ADDRESS = process.env.NEXT_PUBLIC_VENDING
export const VENDING_FACTORY_FLEX_ADDRESS = process.env.NEXT_PUBLIC_VENDING_FACTORY_FLEX_ADDRESS export const VENDING_FACTORY_FLEX_ADDRESS = process.env.NEXT_PUBLIC_VENDING_FACTORY_FLEX_ADDRESS
export const BASE_FACTORY_ADDRESS = process.env.NEXT_PUBLIC_BASE_FACTORY_ADDRESS export const BASE_FACTORY_ADDRESS = process.env.NEXT_PUBLIC_BASE_FACTORY_ADDRESS
export const BASE_FACTORY_UPDATABLE_ADDRESS = process.env.NEXT_PUBLIC_BASE_FACTORY_UPDATABLE_ADDRESS export const BASE_FACTORY_UPDATABLE_ADDRESS = process.env.NEXT_PUBLIC_BASE_FACTORY_UPDATABLE_ADDRESS
export const OPEN_EDITION_FACTORY_ADDRESS = process.env.NEXT_PUBLIC_OPEN_EDITION_FACTORY_ADDRESS
export const OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS = process.env.NEXT_PUBLIC_OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS
export const OPEN_EDITION_MINTER_CODE_ID = parseInt(process.env.NEXT_PUBLIC_OPEN_EDITION_MINTER_CODE_ID, 10)
export const SG721_NAME_ADDRESS = process.env.NEXT_PUBLIC_SG721_NAME_ADDRESS export const SG721_NAME_ADDRESS = process.env.NEXT_PUBLIC_SG721_NAME_ADDRESS
export const BASE_MINTER_CODE_ID = parseInt(process.env.NEXT_PUBLIC_VENDING_MINTER_CODE_ID, 10) export const BASE_MINTER_CODE_ID = parseInt(process.env.NEXT_PUBLIC_VENDING_MINTER_CODE_ID, 10)
export const BADGE_HUB_CODE_ID = parseInt(process.env.NEXT_PUBLIC_BADGE_HUB_CODE_ID, 10) export const BADGE_HUB_CODE_ID = parseInt(process.env.NEXT_PUBLIC_BADGE_HUB_CODE_ID, 10)