diff --git a/next.config.js b/next.config.js index 2562d36e3..9087c1113 100644 --- a/next.config.js +++ b/next.config.js @@ -4,7 +4,7 @@ const nextConfig = { compiler: { styledComponents: true }, reactStrictMode: true, images: { - domains: ['ipfs.near.social','ipfs.io'], + domains: ['ipfs.near.social'], }, experimental: { optimizePackageImports: ['@phosphor-icons/react'], diff --git a/src/assets/images/near-icon.svg b/src/assets/images/near-icon.svg new file mode 100644 index 000000000..ed33db6be --- /dev/null +++ b/src/assets/images/near-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/token_default.svg b/src/assets/images/token_default.svg new file mode 100644 index 000000000..dba1e1907 --- /dev/null +++ b/src/assets/images/token_default.svg @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/src/components/NTFImage.tsx b/src/components/NTFImage.tsx index fa571a236..19ddd9d24 100644 --- a/src/components/NTFImage.tsx +++ b/src/components/NTFImage.tsx @@ -1,76 +1,38 @@ -import Image from 'next/image'; -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useContext } from 'react'; -import styled from 'styled-components'; -import { NearContext } from './wallet-selector/WalletSelector'; - -const RoundedImage = styled(Image)` - border-radius: 50%; -`; +import type { NFT } from '@/utils/types'; -interface Nft { - contractId: string; - tokenId: string; -} +import RoundedImage from './RoundedImage'; +import { NearContext } from './wallet-selector/WalletSelector'; interface NftImageProps { - nft?: Nft; - ipfs_cid?: string; - alt: string; + nft?: NFT; } -const DEFAULT_IMAGE = 'https://ipfs.near.social/ipfs/bafkreibmiy4ozblcgv3fm3gc6q62s55em33vconbavfd2ekkuliznaq3zm'; - -const getImage = (key: string) => { - const imgUrl = localStorage.getItem(`keysImage:${key}`); - return imgUrl || null; -}; - -const setImage = (key: string, url: string) => { - localStorage.setItem(`keysImage:${key}`, url); -}; - -export const NftImage: React.FC = ({ nft, ipfs_cid, alt }) => { +export const NftImage: React.FC = ({ nft }) => { const { wallet } = useContext(NearContext); - const [imageUrl, setImageUrl] = useState(DEFAULT_IMAGE); - - const fetchNftData = useCallback(async () => { - if (!wallet || !nft || !nft.contractId || !nft.tokenId || ipfs_cid) return; - - const imgCache = getImage(nft.tokenId); - if (imgCache) { - setImageUrl(imgCache); - return; - } - const [nftMetadata, tokenData] = await Promise.all([ - wallet.viewMethod({ contractId: nft.contractId, method: 'nft_metadata' }), - wallet.viewMethod({ contractId: nft.contractId, method: 'nft_token', args: { token_id: nft.tokenId } }), - ]); - - const tokenMedia = tokenData?.metadata?.media || ''; - - if (tokenMedia.startsWith('https://') || tokenMedia.startsWith('http://') || tokenMedia.startsWith('data:image')) { - setImageUrl(tokenMedia); - } else if (nftMetadata?.base_uri) { - setImageUrl(`${nftMetadata.base_uri}/${tokenMedia}`); - } else if (tokenMedia.startsWith('Qm') || tokenMedia.startsWith('ba')) { - setImageUrl(`https://ipfs.near.social/ipfs/${tokenMedia}`); - } - }, [wallet, nft, ipfs_cid]); - - useEffect(() => { - if (ipfs_cid) { - setImageUrl(`https://ipfs.near.social/ipfs/${ipfs_cid}`); - } else { - fetchNftData(); - } - }, [ipfs_cid, fetchNftData]); + const [imageUrl, setImageUrl] = useState(''); useEffect(() => { - if (!wallet || !nft || !nft.contractId || !nft.tokenId || ipfs_cid || DEFAULT_IMAGE === imageUrl) return; - setImage(nft.tokenId, imageUrl); - }, [imageUrl, wallet, nft, ipfs_cid]); - - return ; + const fetchNftData = async () => { + if (!wallet || !nft || !nft.token_id) return; + + const tokenMedia = nft.metadata?.media || ''; + + if (tokenMedia.startsWith('https://') || tokenMedia.startsWith('http://')) { + setImageUrl(tokenMedia); + } else if (tokenMedia.startsWith('data:image')) { + setImageUrl(tokenMedia); + } else if (nft.metadata?.base_uri) { + setImageUrl(`${nft.metadata.base_uri}/${tokenMedia}`); + } else if (tokenMedia.startsWith('Qm') || tokenMedia.startsWith('ba')) { + setImageUrl(`https://ipfs.near.social/ipfs/${tokenMedia}`); + } + }; + + fetchNftData(); + }, [nft, imageUrl, wallet]); + + return ; }; diff --git a/src/components/RoundedImage.tsx b/src/components/RoundedImage.tsx new file mode 100644 index 000000000..fc5148be6 --- /dev/null +++ b/src/components/RoundedImage.tsx @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; + +export const Img = styled.img` + border-radius: 50%; + overflow: hidden; + object-fit: cover; +`; + +export const DEFAULT_IMAGE = + 'https://ipfs.near.social/ipfs/bafkreibmiy4ozblcgv3fm3gc6q62s55em33vconbavfd2ekkuliznaq3zm'; + +const RoundedImage = ({ src, alt }: { src: string; alt: string }) => { + const [imageUrl, setImageUrl] = useState(src); + + useEffect(() => { + setImageUrl(src); + }, [src]); + + const handleError = useCallback(() => { + setImageUrl(DEFAULT_IMAGE); + }, []); + + return {alt}; +}; + +export default RoundedImage; diff --git a/src/components/sidebar-navigation/UserDropdownMenu.tsx b/src/components/sidebar-navigation/UserDropdownMenu.tsx index df869b0d5..160cd50e1 100644 --- a/src/components/sidebar-navigation/UserDropdownMenu.tsx +++ b/src/components/sidebar-navigation/UserDropdownMenu.tsx @@ -7,8 +7,10 @@ import styled from 'styled-components'; import { signInContractId } from '@/config'; import { useBosComponents } from '@/hooks/useBosComponents'; +import type { NFT } from '@/utils/types'; import { NftImage } from '../NTFImage'; +import RoundedImage from '../RoundedImage'; import { NearContext } from '../wallet-selector/WalletSelector'; const Wrapper = styled.div` @@ -105,16 +107,29 @@ export const UserDropdownMenu = ({ collapsed }: Props) => { }, [wallet]); const [profile, setProfile] = useState({}); + const [nftProfile, setNftProfile] = useState(null); useEffect(() => { async function getProfile() { - const profile = await wallet?.viewMethod({ - contractId: 'social.near', + const socialProfile = await wallet?.viewMethod({ + contractId: signInContractId, method: 'get', args: { keys: [`${signedAccountId}/profile/**`] }, }); - if (!profile[signedAccountId]) return; - setProfile(profile[signedAccountId].profile); + if (!socialProfile[signedAccountId]) return; + const profile = socialProfile[signedAccountId].profile; + setProfile(profile); + + try { + if (profile.image.nft) { + const nft = await wallet?.viewMethod({ + contractId: profile.image.nft.contractId, + method: 'nft_token', + args: { token_id: profile.image.nft.tokenId }, + }); + setNftProfile(nft); + } + } catch (e) {} } async function getAvailableStorage() { @@ -140,11 +155,14 @@ export const UserDropdownMenu = ({ collapsed }: Props) => { ) : ( - + {nftProfile ? ( + + ) : ( + + )}
{profile.name}
{signedAccountId}
diff --git a/src/components/tools/FungibleToken/CreateTokenForm.tsx b/src/components/tools/FungibleToken/CreateTokenForm.tsx index 4f1729e5c..238c2f9ed 100644 --- a/src/components/tools/FungibleToken/CreateTokenForm.tsx +++ b/src/components/tools/FungibleToken/CreateTokenForm.tsx @@ -1,9 +1,10 @@ import { Button, FileInput, Flex, Form, Grid, Input, openToast, Text } from '@near-pagoda/ui'; -import React, { useContext } from 'react'; -import type { SubmitHandler } from 'react-hook-form'; +import { formatNearAmount } from 'near-api-js/lib/utils/format'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { NearContext } from '@/components/wallet-selector/WalletSelector'; +import { network } from '@/config'; type FormData = { total_supply: string; @@ -13,123 +14,172 @@ type FormData = { decimals: number; }; -const FACTORY_CONTRACT = 'tkn.primitives.near'; +const FACTORY_CONTRACT = network.ftContract; const MAX_FILE_SIZE = 10 * 1024; const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']; -const CreateTokenForm: React.FC = () => { +const validateImage = (files: FileList) => { + if (files.length === 0) return 'Image is required'; + const file = files[0]; + if (file.size > MAX_FILE_SIZE) return 'Image size should be less than 10KB'; + if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) return 'Not a valid image format'; + return true; +}; + +const convertToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result as string); + reader.onerror = (error) => reject(error); + }); +}; + +const CreateTokenForm = ({ reload }: { reload: (delay: number) => void }) => { const { control, register, handleSubmit, + watch, formState: { errors, isSubmitting }, } = useForm(); const { wallet, signedAccountId } = useContext(NearContext); + const [requiredDeposit, setRequiredDeposit] = useState('0'); + + const symbolAvailable = useCallback( + async (symbol: string) => { + try { + await wallet?.getBalance(`${symbol}.${FACTORY_CONTRACT}`); + return `${symbol}.${FACTORY_CONTRACT} already exists`; + } catch { + return true; + } + }, + [wallet], + ); - const validateImage = (files: FileList) => { - if (files.length === 0) return 'Image is required'; - const file = files[0]; - if (file.size > MAX_FILE_SIZE) return 'Image size should be less than 10KB'; - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) return 'Not a valid image format'; - return true; - }; - - const convertToBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => resolve(reader.result as string); - reader.onerror = (error) => reject(error); - }); - }; - - const onSubmit: SubmitHandler = async (data) => { - let base64Image = ''; - if (data.icon[0]) { - base64Image = await convertToBase64(data.icon[0]); - } - - const total_supply = BigInt(data.total_supply) * BigInt(Math.pow(10, Number(data.decimals))); - - const args = { - args: { - owner_id: signedAccountId, - total_supply: total_supply.toString(), - metadata: { - spec: 'ft-1.0.0', - name: data.name, - symbol: data.symbol, - icon: base64Image, - decimals: data.decimals, - }, - }, - account_id: signedAccountId, - }; - - const requiredDeposit = await wallet?.viewMethod({ contractId: FACTORY_CONTRACT, method: 'get_required', args }); - - try { - const result = await wallet?.signAndSendTransactions({ - transactions: [ - { - receiverId: FACTORY_CONTRACT, - actions: [ - { - type: 'FunctionCall', - params: { - methodName: 'create_token', - args, - gas: '300000000000000', - deposit: requiredDeposit, - }, - }, - ], + // Watch all form fields + const formData = watch(); + const onSubmit = useCallback( + async ({ total_supply, decimals, icon, name, symbol }: FormData, actuallySubmit: boolean) => { + if (!signedAccountId) return; + + total_supply = total_supply || '0'; + decimals = decimals || 0; + name = name || ''; + symbol = symbol || ''; + icon = icon || [false]; + + const base64Image = icon[0] ? await convertToBase64(icon[0]) : ''; + + const supply = BigInt(total_supply) * BigInt(Math.pow(10, Number(decimals))); + + const args = { + args: { + owner_id: signedAccountId, + total_supply: supply.toString(), + metadata: { + spec: 'ft-1.0.0', + name, + symbol, + icon: base64Image, + decimals, }, - ], - }); + }, + account_id: signedAccountId, + }; + + const deposit = await wallet?.viewMethod({ contractId: FACTORY_CONTRACT, method: 'get_required', args }); + + setRequiredDeposit(formatNearAmount(deposit, 2)); + + if (!actuallySubmit) return; + + let result = false; + + try { + result = await wallet?.callMethod({ + contractId: FACTORY_CONTRACT, + method: 'create_token', + args, + gas: '300000000000000', + deposit: deposit, + }); + } catch (error) {} if (result) { - const transactionId = result[0].transaction_outcome.id; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - window.open(`https://nearblocks.io/txns/${transactionId}`, '_blank')!.focus(); + openToast({ + type: 'success', + title: 'Token Created', + description: `Token ${name} (${symbol}) created successfully`, + duration: 5000, + }); + reload(5000); + } else { + openToast({ + type: 'error', + title: 'Error', + description: 'Failed to create token', + duration: 5000, + }); } + }, + [wallet, signedAccountId, reload], + ); - openToast({ - type: 'success', - title: 'Token Created', - description: `Token ${data.name} (${data.symbol}) created successfully`, - duration: 5000, - }); - } catch (error) { - openToast({ - type: 'error', - title: 'Error', - description: 'Failed to create token', - duration: 5000, - }); - } - }; + useEffect(() => { + onSubmit(formData, false); + }, [onSubmit, formData]); return ( <> - - Mint a Fungible Token + Mint a Fungible Token + + This tool allows you to deploy your own NEP-141 smart contract (Fungible Tokens) -
- + + onSubmit(data, true))}> + + + + ( + + )} + /> + { min: { value: 0, message: 'Decimals must be non-negative' }, max: { value: 24, message: 'Decimals must be 24 or less' }, })} - /> - - - -
@@ -172,6 +209,7 @@ const CreateTokenForm: React.FC = () => { const files = value; field.onChange(files); }} + disabled={!signedAccountId} /> )} /> @@ -180,7 +218,13 @@ const CreateTokenForm: React.FC = () => {
-