components/create/CreateNft.vue
<template>
<div
class="lg:py-[4.5rem] flex flex-col md:flex-row justify-center gap-3 lg:bg-k-primary-light"
>
<SigningModal
v-if="!autoTeleport"
:is-loading="isLoading"
:title="$t('mint.nft.minting')"
:status="status"
@try-again="createNft"
/>
<MintConfirmModal
v-model="modalShowStatus"
:auto-teleport-actions="autoTeleportActions"
:nft-information="nftInformation"
@confirm="confirm"
/>
<form
class="px-[1.2rem] md:px-8 lg:px-16 py-[3.1rem] sm:py-16 w-full sm:w-1/2 max-w-[40rem] shadow-none lg:shadow-primary lg:border-[1px] lg:border-border-color lg:bg-background-color"
@submit.prevent="submitHandler"
>
<CreateNftPreview
:name="form.name"
:collection="selectedCollection?.name"
:price="form.salePrice"
:symbol="chainSymbol"
:chain="currentChain"
:image="imagePreview"
data-testid="create-nft-preview-box"
/>
<h1 class="title text-3xl mb-7">
{{ $t('mint.nft.create') }}
</h1>
<!-- nft art -->
<NeoField
:label="`${$t('mint.nft.art.label')} *`"
:addons="false"
>
<div>
<p>{{ $t('mint.nft.art.message') }}</p>
<DropUpload
v-model="form.file"
required
expanded
preview
:label="$t('mint.nft.drop')"
/>
</div>
</NeoField>
<!-- nft name -->
<NeoField
:label="`${$t('mint.nft.name.label')} *`"
required
:error="!form.name"
>
<NeoInput
v-model="form.name"
data-testid="create-nft-input-name"
required
:placeholder="$t('mint.nft.name.placeholder')"
/>
</NeoField>
<!-- nft description -->
<NeoField :label="`${$t('mint.nft.description.label')} (optional)`">
<NeoInput
v-model="form.description"
data-testid="create-nft-input-description"
type="textarea"
has-counter
maxlength="1000"
height="10rem"
:placeholder="$t('mint.nft.description.placeholder')"
/>
</NeoField>
<!-- select blockchain -->
<NeoField :label="`${$t('mint.blockchain.label')} *`">
<div class="w-full">
<p>{{ $t('mint.blockchain.message') }}</p>
<NeoSelect
v-model="selectChain"
class="mt-3"
data-testid="create-nft-dropdown-select"
expanded
required
>
<option
v-for="menu in menus"
:key="menu.value"
:value="menu.value"
:data-testid="`nft-chain-dropdown-option-${menu.value}`"
>
{{ menu.text }}
</option>
</NeoSelect>
</div>
</NeoField>
<!-- list for sale -->
<NeoField
:key="currentChain"
:label="$t('mint.nft.sale.label')"
required
>
<div class="w-full">
<p>{{ $t('mint.nft.sale.message') }}</p>
</div>
<NeoSwitch
v-model="form.sale"
data-testid="create-nft-sale-switch"
/>
</NeoField>
<!-- list for sale price -->
<NeoField
v-if="form.sale"
required
:error="!form.salePrice"
:label="`${$t('price')} *`"
>
<div class="w-full">
<div class="flex justify-between items-center relative">
<NeoInput
v-model="form.salePrice"
data-testid="create-nft-input-list-value"
type="number"
step="0.01"
min="0.01"
pattern="[0-9]+([\.,][0-9]+)?"
placeholder="0.01 is the minimum"
expanded
/>
<div class="position-absolute-right text-xs text-k-grey">
~{{ salePriceUsd }} usd
</div>
<div class="form-addons">
{{ chainSymbol }}
</div>
</div>
</div>
</NeoField>
<!-- select collections -->
<NeoField
ref="chooseCollectionRef"
:label="`${$t('mint.nft.collection.label')} *`"
@click="startSelectedCollection = true"
>
<div class="w-full">
<p
:class="{
'text-k-red': startSelectedCollection && !selectedCollection,
}"
>
{{ $t('mint.nft.collection.message') }}
</p>
<ChooseCollectionDropdown
full-width
no-shadow
class="mt-3"
:preselected="preselectedCollectionId"
@selected-collection="onCollectionSelected"
/>
</div>
</NeoField>
<!-- no of copies -->
<NeoField :label="`${$t('mint.nft.copies.label')} (optional)`">
<div class="w-full">
<p>{{ $t('mint.nft.copies.message') }}</p>
<NeoInput
v-model="form.copies"
data-testid="create-nft-input-copies"
class="mt-3"
type="number"
placeholder="e.g 10"
min="1"
expanded
/>
<BasicSwitch
v-if="form.copies > 1"
v-model="form.postfix"
data-testid="create-nft-input-copies-switch"
class="mt-3"
label="mint.expert.postfix"
/>
</div>
</NeoField>
<!-- nft properties -->
<NeoField :label="`${$t('tabs.properties')} (optional)`">
<CustomAttributeInput
v-model="form.tags"
:max="10"
data-testid="create-nft-properties"
/>
</NeoField>
<!-- royalty -->
<NeoField>
<RoyaltyForm
v-model:amount="form.royalty.amount"
v-model:address="form.royalty.address"
data-testid="create-nft-royalty"
/>
</NeoField>
<!-- explicit content -->
<NeoField :label="`${$t('mint.nfsw')}`">
<div class="w-full">
<p>{{ $t('mint.nfswMessage') }}</p>
</div>
<NeoSwitch
v-model="form.nsfw"
data-testid="create-nft-nsfw-switch"
/>
</NeoField>
<hr class="my-6">
<!-- deposit and balance -->
<div>
<div class="flex font-medium text-k-blue hover:text-k-blue-hover">
<div>{{ $t('mint.deposit') }}: </div>
<div>
<span data-testid="create-nft-deposit-amount-token">
{{ deposit }} {{ chainSymbol }}
</span>
<span
class="text-xs text-k-grey ml-2"
data-testid="create-nft-deposit-amount-usd"
>
{{ depositUsd }} usd
</span>
</div>
</div>
<div class="flex">
<div>{{ $t('general.balance') }}: </div>
<div>
<span>{{ balance }} {{ chainSymbol }}</span>
<span class="text-xs text-k-grey ml-2"> {{ balanceUsd }} usd </span>
</div>
</div>
</div>
<hr class="my-6">
<!-- create nft button -->
<NeoButton
expanded
:label="$t('mint.nft.create')"
data-testid="create-nft-button-new"
class="text-base"
native-type="submit"
size="medium"
:loading="isLoading"
/>
<div class="p-4 flex">
<NeoIcon
icon="circle-info"
size="medium"
class="mr-4"
/>
<p class="text-xs">
<span
v-dompurify-html="
$t('mint.requiredDeposit', [`${deposit} ${chainSymbol}`, 'NFT'])
"
/>
<a
href="https://hello.kodadot.xyz/multi-chain/fees"
target="_blank"
class="text-k-blue hover:text-k-blue-hover"
data-testid="create-nft-learn-more-link"
rel="nofollow noopener noreferrer"
>
{{ $t('helper.learnMore') }}
</a>
</p>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import type { Prefix } from '@kodadot1/static'
import type { Ref } from 'vue'
import {
NeoButton,
NeoField,
NeoIcon,
NeoInput,
NeoSelect,
NeoSwitch,
} from '@kodadot1/brick'
import { toNFTId } from '@kodadot1/minimark/v2'
import type { CreatedNFT } from '@kodadot1/minimark/v1'
import { Interaction } from '@kodadot1/minimark/v1'
import CreateNftPreview from './CreateNftPreview.vue'
import type { ActionMintToken, ActionList, TokenToList } from '@/composables/transaction/types'
import ChooseCollectionDropdown from '@/components/common/ChooseCollectionDropdown.vue'
import BasicSwitch from '@/components/shared/form/BasicSwitch.vue'
import CustomAttributeInput from '@/components/rmrk/Create/CustomAttributeInput.vue'
import RoyaltyForm from '@/components/bsx/Create/RoyaltyForm.vue'
import MintConfirmModal from '@/components/create/Confirm/MintConfirmModal.vue'
import resolveQueryPath from '@/utils/queryPathResolver'
import { availablePrefixes } from '@/utils/chain'
import { balanceFrom } from '@/utils/balance'
import { DETAIL_TIMEOUT } from '@/utils/constants'
import { delay } from '@/utils/fetch'
import type { AutoTeleportAction } from '@/composables/autoTeleport/types'
import type { AutoTeleportActionButtonConfirmEvent } from '@/components/common/autoTeleport/AutoTeleportActionButton.vue'
// composables
const { $consola } = useNuxtApp()
const { urlPrefix, setUrlPrefix } = usePrefix()
const { accountId } = useAuth()
const { transaction, status, isLoading, blockNumber, isError }
= useTransaction()
const router = useRouter()
const { decimals } = useChain()
const { toUsdPrice } = useUsdValue()
// form state
const form = reactive({
file: null,
name: '',
description: '',
collections: null,
sale: false,
salePrice: 0,
copies: 1,
postfix: false,
nsfw: false,
tags: [],
royalty: {
amount: 0,
address: accountId.value,
},
})
// select collections
const { selectedCollection, preselectedCollectionId, onCollectionSelected }
= useCollectionDropdown()
const startSelectedCollection = ref<boolean>(false)
const chooseCollectionRef = ref()
const modalShowStatus = ref(false)
const nftInformation = computed(() => ({
file: form.file,
name: form.name,
selectedCollection: selectedCollection.value,
price: balanceFrom(form.salePrice, decimals.value),
listForSale: form.sale,
paidToken: chain.value,
mintType: CreateComponent.NFT,
}))
const imagePreview = computed(() => {
if (form.file) {
return URL?.createObjectURL(form.file)
}
return null
})
// select available blockchain
const menus = availablePrefixes().filter(
(menu) => {
const { isEvm } = useIsChain(computed(() => menu.value as Prefix))
return !isEvm.value
})
const chainByPrefix = computed(() =>
menus.find(menu => menu.value === urlPrefix.value),
)
const selectChain = ref(chainByPrefix.value?.value || menus[0].value)
watch(urlPrefix, (value) => {
selectChain.value = value
})
// get/set current chain/prefix
const currentChain = computed(() => selectChain.value as Prefix)
watch(currentChain, () => {
// reset some state on chain change
form.salePrice = 0
form.royalty.amount = 0
if (currentChain.value !== urlPrefix.value) {
setUrlPrefix(currentChain.value as Prefix)
}
})
// deposit stuff
const { balance, totalItemDeposit, chainSymbol, chain }
= useDeposit(currentChain)
const deposit = computed(() =>
(Number(totalItemDeposit.value) * form.copies).toFixed(4),
)
// usd value
// when left undefined urlPrefix will be used
// TODO: evaluate better
const tokenType = computed(() => undefined)
const calculateUsdValue = (amount) => {
// remove comma from amount - required because bsx balance is formatted string
const parsedAmount = parseFloat(amount?.replace(/,/g, '') || '0')
return toUsdPrice(parsedAmount, tokenType.value)
}
const salePriceUsd = computed(() => toUsdPrice(form.salePrice, tokenType.value))
const depositUsd = computed(() => calculateUsdValue(deposit.value))
const balanceUsd = computed(() => calculateUsdValue(balance.value))
// create nft
const transactionStatus = ref<
'list' | 'checkListed' | 'mint' | 'done' | 'idle'
>('idle')
const createdItems = ref()
const mintedBlockNumber = ref()
const mintAction = computed<ActionMintToken>(() => ({
interaction: Interaction.MINTNFT,
urlPrefix: currentChain.value,
token: {
file: form.file,
name: form.name,
description: form.description,
selectedCollection: selectedCollection.value || null,
copies: form.copies,
nsfw: form.nsfw,
postfix: form.postfix,
price: balanceFrom(form.salePrice, decimals.value),
tags: form.tags,
secondFile: null,
hasRoyalty: Boolean(form.royalty.amount),
royalty: form.royalty,
},
}))
const listAction = computed<ActionList>(() => {
const list: TokenToList[] = createdItems.value?.map(nft => ({
price: balanceFrom(form.salePrice, decimals.value),
nftId: toNFTId(nft, String(blockNumber.value)),
}))
return {
interaction: Interaction.LIST,
urlPrefix: currentChain.value,
token: list,
successMessage: `[💰] Listed ${form.name} for ${form.salePrice} ${chainSymbol.value}`,
}
})
const submitHandler = () => {
startSelectedCollection.value = true
if (selectedCollection.value) {
toggleConfirm()
}
else {
;(chooseCollectionRef.value?.$el as HTMLElement)?.scrollIntoView({
block: 'center',
})
}
}
const toggleConfirm = () => {
modalShowStatus.value = !modalShowStatus.value
}
const confirm = async ({
autoteleport,
}: AutoTeleportActionButtonConfirmEvent) => {
toggleConfirm()
autoTeleport.value = autoteleport
if (!autoteleport) {
await createNft()
}
}
const createNft = async () => {
try {
(await transaction(
mintAction.value,
currentChain.value,
)) as unknown as {
createdNFTs?: Ref<CreatedNFT[]>
}
transactionStatus.value = 'mint'
}
catch (error) {
warningMessage(`${error}`)
$consola.error(error)
}
}
// autoteleport stuff
const autoTeleport = ref(false)
const {
transaction: listTransaction,
status: listStatus,
} = useTransaction()
const autoTeleportActions = computed<AutoTeleportAction[]>(() => {
const actions = [
{
action: mintAction.value,
handler: createNft,
prefix: currentChain.value,
details: {
isLoading: isLoading.value,
isError: isError.value,
status: status.value,
blockNumber: blockNumber.value,
},
},
]
return actions
})
// currently, on rmrk we need to list price manually
const listNft = async () => {
try {
await listTransaction(listAction.value, currentChain.value)
transactionStatus.value = 'checkListed'
}
catch (error) {
warningMessage(`${error}`)
$consola.error(error)
}
}
watchEffect(async () => {
if (
blockNumber.value
&& createdItems.value
&& transactionStatus.value === 'list'
) {
await listNft()
}
})
watchEffect(() => {
const listStatusFinalized = listStatus.value === 'loader.finalized'
const mintStatusFinalized = status.value === 'loader.finalized'
// prepare nft blockNumber for redirect to detail page
if (
(transactionStatus.value === 'mint'
|| transactionStatus.value === 'list')
&& mintStatusFinalized
&& blockNumber.value
) {
mintedBlockNumber.value = blockNumber.value
transactionStatus.value = 'done'
}
// if listing price is done, then redirect to detail page
if (transactionStatus.value === 'checkListed' && listStatusFinalized) {
transactionStatus.value = 'done'
}
})
// navigate to gallery detail page after success create nft
const retry = ref(10) // max retry 10 times
type NftId = {
nftEntities?: {
id: string
}[]
}
async function getNftId() {
const query = await resolveQueryPath(currentChain.value, 'nftByBlockNumber')
const { data }: { data: Ref<NftId> } = await useAsyncQuery({
query: query.default,
clientId: currentChain.value,
variables: {
limit: 1,
blockNumber: mintedBlockNumber.value,
},
})
return data.value.nftEntities?.[0]?.id
}
watchEffect(async () => {
if (
mintedBlockNumber.value
&& retry.value
&& transactionStatus.value === 'done'
) {
infoMessage(
`You will go to the detail in ${DETAIL_TIMEOUT / 1000} seconds`,
{ duration: DETAIL_TIMEOUT },
)
await delay(DETAIL_TIMEOUT)
const nftId = await getNftId()
if (nftId) {
router.push({
path: `/${urlPrefix.value}/gallery/${nftId}`,
query: { congratsNft: form.name },
})
}
else {
retry.value -= 1
}
}
})
</script>
<style lang="scss" scoped src="@/assets/styles/pages/create.scss"></style>
<style lang="scss" scoped>
.position-absolute-right {
position: absolute;
right: 6rem;
}
</style>