diff --git a/packages/adena-extension/src/assets/icon-empty-image.svg b/packages/adena-extension/src/assets/icon-empty-image.svg new file mode 100644 index 000000000..94cd4816d --- /dev/null +++ b/packages/adena-extension/src/assets/icon-empty-image.svg @@ -0,0 +1,28 @@ + + + + + + diff --git a/packages/adena-extension/src/assets/icon-hide.tsx b/packages/adena-extension/src/assets/icon-hide.tsx new file mode 100644 index 000000000..1f2cfaeac --- /dev/null +++ b/packages/adena-extension/src/assets/icon-hide.tsx @@ -0,0 +1,27 @@ +/* eslint-disable react/no-unknown-property */ +import React from 'react'; + +const IconHide = ({ className }: { className?: string }): JSX.Element => ( + + + + + + + + + + +); + +export default IconHide; diff --git a/packages/adena-extension/src/assets/icon-pin.tsx b/packages/adena-extension/src/assets/icon-pin.tsx new file mode 100644 index 000000000..92cf0a9bf --- /dev/null +++ b/packages/adena-extension/src/assets/icon-pin.tsx @@ -0,0 +1,22 @@ +/* eslint-disable react/no-unknown-property */ +import React from 'react'; + +const IconPin = ({ className }: { className?: string }): JSX.Element => ( + + + +); + +export default IconPin; diff --git a/packages/adena-extension/src/assets/icon-show.tsx b/packages/adena-extension/src/assets/icon-show.tsx new file mode 100644 index 000000000..9fe5accc8 --- /dev/null +++ b/packages/adena-extension/src/assets/icon-show.tsx @@ -0,0 +1,31 @@ +/* eslint-disable react/no-unknown-property */ +import React from 'react'; + +const IconShow = ({ className }: { className?: string }): JSX.Element => ( + + + + + + + + + + + +); + +export default IconShow; diff --git a/packages/adena-extension/src/assets/icon-unpin.tsx b/packages/adena-extension/src/assets/icon-unpin.tsx new file mode 100644 index 000000000..316cef1a4 --- /dev/null +++ b/packages/adena-extension/src/assets/icon-unpin.tsx @@ -0,0 +1,35 @@ +/* eslint-disable react/no-unknown-property */ +import React from 'react'; + +const IconUnpin = ({ className }: { className?: string }): JSX.Element => ( + + + + + + + + + + + + +); + +export default IconUnpin; diff --git a/packages/adena-extension/src/common/constants/tx.constant.ts b/packages/adena-extension/src/common/constants/tx.constant.ts index 15821da05..3a62df652 100644 --- a/packages/adena-extension/src/common/constants/tx.constant.ts +++ b/packages/adena-extension/src/common/constants/tx.constant.ts @@ -1,3 +1,5 @@ export const DEFAULT_GAS_WANTED = 10_000_000; +export const DEFAULT_NETWORK_FEE = 1; + export const TRANSACTION_MESSAGE_SEND_OF_REGISTER = '200000000ugnot'; diff --git a/packages/adena-extension/src/common/provider/gno/gno-provider.ts b/packages/adena-extension/src/common/provider/gno/gno-provider.ts index 9112ab104..386d79b6d 100644 --- a/packages/adena-extension/src/common/provider/gno/gno-provider.ts +++ b/packages/adena-extension/src/common/provider/gno/gno-provider.ts @@ -121,11 +121,21 @@ export class GnoProvider extends GnoJSONRPCProvider { return this.evaluateExpression(packagePath, expression) .then((result) => { - const parseData = result.replace('(', '').replace(')', '').split(' '); - if (parseData.length === 0) { - return null; + const regex = /\((?:"((?:\\.|[^"\\])*)"|(\S+))\s+\w+\)/g; + const matches = result.matchAll(regex); + + for (const match of matches) { + if (match?.[1] !== undefined) { + const unescaped = match[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + return unescaped; + } + + if (match?.[2] !== undefined) { + return `${match[2]}`; + } } - return parseData[0]; + + return null; }) .catch(() => null); } diff --git a/packages/adena-extension/src/common/storage/chrome-local-storage.ts b/packages/adena-extension/src/common/storage/chrome-local-storage.ts index 7ea55266d..1cfd62d8d 100644 --- a/packages/adena-extension/src/common/storage/chrome-local-storage.ts +++ b/packages/adena-extension/src/common/storage/chrome-local-storage.ts @@ -1,6 +1,6 @@ -import { Storage } from '.'; -import { StorageMigrator, StorageModelLatest } from '@migrates/storage-migrator'; import { CommonError } from '@common/errors/common'; +import { StorageMigrator, StorageModelLatest } from '@migrates/storage-migrator'; +import { Storage } from '.'; type StorageKeyTypes = | 'NETWORKS' @@ -15,7 +15,9 @@ type StorageKeyTypes = | 'ACCOUNT_TOKEN_METAINFOS' | 'QUESTIONNAIRE_EXPIRED_DATE' | 'WALLET_CREATION_GUIDE_CONFIRM_DATE' - | 'ADD_ACCOUNT_GUIDE_CONFIRM_DATE'; + | 'ADD_ACCOUNT_GUIDE_CONFIRM_DATE' + | 'ACCOUNT_GRC721_COLLECTIONS' + | 'ACCOUNT_GRC721_PINNED_PACKAGES'; const StorageKeys: StorageKeyTypes[] = [ 'NETWORKS', @@ -31,6 +33,8 @@ const StorageKeys: StorageKeyTypes[] = [ 'QUESTIONNAIRE_EXPIRED_DATE', 'WALLET_CREATION_GUIDE_CONFIRM_DATE', 'ADD_ACCOUNT_GUIDE_CONFIRM_DATE', + 'ACCOUNT_GRC721_COLLECTIONS', + 'ACCOUNT_GRC721_PINNED_PACKAGES', ]; function isStorageKey(key: string): key is StorageKeyTypes { diff --git a/packages/adena-extension/src/common/utils/parse-utils.ts b/packages/adena-extension/src/common/utils/parse-utils.ts index 1d5ae7b4b..ce0f74716 100644 --- a/packages/adena-extension/src/common/utils/parse-utils.ts +++ b/packages/adena-extension/src/common/utils/parse-utils.ts @@ -95,3 +95,148 @@ export const parseGRC20ByFileContents = ( tokenDecimals, }; }; + +function checkImport(code: string, importPath: string): boolean { + const importRegex = /import\s*\(([^)]+)\)/m; + const match = code.match(importRegex); + if (!match) return false; + + const imports = match[1].split('\n').map((line) => + line + .trim() + .replace(/"/g, '') + .replace(/^[a-zA-Z_][a-zA-Z0-9_]*\s+/, ''), + ); + return imports.includes(importPath); +} + +interface MethodSignature { + [methodName: string]: string; +} + +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function extractMethods(code: string, typeName: string): MethodSignature { + const escapedTypeName = escapeRegExp(typeName); + const methodRegex = new RegExp( + // eslint-disable-next-line no-useless-escape + `func\\s+${escapedTypeName}\\.([a-zA-Z0-9_]+)\\s*\$begin:math:text$([^)]*)\\$end:math:text$\\s*([^{}]+)\\{`, + 'g', + ); + + let match: RegExpExecArray | null; + const methods: MethodSignature = {}; + + while ((match = methodRegex.exec(code)) !== null) { + const methodName = match[1]; + const params = match[2].trim(); + const returns = match[3].trim().replace(/\s+/g, ' '); + methods[methodName] = `${methodName}(${params}) ${returns}`; + } + + return methods; +} + +interface InterfaceCheckResult { + implementsInterface: boolean; + missingMethods: string[]; +} + +function checkInterfaceImplementation( + code: string, + typeName: string, + interfaceDef: { [key: string]: string }, +): InterfaceCheckResult { + const methods = extractMethods(code, typeName); + const missingMethods: string[] = []; + + for (const methodName in interfaceDef) { + if (!(methodName in methods)) { + missingMethods.push(methodName); + } + } + + return { + implementsInterface: missingMethods.length === 0, + missingMethods, + }; +} + +interface GRC721Meta { + variableName: string; + name: string; + symbol: string; + isTokenUri: boolean; + isMetadata: boolean; +} + +function parseGRC721NewFunctions(code: string): GRC721Meta | null { + const grcNewRegex = + /([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*grc721\.New\w+\s*\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\)/g; + let match: RegExpExecArray | null; + + while ((match = grcNewRegex.exec(code)) !== null) { + if (match.length < 3) { + continue; + } + + const variableName = match[1]; + const name = match[2]; + const symbol = match[3]; + + return { + variableName, + name, + symbol, + isTokenUri: false, + isMetadata: false, + }; + } + + return null; +} + +export function parseGRC721FileContents(contents: string): GRC721Meta | null { + const importPath = 'gno.land/p/demo/grc/grc721'; + const hasImport = checkImport(contents, importPath); + if (!hasImport) { + return null; + } + + const grc721Meta = parseGRC721NewFunctions(contents); + if (!grc721Meta) { + return null; + } + + const interfaceCheck = checkInterfaceImplementation(contents, grc721Meta.variableName, { + BalanceOf: 'BalanceOf(owner std.Address) (uint64, error)', + OwnerOf: 'OwnerOf(tid TokenID) (std.Address, error)', + SetTokenURI: 'SetTokenURI(tid TokenID, tURI TokenURI) (bool, error)', + SafeTransferFrom: 'SafeTransferFrom(from, to std.Address, tid TokenID) error', + TransferFrom: 'TransferFrom(from, to std.Address, tid TokenID) error', + Approve: 'Approve(approved std.Address, tid TokenID) error', + SetApprovalForAll: 'SetApprovalForAll(operator std.Address, approved bool) error', + GetApproved: 'GetApproved(tid TokenID) (std.Address, error)', + IsApprovedForAll: 'IsApprovedForAll(owner, operator std.Address) bool', + }); + + if (!interfaceCheck.implementsInterface) { + return null; + } + + const tokenUriCheck = checkInterfaceImplementation(contents, grc721Meta.variableName, { + TokenUri: 'TokenURI(tid TokenID) (string, error)', + }); + + const metadataCheck = checkInterfaceImplementation(contents, grc721Meta.variableName, { + TokenMetadata: 'TokenMetadata(tid TokenID) (Metadata, error)', + }); + + return { + ...grc721Meta, + isTokenUri: tokenUriCheck.implementsInterface, + isMetadata: metadataCheck.implementsInterface, + }; +} diff --git a/packages/adena-extension/src/components/atoms/option-dropdown/index.ts b/packages/adena-extension/src/components/atoms/option-dropdown/index.ts new file mode 100644 index 000000000..fc4969c03 --- /dev/null +++ b/packages/adena-extension/src/components/atoms/option-dropdown/index.ts @@ -0,0 +1 @@ +export * from './option-dropdown'; diff --git a/packages/adena-extension/src/components/atoms/option-dropdown/option-dropdown.spec.tsx b/packages/adena-extension/src/components/atoms/option-dropdown/option-dropdown.spec.tsx new file mode 100644 index 000000000..2e6a9c46d --- /dev/null +++ b/packages/adena-extension/src/components/atoms/option-dropdown/option-dropdown.spec.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import OptionDropdown, { OptionDropdownProps } from './option-dropdown'; + +describe('OptionDropdown Component', () => { + it('OptionDropdown render', () => { + const args: OptionDropdownProps = { + options: [], + buttonNode: <>>, + hover: false, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/atoms/option-dropdown/option-dropdown.stories.tsx b/packages/adena-extension/src/components/atoms/option-dropdown/option-dropdown.stories.tsx new file mode 100644 index 000000000..3f248b37f --- /dev/null +++ b/packages/adena-extension/src/components/atoms/option-dropdown/option-dropdown.stories.tsx @@ -0,0 +1,11 @@ +import { Meta, StoryObj } from '@storybook/react'; +import OptionDropdown, { type OptionDropdownProps } from './option-dropdown'; + +export default { + title: 'components/common/OptionDropdown', + component: OptionDropdown, +} as Meta; + +export const Default: StoryObj = { + args: {}, +}; diff --git a/packages/adena-extension/src/components/atoms/option-dropdown/option-dropdown.styles.ts b/packages/adena-extension/src/components/atoms/option-dropdown/option-dropdown.styles.ts new file mode 100644 index 000000000..709547c53 --- /dev/null +++ b/packages/adena-extension/src/components/atoms/option-dropdown/option-dropdown.styles.ts @@ -0,0 +1,95 @@ +import { Row, View } from '@components/atoms'; +import mixins from '@styles/mixins'; +import { fonts, getTheme } from '@styles/theme'; +import styled, { css, FlattenSimpleInterpolation } from 'styled-components'; + +export const OptionDropdownWrapper = styled(View)<{ position: 'left' | 'right' }>` + position: relative; + width: 24px; + height: auto; + + .button-wrapper { + display: flex; + width: 24px; + height: 24px; + border-radius: 24px; + justify-content: center; + align-items: center; + cursor: pointer; + + :hover { + background-color: ${getTheme('neutral', '_7')}; + } + } + + &.opened { + .button-wrapper { + background-color: ${getTheme('neutral', '_7')}; + } + } + + .dropdown-static-wrapper { + ${mixins.flex()} + position: absolute; + min-width: 146px; + background-color: ${getTheme('neutral', '_8')}; + border: 1px solid ${getTheme('neutral', '_7')}; + border-radius: 12.5px; + box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.2); + z-index: 99; + overflow: hidden; + + ${({ position }): FlattenSimpleInterpolation | string => + position === 'left' + ? css` + left: -122px; + top: 100%; + ` + : ''} + } +`; + +export const OptionDropdownItemWrapper = styled(Row)` + width: 100%; + height: 30px; + padding: 7px 0 7px 12px; + gap: 8px; + justify-content: flex-start; + align-items: center; + transition: 0.2s; + cursor: pointer; + + .item-icon-wrapper { + display: block; + width: 12px; + height: 14px; + + & > svg { + width: 12px; + height: 12px; + + &.large { + width: 14px; + height: 14px; + } + } + } + + & > .title { + display: inline-flex; + width: 100%; + ${fonts.body3Reg} + } + + & + & { + border-top: 1px solid ${getTheme('neutral', '_7')}; + } + + &.selected { + background-color: ${getTheme('neutral', '_7')}; + } + + &:hover { + background-color: ${getTheme('neutral', '_7')}; + } +`; diff --git a/packages/adena-extension/src/components/atoms/option-dropdown/option-dropdown.tsx b/packages/adena-extension/src/components/atoms/option-dropdown/option-dropdown.tsx new file mode 100644 index 000000000..d04252584 --- /dev/null +++ b/packages/adena-extension/src/components/atoms/option-dropdown/option-dropdown.tsx @@ -0,0 +1,87 @@ +import React, { useCallback, useState } from 'react'; +import { OptionDropdownItemWrapper, OptionDropdownWrapper } from './option-dropdown.styles'; + +interface OptionItem { + icon?: React.ReactNode; + text: string; + onClick: () => void; +} + +export interface OptionDropdownProps { + buttonNode: React.ReactNode; + options: OptionItem[]; + hover?: boolean; +} + +const OptionDropdown: React.FC = ({ buttonNode, options, hover }) => { + const [opened, setOpened] = useState(false); + + const onMouseOverDropdown = useCallback(() => { + if (!hover) { + return; + } + + setOpened(true); + }, [hover]); + + const onMouseOutDropdown = useCallback(() => { + setOpened(false); + }, [hover]); + + const onClickDropdown = useCallback(() => { + if (hover) { + return; + } + setOpened(true); + }, [hover]); + + const onClickOptionItem = useCallback((option: OptionDropdownItemProps) => { + option.onClick(); + setOpened(false); + }, []); + + return ( + + {buttonNode} + + {opened && ( + + {options.map((option, index) => ( + onClickOptionItem(option)} + /> + ))} + + )} + + ); +}; + +/** + * Option Dropdown Item Component + */ +interface OptionDropdownItemProps { + icon?: React.ReactNode; + text: string; + onClick: () => void; +} + +const OptionDropdownItem: React.FC = ({ icon, text, onClick }) => { + return ( + + {!!icon && icon} + {text} + + ); +}; + +export default OptionDropdown; diff --git a/packages/adena-extension/src/components/atoms/sub-header/sub-header.styles.ts b/packages/adena-extension/src/components/atoms/sub-header/sub-header.styles.ts index 30d632772..0429e659f 100644 --- a/packages/adena-extension/src/components/atoms/sub-header/sub-header.styles.ts +++ b/packages/adena-extension/src/components/atoms/sub-header/sub-header.styles.ts @@ -1,5 +1,5 @@ import mixins from '@styles/mixins'; -import { fonts } from '@styles/theme'; +import { fonts, getTheme } from '@styles/theme'; import styled from 'styled-components'; export const SubHeaderWrapper = styled.div` @@ -7,6 +7,12 @@ export const SubHeaderWrapper = styled.div` position: relative; width: 100%; + .icon-dropdown { + path { + fill: ${getTheme('neutral', 'a')}; + } + } + .icon-wrapper { position: absolute; display: flex; @@ -31,6 +37,11 @@ export const SubHeaderWrapper = styled.div` } .title-wrapper { + max-width: calc(100% - 48px - 32px); + text-overflow: ellipsis; + display: block; + white-space: nowrap; + overflow: hidden; ${fonts.header4} } `; diff --git a/packages/adena-extension/src/components/pages/manage-token/toggle/index.tsx b/packages/adena-extension/src/components/atoms/toggle/index.tsx similarity index 100% rename from packages/adena-extension/src/components/pages/manage-token/toggle/index.tsx rename to packages/adena-extension/src/components/atoms/toggle/index.tsx diff --git a/packages/adena-extension/src/components/pages/manage-token/toggle/toggle.spec.tsx b/packages/adena-extension/src/components/atoms/toggle/toggle.spec.tsx similarity index 100% rename from packages/adena-extension/src/components/pages/manage-token/toggle/toggle.spec.tsx rename to packages/adena-extension/src/components/atoms/toggle/toggle.spec.tsx diff --git a/packages/adena-extension/src/components/pages/manage-token/toggle/toggle.stories.tsx b/packages/adena-extension/src/components/atoms/toggle/toggle.stories.tsx similarity index 100% rename from packages/adena-extension/src/components/pages/manage-token/toggle/toggle.stories.tsx rename to packages/adena-extension/src/components/atoms/toggle/toggle.stories.tsx diff --git a/packages/adena-extension/src/components/pages/manage-token/toggle/toggle.styles.ts b/packages/adena-extension/src/components/atoms/toggle/toggle.styles.ts similarity index 100% rename from packages/adena-extension/src/components/pages/manage-token/toggle/toggle.styles.ts rename to packages/adena-extension/src/components/atoms/toggle/toggle.styles.ts diff --git a/packages/adena-extension/src/components/molecules/loading-nft/index.tsx b/packages/adena-extension/src/components/molecules/loading-nft/index.tsx index 01cde3849..378ecd625 100644 --- a/packages/adena-extension/src/components/molecules/loading-nft/index.tsx +++ b/packages/adena-extension/src/components/molecules/loading-nft/index.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement } from 'react'; +import { ReactElement } from 'react'; import styled from 'styled-components'; import { Loading, SkeletonBoxStyle } from '@components/atoms'; @@ -15,16 +15,13 @@ const ListBoxWrap = styled.div` ${mixins.flex({ direction: 'row', justify: 'flex-start' })} width: 100%; gap: 16px; - :first-child { - margin-top: 8px; - } `; const SkeletonBox = styled(SkeletonBoxStyle)` ${mixins.flex({ align: 'flex-end', justify: 'space-between' })} width: 100%; + aspect-ratio: 1; flex: 1; - height: 152px; padding: 10px; `; @@ -33,8 +30,7 @@ const NftRowBox = (): ReactElement => { {Array.from({ length: 2 }, (v, i) => ( - - + ))} diff --git a/packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list-item.tsx b/packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list-item.tsx new file mode 100644 index 000000000..bbbd70a0a --- /dev/null +++ b/packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list-item.tsx @@ -0,0 +1,139 @@ +import React, { useMemo } from 'react'; + +import IconEmptyImage from '@assets/icon-empty-image.svg'; +import Toggle from '@components/atoms/toggle'; +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import BigNumber from 'bignumber.js'; +import { useTheme } from 'styled-components'; +import { TokenBalance } from '../token-balance'; +import { ManageGRC721Info, ManageTokenInfo } from './manage-token-list'; +import { ManageTokenListItemWrapper } from './manage-token-list.styles'; + +export interface ManageTokenListItemProps { + token: ManageTokenInfo | ManageGRC721Info; + queryGRC721TokenUri?: ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, + ) => UseQueryResult; + queryGRC721Balance?: ( + packagePath: string, + options?: UseQueryOptions, + ) => UseQueryResult; + onToggleActiveItem: (tokenId: string, activated: boolean) => void; +} + +function isManageTokenInfo(token: ManageTokenInfo | ManageGRC721Info): token is ManageTokenInfo { + return token.type === 'token'; +} + +const ManageTokenListItem: React.FC = ({ + token, + queryGRC721TokenUri, + queryGRC721Balance, + onToggleActiveItem, +}) => { + const theme = useTheme(); + const isTokenInfo = isManageTokenInfo(token); + const tokenUriResponse = + !isTokenInfo && token.isTokenUri && queryGRC721TokenUri + ? queryGRC721TokenUri(token.packagePath, '0', { enabled: !!token.isTokenUri }) + : null; + const tokenBalanceResponse = + !isTokenInfo && queryGRC721Balance + ? queryGRC721Balance(token.packagePath, { refetchOnMount: true }) + : null; + + const grc721CollectionImage = useMemo(() => { + if (!tokenUriResponse) { + return null; + } + + if (!tokenUriResponse.data) { + return null; + } + + return tokenUriResponse.data; + }, [tokenUriResponse]); + + const grc721BalanceStr = useMemo(() => { + if (isTokenInfo) { + return ''; + } + + if ( + tokenBalanceResponse === null || + tokenBalanceResponse.data === undefined || + tokenBalanceResponse.data === null + ) { + return '-'; + } + + const balanceBN = BigNumber(tokenBalanceResponse.data); + if (balanceBN.isGreaterThan(1)) { + return `${balanceBN.toFormat()} Items`; + } + + return `${balanceBN.toFormat()} Item`; + }, [token]); + + if (isTokenInfo) { + return ( + + + + + + + {token.name} + + + + + + {!token.main && ( + onToggleActiveItem(token.tokenId, !token.display)} + /> + )} + + + ); + } + + return ( + + + {grc721CollectionImage ? ( + + ) : ( + + + + )} + + + + {token.name} + {grc721BalanceStr} + + + + onToggleActiveItem(token.packagePath, !token.display)} + /> + + + ); +}; + +export default ManageTokenListItem; diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token-list/manage-token-list.spec.tsx b/packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list.spec.tsx similarity index 91% rename from packages/adena-extension/src/components/pages/manage-token/manage-token-list/manage-token-list.spec.tsx rename to packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list.spec.tsx index ed644c59b..fe4080c8a 100644 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token-list/manage-token-list.spec.tsx +++ b/packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list.spec.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { GlobalPopupStyle } from '@styles/global-style'; import theme from '@styles/theme'; import { render } from '@testing-library/react'; @@ -8,10 +9,11 @@ import ManageTokenList, { ManageTokenListProps } from './manage-token-list'; const tokens = [ { tokenId: 'token1', + type: 'token' as const, symbol: 'GNOT', logo: 'https://raw.githubusercontent.com/onbloc/gno-token-resource/main/gno-native/images/gnot.svg', name: 'gno.land', - balanceAmount: { + balance: { value: '240,255.241155', denom: 'GNOT', }, @@ -19,10 +21,11 @@ const tokens = [ }, { tokenId: 'token2', + type: 'token' as const, symbol: 'GNOS', logo: 'https://avatars.githubusercontent.com/u/118414737?s=200&v=4', name: 'Gnoswap', - balanceAmount: { + balance: { value: '252.844', denom: 'GNOS', }, diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token-list/manage-token-list.stories.tsx b/packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list.stories.tsx similarity index 89% rename from packages/adena-extension/src/components/pages/manage-token/manage-token-list/manage-token-list.stories.tsx rename to packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list.stories.tsx index a6eebea6c..76b954267 100644 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token-list/manage-token-list.stories.tsx +++ b/packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list.stories.tsx @@ -10,10 +10,11 @@ export default { const tokens = [ { tokenId: 'token1', + type: 'token' as const, symbol: 'GNOT', logo: 'https://raw.githubusercontent.com/onbloc/gno-token-resource/main/gno-native/images/gnot.svg', - name: 'gno.land', - balanceAmount: { + name: 'Gnoland', + balance: { value: '240,255.241155', denom: 'GNOT', }, @@ -21,10 +22,11 @@ const tokens = [ }, { tokenId: 'token2', + type: 'token' as const, symbol: 'GNOS', logo: 'https://avatars.githubusercontent.com/u/118414737?s=200&v=4', name: 'Gnoswap', - balanceAmount: { + balance: { value: '252.844', denom: 'GNOS', }, diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item/manage-token-list-item.styles.ts b/packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list.styles.ts similarity index 67% rename from packages/adena-extension/src/components/pages/manage-token/manage-token-list-item/manage-token-list-item.styles.ts rename to packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list.styles.ts index e6bba5d1b..74def1564 100644 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item/manage-token-list-item.styles.ts +++ b/packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list.styles.ts @@ -2,6 +2,12 @@ import mixins from '@styles/mixins'; import { fonts, getTheme } from '@styles/theme'; import styled from 'styled-components'; +export const ManageTokenListWrapper = styled.div` + ${mixins.flex({ align: 'normal', justify: 'normal' })}; + width: 100%; + height: auto; +`; + export const ManageTokenListItemWrapper = styled.div` ${mixins.flex({ direction: 'row', justify: 'flex-start' })}; padding: 10px 14px; @@ -26,6 +32,23 @@ export const ManageTokenListItemWrapper = styled.div` width: 100%; height: 100%; border-radius: 50%; + + &.empty { + background-color: ${getTheme('neutral', '_7')}; + } + } + + .icon-empty { + display: block; + width: 20px; + height: 100%; + margin: auto; + } + + &.square { + .logo { + border-radius: 8px; + } } } @@ -41,6 +64,11 @@ export const ManageTokenListItemWrapper = styled.div` ${fonts.body2Bold}; line-height: 15px; } + + .balance { + color: ${getTheme('neutral', 'a')}; + ${fonts.captionReg}; + } } .toggle-wrapper { diff --git a/packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list.tsx b/packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list.tsx new file mode 100644 index 000000000..0ce67ceed --- /dev/null +++ b/packages/adena-extension/src/components/molecules/manage-token-list/manage-token-list.tsx @@ -0,0 +1,63 @@ +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import React from 'react'; +import ManageTokenListItem from './manage-token-list-item'; +import { ManageTokenListWrapper } from './manage-token-list.styles'; + +export interface ManageTokenInfo { + tokenId: string; + type: 'token'; + logo: string; + name: string; + display?: boolean; + main?: boolean; + balance: { + value: string; + denom: string; + }; +} + +export interface ManageGRC721Info { + tokenId: string; + type: 'grc721'; + packagePath: string; + isTokenUri: boolean; + name: string; + display?: boolean; +} + +export interface ManageTokenListProps { + tokens: ManageTokenInfo[] | ManageGRC721Info[]; + queryGRC721TokenUri?: ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, + ) => UseQueryResult; + queryGRC721Balance?: ( + packagePath: string, + options?: UseQueryOptions, + ) => UseQueryResult; + onToggleActiveItem: (tokenId: string, activated: boolean) => void; +} + +const ManageTokenList: React.FC = ({ + tokens, + queryGRC721TokenUri, + queryGRC721Balance, + onToggleActiveItem, +}) => { + return ( + + {tokens.map((token, index) => ( + + ))} + + ); +}; + +export default ManageTokenList; diff --git a/packages/adena-extension/src/components/molecules/nft-asset-image-card/nft-asset-image-card.spec.tsx b/packages/adena-extension/src/components/molecules/nft-asset-image-card/nft-asset-image-card.spec.tsx new file mode 100644 index 000000000..8311469e1 --- /dev/null +++ b/packages/adena-extension/src/components/molecules/nft-asset-image-card/nft-asset-image-card.spec.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { UseQueryResult } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import NFTAssetImageCard, { NFTAssetImageCardProps } from './nft-asset-image-card'; + +describe('NFTAssetImageCard Component', () => { + it('NFTAssetImageCard render', () => { + const args: NFTAssetImageCardProps = { + asset: { + metadata: null, + name: '', + networkId: '', + packagePath: '', + symbol: '', + tokenId: '', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + queryGRC721TokenUri: () => ({}) as unknown as UseQueryResult, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/molecules/nft-asset-image-card/nft-asset-image-card.stories.tsx b/packages/adena-extension/src/components/molecules/nft-asset-image-card/nft-asset-image-card.stories.tsx new file mode 100644 index 000000000..ee24546ca --- /dev/null +++ b/packages/adena-extension/src/components/molecules/nft-asset-image-card/nft-asset-image-card.stories.tsx @@ -0,0 +1,11 @@ +import { Meta, StoryObj } from '@storybook/react'; +import NFTAssetImageCard, { type NFTAssetImageCardProps } from './nft-asset-image-card'; + +export default { + title: 'components/nft-asset/NFTAssetImageCard', + component: NFTAssetImageCard, +} as Meta; + +export const Default: StoryObj = { + args: {}, +}; diff --git a/packages/adena-extension/src/components/molecules/nft-asset-image-card/nft-asset-image-card.styles.ts b/packages/adena-extension/src/components/molecules/nft-asset-image-card/nft-asset-image-card.styles.ts new file mode 100644 index 000000000..7c469706f --- /dev/null +++ b/packages/adena-extension/src/components/molecules/nft-asset-image-card/nft-asset-image-card.styles.ts @@ -0,0 +1,12 @@ +import { View } from '@components/atoms'; +import styled from 'styled-components'; + +export const NFTAssetImageCardWrapper = styled(View)` + position: relative; + width: 100%; + aspect-ratio: 1; + height: auto; + flex-shrink: 0; + overflow: hidden; + border-radius: 8px; +`; diff --git a/packages/adena-extension/src/components/molecules/nft-asset-image-card/nft-asset-image-card.tsx b/packages/adena-extension/src/components/molecules/nft-asset-image-card/nft-asset-image-card.tsx new file mode 100644 index 000000000..7caa74b9b --- /dev/null +++ b/packages/adena-extension/src/components/molecules/nft-asset-image-card/nft-asset-image-card.tsx @@ -0,0 +1,41 @@ +import NFTCardImage from '@components/molecules/nft-card-image/nft-card-image'; +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { GRC721Model } from '@types'; +import React, { useMemo } from 'react'; +import { NFTAssetImageCardWrapper } from './nft-asset-image-card.styles'; + +export interface NFTAssetImageCardProps { + asset: GRC721Model; + queryGRC721TokenUri: ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, + ) => UseQueryResult; +} + +const NFTAssetImageCard: React.FC = ({ asset, queryGRC721TokenUri }) => { + const { data: tokenUri, isFetched: isFetchedTokenUri } = queryGRC721TokenUri( + asset.packagePath, + asset.tokenId, + { + enabled: asset.isTokenUri, + refetchOnMount: true, + }, + ); + + const isFetchedTokenUriWithEnabled = useMemo(() => { + if (asset.isTokenUri) { + return true; + } + + return isFetchedTokenUri; + }, [asset, isFetchedTokenUri]); + + return ( + + + + ); +}; + +export default NFTAssetImageCard; diff --git a/packages/adena-extension/src/components/molecules/nft-card-image/nft-card-image.spec.tsx b/packages/adena-extension/src/components/molecules/nft-card-image/nft-card-image.spec.tsx new file mode 100644 index 000000000..edffdc610 --- /dev/null +++ b/packages/adena-extension/src/components/molecules/nft-card-image/nft-card-image.spec.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import NFTCardImage, { NFTCardImageProps } from './nft-card-image'; + +describe('NFTCardImage Component', () => { + it('NFTCardImage render', () => { + const args: NFTCardImageProps = { + image: '', + isFetched: false, + hasBadge: false, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/molecules/nft-card-image/nft-card-image.stories.tsx b/packages/adena-extension/src/components/molecules/nft-card-image/nft-card-image.stories.tsx new file mode 100644 index 000000000..be13aff59 --- /dev/null +++ b/packages/adena-extension/src/components/molecules/nft-card-image/nft-card-image.stories.tsx @@ -0,0 +1,15 @@ +import { Meta, StoryObj } from '@storybook/react'; +import NFTCardImage, { type NFTCardImageProps } from './nft-card-image'; + +export default { + title: 'components/common/NFTCardImage', + component: NFTCardImage, +} as Meta; + +export const Default: StoryObj = { + args: { + image: '', + isFetched: false, + hasBadge: false, + }, +}; diff --git a/packages/adena-extension/src/components/molecules/nft-card-image/nft-card-image.styles.ts b/packages/adena-extension/src/components/molecules/nft-card-image/nft-card-image.styles.ts new file mode 100644 index 000000000..3c324757b --- /dev/null +++ b/packages/adena-extension/src/components/molecules/nft-card-image/nft-card-image.styles.ts @@ -0,0 +1,33 @@ +import { SkeletonBoxStyle, View } from '@components/atoms'; +import mixins from '@styles/mixins'; +import { getTheme } from '@styles/theme'; +import styled from 'styled-components'; + +export const NFTCardImageWrapper = styled(View)` + width: 100%; + height: 100%; + background-color: ${getTheme('neutral', '_7')}; + align-items: center; + justify-content: center; + + .empty-image { + width: 31px; + height: auto; + } + + .nft-image { + width: auto; + height: auto; + min-width: 100%; + min-height: 100%; + object-fit: cover; + } +`; + +export const NFTCardImageSkeletonBox = styled(SkeletonBoxStyle)` + ${mixins.flex({ align: 'flex-end', justify: 'space-between' })} + width: 100%; + flex: 1; + height: 100%; + padding: 10px; +`; diff --git a/packages/adena-extension/src/components/molecules/nft-card-image/nft-card-image.tsx b/packages/adena-extension/src/components/molecules/nft-card-image/nft-card-image.tsx new file mode 100644 index 000000000..3cd5f4f9c --- /dev/null +++ b/packages/adena-extension/src/components/molecules/nft-card-image/nft-card-image.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import IconEmptyImage from '@assets/icon-empty-image.svg'; +import { Loading } from '@components/atoms'; +import { NFTCardImageSkeletonBox, NFTCardImageWrapper } from './nft-card-image.styles'; + +export interface NFTCardImageProps { + isFetched: boolean; + image: string | null | undefined; + hasBadge?: boolean; +} + +const NFTCardImage: React.FC = ({ isFetched, image, hasBadge = false }) => { + if (!isFetched) { + return ( + + {hasBadge && } + + ); + } + + if (!image) { + return ( + + + + ); + } + + return ( + + + + ); +}; + +export default NFTCardImage; diff --git a/packages/adena-extension/src/components/molecules/transaction-history/transaction-history-list-item/transaction-history-list-item.styles.ts b/packages/adena-extension/src/components/molecules/transaction-history/transaction-history-list-item/transaction-history-list-item.styles.ts index e86abc971..990bfcd76 100644 --- a/packages/adena-extension/src/components/molecules/transaction-history/transaction-history-list-item/transaction-history-list-item.styles.ts +++ b/packages/adena-extension/src/components/molecules/transaction-history/transaction-history-list-item/transaction-history-list-item.styles.ts @@ -30,6 +30,7 @@ export const TransactionHistoryListItemWrapper = styled.div` .logo { width: 100%; height: 100%; + border-radius: 8px; } .badge { diff --git a/packages/adena-extension/src/components/molecules/transaction-history/transaction-history-list-item/transaction-history-list-item.tsx b/packages/adena-extension/src/components/molecules/transaction-history/transaction-history-list-item/transaction-history-list-item.tsx index 2f8f6f8fd..dd3d7fcfe 100644 --- a/packages/adena-extension/src/components/molecules/transaction-history/transaction-history-list-item/transaction-history-list-item.tsx +++ b/packages/adena-extension/src/components/molecules/transaction-history/transaction-history-list-item/transaction-history-list-item.tsx @@ -1,16 +1,17 @@ -import React, { useCallback } from 'react'; -import { TransactionHistoryListItemWrapper } from './transaction-history-list-item.styles'; -import SuccessIcon from '@assets/success.svg'; -import FailedIcon from '@assets/failed.svg'; -import ContractIcon from '@assets/contract.svg'; import AddPackageIcon from '@assets/addpkg.svg'; -import { TokenBalance } from '@components/molecules'; import UnknownTokenIcon from '@assets/common-unknown-token.svg'; +import ContractIcon from '@assets/contract.svg'; +import FailedIcon from '@assets/failed.svg'; +import SuccessIcon from '@assets/success.svg'; +import { TokenBalance } from '@components/molecules'; +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import React, { useCallback } from 'react'; +import { TransactionHistoryListItemWrapper } from './transaction-history-list-item.styles'; export interface TransactionHistoryListItemProps { hash: string; logo?: string; - type: 'TRANSFER' | 'ADD_PACKAGE' | 'CONTRACT_CALL' | 'MULTI_CONTRACT_CALL'; + type: 'TRANSFER' | 'TRANSFER_GRC721' | 'ADD_PACKAGE' | 'CONTRACT_CALL' | 'MULTI_CONTRACT_CALL'; status: 'SUCCESS' | 'FAIL'; title: string; description?: string; @@ -20,6 +21,11 @@ export interface TransactionHistoryListItemProps { denom: string; }; valueType: 'DEFAULT' | 'ACTIVE' | 'BLUR'; + queryGRC721TokenUri?: ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, + ) => UseQueryResult; onClickItem: (hash: string) => void; } @@ -33,9 +39,17 @@ const TransactionHistoryListItem: React.FC = ({ description, amount, valueType, + queryGRC721TokenUri, onClickItem, }) => { + const tokenUriQuery = + type === 'TRANSFER_GRC721' && queryGRC721TokenUri ? queryGRC721TokenUri(logo || '', '0') : null; + const getLogoImage = useCallback(() => { + if (type === 'TRANSFER_GRC721' && tokenUriQuery) { + return tokenUriQuery?.data || `${UnknownTokenIcon}`; + } + if (type === 'ADD_PACKAGE') { return `${AddPackageIcon}`; } @@ -49,7 +63,7 @@ const TransactionHistoryListItem: React.FC = ({ return `${UnknownTokenIcon}`; } return `${logo}`; - }, [type, logo]); + }, [type, logo, tokenUriQuery]); const getValueTypeClassName = useCallback(() => { if (valueType === 'ACTIVE') { @@ -83,6 +97,8 @@ const TransactionHistoryListItem: React.FC = ({ {type === 'MULTI_CONTRACT_CALL' ? ( More + ) : type === 'TRANSFER_GRC721' ? ( + {`${amount.denom} #${amount.value}`} ) : ( , + ) => UseQueryResult; onClickItem: (hash: string) => void; } const TransactionHistoryList: React.FC = ({ title, transactions, + queryGRC721TokenUri, onClickItem, }) => { return ( @@ -19,7 +26,12 @@ const TransactionHistoryList: React.FC = ({ {title} {transactions.map((transaction, index) => ( - + ))} diff --git a/packages/adena-extension/src/components/molecules/transaction-history/transaction-history/index.tsx b/packages/adena-extension/src/components/molecules/transaction-history/transaction-history/index.tsx index 3e7c8a35c..2ecd53b69 100644 --- a/packages/adena-extension/src/components/molecules/transaction-history/transaction-history/index.tsx +++ b/packages/adena-extension/src/components/molecules/transaction-history/transaction-history/index.tsx @@ -1,14 +1,15 @@ import React from 'react'; import { useTheme } from 'styled-components'; +import { Text } from '@components/atoms'; +import TransactionHistoryList from '@components/molecules/transaction-history/transaction-history-list/transaction-history-list'; +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { TransactionInfo } from '@types'; +import LoadingHistory from '../loading-history'; import { TransactionHistoryDescriptionWrapper, TransactionHistoryWrapper, } from './transaction-history.styles'; -import TransactionHistoryList from '@components/molecules/transaction-history/transaction-history-list/transaction-history-list'; -import { Text } from '@components/atoms'; -import LoadingHistory from '../loading-history'; -import { TransactionInfo } from '@types'; export interface TransactionHistoryProps { status: 'error' | 'loading' | 'success'; @@ -16,12 +17,18 @@ export interface TransactionHistoryProps { title: string; transactions: TransactionInfo[]; }[]; + queryGRC721TokenUri?: ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, + ) => UseQueryResult; onClickItem: (hash: string) => void; } export const TransactionHistory: React.FC = ({ status, transactionInfoLists, + queryGRC721TokenUri, onClickItem, }) => { const theme = useTheme(); @@ -41,7 +48,12 @@ export const TransactionHistory: React.FC = ({ return ( {transactionInfoLists.map((transactionInfoList, index) => ( - + ))} ); diff --git a/packages/adena-extension/src/components/pages/main/main-manage-token-button/main-manage-token-button.tsx b/packages/adena-extension/src/components/pages/main/main-manage-token-button/main-manage-token-button.tsx index 7a6f53870..382f6f8f8 100644 --- a/packages/adena-extension/src/components/pages/main/main-manage-token-button/main-manage-token-button.tsx +++ b/packages/adena-extension/src/components/pages/main/main-manage-token-button/main-manage-token-button.tsx @@ -1,6 +1,6 @@ +import MainManageTokensFilterIcon from '@assets/main-manage-tokens-filter.svg'; import React from 'react'; import { MainManageTokenButtonWrapper } from './main-manage-token-button.styles'; -import MainManageTokensFilterIcon from '@assets/main-manage-tokens-filter.svg'; export interface MainManageTokenButtonProps { onClick: () => void; @@ -15,4 +15,4 @@ const MainManageTokenButton: React.FC = ({ onClick } ); }; -export default MainManageTokenButton; \ No newline at end of file +export default MainManageTokenButton; diff --git a/packages/adena-extension/src/components/pages/manage-nft/manage-collection-search-input/manage-collection-search-input.spec.tsx b/packages/adena-extension/src/components/pages/manage-nft/manage-collection-search-input/manage-collection-search-input.spec.tsx new file mode 100644 index 000000000..ff83fd82c --- /dev/null +++ b/packages/adena-extension/src/components/pages/manage-nft/manage-collection-search-input/manage-collection-search-input.spec.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import ManageCollectionSearchInput, { + ManageCollectionSearchInputProps, +} from './manage-collection-search-input'; + +describe('ManageCollectionSearchInput Component', () => { + it('ManageCollectionSearchInput render', () => { + const args: ManageCollectionSearchInputProps = { + keyword: 'as', + onChangeKeyword: () => { + return; + }, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/manage-nft/manage-collection-search-input/manage-collection-search-input.stories.tsx b/packages/adena-extension/src/components/pages/manage-nft/manage-collection-search-input/manage-collection-search-input.stories.tsx new file mode 100644 index 000000000..f818d0ebd --- /dev/null +++ b/packages/adena-extension/src/components/pages/manage-nft/manage-collection-search-input/manage-collection-search-input.stories.tsx @@ -0,0 +1,17 @@ +import { action } from '@storybook/addon-actions'; +import { Meta, StoryObj } from '@storybook/react'; +import ManageCollectionSearchInput, { + type ManageCollectionSearchInputProps, +} from './manage-collection-search-input'; + +export default { + title: 'components/manage-nft/ManageCollectionSearchInput', + component: ManageCollectionSearchInput, +} as Meta; + +export const Default: StoryObj = { + args: { + keyword: '', + onChangeKeyword: action('change keywords'), + }, +}; diff --git a/packages/adena-extension/src/components/pages/manage-nft/manage-collection-search-input/manage-collection-search-input.styles.ts b/packages/adena-extension/src/components/pages/manage-nft/manage-collection-search-input/manage-collection-search-input.styles.ts new file mode 100644 index 000000000..5efddbeaf --- /dev/null +++ b/packages/adena-extension/src/components/pages/manage-nft/manage-collection-search-input/manage-collection-search-input.styles.ts @@ -0,0 +1,61 @@ +import mixins from '@styles/mixins'; +import { fonts, getTheme } from '@styles/theme'; +import styled from 'styled-components'; + +export const ManageCollectionSearchInputWrapper = styled.div` + ${mixins.flex({ direction: 'row', justify: 'flex-start' })}; + width: 100%; + height: 48px; + padding: 12px 16px; + background-color: ${getTheme('neutral', '_9')}; + border-radius: 30px; + border: 1px solid ${getTheme('neutral', '_7')}; + + .search-icon-wrapper { + display: inline-flex; + flex-shrink: 0; + width: fit-content; + align-items: center; + padding: 0 5px; + + .search { + width: 17px; + height: 17px; + } + } + + .input-wrapper { + display: inline-flex; + flex-shrink: 1; + width: 100%; + height: 24px; + padding: 0 12px; + + .search-input { + width: 100%; + ${fonts.body2Reg}; + } + } + + .added-icon-wrapper { + display: inline-flex; + flex-shrink: 0; + width: 24px; + height: 24px; + align-items: center; + cursor: pointer; + + .added { + width: 100%; + height: 100%; + fill: ${getTheme('neutral', '_7')}; + transition: 0.2s; + } + + :hover { + .added { + fill: ${getTheme('neutral', 'b')}; + } + } + } +`; diff --git a/packages/adena-extension/src/components/pages/manage-nft/manage-collection-search-input/manage-collection-search-input.tsx b/packages/adena-extension/src/components/pages/manage-nft/manage-collection-search-input/manage-collection-search-input.tsx new file mode 100644 index 000000000..62ebadbda --- /dev/null +++ b/packages/adena-extension/src/components/pages/manage-nft/manage-collection-search-input/manage-collection-search-input.tsx @@ -0,0 +1,32 @@ +import ManageTokenSearchIcon from '@assets/manage-token-search.svg'; +import React from 'react'; +import { ManageCollectionSearchInputWrapper } from './manage-collection-search-input.styles'; + +export interface ManageCollectionSearchInputProps { + keyword: string; + onChangeKeyword: (keyword: string) => void; +} + +const ManageCollectionSearchInput: React.FC = ({ + keyword, + onChangeKeyword, +}) => { + return ( + + + + + + + onChangeKeyword(event.target.value)} + placeholder='Search' + /> + + + ); +}; + +export default ManageCollectionSearchInput; diff --git a/packages/adena-extension/src/components/pages/manage-nft/manage-collections/manage-collections.spec.tsx b/packages/adena-extension/src/components/pages/manage-nft/manage-collections/manage-collections.spec.tsx new file mode 100644 index 000000000..c8dc84321 --- /dev/null +++ b/packages/adena-extension/src/components/pages/manage-nft/manage-collections/manage-collections.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { UseQueryResult } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import ManageCollections, { ManageCollectionsProps } from './manage-collections'; + +-describe('ManageCollections Component', () => { + it('ManageCollections render', () => { + const args: ManageCollectionsProps = { + collections: [], + keyword: '', + queryGRC721TokenUri: () => ({}) as unknown as UseQueryResult, + queryGRC721Balance: () => ({}) as unknown as UseQueryResult, + onChangeKeyword: () => { + return; + }, + onClickClose: () => { + return; + }, + onToggleActiveItem: () => { + return; + }, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/manage-nft/manage-collections/manage-collections.stories.tsx b/packages/adena-extension/src/components/pages/manage-nft/manage-collections/manage-collections.stories.tsx new file mode 100644 index 000000000..12c79222c --- /dev/null +++ b/packages/adena-extension/src/components/pages/manage-nft/manage-collections/manage-collections.stories.tsx @@ -0,0 +1,18 @@ +import { action } from '@storybook/addon-actions'; +import { Meta, StoryObj } from '@storybook/react'; +import ManageCollections, { type ManageCollectionsProps } from './manage-collections'; + +export default { + title: 'components/manage-nft/ManageCollections', + component: ManageCollections, +} as Meta; + +export const Default: StoryObj = { + args: { + collections: [], + keyword: '', + onClickClose: action('click close'), + onChangeKeyword: action('change keyword'), + onToggleActiveItem: action('toggle item'), + }, +}; diff --git a/packages/adena-extension/src/components/pages/manage-nft/manage-collections/manage-collections.styles.ts b/packages/adena-extension/src/components/pages/manage-nft/manage-collections/manage-collections.styles.ts new file mode 100644 index 000000000..c73bce615 --- /dev/null +++ b/packages/adena-extension/src/components/pages/manage-nft/manage-collections/manage-collections.styles.ts @@ -0,0 +1,41 @@ +import mixins from '@styles/mixins'; +import { fonts, getTheme } from '@styles/theme'; +import styled from 'styled-components'; + +export const ManageCollectionsWrapper = styled.div` + ${mixins.flex({ align: 'normal', justify: 'normal' })}; + width: 100%; + height: auto; + + .list-wrapper { + display: flex; + margin-top: 24px; + max-height: 284px; + overflow-y: auto; + padding-bottom: 24px; + } + + .close-wrapper { + position: absolute; + display: flex; + left: 0; + bottom: 0; + width: 100%; + height: 96px; + padding: 24px 20px; + box-shadow: 0px -4px 4px rgba(0, 0, 0, 0.4); + + .close { + width: 100%; + height: 100%; + background-color: ${getTheme('neutral', '_5')}; + border-radius: 30px; + ${fonts.body1Bold}; + transition: 0.2s; + + :hover { + background-color: ${getTheme('neutral', '_6')}; + } + } + } +`; diff --git a/packages/adena-extension/src/components/pages/manage-nft/manage-collections/manage-collections.tsx b/packages/adena-extension/src/components/pages/manage-nft/manage-collections/manage-collections.tsx new file mode 100644 index 000000000..7e1834209 --- /dev/null +++ b/packages/adena-extension/src/components/pages/manage-nft/manage-collections/manage-collections.tsx @@ -0,0 +1,58 @@ +import ManageTokenList from '@components/molecules/manage-token-list/manage-token-list'; +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { ManageGRC721Info } from '@types'; +import React from 'react'; +import ManageCollectionSearchInput from '../manage-collection-search-input/manage-collection-search-input'; +import { ManageCollectionsWrapper } from './manage-collections.styles'; + +export interface ManageCollectionsProps { + keyword: string; + collections: ManageGRC721Info[]; + onClickClose: () => void; + queryGRC721TokenUri: ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, + ) => UseQueryResult; + queryGRC721Balance: ( + packagePath: string, + options?: UseQueryOptions, + ) => UseQueryResult; + onChangeKeyword: (keyword: string) => void; + onToggleActiveItem: (tokenId: string, activated: boolean) => void; +} + +const ManageCollections: React.FC = ({ + keyword, + collections, + queryGRC721TokenUri, + queryGRC721Balance, + onClickClose, + onChangeKeyword, + onToggleActiveItem, +}) => { + return ( + + + + + + + + + + + + Close + + + + ); +}; + +export default ManageCollections; diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item-balance/manage-token-list-item-balance.stories.tsx b/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item-balance/manage-token-list-item-balance.stories.tsx deleted file mode 100644 index f663ad859..000000000 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item-balance/manage-token-list-item-balance.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import ManageTokenListItemBalance, { - type ManageTokenListItemBalanceProps, -} from './manage-token-list-item-balance'; -import { Meta, StoryObj } from '@storybook/react'; - -export default { - title: 'components/manage-token/ManageTokenListItemBalance', - component: ManageTokenListItemBalance, -} as Meta; - -export const Default: StoryObj = { - args: { - amount: { - value: '240,255.241155', - denom: 'GNOT', - }, - }, -}; diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item-balance/manage-token-list-item-balance.tsx b/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item-balance/manage-token-list-item-balance.tsx deleted file mode 100644 index 841e80490..000000000 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item-balance/manage-token-list-item-balance.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { useTheme } from 'styled-components'; - -import { TokenBalance } from '@components/molecules'; - -export interface ManageTokenListItemBalanceProps { - amount: { - value: string; - denom: string; - }; -} - -const ManageTokenListItemBalance: React.FC = ({ amount }) => { - const { value, denom } = amount; - - const theme = useTheme(); - - return ( - - ); -}; - -export default ManageTokenListItemBalance; diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item/manage-token-list-item.spec.tsx b/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item/manage-token-list-item.spec.tsx deleted file mode 100644 index 0d421a6cf..000000000 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item/manage-token-list-item.spec.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { GlobalPopupStyle } from '@styles/global-style'; -import theme from '@styles/theme'; -import { render } from '@testing-library/react'; -import { RecoilRoot } from 'recoil'; -import { ThemeProvider } from 'styled-components'; -import ManageTokenListItem, { ManageTokenListItemProps } from './manage-token-list-item'; - -const token = { - tokenId: 'token1', - logo: 'https://raw.githubusercontent.com/onbloc/gno-token-resource/main/gno-native/images/gnot.svg', - name: 'gno.land', - symbol: 'GNOT', - balanceAmount: { - value: '240,255.241155', - denom: 'GNOT', - }, - activated: true, -}; - -describe('ManageTokenListItem Component', () => { - it('ManageTokenListItem render', () => { - const args: ManageTokenListItemProps = { - token, - onToggleActiveItem: () => { - return; - }, - }; - - render( - - - - - - , - ); - }); -}); diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item/manage-token-list-item.stories.tsx b/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item/manage-token-list-item.stories.tsx deleted file mode 100644 index 548b5167e..000000000 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item/manage-token-list-item.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { action } from '@storybook/addon-actions'; -import { Meta, StoryObj } from '@storybook/react'; -import ManageTokenListItem, { type ManageTokenListItemProps } from './manage-token-list-item'; - -export default { - title: 'components/manage-token/ManageTokenListItem', - component: ManageTokenListItem, -} as Meta; - -const token = { - tokenId: 'token1', - logo: 'https://raw.githubusercontent.com/onbloc/gno-token-resource/main/gno-native/images/gnot.svg', - name: 'gno.land', - symbol: 'GNOT', - balanceAmount: { - value: '240,255.241155', - denom: 'GNOT', - }, - display: true, -}; - -export const Main: StoryObj = { - args: { - token: { - ...token, - main: true, - }, - onToggleActiveItem: action('token item click'), - }, -}; - -export const Activated: StoryObj = { - args: { - token, - onToggleActiveItem: action('token item click'), - }, -}; - -export const Deactivated: StoryObj = { - args: { - token: { - ...token, - display: false, - }, - onToggleActiveItem: action('token item click'), - }, -}; diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item/manage-token-list-item.tsx b/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item/manage-token-list-item.tsx deleted file mode 100644 index f5e179e99..000000000 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item/manage-token-list-item.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import { ManageTokenListItemWrapper } from './manage-token-list-item.styles'; -import ManageTokenListItemBalance from '@components/pages/manage-token/manage-token-list-item-balance/manage-token-list-item-balance'; -import { ManageTokenInfo } from '@types'; - -import Toggle from '../toggle'; - -export interface ManageTokenListItemProps { - token: ManageTokenInfo; - onToggleActiveItem: (tokenId: string, activated: boolean) => void; -} - -const ManageTokenListItem: React.FC = ({ token, onToggleActiveItem }) => { - const { main, tokenId, logo, name, balanceAmount, display } = token; - - return ( - - - - - - - {name} - - - - - {!main && ( - onToggleActiveItem(tokenId, !display)} - /> - )} - - - ); -}; - -export default ManageTokenListItem; diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token-list/manage-token-list.styles.ts b/packages/adena-extension/src/components/pages/manage-token/manage-token-list/manage-token-list.styles.ts deleted file mode 100644 index 1955bb0ce..000000000 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token-list/manage-token-list.styles.ts +++ /dev/null @@ -1,8 +0,0 @@ -import mixins from '@styles/mixins'; -import styled from 'styled-components'; - -export const ManageTokenListWrapper = styled.div` - ${mixins.flex({ align: 'normal', justify: 'normal' })}; - width: 100%; - height: auto; -`; diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token-list/manage-token-list.tsx b/packages/adena-extension/src/components/pages/manage-token/manage-token-list/manage-token-list.tsx deleted file mode 100644 index d3a42f736..000000000 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token-list/manage-token-list.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import { ManageTokenListWrapper } from './manage-token-list.styles'; -import ManageTokenListItem from '@components/pages/manage-token/manage-token-list-item/manage-token-list-item'; -import { ManageTokenInfo } from '@types'; - -export interface ManageTokenListProps { - tokens: Array; - onToggleActiveItem: (tokenId: string, activated: boolean) => void; -} - -const ManageTokenList: React.FC = ({ tokens, onToggleActiveItem }) => { - return ( - - {tokens.map((token, index) => ( - - ))} - - ); -}; - -export default ManageTokenList; diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token/index.tsx b/packages/adena-extension/src/components/pages/manage-token/manage-token/index.tsx index 7e607cd43..96b2e5829 100644 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token/index.tsx +++ b/packages/adena-extension/src/components/pages/manage-token/manage-token/index.tsx @@ -1,8 +1,8 @@ -import React from 'react'; -import { ManageTokenSearchWrapper } from './manage-token.styles'; +import ManageTokenList from '@components/molecules/manage-token-list/manage-token-list'; import { ManageTokenInfo } from '@types'; -import ManageTokenList from '../manage-token-list/manage-token-list'; +import React from 'react'; import ManageTokenSearchInput from '../manage-token-search-input/manage-token-search-input'; +import { ManageTokenSearchWrapper } from './manage-token.styles'; export interface ManageTokenSearchProps { keyword: string; diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token/manage-token.spec.tsx b/packages/adena-extension/src/components/pages/manage-token/manage-token/manage-token.spec.tsx index eb2f586eb..26741bdb5 100644 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token/manage-token.spec.tsx +++ b/packages/adena-extension/src/components/pages/manage-token/manage-token/manage-token.spec.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { GlobalPopupStyle } from '@styles/global-style'; import theme from '@styles/theme'; import { render } from '@testing-library/react'; @@ -8,10 +9,11 @@ import ManageTokenSearch, { ManageTokenSearchProps } from '.'; const tokens = [ { tokenId: 'token1', + type: 'token' as const, symbol: 'GNOT', logo: 'https://raw.githubusercontent.com/onbloc/gno-token-resource/main/gno-native/images/gnot.svg', name: 'gno.land', - balanceAmount: { + balance: { value: '240,255.241155', denom: 'GNOT', }, @@ -19,10 +21,11 @@ const tokens = [ }, { tokenId: 'token2', + type: 'token' as const, symbol: 'GNOS', logo: 'https://avatars.githubusercontent.com/u/118414737?s=200&v=4', name: 'Gnoswap', - balanceAmount: { + balance: { value: '252.844', denom: 'GNOS', }, diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token/manage-token.stories.tsx b/packages/adena-extension/src/components/pages/manage-token/manage-token/manage-token.stories.tsx index 68598c83b..a071d062a 100644 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token/manage-token.stories.tsx +++ b/packages/adena-extension/src/components/pages/manage-token/manage-token/manage-token.stories.tsx @@ -10,10 +10,11 @@ export default { const tokens = [ { tokenId: 'token1', + type: 'token' as const, symbol: 'GNOT', logo: 'https://raw.githubusercontent.com/onbloc/gno-token-resource/main/gno-native/images/gnot.svg', name: 'gno.land', - balanceAmount: { + balance: { value: '240,255.241155', denom: 'GNOT', }, @@ -22,10 +23,11 @@ const tokens = [ }, { tokenId: 'token2', + type: 'token' as const, symbol: 'GNOS', logo: 'https://avatars.githubusercontent.com/u/118414737?s=200&v=4', name: 'Gnoswap', - balanceAmount: { + balance: { value: '252.844', denom: 'GNOS', }, diff --git a/packages/adena-extension/src/components/pages/nft-asset/nft-asset-header/nft-asset-header.spec.tsx b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-header/nft-asset-header.spec.tsx new file mode 100644 index 000000000..ae4d9cec7 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-header/nft-asset-header.spec.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import NFTAssetHeader, { NFTAssetHeaderProps } from './nft-asset-header'; + +describe('NFTAssetHeader Component', () => { + it('NFTAssetHeader render', () => { + const args: NFTAssetHeaderProps = { + title: '', + pinned: true, + visible: true, + moveBack: () => { + return; + }, + openGnoscanCollection: () => { + return; + }, + pinCollection: () => { + return; + }, + unpinCollection: () => { + return; + }, + showCollection: () => { + return; + }, + hideCollection: () => { + return; + }, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/nft-asset/nft-asset-header/nft-asset-header.stories.tsx b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-header/nft-asset-header.stories.tsx new file mode 100644 index 000000000..8e2ec9053 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-header/nft-asset-header.stories.tsx @@ -0,0 +1,17 @@ +import { Meta, StoryObj } from '@storybook/react'; +import NFTAssetHeader, { type NFTAssetHeaderProps } from './nft-asset-header'; + +import { action } from '@storybook/addon-actions'; + +export default { + title: 'components/nft/NFTAssetHeader', + component: NFTAssetHeader, +} as Meta; + +export const Default: StoryObj = { + args: { + title: 'title', + moveBack: action('moveBack'), + openGnoscanCollection: action('openGnoscanCollection'), + }, +}; diff --git a/packages/adena-extension/src/components/pages/nft-asset/nft-asset-header/nft-asset-header.tsx b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-header/nft-asset-header.tsx new file mode 100644 index 000000000..7e84dc6cd --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-header/nft-asset-header.tsx @@ -0,0 +1,80 @@ +import React, { useMemo } from 'react'; + +import LeftArrowIcon from '@assets/arrowL-left.svg'; +import IconEtc from '@assets/etc.svg'; +import IconHide from '@assets/icon-hide'; +import IconLink from '@assets/icon-link'; +import IconPin from '@assets/icon-pin'; +import IconShow from '@assets/icon-show'; +import IconUnpin from '@assets/icon-unpin'; +import { SubHeader } from '@components/atoms'; +import OptionDropdown from '@components/atoms/option-dropdown/option-dropdown'; + +export interface NFTAssetHeaderProps { + title: string; + pinned: boolean; + visible: boolean; + moveBack: () => void; + openGnoscanCollection: () => void; + pinCollection: () => void; + unpinCollection: () => void; + showCollection: () => void; + hideCollection: () => void; +} + +const NFTAssetHeader: React.FC = ({ + title, + pinned, + visible, + moveBack, + openGnoscanCollection, + pinCollection, + unpinCollection, + showCollection, + hideCollection, +}) => { + const dropdownOptions = useMemo( + () => [ + { + text: 'View on Gnoscan', + icon: , + onClick: openGnoscanCollection, + }, + { + text: pinned ? 'Unpin Collection' : 'Pin Collection', + icon: pinned ? : , + onClick: pinned ? unpinCollection : pinCollection, + }, + { + text: visible ? 'Hide Collection' : 'Show Collection', + icon: visible ? : , + onClick: visible ? hideCollection : showCollection, + }, + ], + [pinned, visible, openGnoscanCollection], + ); + + return ( + , + onClick: moveBack, + }} + rightElement={{ + element: ( + } + options={dropdownOptions} + hover + /> + ), + onClick: (): void => { + return; + }, + }} + /> + ); +}; + +export default NFTAssetHeader; diff --git a/packages/adena-extension/src/components/pages/nft-asset/nft-asset-metadata/nft-asset-metadata.spec.tsx b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-metadata/nft-asset-metadata.spec.tsx new file mode 100644 index 000000000..c4f73a90c --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-metadata/nft-asset-metadata.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { UseQueryResult } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import { GRC721MetadataModel } from '@types'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import NFTAssetMetadata, { NFTAssetMetadataProps } from './nft-asset-metadata'; + +describe('NFTAssetMetadata Component', () => { + it('NFTAssetMetadata render', () => { + const args: NFTAssetMetadataProps = { + asset: { + metadata: null, + name: '', + networkId: '', + packagePath: '', + symbol: '', + tokenId: '', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + queryGRC721TokenMetadata: () => ({}) as unknown as UseQueryResult, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/nft-asset/nft-asset-metadata/nft-asset-metadata.stories.tsx b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-metadata/nft-asset-metadata.stories.tsx new file mode 100644 index 000000000..8b20cce4c --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-metadata/nft-asset-metadata.stories.tsx @@ -0,0 +1,11 @@ +import { Meta, StoryObj } from '@storybook/react'; +import NFTAssetMetadata, { type NFTAssetMetadataProps } from './nft-asset-metadata'; + +export default { + title: 'components/nft-asset/NFTAssetMetadata', + component: NFTAssetMetadata, +} as Meta; + +export const Default: StoryObj = { + args: {}, +}; diff --git a/packages/adena-extension/src/components/pages/nft-asset/nft-asset-metadata/nft-asset-metadata.styles.ts b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-metadata/nft-asset-metadata.styles.ts new file mode 100644 index 000000000..c0067c0d2 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-metadata/nft-asset-metadata.styles.ts @@ -0,0 +1,53 @@ +import { View } from '@components/atoms'; +import mixins from '@styles/mixins'; +import { fonts, getTheme } from '@styles/theme'; +import styled from 'styled-components'; + +export const NFTAssetMetadataWrapper = styled(View)` + width: 100%; + height: auto; + flex-shrink: 0; + margin-top: 8px; + gap: 20px; + + .title { + color: ${getTheme('neutral', '_2')}; + ${fonts.body1Reg}; + } + + .content { + color: ${getTheme('neutral', 'a')}; + ${fonts.body2Reg}; + } + + .content-wrapper, + .attribute-wrapper { + ${mixins.flex({ direction: 'column', align: 'flex-start', justify: 'flex-start' })}; + gap: 8px; + } + + .attribute-wrapper { + flex-flow: wrap; + } + + .trait-wrapper { + ${mixins.flex({ direction: 'column', align: 'flex-start', justify: 'flex-start' })}; + width: auto; + max-width: 100%; + height: auto; + padding: 5px 10px; + gap: 4px; + background-color: ${getTheme('neutral', '_9')}; + border-radius: 8px; + + .trait-type { + color: ${getTheme('neutral', '_5')}; + ${fonts.body2Reg}; + } + + .trait-value { + color: ${getTheme('neutral', '_1')}; + ${fonts.body2Reg}; + } + } +`; diff --git a/packages/adena-extension/src/components/pages/nft-asset/nft-asset-metadata/nft-asset-metadata.tsx b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-metadata/nft-asset-metadata.tsx new file mode 100644 index 000000000..2d4195708 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-asset/nft-asset-metadata/nft-asset-metadata.tsx @@ -0,0 +1,60 @@ +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { GRC721MetadataModel, GRC721Model } from '@types'; +import React, { useMemo } from 'react'; +import { NFTAssetMetadataWrapper } from './nft-asset-metadata.styles'; + +export interface NFTAssetMetadataProps { + asset: GRC721Model; + queryGRC721TokenMetadata: ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, + ) => UseQueryResult; +} + +const NFTAssetMetadata: React.FC = ({ asset, queryGRC721TokenMetadata }) => { + const { data: tokenMetadata, isFetched: isFetchedTokenMetadata } = queryGRC721TokenMetadata( + asset.packagePath, + asset.tokenId, + { + enabled: asset.isMetadata, + refetchOnMount: true, + }, + ); + + const isFetchedTokenMetadataWithEnabled = useMemo(() => { + if (!asset.isMetadata) { + return false; + } + + return isFetchedTokenMetadata && !!tokenMetadata; + }, [asset, tokenMetadata, isFetchedTokenMetadata]); + + if (!isFetchedTokenMetadataWithEnabled) { + return ; + } + + return ( + + + Description + {tokenMetadata?.description} + + + + Attributes + + + {tokenMetadata?.attributes.map((trait, index) => ( + + {trait.traitType} + {trait.value} + + ))} + + + + ); +}; + +export default NFTAssetMetadata; diff --git a/packages/adena-extension/src/components/pages/nft-collection/nft-collection-asset-card/nft-collection-asset-card.spec.tsx b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-asset-card/nft-collection-asset-card.spec.tsx new file mode 100644 index 000000000..c6b6c92ad --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-asset-card/nft-collection-asset-card.spec.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { UseQueryResult } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import NFTCollectionAssetCard, { NFTCollectionAssetCardProps } from './nft-collection-asset-card'; + +describe('NFTCollectionAssetCard Component', () => { + it('NFTCollectionAssetCard render', () => { + const args: NFTCollectionAssetCardProps = { + grc721Token: { + metadata: null, + name: '', + networkId: '', + packagePath: '', + symbol: '', + tokenId: '', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + moveAssetPage: () => { + return; + }, + queryGRC721TokenUri: () => ({}) as unknown as UseQueryResult, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/nft-collection/nft-collection-asset-card/nft-collection-asset-card.stories.tsx b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-asset-card/nft-collection-asset-card.stories.tsx new file mode 100644 index 000000000..8479a2656 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-asset-card/nft-collection-asset-card.stories.tsx @@ -0,0 +1,77 @@ +import { action } from '@storybook/addon-actions'; +import { Meta, StoryObj } from '@storybook/react'; +import { UseQueryResult } from '@tanstack/react-query'; +import NFTCollectionAssetCard, { + type NFTCollectionAssetCardProps, +} from './nft-collection-asset-card'; + +export default { + title: 'components/nft/NFTCollectionAssetCard', + component: NFTCollectionAssetCard, +} as Meta; + +export const Default: StoryObj = { + args: { + grc721Token: { + metadata: null, + name: 'Gnopunks', + networkId: '', + packagePath: 'package path', + symbol: '', + tokenId: '0', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + moveAssetPage: action('moveAssetPage'), + queryGRC721TokenUri: () => + ({ + data: 'https://cdn.prod.website-files.com/6615636a03a6003b067c36dd/661ffd0dbe9673d914edca2d_6423fc9ca8b5e94da1681a70_Screenshot%25202023-03-29%2520at%252010.53.43.jpeg', + isFetched: true, + }) as unknown as UseQueryResult, + }, +}; + +export const Loading: StoryObj = { + args: { + grc721Token: { + metadata: null, + name: 'Gnopunks', + networkId: '', + packagePath: 'package path', + symbol: '', + tokenId: '0', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + moveAssetPage: action('moveAssetPage'), + queryGRC721TokenUri: () => + ({ + data: 'https://cdn.prod.website-files.com/6615636a03a6003b067c36dd/661ffd0dbe9673d914edca2d_6423fc9ca8b5e94da1681a70_Screenshot%25202023-03-29%2520at%252010.53.43.jpeg', + isFetched: false, + }) as unknown as UseQueryResult, + }, +}; + +export const EmptyImage: StoryObj = { + args: { + grc721Token: { + metadata: null, + name: 'Gnopunks', + networkId: '', + packagePath: 'package path', + symbol: '', + tokenId: '0', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + moveAssetPage: action('moveAssetPage'), + queryGRC721TokenUri: () => + ({ + data: null, + isFetched: true, + }) as unknown as UseQueryResult, + }, +}; diff --git a/packages/adena-extension/src/components/pages/nft-collection/nft-collection-asset-card/nft-collection-asset-card.styles.ts b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-asset-card/nft-collection-asset-card.styles.ts new file mode 100644 index 000000000..d65529786 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-asset-card/nft-collection-asset-card.styles.ts @@ -0,0 +1,46 @@ +import { View } from '@components/atoms'; +import mixins from '@styles/mixins'; +import { fonts, getTheme } from '@styles/theme'; +import styled from 'styled-components'; + +export const NFTCollectionAssetCardWrapper = styled(View)` + position: relative; + width: 100%; + aspect-ratio: 1; + height: auto; + overflow: hidden; + border-radius: 8px; + cursor: pointer; + + .info-static-wrapper { + ${mixins.flex({ direction: 'row', align: 'center', justify: 'center' })} + position: absolute; + top: 10px; + width: 132px; + flex-shrink: 0; + height: 20px; + padding: 0 7px; + gap: 2px; + flex-shrink: 0; + align-self: center; + border-radius: 10px; + background-color: ${getTheme('neutral', '_9')}; + + .name-wrapper { + display: inline-block; + width: auto; + ${fonts.captionBold} + word-break: break-all; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .id-wrapper { + ${mixins.flex({ direction: 'column', align: 'flex-end', justify: 'flex-end' })} + width: auto; + flex-shrink: 0; + ${fonts.captionBold} + } + } +`; diff --git a/packages/adena-extension/src/components/pages/nft-collection/nft-collection-asset-card/nft-collection-asset-card.tsx b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-asset-card/nft-collection-asset-card.tsx new file mode 100644 index 000000000..9750c66a2 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-asset-card/nft-collection-asset-card.tsx @@ -0,0 +1,62 @@ +import NFTCardImage from '@components/molecules/nft-card-image/nft-card-image'; +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { GRC721Model } from '@types'; +import React, { useCallback, useMemo } from 'react'; +import { NFTCollectionAssetCardWrapper } from './nft-collection-asset-card.styles'; + +export interface NFTCollectionAssetCardProps { + grc721Token: GRC721Model; + queryGRC721TokenUri: ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, + ) => UseQueryResult; + moveAssetPage: (grc721Token: GRC721Model) => void; +} + +const NFTCollectionAssetCard: React.FC = ({ + grc721Token, + queryGRC721TokenUri, + moveAssetPage, +}) => { + const { data: tokenUri, isFetched: isFetchedTokenUri } = queryGRC721TokenUri( + grc721Token.packagePath, + grc721Token.tokenId, + { + enabled: grc721Token.isTokenUri, + }, + ); + + const isFetchedCardTokenUri = useMemo(() => { + if (!grc721Token.isTokenUri) { + return true; + } + + return isFetchedTokenUri; + }, [grc721Token, isFetchedTokenUri]); + + const tokenName = useMemo(() => { + return `${grc721Token.name}`; + }, [grc721Token.name]); + + const tokenId = useMemo(() => { + return `#${grc721Token.tokenId}`; + }, [grc721Token.tokenId]); + + const onClickCard = useCallback(() => { + moveAssetPage(grc721Token); + }, [grc721Token, moveAssetPage]); + + return ( + + + + + {tokenName} + {tokenId} + + + ); +}; + +export default NFTCollectionAssetCard; diff --git a/packages/adena-extension/src/components/pages/nft-collection/nft-collection-assets/nft-collection-assets.spec.tsx b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-assets/nft-collection-assets.spec.tsx new file mode 100644 index 000000000..fb3829db7 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-assets/nft-collection-assets.spec.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { UseQueryResult } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import NFTCollectionAssets, { NFTCollectionAssetsProps } from './nft-collection-assets'; + +describe('NFTCollectionAssets Component', () => { + it('NFTCollectionAssets render', () => { + const args: NFTCollectionAssetsProps = { + tokens: [], + isFetchedTokens: true, + moveAssetPage: () => { + return; + }, + queryGRC721TokenUri: () => ({}) as unknown as UseQueryResult, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/nft-collection/nft-collection-assets/nft-collection-assets.stories.tsx b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-assets/nft-collection-assets.stories.tsx new file mode 100644 index 000000000..447da12f5 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-assets/nft-collection-assets.stories.tsx @@ -0,0 +1,11 @@ +import { Meta, StoryObj } from '@storybook/react'; +import NFTCollectionAssets, { type NFTCollectionAssetsProps } from './nft-collection-assets'; + +export default { + title: 'components/nft/NFTCollectionAssets', + component: NFTCollectionAssets, +} as Meta; + +export const Default: StoryObj = { + args: {}, +}; diff --git a/packages/adena-extension/src/components/pages/nft-collection/nft-collection-assets/nft-collection-assets.styles.ts b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-assets/nft-collection-assets.styles.ts new file mode 100644 index 000000000..80632333e --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-assets/nft-collection-assets.styles.ts @@ -0,0 +1,28 @@ +import { getTheme } from '@styles/theme'; +import styled from 'styled-components'; + +export const NFTCollectionAssetsWrapper = styled.div` + position: relative; + display: grid; + width: 100%; + min-height: auto; + grid-template-columns: 1fr 1fr; + gap: 16px; + + .description { + position: absolute; + top: 210px; + left: 0px; + width: 100%; + text-align: center; + } + + .loading-wrapper { + position: absolute; + width: 100%; + height: auto; + top: 0; + left: 0; + background-color: ${getTheme('neutral', '_8')}; + } +`; diff --git a/packages/adena-extension/src/components/pages/nft-collection/nft-collection-assets/nft-collection-assets.tsx b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-assets/nft-collection-assets.tsx new file mode 100644 index 000000000..92d3822ec --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-assets/nft-collection-assets.tsx @@ -0,0 +1,71 @@ +import { Text } from '@components/atoms'; +import { LoadingNft } from '@components/molecules'; +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { GRC721Model } from '@types'; +import React, { useMemo } from 'react'; +import { useTheme } from 'styled-components'; +import NFTCollectionAssetCard from '../nft-collection-asset-card/nft-collection-asset-card'; +import { NFTCollectionAssetsWrapper } from './nft-collection-assets.styles'; + +export interface NFTCollectionAssetsProps { + tokens: GRC721Model[] | null | undefined; + isFetchedTokens: boolean; + queryGRC721TokenUri: ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, + ) => UseQueryResult; + moveAssetPage: (grc721Token: GRC721Model) => void; +} + +const NFTCollectionAssets: React.FC = ({ + tokens, + isFetchedTokens, + queryGRC721TokenUri, + moveAssetPage, +}) => { + const theme = useTheme(); + + const isLoading = useMemo(() => { + if (!isFetchedTokens) { + return true; + } + + return tokens === null; + }, [isFetchedTokens, tokens]); + + const isEmptyAssets = useMemo(() => { + return tokens?.length === 0; + }, [tokens]); + + if (isEmptyAssets) { + return ( + + + No NFTs to display + + + ); + } + + return ( + + {tokens?.map((token, index) => ( + + ))} + + {isLoading && ( + + + + )} + + ); +}; + +export default NFTCollectionAssets; diff --git a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item-balance/manage-token-list-item-balance.spec.tsx b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-header/nft-collection-header.spec.tsx similarity index 52% rename from packages/adena-extension/src/components/pages/manage-token/manage-token-list-item-balance/manage-token-list-item-balance.spec.tsx rename to packages/adena-extension/src/components/pages/nft-collection/nft-collection-header/nft-collection-header.spec.tsx index 1372e3059..b766684c1 100644 --- a/packages/adena-extension/src/components/pages/manage-token/manage-token-list-item-balance/manage-token-list-item-balance.spec.tsx +++ b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-header/nft-collection-header.spec.tsx @@ -1,27 +1,28 @@ import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { render } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; import { ThemeProvider } from 'styled-components'; -import { render } from '@testing-library/react'; -import theme from '@styles/theme'; -import { GlobalPopupStyle } from '@styles/global-style'; -import ManageTokenListItemBalance, { - ManageTokenListItemBalanceProps, -} from './manage-token-list-item-balance'; +import NFTCollectionHeader, { NFTCollectionHeaderProps } from './nft-collection-header'; -describe('ManageTokenListItemBalance Component', () => { - it('ManageTokenListItemBalance render', () => { - const args: ManageTokenListItemBalanceProps = { - amount: { - value: '240,255.241155', - denom: 'GNOT', +describe('NFTCollectionHeader Component', () => { + it('NFTCollectionHeader render', () => { + const args: NFTCollectionHeaderProps = { + moveBack: () => { + return; + }, + openGnoscanCollection: () => { + return; }, + title: '', }; render( - + , ); diff --git a/packages/adena-extension/src/components/pages/nft-collection/nft-collection-header/nft-collection-header.stories.tsx b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-header/nft-collection-header.stories.tsx new file mode 100644 index 000000000..33653e146 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-header/nft-collection-header.stories.tsx @@ -0,0 +1,17 @@ +import { Meta, StoryObj } from '@storybook/react'; +import NFTCollectionHeader, { type NFTCollectionHeaderProps } from './nft-collection-header'; + +import { action } from '@storybook/addon-actions'; + +export default { + title: 'components/nft/NFTCollectionHeader', + component: NFTCollectionHeader, +} as Meta; + +export const Default: StoryObj = { + args: { + title: 'title', + moveBack: action('moveBack'), + openGnoscanCollection: action('openGnoscanCollection'), + }, +}; diff --git a/packages/adena-extension/src/components/pages/nft-collection/nft-collection-header/nft-collection-header.tsx b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-header/nft-collection-header.tsx new file mode 100644 index 000000000..06f66f692 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-collection/nft-collection-header/nft-collection-header.tsx @@ -0,0 +1,54 @@ +import React, { useMemo } from 'react'; + +import LeftArrowIcon from '@assets/arrowL-left.svg'; +import IconEtc from '@assets/etc.svg'; +import IconLink from '@assets/icon-link'; +import { SubHeader } from '@components/atoms'; +import OptionDropdown from '@components/atoms/option-dropdown/option-dropdown'; + +export interface NFTCollectionHeaderProps { + title: string; + moveBack: () => void; + openGnoscanCollection: () => void; +} + +const NFTCollectionHeader: React.FC = ({ + title, + moveBack, + openGnoscanCollection, +}) => { + const dropdownOptions = useMemo( + () => [ + { + text: 'View on Gnoscan', + icon: , + onClick: openGnoscanCollection, + }, + ], + [], + ); + + return ( + , + onClick: moveBack, + }} + rightElement={{ + element: ( + } + options={dropdownOptions} + hover + /> + ), + onClick: (): void => { + return; + }, + }} + /> + ); +}; + +export default NFTCollectionHeader; diff --git a/packages/adena-extension/src/components/pages/nft-transfer-input/nft-transfer-input/nft-transfer-input.spec.tsx b/packages/adena-extension/src/components/pages/nft-transfer-input/nft-transfer-input/nft-transfer-input.spec.tsx new file mode 100644 index 000000000..868e0534a --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-transfer-input/nft-transfer-input/nft-transfer-input.spec.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { UseQueryResult } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import NFTTransferInput, { NFTTransferInputProps } from './nft-transfer-input'; + +describe('NFTTransferInput Component', () => { + it('NFTTransferInput render', () => { + const args: NFTTransferInputProps = { + grc721Token: { + metadata: null, + name: '', + networkId: '', + packagePath: '', + symbol: '', + tokenId: '', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + queryGRC721TokenUri: () => ({}) as unknown as UseQueryResult, + addressInput: { + opened: false, + hasError: false, + errorMessage: 'Invalid address', + selected: false, + selectedName: '', + selectedDescription: '(g1ff...jpae)', + address: '', + addressBookInfos: [], + onClickInputIcon: () => { + return; + }, + onChangeAddress: () => { + return; + }, + onClickAddressBook: () => { + return; + }, + }, + memoInput: { + memo: '', + onChangeMemo: () => { + return; + }, + }, + isNext: true, + hasBackButton: true, + onClickBack: () => { + return; + }, + onClickCancel: () => { + return; + }, + onClickNext: () => { + return; + }, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/nft-transfer-input/nft-transfer-input/nft-transfer-input.stories.tsx b/packages/adena-extension/src/components/pages/nft-transfer-input/nft-transfer-input/nft-transfer-input.stories.tsx new file mode 100644 index 000000000..0e9b7ac64 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-transfer-input/nft-transfer-input/nft-transfer-input.stories.tsx @@ -0,0 +1,46 @@ +import { action } from '@storybook/addon-actions'; +import { Meta, StoryObj } from '@storybook/react'; +import NFTTransferInput, { type NFTTransferInputProps } from './nft-transfer-input'; + +export default { + title: 'components/transfer/NFTTransferInput', + component: NFTTransferInput, +} as Meta; + +export const Default: StoryObj = { + args: { + grc721Token: { + metadata: null, + name: '', + networkId: '', + packagePath: '', + symbol: '', + tokenId: '', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + addressInput: { + opened: false, + hasError: false, + errorMessage: 'Invalid address', + selected: false, + selectedName: '', + selectedDescription: '(g1ff...jpae)', + address: '', + addressBookInfos: [], + onClickInputIcon: action('click input icon'), + onChangeAddress: action('change address'), + onClickAddressBook: action('click address book'), + }, + memoInput: { + memo: '', + onChangeMemo: action('onChangeMemo'), + }, + isNext: true, + hasBackButton: true, + onClickBack: action('click back'), + onClickCancel: action('click cancel'), + onClickNext: action('click next'), + }, +}; diff --git a/packages/adena-extension/src/components/pages/nft-transfer-input/nft-transfer-input/nft-transfer-input.styles.ts b/packages/adena-extension/src/components/pages/nft-transfer-input/nft-transfer-input/nft-transfer-input.styles.ts new file mode 100644 index 000000000..b0f811005 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-transfer-input/nft-transfer-input/nft-transfer-input.styles.ts @@ -0,0 +1,34 @@ +import mixins from '@styles/mixins'; +import styled from 'styled-components'; + +export const NFTTransferInputWrapper = styled.div` + ${mixins.flex({ align: 'normal', justify: 'normal' })}; + position: relative; + width: 100%; + width: 100%; + height: auto; + padding: 24px 20px 96px; + + .asset-card-wrapper { + display: flex; + justify-content: center; + align-items: center; + margin: 25px auto 30px; + width: 100px; + } + + .address-input-wrapper { + display: flex; + margin-bottom: 12px; + } + + .balance-input-wrapper { + display: flex; + margin-bottom: 12px; + } + + .memo-input-wrapper { + display: flex; + padding-bottom: 20px; + } +`; diff --git a/packages/adena-extension/src/components/pages/nft-transfer-input/nft-transfer-input/nft-transfer-input.tsx b/packages/adena-extension/src/components/pages/nft-transfer-input/nft-transfer-input/nft-transfer-input.tsx new file mode 100644 index 000000000..c544c7f72 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-transfer-input/nft-transfer-input/nft-transfer-input.tsx @@ -0,0 +1,107 @@ +import React, { useMemo } from 'react'; + +import ArrowLeftIcon from '@assets/arrowL-left.svg'; +import { SubHeader } from '@components/atoms'; +import { NFTTransferInputWrapper } from './nft-transfer-input.styles'; + +import { BaseError } from '@common/errors'; +import { BottomFixedButtonGroup } from '@components/molecules'; +import NFTAssetImageCard from '@components/molecules/nft-asset-image-card/nft-asset-image-card'; +import AddressInput from '@components/pages/transfer-input/address-input/address-input'; +import MemoInput from '@components/pages/transfer-input/memo-input/memo-input'; +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { GRC721Model } from '@types'; + +export interface NFTTransferInputProps { + grc721Token: GRC721Model; + addressInput: { + opened: boolean; + hasError: boolean; + selected: boolean; + selectedName: string; + selectedDescription: string; + address: string; + errorMessage?: string; + addressBookInfos: { + addressBookId: string; + name: string; + description: string; + }[]; + onClickInputIcon: (selected: boolean) => void; + onChangeAddress: (address: string) => void; + onClickAddressBook: (addressBookId: string) => void; + }; + memoInput: { + memo: string; + memoError?: BaseError | null; + onChangeMemo: (memo: string) => void; + }; + isNext: boolean; + hasBackButton: boolean; + queryGRC721TokenUri: ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, + ) => UseQueryResult; + onClickBack: () => void; + onClickCancel: () => void; + onClickNext: () => void; +} + +const NFTTransferInput: React.FC = ({ + grc721Token, + addressInput, + memoInput, + hasBackButton, + isNext, + queryGRC721TokenUri, + onClickBack, + onClickCancel, + onClickNext, +}) => { + const title = useMemo(() => { + return `Send ${grc721Token.name} #${grc721Token.tokenId}`; + }, [grc721Token]); + + return ( + + {hasBackButton ? ( + , + onClick: onClickBack, + }} + /> + ) : ( + + )} + + + + + + + + + + + + + + ); +}; + +export default NFTTransferInput; diff --git a/packages/adena-extension/src/components/pages/nft-transfer-summary/nft-transfer-summary/nft-transfer-summary.spec.tsx b/packages/adena-extension/src/components/pages/nft-transfer-summary/nft-transfer-summary/nft-transfer-summary.spec.tsx new file mode 100644 index 000000000..8b713f5e1 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-transfer-summary/nft-transfer-summary/nft-transfer-summary.spec.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { UseQueryResult } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import NFTTransferSummary, { NFTTransferSummaryProps } from './nft-transfer-summary'; + +describe('NFTTransferSummary Component', () => { + it('NFTTransferSummary render', () => { + const args: NFTTransferSummaryProps = { + grc721Token: { + metadata: null, + name: '', + networkId: '', + packagePath: '', + symbol: '', + tokenId: '', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + toAddress: '', + networkFee: { + value: '0.0048', + denom: 'GNOT', + }, + memo: '', + queryGRC721TokenUri: () => ({}) as unknown as UseQueryResult, + onClickBack: () => { + return; + }, + onClickCancel: () => { + return; + }, + onClickSend: () => { + return; + }, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/nft-transfer-summary/nft-transfer-summary/nft-transfer-summary.stories.tsx b/packages/adena-extension/src/components/pages/nft-transfer-summary/nft-transfer-summary/nft-transfer-summary.stories.tsx new file mode 100644 index 000000000..b5ca13743 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-transfer-summary/nft-transfer-summary/nft-transfer-summary.stories.tsx @@ -0,0 +1,40 @@ +import { action } from '@storybook/addon-actions'; +import { Meta, StoryObj } from '@storybook/react'; +import { UseQueryResult } from '@tanstack/react-query'; +import NFTTransferSummary, { type NFTTransferSummaryProps } from './nft-transfer-summary'; + +export default { + title: 'components/transfer/NFTTransferSummary', + component: NFTTransferSummary, +} as Meta; + +export const Default: StoryObj = { + args: { + isErrorNetworkFee: false, + grc721Token: { + metadata: null, + name: '', + networkId: '', + packagePath: '', + symbol: '', + tokenId: '', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + queryGRC721TokenUri: () => ({}) as unknown as UseQueryResult, + toAddress: 'g1fnakf9vrd6uqn8qdmp88yac4p0ngy572answ9f', + networkFee: { + value: '0.0048', + denom: 'GNOT', + }, + onClickBack: action('click back'), + onClickCancel: action('click cancel'), + onClickSend: action('click send'), + }, + render: (args) => ( + + + + ), +}; diff --git a/packages/adena-extension/src/components/pages/nft-transfer-summary/nft-transfer-summary/nft-transfer-summary.styles.ts b/packages/adena-extension/src/components/pages/nft-transfer-summary/nft-transfer-summary/nft-transfer-summary.styles.ts new file mode 100644 index 000000000..95a43130a --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-transfer-summary/nft-transfer-summary/nft-transfer-summary.styles.ts @@ -0,0 +1,79 @@ +import mixins from '@styles/mixins'; +import { fonts, getTheme } from '@styles/theme'; +import styled from 'styled-components'; + +export const NFTTransferSummaryWrapper = styled.div` + ${mixins.flex({ align: 'normal', justify: 'flex-start' })}; + position: relative; + width: 100%; + height: 100%; + min-height: auto; + align-items: center; + + .sub-header-wrapper { + width: 100%; + } + + .info-wrapper { + width: 100%; + margin-top: 25px; + + .asset-card-wrapper { + width: 100px; + margin: 0 auto; + } + } + + .direction-icon-wrapper { + width: 100%; + text-align: center; + margin: 10px 0; + } + + .network-fee-wrapper { + width: 100%; + height: 100%; + margin-top: 12px; + + .error-message { + position: relative; + width: 100%; + padding: 0 16px; + ${fonts.captionReg}; + color: ${getTheme('red', '_5')}; + } + } + + .button-group { + position: absolute; + display: flex; + width: 100%; + bottom: 0; + justify-content: space-between; + + button { + width: 100%; + height: 48px; + border-radius: 30px; + ${fonts.body1Bold}; + background-color: ${getTheme('neutral', '_5')}; + transition: 0.2s; + + :hover { + background-color: ${getTheme('neutral', '_6')}; + } + + &:last-child { + margin-left: 10px; + } + + &.send { + background-color: ${getTheme('primary', '_6')}; + + :hover { + background-color: ${getTheme('primary', '_7')}; + } + } + } + } +`; diff --git a/packages/adena-extension/src/components/pages/nft-transfer-summary/nft-transfer-summary/nft-transfer-summary.tsx b/packages/adena-extension/src/components/pages/nft-transfer-summary/nft-transfer-summary/nft-transfer-summary.tsx new file mode 100644 index 000000000..7c91e809e --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft-transfer-summary/nft-transfer-summary/nft-transfer-summary.tsx @@ -0,0 +1,110 @@ +import React, { useMemo } from 'react'; + +import { SubHeader } from '@components/atoms'; + +import ArrowLeftIcon from '@assets/arrowL-left.svg'; +import ArrowDownIcon from '@assets/transfer-arrow-down.svg'; +import { NFTTransferSummaryWrapper } from './nft-transfer-summary.styles'; + +import { TransactionValidationError } from '@common/errors/validation/transaction-validation-error'; +import { BottomFixedButtonGroup } from '@components/molecules'; +import NFTAssetImageCard from '@components/molecules/nft-asset-image-card/nft-asset-image-card'; +import TransferSummaryAddress from '@components/pages/transfer-summary/transfer-summary-address/transfer-summary-address'; +import TransferSummaryNetworkFee from '@components/pages/transfer-summary/transfer-summary-network-fee/transfer-summary-network-fee'; +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { GRC721Model } from '@types'; + +export interface NFTTransferSummaryProps { + grc721Token: GRC721Model; + toAddress: string; + networkFee: { + value: string; + denom: string; + }; + memo: string; + isErrorNetworkFee?: boolean; + queryGRC721TokenUri: ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, + ) => UseQueryResult; + onClickBack: () => void; + onClickCancel: () => void; + onClickSend: () => void; +} + +const NFTTransferSummary: React.FC = ({ + grc721Token, + toAddress, + networkFee, + memo, + isErrorNetworkFee, + queryGRC721TokenUri, + onClickBack, + onClickCancel, + onClickSend, +}) => { + const insufficientNetworkFeeError = new TransactionValidationError('INSUFFICIENT_NETWORK_FEE'); + + const title = useMemo(() => { + return `Sending ${grc721Token.name} #${grc721Token.tokenId}`; + }, [grc721Token]); + + const errorMessage = useMemo(() => { + if (!isErrorNetworkFee) { + return ''; + } + + return insufficientNetworkFeeError.message; + }, [isErrorNetworkFee]); + + return ( + + + , + onClick: onClickBack, + }} + title={title} + /> + + + + + + + + + + + + + + + + + {isErrorNetworkFee && {errorMessage}} + + + + + ); +}; + +export default NFTTransferSummary; diff --git a/packages/adena-extension/src/components/pages/nft/manage-collections-button/manage-collections-button.spec.tsx b/packages/adena-extension/src/components/pages/nft/manage-collections-button/manage-collections-button.spec.tsx new file mode 100644 index 000000000..3b5f790f0 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/manage-collections-button/manage-collections-button.spec.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import ManageCollectionsButton, { ManageCollectionsButtonProps } from './manage-collections-button'; + +describe('ManageCollectionsButton Component', () => { + it('ManageCollectionsButton render', () => { + const args: ManageCollectionsButtonProps = { + onClick: () => { + return; + }, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/nft/manage-collections-button/manage-collections-button.stories.tsx b/packages/adena-extension/src/components/pages/nft/manage-collections-button/manage-collections-button.stories.tsx new file mode 100644 index 000000000..5ac71aa17 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/manage-collections-button/manage-collections-button.stories.tsx @@ -0,0 +1,16 @@ +import { action } from '@storybook/addon-actions'; +import { Meta, StoryObj } from '@storybook/react'; +import ManageCollectionsButton, { + type ManageCollectionsButtonProps, +} from './manage-collections-button'; + +export default { + title: 'components/nft/ManageCollectionsButton', + component: ManageCollectionsButton, +} as Meta; + +export const Default: StoryObj = { + args: { + onClick: action('manage token click'), + }, +}; diff --git a/packages/adena-extension/src/components/pages/nft/manage-collections-button/manage-collections-button.styles.ts b/packages/adena-extension/src/components/pages/nft/manage-collections-button/manage-collections-button.styles.ts new file mode 100644 index 000000000..2a574bd87 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/manage-collections-button/manage-collections-button.styles.ts @@ -0,0 +1,27 @@ +import mixins from '@styles/mixins'; +import { fonts, getTheme } from '@styles/theme'; +import styled from 'styled-components'; + +export const ManageCollectionsButtonWrapper = styled.div` + ${mixins.flex({ direction: 'row' })}; + flex-shrink: 0; + width: auto; + height: 24px; + cursor: pointer; + transition: 0.2s; + + &:hover { + opacity: 0.7; + } + + .icon { + width: 24px; + height: 24px; + margin-right: 5px; + } + + .title { + color: ${getTheme('neutral', 'a')}; + ${fonts.body1Reg}; + } +`; diff --git a/packages/adena-extension/src/components/pages/nft/manage-collections-button/manage-collections-button.tsx b/packages/adena-extension/src/components/pages/nft/manage-collections-button/manage-collections-button.tsx new file mode 100644 index 000000000..4dd00beec --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/manage-collections-button/manage-collections-button.tsx @@ -0,0 +1,18 @@ +import MainManageTokensFilterIcon from '@assets/main-manage-tokens-filter.svg'; +import React from 'react'; +import { ManageCollectionsButtonWrapper } from './manage-collections-button.styles'; + +export interface ManageCollectionsButtonProps { + onClick: () => void; +} + +const ManageCollectionsButton: React.FC = ({ onClick }) => { + return ( + + + {'Manage Collectables'} + + ); +}; + +export default ManageCollectionsButton; diff --git a/packages/adena-extension/src/components/pages/nft/nft-collection-card/nft-collection-card.spec.tsx b/packages/adena-extension/src/components/pages/nft/nft-collection-card/nft-collection-card.spec.tsx new file mode 100644 index 000000000..adde15f61 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/nft-collection-card/nft-collection-card.spec.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { UseQueryResult } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import NFTCollectionCard, { NFTCollectionCardProps } from './nft-collection-card'; + +describe('NFTCollectionCard Component', () => { + it('NFTCollectionCard render', () => { + const args: NFTCollectionCardProps = { + grc721Collection: { + display: false, + name: '', + networkId: '', + packagePath: '', + symbol: '', + image: null, + tokenId: '', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + pin: () => { + return; + }, + unpin: () => { + return; + }, + moveCollectionPage: () => { + return; + }, + exitsPinnedCollections: () => false, + queryGRC721TokenUri: () => ({}) as unknown as UseQueryResult, + queryGRC721Balance: () => ({}) as unknown as UseQueryResult, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/nft/nft-collection-card/nft-collection-card.stories.tsx b/packages/adena-extension/src/components/pages/nft/nft-collection-card/nft-collection-card.stories.tsx new file mode 100644 index 000000000..cabab2e8f --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/nft-collection-card/nft-collection-card.stories.tsx @@ -0,0 +1,95 @@ +import { action } from '@storybook/addon-actions'; +import { Meta, StoryObj } from '@storybook/react'; +import { UseQueryResult } from '@tanstack/react-query'; +import NFTCollectionCard, { type NFTCollectionCardProps } from './nft-collection-card'; + +export default { + title: 'components/nft/NFTCollectionCard', + component: NFTCollectionCard, +} as Meta; + +export const Default: StoryObj = { + args: { + grc721Collection: { + display: true, + name: 'Gnopunks', + networkId: '', + packagePath: 'package path', + symbol: '', + image: null, + tokenId: '0', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + pin: action('pin'), + unpin: action('pin'), + exitsPinnedCollections: () => false, + queryGRC721TokenUri: () => + ({ + data: 'https://cdn.prod.website-files.com/6615636a03a6003b067c36dd/661ffd0dbe9673d914edca2d_6423fc9ca8b5e94da1681a70_Screenshot%25202023-03-29%2520at%252010.53.43.jpeg', + isFetched: true, + }) as unknown as UseQueryResult, + queryGRC721Balance: () => + ({ + data: 3, + }) as unknown as UseQueryResult, + }, +}; + +export const Loading: StoryObj = { + args: { + grc721Collection: { + display: true, + name: 'Gnopunks', + networkId: '', + packagePath: 'package path', + symbol: '', + image: null, + tokenId: '0', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + pin: action('pin'), + unpin: action('pin'), + queryGRC721TokenUri: () => + ({ + data: 'https://cdn.prod.website-files.com/6615636a03a6003b067c36dd/661ffd0dbe9673d914edca2d_6423fc9ca8b5e94da1681a70_Screenshot%25202023-03-29%2520at%252010.53.43.jpeg', + isFetched: false, + }) as unknown as UseQueryResult, + queryGRC721Balance: () => + ({ + data: 3, + }) as unknown as UseQueryResult, + }, +}; + +export const EmptyImage: StoryObj = { + args: { + grc721Collection: { + display: true, + name: 'Gnopunks', + networkId: '', + packagePath: 'package path', + symbol: '', + image: null, + tokenId: '0', + type: 'grc721', + isMetadata: true, + isTokenUri: true, + }, + pin: action('pin'), + unpin: action('pin'), + queryGRC721TokenUri: () => + ({ + data: null, + isFetched: true, + }) as unknown as UseQueryResult, + queryGRC721Balance: () => + ({ + data: 3, + }) as unknown as UseQueryResult, + exitsPinnedCollections: () => false, + }, +}; diff --git a/packages/adena-extension/src/components/pages/nft/nft-collection-card/nft-collection-card.styles.ts b/packages/adena-extension/src/components/pages/nft/nft-collection-card/nft-collection-card.styles.ts new file mode 100644 index 000000000..6aa811e8c --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/nft-collection-card/nft-collection-card.styles.ts @@ -0,0 +1,99 @@ +import { SkeletonBoxStyle, View } from '@components/atoms'; +import mixins from '@styles/mixins'; +import { fonts, getTheme } from '@styles/theme'; +import styled from 'styled-components'; + +export const NFTCollectionCardWrapper = styled(View)` + position: relative; + width: 100%; + aspect-ratio: 1; + height: auto; + overflow: hidden; + border-radius: 8px; + cursor: pointer; + + .info-static-wrapper { + ${mixins.flex({ direction: 'row', align: 'center', justify: 'space-between' })} + position: absolute; + top: 10px; + width: 132px; + flex-shrink: 0; + height: 20px; + padding: 0 5px 0 8px; + gap: 4px; + flex-shrink: 0; + align-self: center; + border-radius: 10px; + background-color: ${getTheme('neutral', '_9')}; + cursor: default; + + .pin-wrapper { + ${mixins.flex({ direction: 'column', align: 'center', justify: 'center' })} + width: 10px; + height: 10px; + cursor: pointer; + + .icon-pin { + path { + transition: 0.2s; + fill: ${getTheme('neutral', '_5')}; + } + + &:hover, + &.pinned.pinned { + path { + fill: ${getTheme('neutral', '_1')}; + } + } + } + } + + .name-wrapper { + display: inline-block; + width: 100%; + ${fonts.captionBold} + text-align: center; + word-break: break-all; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .balance-wrapper { + ${mixins.flex({ direction: 'column', align: 'flex-end', justify: 'flex-end' })} + width: auto; + flex-shrink: 0; + color: ${getTheme('neutral', 'a')}; + ${fonts.light1Bold}; + } + } +`; + +export const NFTCollectionCardImageWrapper = styled(View)` + width: 100%; + height: 100%; + background-color: ${getTheme('neutral', '_7')}; + align-items: center; + justify-content: center; + + .empty-image { + width: 31px; + height: auto; + } + + .nft-image { + width: auto; + height: auto; + min-width: 100%; + min-height: 100%; + object-fit: cover; + } +`; + +export const NFTCollectionCardImageSkeletonBox = styled(SkeletonBoxStyle)` + ${mixins.flex({ align: 'flex-end', justify: 'space-between' })} + width: 100%; + flex: 1; + height: 100%; + padding: 10px; +`; diff --git a/packages/adena-extension/src/components/pages/nft/nft-collection-card/nft-collection-card.tsx b/packages/adena-extension/src/components/pages/nft/nft-collection-card/nft-collection-card.tsx new file mode 100644 index 000000000..4f444b97e --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/nft-collection-card/nft-collection-card.tsx @@ -0,0 +1,110 @@ +import IconPin from '@assets/icon-pin'; +import NFTCardImage from '@components/molecules/nft-card-image/nft-card-image'; +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { GRC721CollectionModel } from '@types'; +import BigNumber from 'bignumber.js'; +import React, { useCallback, useMemo } from 'react'; +import { NFTCollectionCardWrapper } from './nft-collection-card.styles'; + +export interface NFTCollectionCardProps { + grc721Collection: GRC721CollectionModel; + exitsPinnedCollections: (collection: GRC721CollectionModel) => boolean; + queryGRC721TokenUri: ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, + ) => UseQueryResult; + queryGRC721Balance: ( + packagePath: string, + options?: UseQueryOptions, + ) => UseQueryResult; + pin: (collection: GRC721CollectionModel) => void; + unpin: (collection: GRC721CollectionModel) => void; + moveCollectionPage: (collection: GRC721CollectionModel) => void; +} + +const NFTCollectionCard: React.FC = ({ + grc721Collection, + exitsPinnedCollections, + pin, + unpin, + queryGRC721TokenUri, + queryGRC721Balance, + moveCollectionPage, +}) => { + const { data: tokenUri, isFetched: isFetchedTokenUri } = queryGRC721TokenUri( + grc721Collection.packagePath, + grc721Collection.tokenId, + { + enabled: grc721Collection.isTokenUri, + }, + ); + + const { data: balance } = queryGRC721Balance(grc721Collection.packagePath); + + const isFetchedCardTokenUri = useMemo(() => { + if (!grc721Collection.isTokenUri) { + return true; + } + + return isFetchedTokenUri; + }, [grc721Collection, isFetchedTokenUri]); + + const tokenName = useMemo(() => { + return `${grc721Collection.name}`; + }, [grc721Collection.name]); + + const balanceStr = useMemo(() => { + if (balance === undefined || balance === null) { + return ''; + } + + return BigNumber(balance).toFormat(); + }, [balance]); + + const pinned = useMemo(() => { + return exitsPinnedCollections(grc721Collection); + }, [grc721Collection, exitsPinnedCollections]); + + const onClickPin = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (pinned) { + unpin(grc721Collection); + return; + } + + pin(grc721Collection); + }, + [pinned, pin, unpin], + ); + + const onClickCard = useCallback(() => { + moveCollectionPage(grc721Collection); + }, [grc721Collection, moveCollectionPage]); + + return ( + + + + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + + + {tokenName} + + {balanceStr} + + + ); +}; + +export default NFTCollectionCard; diff --git a/packages/adena-extension/src/components/pages/nft/nft-collections/nft-collections.spec.tsx b/packages/adena-extension/src/components/pages/nft/nft-collections/nft-collections.spec.tsx new file mode 100644 index 000000000..2c25b89ee --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/nft-collections/nft-collections.spec.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { UseQueryResult } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import NFTCollections, { NFTCollectionsProps } from './nft-collections'; + +describe('NFTCollections Component', () => { + it('NFTCollections render', () => { + const args: NFTCollectionsProps = { + collections: [], + isFetchedCollections: true, + pinnedCollections: [], + isFetchedPinnedCollections: true, + pin: async () => { + return; + }, + unpin: async () => { + return; + }, + moveCollectionPage: () => { + return; + }, + moveManageCollectionsPage: () => { + return; + }, + queryGRC721TokenUri: () => ({}) as unknown as UseQueryResult, + queryGRC721Balance: () => ({}) as unknown as UseQueryResult, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/nft/nft-collections/nft-collections.stories.tsx b/packages/adena-extension/src/components/pages/nft/nft-collections/nft-collections.stories.tsx new file mode 100644 index 000000000..04c8a1419 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/nft-collections/nft-collections.stories.tsx @@ -0,0 +1,11 @@ +import { Meta, StoryObj } from '@storybook/react'; +import NFTCollections, { type NFTCollectionsProps } from './nft-collections'; + +export default { + title: 'components/nft/NFTCollections', + component: NFTCollections, +} as Meta; + +export const Default: StoryObj = { + args: {}, +}; diff --git a/packages/adena-extension/src/components/pages/nft/nft-collections/nft-collections.styles.ts b/packages/adena-extension/src/components/pages/nft/nft-collections/nft-collections.styles.ts new file mode 100644 index 000000000..d0ca29ab0 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/nft-collections/nft-collections.styles.ts @@ -0,0 +1,46 @@ +import { getTheme } from '@styles/theme'; +import styled from 'styled-components'; + +export const NFTCollectionsWrapper = styled.div` + position: relative; + display: flex; + flex-direction: column; + width: 100%; + gap: 24px; + + &.non-items { + padding-top: 151px; + } + + .collection-wrapper { + display: grid; + width: 100%; + min-height: auto; + grid-template-columns: 1fr 1fr; + gap: 16px; + } + + .description { + position: absolute; + top: 210px; + left: 0px; + width: 100%; + text-align: center; + } + + .manage-collection-button-wrapper { + display: flex; + width: 100%; + justify-content: center; + align-items: center; + } + + .loading-wrapper { + position: absolute; + width: 100%; + height: auto; + top: 0; + left: 0; + background-color: ${getTheme('neutral', '_8')}; + } +`; diff --git a/packages/adena-extension/src/components/pages/nft/nft-collections/nft-collections.tsx b/packages/adena-extension/src/components/pages/nft/nft-collections/nft-collections.tsx new file mode 100644 index 000000000..b5cd223be --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/nft-collections/nft-collections.tsx @@ -0,0 +1,150 @@ +import { Text } from '@components/atoms'; +import { LoadingNft } from '@components/molecules'; +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { GRC721CollectionModel } from '@types'; +import React, { useCallback, useMemo } from 'react'; +import { useTheme } from 'styled-components'; +import ManageCollectionsButton from '../manage-collections-button/manage-collections-button'; +import NFTCollectionCard from '../nft-collection-card/nft-collection-card'; +import { NFTCollectionsWrapper } from './nft-collections.styles'; + +export interface NFTCollectionsProps { + collections: GRC721CollectionModel[] | null | undefined; + isFetchedCollections: boolean; + pinnedCollections: string[] | null | undefined; + isFetchedPinnedCollections: boolean; + pin: (packagePath: string) => Promise; + unpin: (packagePath: string) => Promise; + queryGRC721TokenUri: ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, + ) => UseQueryResult; + queryGRC721Balance: ( + packagePath: string, + options?: UseQueryOptions, + ) => UseQueryResult; + moveCollectionPage: (collection: GRC721CollectionModel) => void; + moveManageCollectionsPage: () => void; +} + +const NFTCollections: React.FC = ({ + isFetchedCollections, + collections, + isFetchedPinnedCollections, + pinnedCollections, + pin, + unpin, + queryGRC721TokenUri, + queryGRC721Balance, + moveCollectionPage, + moveManageCollectionsPage, +}) => { + const theme = useTheme(); + + const isLoading = useMemo(() => { + if (!isFetchedCollections || !isFetchedPinnedCollections) { + return true; + } + + return collections === null; + }, [isFetchedCollections, isFetchedPinnedCollections, collections]); + + const isEmptyCollections = useMemo(() => { + return collections?.length === 0; + }, [collections]); + + const isEmptyDisplayCollections = useMemo(() => { + return collections?.filter((collection) => collection.display).length === 0; + }, [collections]); + + const sortedCollections = useMemo(() => { + if (!Array.isArray(collections)) { + return collections; + } + + if (!Array.isArray(pinnedCollections)) { + return null; + } + + const pinned = pinnedCollections + .map((packagePath) => + collections.find((collection) => collection.packagePath === packagePath), + ) + .filter((collection) => !!collection) as GRC721CollectionModel[]; + + const unpinned = collections.filter( + (collection) => !pinnedCollections.includes(collection.packagePath), + ); + + return [...pinned, ...unpinned]; + }, [pinnedCollections, collections]); + + const exitsPinnedCollections = useCallback( + (collection: GRC721CollectionModel) => { + if (!pinnedCollections) { + return false; + } + + return pinnedCollections.findIndex((path) => path === collection.packagePath) > -1; + }, + [pinnedCollections], + ); + + const onClickManageCollectionsButton = useCallback(() => { + moveManageCollectionsPage(); + }, [moveManageCollectionsPage]); + + if (isEmptyCollections) { + return ( + + + No NFTs to display + + + ); + } + + if (isEmptyDisplayCollections) { + return ( + + + + ); + } + + return ( + + + {sortedCollections?.map((collection, index) => ( + { + pin(collection.packagePath); + }} + unpin={(collection: GRC721CollectionModel): void => { + unpin(collection.packagePath); + }} + exitsPinnedCollections={exitsPinnedCollections} + queryGRC721Balance={queryGRC721Balance} + queryGRC721TokenUri={queryGRC721TokenUri} + moveCollectionPage={moveCollectionPage} + /> + ))} + + + + + + + {isLoading && ( + + + + )} + + ); +}; + +export default NFTCollections; diff --git a/packages/adena-extension/src/components/pages/nft/nft-header/nft-header.spec.tsx b/packages/adena-extension/src/components/pages/nft/nft-header/nft-header.spec.tsx new file mode 100644 index 000000000..db211e0f3 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/nft-header/nft-header.spec.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import NFTHeader, { NFTHeaderProps } from './nft-header'; + +describe('NFTHeader Component', () => { + it('NFTHeader render', () => { + const args: NFTHeaderProps = { + moveDepositPage: () => { + return; + }, + openGnoscan: () => { + return; + }, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/nft/nft-header/nft-header.stories.tsx b/packages/adena-extension/src/components/pages/nft/nft-header/nft-header.stories.tsx new file mode 100644 index 000000000..cbf44fea3 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/nft-header/nft-header.stories.tsx @@ -0,0 +1,15 @@ +import { action } from '@storybook/addon-actions'; +import { Meta, StoryObj } from '@storybook/react'; +import NFTHeader, { type NFTHeaderProps } from './nft-header'; + +export default { + title: 'components/nft/NFTHeader', + component: NFTHeader, +} as Meta; + +export const Default: StoryObj = { + args: { + moveDepositPage: action('moveDepositPage'), + openGnoscan: action('openGnoscan'), + }, +}; diff --git a/packages/adena-extension/src/components/pages/nft/nft-header/nft-header.styles.ts b/packages/adena-extension/src/components/pages/nft/nft-header/nft-header.styles.ts new file mode 100644 index 000000000..c06d678e1 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/nft-header/nft-header.styles.ts @@ -0,0 +1,9 @@ +import { Row } from '@components/atoms'; +import styled from 'styled-components'; + +export const NFTHeaderWrapper = styled(Row)` + width: 100%; + height: auto; + justify-content: space-between; + align-items: center; +`; diff --git a/packages/adena-extension/src/components/pages/nft/nft-header/nft-header.tsx b/packages/adena-extension/src/components/pages/nft/nft-header/nft-header.tsx new file mode 100644 index 000000000..00f57f858 --- /dev/null +++ b/packages/adena-extension/src/components/pages/nft/nft-header/nft-header.tsx @@ -0,0 +1,43 @@ +import IconEtc from '@assets/etc.svg'; +import IconLink from '@assets/icon-link'; +import IconQRCode from '@assets/icon-qrcode'; +import { Text } from '@components/atoms'; +import OptionDropdown from '@components/atoms/option-dropdown/option-dropdown'; +import React, { useMemo } from 'react'; +import { NFTHeaderWrapper } from './nft-header.styles'; + +export interface NFTHeaderProps { + openGnoscan: () => void; + moveDepositPage: () => void; +} + +const NFTHeader: React.FC = ({ openGnoscan, moveDepositPage }) => { + const dropdownOptions = useMemo( + () => [ + { + text: 'Deposit NFT', + icon: , + onClick: moveDepositPage, + }, + { + text: 'View on Gnoscan', + icon: , + onClick: openGnoscan, + }, + ], + [openGnoscan, moveDepositPage], + ); + + return ( + + NFTs + } + options={dropdownOptions} + hover + /> + + ); +}; + +export default NFTHeader; diff --git a/packages/adena-extension/src/components/pages/transfer-input/transfer-input/transfer-input.spec.tsx b/packages/adena-extension/src/components/pages/transfer-input/transfer-input/transfer-input.spec.tsx index db95e5c79..514f6f7f8 100644 --- a/packages/adena-extension/src/components/pages/transfer-input/transfer-input/transfer-input.spec.tsx +++ b/packages/adena-extension/src/components/pages/transfer-input/transfer-input/transfer-input.spec.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { GlobalPopupStyle } from '@styles/global-style'; import theme from '@styles/theme'; import { render } from '@testing-library/react'; diff --git a/packages/adena-extension/src/components/pages/transfer-summary/transfer-summary/transfer-summary.spec.tsx b/packages/adena-extension/src/components/pages/transfer-summary/transfer-summary/transfer-summary.spec.tsx index 363b170e4..ad2c809e7 100644 --- a/packages/adena-extension/src/components/pages/transfer-summary/transfer-summary/transfer-summary.spec.tsx +++ b/packages/adena-extension/src/components/pages/transfer-summary/transfer-summary/transfer-summary.spec.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { GlobalPopupStyle } from '@styles/global-style'; import theme from '@styles/theme'; import { render } from '@testing-library/react'; diff --git a/packages/adena-extension/src/components/pages/wallet-main/token-list-item/token-list-item.spec.tsx b/packages/adena-extension/src/components/pages/wallet-main/token-list-item/token-list-item.spec.tsx index abbcaca34..62b72d7bc 100644 --- a/packages/adena-extension/src/components/pages/wallet-main/token-list-item/token-list-item.spec.tsx +++ b/packages/adena-extension/src/components/pages/wallet-main/token-list-item/token-list-item.spec.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { GlobalPopupStyle } from '@styles/global-style'; import theme from '@styles/theme'; import { render } from '@testing-library/react'; diff --git a/packages/adena-extension/src/components/pages/wallet-main/token-list/token-list.spec.tsx b/packages/adena-extension/src/components/pages/wallet-main/token-list/token-list.spec.tsx index edf81f5d6..53e857226 100644 --- a/packages/adena-extension/src/components/pages/wallet-main/token-list/token-list.spec.tsx +++ b/packages/adena-extension/src/components/pages/wallet-main/token-list/token-list.spec.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { GlobalPopupStyle } from '@styles/global-style'; import theme from '@styles/theme'; import { render } from '@testing-library/react'; diff --git a/packages/adena-extension/src/hooks/nft/use-collection-handler.ts b/packages/adena-extension/src/hooks/nft/use-collection-handler.ts new file mode 100644 index 000000000..d4dcf9d6a --- /dev/null +++ b/packages/adena-extension/src/hooks/nft/use-collection-handler.ts @@ -0,0 +1,132 @@ +import { useAdenaContext } from '@hooks/use-context'; +import { useCurrentAccount } from '@hooks/use-current-account'; +import { useNetwork } from '@hooks/use-network'; +import { GRC721CollectionModel } from '@types'; + +interface UseNFTCollectionHandlerReturn { + addCollections: (collections: GRC721CollectionModel[]) => Promise; + pinCollection: (packagePath: string) => Promise; + unpinCollection: (packagePath: string) => Promise; + showCollection: (packagePath: string) => Promise; + hideCollection: (packagePath: string) => Promise; +} + +export const useNFTCollectionHandler = (): UseNFTCollectionHandlerReturn => { + const { tokenService } = useAdenaContext(); + const { currentAccount } = useCurrentAccount(); + const { currentNetwork } = useNetwork(); + + const addCollections = async (collections: GRC721CollectionModel[]): Promise => { + if (!currentAccount) { + return false; + } + + if (collections.length === 0) { + return true; + } + + const storedCollections = await tokenService.getAccountGRC721Collections( + currentAccount.id, + currentNetwork.chainId, + ); + + const addedCollections = collections + .map((collection) => ({ ...collection, display: true })) + .filter( + (c1) => + !storedCollections.find( + (c2) => c1.packagePath === c2.packagePath && c1.networkId === c2.networkId, + ), + ); + + return tokenService.saveAccountGRC721Collections(currentAccount.id, currentNetwork.chainId, [ + ...storedCollections, + ...addedCollections, + ]); + }; + + const pinCollection = async (packagePath: string): Promise => { + if (!currentAccount) { + return false; + } + + const pinnedCollections = await tokenService.getAccountGRC721PinnedPackages( + currentAccount.id, + currentNetwork.chainId, + ); + return tokenService.saveAccountGRC721PinnedPackages(currentAccount.id, currentNetwork.chainId, [ + ...pinnedCollections, + packagePath, + ]); + }; + + const unpinCollection = async (packagePath: string): Promise => { + if (!currentAccount) { + return false; + } + + const pinnedCollections = await tokenService.getAccountGRC721PinnedPackages( + currentAccount.id, + currentNetwork.chainId, + ); + return tokenService.saveAccountGRC721PinnedPackages( + currentAccount.id, + currentNetwork.chainId, + pinnedCollections.filter((path) => path !== packagePath), + ); + }; + + const showCollection = async (packagePath: string): Promise => { + if (!currentAccount) { + return false; + } + + const collections = await tokenService.getAccountGRC721Collections( + currentAccount.id, + currentNetwork.chainId, + ); + const changedCollections = collections.map((collection) => { + if (collection.packagePath !== packagePath) { + return collection; + } + return { + ...collection, + display: true, + }; + }); + + return tokenService.saveAccountGRC721Collections( + currentAccount.id, + currentNetwork.chainId, + changedCollections, + ); + }; + + const hideCollection = async (packagePath: string): Promise => { + if (!currentAccount) { + return false; + } + + const collections = await tokenService.getAccountGRC721Collections( + currentAccount.id, + currentNetwork.chainId, + ); + const changedCollections = collections.map((collection) => { + if (collection.packagePath !== packagePath) { + return collection; + } + return { + ...collection, + display: false, + }; + }); + + return tokenService.saveAccountGRC721Collections( + currentAccount.id, + currentNetwork.chainId, + changedCollections, + ); + }; + + return { addCollections, pinCollection, unpinCollection, showCollection, hideCollection }; +}; diff --git a/packages/adena-extension/src/hooks/nft/use-get-all-grc721-collections.ts b/packages/adena-extension/src/hooks/nft/use-get-all-grc721-collections.ts new file mode 100644 index 000000000..ee4c2db75 --- /dev/null +++ b/packages/adena-extension/src/hooks/nft/use-get-all-grc721-collections.ts @@ -0,0 +1,21 @@ +import { useAdenaContext } from '@hooks/use-context'; +import { useCurrentAccount } from '@hooks/use-current-account'; +import { useNetwork } from '@hooks/use-network'; +import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { GRC721CollectionModel } from '@types'; + +export const useGetAllGRC721Collections = ( + options?: UseQueryOptions, +): UseQueryResult => { + const { tokenService } = useAdenaContext(); + const { currentNetwork } = useNetwork(); + const { currentAccount } = useCurrentAccount(); + + return useQuery({ + queryKey: ['nft/useGetAllGRC721Collections', currentNetwork.id], + queryFn: () => tokenService.fetchGRC721Collections(), + staleTime: Infinity, + enabled: !!currentAccount, + ...options, + }); +}; diff --git a/packages/adena-extension/src/hooks/nft/use-get-grc721-balance.ts b/packages/adena-extension/src/hooks/nft/use-get-grc721-balance.ts new file mode 100644 index 000000000..d7f3e3311 --- /dev/null +++ b/packages/adena-extension/src/hooks/nft/use-get-grc721-balance.ts @@ -0,0 +1,28 @@ +import { useAdenaContext } from '@hooks/use-context'; +import { useCurrentAccount } from '@hooks/use-current-account'; +import { useNetwork } from '@hooks/use-network'; +import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; + +export const GET_GRC721_BALANCE_QUERY_KEY = 'nft/useGetGRC721TokenBalance'; + +export const useGetGRC721Balance = ( + packagePath: string, + options?: UseQueryOptions, +): UseQueryResult => { + const { tokenService } = useAdenaContext(); + const { currentAddress } = useCurrentAccount(); + const { currentNetwork } = useNetwork(); + + return useQuery({ + queryKey: [GET_GRC721_BALANCE_QUERY_KEY, packagePath, currentAddress, currentNetwork.chainId], + queryFn: () => { + if (!currentAddress) { + return null; + } + + return tokenService.fetchGRC721Balance(packagePath, currentAddress).catch(() => null); + }, + staleTime: Infinity, + ...options, + }); +}; diff --git a/packages/adena-extension/src/hooks/nft/use-get-grc721-collections.ts b/packages/adena-extension/src/hooks/nft/use-get-grc721-collections.ts new file mode 100644 index 000000000..2fe72eb0a --- /dev/null +++ b/packages/adena-extension/src/hooks/nft/use-get-grc721-collections.ts @@ -0,0 +1,33 @@ +import { useAdenaContext } from '@hooks/use-context'; +import { useCurrentAccount } from '@hooks/use-current-account'; +import { useNetwork } from '@hooks/use-network'; +import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { GRC721CollectionModel } from '@types'; + +export const GET_GRC721_COLLECTIONS_QUERY_KEY = 'nft/useGetGRC721Collections'; + +export const useGetGRC721Collections = ( + options?: UseQueryOptions, +): UseQueryResult => { + const { tokenService } = useAdenaContext(); + const { currentAccount } = useCurrentAccount(); + const { currentNetwork } = useNetwork(); + + return useQuery({ + queryKey: [GET_GRC721_COLLECTIONS_QUERY_KEY, currentAccount?.id || '', currentNetwork.chainId], + queryFn: async () => { + if (!currentAccount) { + return null; + } + + const collections = await tokenService + .getAccountGRC721Collections(currentAccount.id, currentNetwork.chainId) + .catch(() => []); + + return collections.map((collection) => ({ ...collection, tokenId: '0' })); + }, + staleTime: Infinity, + keepPreviousData: true, + ...options, + }); +}; diff --git a/packages/adena-extension/src/hooks/nft/use-get-grc721-pinned-collections.ts b/packages/adena-extension/src/hooks/nft/use-get-grc721-pinned-collections.ts new file mode 100644 index 000000000..6a4fd1d44 --- /dev/null +++ b/packages/adena-extension/src/hooks/nft/use-get-grc721-pinned-collections.ts @@ -0,0 +1,30 @@ +import { useAdenaContext } from '@hooks/use-context'; +import { useCurrentAccount } from '@hooks/use-current-account'; +import { useNetwork } from '@hooks/use-network'; +import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; + +export const useGetGRC721PinnedCollections = ( + options?: UseQueryOptions, +): UseQueryResult => { + const { tokenService } = useAdenaContext(); + const { currentAccount } = useCurrentAccount(); + const { currentNetwork } = useNetwork(); + + return useQuery({ + queryKey: [ + 'nft/useGetGRC721PinnedCollections', + currentAccount?.id || '', + currentNetwork.chainId, + ], + queryFn: () => { + if (!currentAccount) { + return []; + } + + return tokenService + .getAccountGRC721PinnedPackages(currentAccount.id, currentNetwork.chainId) + .catch(() => []); + }, + ...options, + }); +}; diff --git a/packages/adena-extension/src/hooks/nft/use-get-grc721-token-metadata.ts b/packages/adena-extension/src/hooks/nft/use-get-grc721-token-metadata.ts new file mode 100644 index 000000000..78154d98f --- /dev/null +++ b/packages/adena-extension/src/hooks/nft/use-get-grc721-token-metadata.ts @@ -0,0 +1,20 @@ +import { useAdenaContext } from '@hooks/use-context'; +import { useNetwork } from '@hooks/use-network'; +import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { GRC721MetadataModel } from '@types'; + +export const useGetGRC721TokenMetadata = ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, +): UseQueryResult => { + const { tokenService } = useAdenaContext(); + const { currentNetwork } = useNetwork(); + + return useQuery({ + queryKey: ['nft/useGetGRC721TokenMetadata', packagePath, tokenId, currentNetwork.chainId], + queryFn: () => tokenService.fetchGRC721TokenMetadata(packagePath, tokenId).catch(() => null), + keepPreviousData: true, + ...options, + }); +}; diff --git a/packages/adena-extension/src/hooks/nft/use-get-grc721-token-uri.ts b/packages/adena-extension/src/hooks/nft/use-get-grc721-token-uri.ts new file mode 100644 index 000000000..cf129160b --- /dev/null +++ b/packages/adena-extension/src/hooks/nft/use-get-grc721-token-uri.ts @@ -0,0 +1,21 @@ +import { useAdenaContext } from '@hooks/use-context'; +import { useNetwork } from '@hooks/use-network'; +import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; + +export const GET_GRC721_TOKEN_URI_QUERY_KEY = 'nft/useGetGRC721TokenUri'; + +export const useGetGRC721TokenUri = ( + packagePath: string, + tokenId: string, + options?: UseQueryOptions, +): UseQueryResult => { + const { tokenService } = useAdenaContext(); + const { currentNetwork } = useNetwork(); + + return useQuery({ + queryKey: [GET_GRC721_TOKEN_URI_QUERY_KEY, packagePath, tokenId, currentNetwork.chainId], + queryFn: () => tokenService.fetchGRC721TokenUri(packagePath, tokenId).catch(() => null), + staleTime: Infinity, + ...options, + }); +}; diff --git a/packages/adena-extension/src/hooks/nft/use-get-grc721-tokens.ts b/packages/adena-extension/src/hooks/nft/use-get-grc721-tokens.ts new file mode 100644 index 000000000..13b3053c5 --- /dev/null +++ b/packages/adena-extension/src/hooks/nft/use-get-grc721-tokens.ts @@ -0,0 +1,45 @@ +import { useAdenaContext } from '@hooks/use-context'; +import { useCurrentAccount } from '@hooks/use-current-account'; +import { useNetwork } from '@hooks/use-network'; +import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { GRC721CollectionModel, GRC721Model } from '@types'; + +export const useGetGRC721Tokens = ( + collection: GRC721CollectionModel | null, + options?: UseQueryOptions, +): UseQueryResult => { + const { tokenService } = useAdenaContext(); + const { currentAddress } = useCurrentAccount(); + const { currentNetwork } = useNetwork(); + + return useQuery({ + queryKey: [ + 'nft/useGetGRC721Tokens', + currentAddress || '', + currentNetwork.chainId, + collection?.packagePath, + ], + queryFn: async () => { + if (!currentAddress || !collection) { + return null; + } + + const tokens = await tokenService + .fetchGRC721Tokens(collection.packagePath, currentAddress) + .catch(() => []); + + return tokens + .map((token) => ({ + ...token, + name: collection.name, + symbol: collection.symbol, + isTokenUri: collection.isTokenUri, + isMetadata: collection.isMetadata, + })) + .reverse(); + }, + staleTime: Infinity, + keepPreviousData: true, + ...options, + }); +}; diff --git a/packages/adena-extension/src/hooks/nft/use-is-loading-nft.ts b/packages/adena-extension/src/hooks/nft/use-is-loading-nft.ts new file mode 100644 index 000000000..4ab58a7eb --- /dev/null +++ b/packages/adena-extension/src/hooks/nft/use-is-loading-nft.ts @@ -0,0 +1,20 @@ +import { useIsFetching } from '@tanstack/react-query'; +import { GET_GRC721_BALANCE_QUERY_KEY } from './use-get-grc721-balance'; +import { GET_GRC721_COLLECTIONS_QUERY_KEY } from './use-get-grc721-collections'; +import { GET_GRC721_TOKEN_URI_QUERY_KEY } from './use-get-grc721-token-uri'; + +export const useIsLoadingNFT = (): number => { + return useIsFetching({ + predicate: (query) => { + return ( + Array.isArray(query.queryKey) && + query.state.data === undefined && + [ + GET_GRC721_COLLECTIONS_QUERY_KEY, + GET_GRC721_BALANCE_QUERY_KEY, + GET_GRC721_TOKEN_URI_QUERY_KEY, + ].includes(query.queryKey[0]) + ); + }, + }); +}; diff --git a/packages/adena-extension/src/hooks/use-grc20-tokens.ts b/packages/adena-extension/src/hooks/use-grc20-tokens.ts index 7311c8c76..1ef20bc0e 100644 --- a/packages/adena-extension/src/hooks/use-grc20-tokens.ts +++ b/packages/adena-extension/src/hooks/use-grc20-tokens.ts @@ -1,11 +1,11 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; import { GRC20TokenModel } from '@types'; import { useAdenaContext } from './use-context'; import { useCurrentAccount } from './use-current-account'; import { useNetwork } from './use-network'; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export const useGRC20Tokens = () => { +export const useGRC20Tokens = (options?: UseQueryOptions) => { const { tokenService } = useAdenaContext(); const { currentAddress } = useCurrentAccount(); const { currentNetwork } = useNetwork(); @@ -15,7 +15,8 @@ export const useGRC20Tokens = () => { queryFn: () => { return tokenService.fetchGRC20Tokens(); }, - keepPreviousData: true, + staleTime: Infinity, enabled: !!currentAddress, + ...options, }); }; diff --git a/packages/adena-extension/src/hooks/use-link.ts b/packages/adena-extension/src/hooks/use-link.ts index 532ec5345..497bf5bdc 100644 --- a/packages/adena-extension/src/hooks/use-link.ts +++ b/packages/adena-extension/src/hooks/use-link.ts @@ -1,22 +1,41 @@ +import { SCANNER_URL } from '@common/constants/resource.constant'; +import { makeQueryString } from '@common/utils/string-utils'; import { REGISTER_PATH, SECURITY_PATH } from '@types'; +import { useNetwork } from './use-network'; export type UseLinkReturn = { openLink: (link: string) => void; + openScannerLink: (path: string, parameters?: { [key in string]: string }) => void; openRegister: () => void; openSecurity: () => void; }; const useLink = (): UseLinkReturn => { + const { currentNetwork, scannerParameters } = useNetwork(); + const openLink = (link: string): void => { window.open(link, '_blank'); }; + + const openScannerLink = (path: string, parameters: { [key in string]: string } = {}): void => { + const scannerUrl = currentNetwork.linkUrl || SCANNER_URL; + const queryString = scannerParameters + ? makeQueryString({ ...scannerParameters, ...parameters }) + : makeQueryString(parameters); + const link = `${scannerUrl}${path}?${queryString}`; + + openLink(link); + }; + const openRegister = (): void => { window.open(REGISTER_PATH, '_blank'); }; + const openSecurity = (): void => { window.open(SECURITY_PATH, '_blank'); }; - return { openLink, openRegister, openSecurity }; + + return { openLink, openScannerLink, openRegister, openSecurity }; }; export default useLink; diff --git a/packages/adena-extension/src/hooks/use-make-transactions-with-time.ts b/packages/adena-extension/src/hooks/use-make-transactions-with-time.ts index 6b4dda20e..9aae6bf37 100644 --- a/packages/adena-extension/src/hooks/use-make-transactions-with-time.ts +++ b/packages/adena-extension/src/hooks/use-make-transactions-with-time.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { TransactionInfo } from '@types'; +import { useGetAllGRC721Collections } from './nft/use-get-all-grc721-collections'; import { useAdenaContext } from './use-context'; import { useGRC20Tokens } from './use-grc20-tokens'; import { useNetwork } from './use-network'; @@ -21,11 +22,13 @@ export const useMakeTransactionsWithTime = ( const { transactionHistoryService } = useAdenaContext(); const { getTokenImageByDenom, getTokenAmount } = useTokenMetainfo(); const { isFetched: isFetchedTokens } = useGRC20Tokens(); + const { data: grc721Collections = [], isFetched: isFetchedGRC721Collections } = + useGetAllGRC721Collections(); const { status, isLoading, isFetched, isFetching, data } = useQuery({ queryKey: ['useMakeTransactionsWithTime', currentNetwork.chainId, key || ''], queryFn: () => { - if (!transactions) { + if (!transactions || !grc721Collections) { return null; } @@ -34,6 +37,27 @@ export const useMakeTransactionsWithTime = ( const time = await transactionHistoryService.fetchBlockTime( Number(transaction.height || 1), ); + if (transaction.type === 'TRANSFER_GRC721') { + const amount = transaction.amount; + const collection = grc721Collections.find( + (collection) => collection.packagePath === amount.denom, + ); + return { + ...transaction, + amount: { + ...amount, + denom: collection?.symbol || amount.denom, + }, + networkFee: getTokenAmount( + transaction.networkFee || { + value: '0', + denom: 'GNOT', + }, + ), + logo: collection?.packagePath || '', + date: time || '', + }; + } return { ...transaction, amount: getTokenAmount(transaction.amount), @@ -49,7 +73,11 @@ export const useMakeTransactionsWithTime = ( }), ); }, - enabled: !!transactionHistoryService.supported && !!transactions && isFetchedTokens, + enabled: + !!transactionHistoryService.supported && + !!transactions && + isFetchedTokens && + isFetchedGRC721Collections, keepPreviousData: true, }); diff --git a/packages/adena-extension/src/hooks/wallet/use-transfer-tokens.ts b/packages/adena-extension/src/hooks/wallet/use-transfer-tokens.ts new file mode 100644 index 000000000..ec0b306c6 --- /dev/null +++ b/packages/adena-extension/src/hooks/wallet/use-transfer-tokens.ts @@ -0,0 +1,55 @@ +import { useAdenaContext } from '@hooks/use-context'; +import { GRC20TokenModel, GRC721CollectionModel, TokenModel } from '@types'; + +export interface UseTransferTokenReturn { + fetchTransferTokens: (address: string) => Promise<{ + grc20Packages: TokenModel[]; + grc721Packages: GRC721CollectionModel[]; + }>; +} + +export const useTransferTokens = (): UseTransferTokenReturn => { + const { tokenService } = useAdenaContext(); + + const fetchTransferTokens = async ( + address: string, + ): Promise<{ + grc20Packages: TokenModel[]; + grc721Packages: GRC721CollectionModel[]; + }> => { + const [transferEventPackages, deployedGRC20Tokens, deployedCollections]: [ + string[], + GRC20TokenModel[], + GRC721CollectionModel[], + ] = await Promise.all([ + tokenService.fetchAllTransferPackagesBy(address), + tokenService.fetchGRC20Tokens(), + tokenService.fetchGRC721Collections(), + ]).catch(() => [[], [], []]); + + const filteredGRC20Packages = (deployedGRC20Tokens || []).filter((grc20Token) => { + if (!transferEventPackages || transferEventPackages.length === 0) { + return false; + } + + return transferEventPackages.includes(grc20Token.pkgPath); + }); + + const filteredGRC721Packages = (deployedCollections || []).filter((grc721Token) => { + if (!transferEventPackages || transferEventPackages.length === 0) { + return false; + } + + return transferEventPackages.includes(grc721Token.packagePath); + }); + + return { + grc20Packages: filteredGRC20Packages, + grc721Packages: filteredGRC721Packages, + }; + }; + + return { + fetchTransferTokens, + }; +}; diff --git a/packages/adena-extension/src/migrates/migrations/v007/storage-migration-v007.spec.ts b/packages/adena-extension/src/migrates/migrations/v007/storage-migration-v007.spec.ts new file mode 100644 index 000000000..ecc172688 --- /dev/null +++ b/packages/adena-extension/src/migrates/migrations/v007/storage-migration-v007.spec.ts @@ -0,0 +1,71 @@ +import { decryptAES } from 'adena-module'; +import { StorageMigration007 } from './storage-migration-v007'; + +const mockStorageData = { + NETWORKS: [], + CURRENT_CHAIN_ID: 'test3', + CURRENT_NETWORK_ID: 'test3', + SERIALIZED: 'U2FsdGVkX19eI8kOCI/T9o1Ru0b2wdj5rHxmG4QbLQ0yZH4kDa8/gg6Ac2JslvEm', + ENCRYPTED_STORED_PASSWORD: '', + CURRENT_ACCOUNT_ID: '', + ACCOUNT_NAMES: {}, + ESTABLISH_SITES: {}, + ADDRESS_BOOK: '', + ACCOUNT_TOKEN_METAINFOS: {}, + QUESTIONNAIRE_EXPIRED_DATE: null, + WALLET_CREATION_GUIDE_CONFIRM_DATE: null, + ADD_ACCOUNT_GUIDE_CONFIRM_DATE: null, +}; + +describe('serialized wallet migration V007', () => { + it('version', () => { + const migration = new StorageMigration007(); + expect(migration.version).toBe(7); + }); + + it('up success', async () => { + const mockData = { + version: 6, + data: mockStorageData, + }; + const migration = new StorageMigration007(); + const result = await migration.up(mockData); + + expect(result.data.ACCOUNT_GRC721_COLLECTIONS).toEqual({}); + expect(result.data.ACCOUNT_GRC721_PINNED_PACKAGES).toEqual({}); + }); + + it('up password success', async () => { + const mockData = { + version: 1, + data: mockStorageData, + }; + const password = '123'; + const migration = new StorageMigration007(); + const result = await migration.up(mockData); + + expect(result.version).toBe(7); + expect(result.data).not.toBeNull(); + expect(result.data.ACCOUNT_GRC721_COLLECTIONS).toEqual({}); + expect(result.data.ACCOUNT_GRC721_PINNED_PACKAGES).toEqual({}); + + const serialized = result.data.SERIALIZED; + const decrypted = await decryptAES(serialized, password); + const wallet = JSON.parse(decrypted); + + expect(wallet.accounts).toHaveLength(0); + expect(wallet.keyrings).toHaveLength(0); + }); + + it('up failed throw error', async () => { + const mockData: any = { + version: 1, + data: { ...mockStorageData, SERIALIZED: null }, + }; + const migration = new StorageMigration007(); + + await expect(migration.up(mockData)).rejects.toThrow( + 'Storage Data does not match version V006', + ); + }); +}); diff --git a/packages/adena-extension/src/migrates/migrations/v007/storage-migration-v007.ts b/packages/adena-extension/src/migrates/migrations/v007/storage-migration-v007.ts new file mode 100644 index 000000000..84a76ecde --- /dev/null +++ b/packages/adena-extension/src/migrates/migrations/v007/storage-migration-v007.ts @@ -0,0 +1,72 @@ +import { StorageModel } from '@common/storage'; +import { Migration } from '@migrates/migrator'; +import { StorageModelDataV006 } from '../v006/storage-model-v006'; +import { StorageModelDataV007 } from './storage-model-v007'; + +export class StorageMigration007 implements Migration { + public readonly version = 7; + + async up( + current: StorageModel, + ): Promise> { + if (!this.validateModelV006(current.data)) { + throw new Error('Storage Data does not match version V006'); + } + const previous: StorageModelDataV006 = current.data; + return { + version: this.version, + data: { + ...previous, + ACCOUNT_GRC721_COLLECTIONS: {}, + ACCOUNT_GRC721_PINNED_PACKAGES: {}, + }, + }; + } + + private validateModelV006(currentData: StorageModelDataV006): boolean { + const storageDataKeys = [ + 'NETWORKS', + 'CURRENT_CHAIN_ID', + 'CURRENT_NETWORK_ID', + 'SERIALIZED', + 'ENCRYPTED_STORED_PASSWORD', + 'CURRENT_ACCOUNT_ID', + 'ESTABLISH_SITES', + 'ADDRESS_BOOK', + 'ACCOUNT_TOKEN_METAINFOS', + ]; + const currentDataKeys = Object.keys(currentData); + const hasKeys = storageDataKeys.every((dataKey) => { + return currentDataKeys.includes(dataKey); + }); + + if (!hasKeys) { + return false; + } + if (!Array.isArray(currentData.NETWORKS)) { + return false; + } + if (typeof currentData.CURRENT_CHAIN_ID !== 'string') { + return false; + } + if (typeof currentData.CURRENT_NETWORK_ID !== 'string') { + return false; + } + if (typeof currentData.SERIALIZED !== 'string') { + return false; + } + if (typeof currentData.ENCRYPTED_STORED_PASSWORD !== 'string') { + return false; + } + if (typeof currentData.CURRENT_ACCOUNT_ID !== 'string') { + return false; + } + if (currentData.ACCOUNT_NAMES && typeof currentData.ACCOUNT_NAMES !== 'object') { + return false; + } + if (currentData.ESTABLISH_SITES && typeof currentData.ESTABLISH_SITES !== 'object') { + return false; + } + return true; + } +} diff --git a/packages/adena-extension/src/migrates/migrations/v007/storage-model-v007.ts b/packages/adena-extension/src/migrates/migrations/v007/storage-model-v007.ts new file mode 100644 index 000000000..2dd315771 --- /dev/null +++ b/packages/adena-extension/src/migrates/migrations/v007/storage-model-v007.ts @@ -0,0 +1,145 @@ +export type StorageModelV007 = { + version: 7; + data: StorageModelDataV007; +}; + +export type StorageModelDataV007 = { + NETWORKS: NetworksModelV007; + CURRENT_CHAIN_ID: CurrentChainIdModelV007; + CURRENT_NETWORK_ID: CurrentNetworkIdModelV007; + SERIALIZED: SerializedModelV007; + ENCRYPTED_STORED_PASSWORD: EncryptedStoredPasswordModelV007; + CURRENT_ACCOUNT_ID: CurrentAccountIdModelV007; + ACCOUNT_NAMES: AccountNamesModelV007; + ESTABLISH_SITES: EstablishSitesModelV007; + ADDRESS_BOOK: AddressBookModelV007; + ACCOUNT_TOKEN_METAINFOS: AccountTokenMetainfoModelV007; + QUESTIONNAIRE_EXPIRED_DATE: QuestionnaireExpiredDateModelV007; + WALLET_CREATION_GUIDE_CONFIRM_DATE: WalletCreationGuideConfirmDateModelV007; + ADD_ACCOUNT_GUIDE_CONFIRM_DATE: AddAccountGuideConfirmDateModelV007; + ACCOUNT_GRC721_COLLECTIONS: AccountGRC721CollectionsV007; + ACCOUNT_GRC721_PINNED_PACKAGES: AccountGRC721PinnedPackagesV007; +}; + +export type NetworksModelV007 = { + id: string; + default: boolean; + main: boolean; + chainId: string; + chainName: string; + networkId: string; + networkName: string; + addressPrefix: string; + rpcUrl: string; + indexerUrl: string; + gnoUrl: string; + apiUrl: string; + linkUrl: string; + deleted?: boolean; +}[]; + +export type CurrentChainIdModelV007 = string; + +export type CurrentNetworkIdModelV007 = string; + +export type SerializedModelV007 = string; + +export type QuestionnaireExpiredDateModelV007 = number | null; + +export type WalletCreationGuideConfirmDateModelV007 = number | null; + +export type AddAccountGuideConfirmDateModelV007 = number | null; + +export type WalletModelV007 = { + accounts: AccountDataModelV007[]; + keyrings: KeyringDataModelV007[]; + currentAccountId?: string; +}; + +type AccountDataModelV007 = { + id?: string; + index: number; + type: 'HD_WALLET' | 'PRIVATE_KEY' | 'LEDGER' | 'WEB3_AUTH' | 'AIRGAP'; + name: string; + keyringId: string; + hdPath?: number; + publicKey: number[]; + addressBytes?: number[]; +}; + +type KeyringDataModelV007 = { + id?: string; + type: 'HD_WALLET' | 'PRIVATE_KEY' | 'LEDGER' | 'WEB3_AUTH' | 'AIRGAP'; + publicKey?: number[]; + privateKey?: number[]; + seed?: number[]; + mnemonic?: string; + addressBytes?: number[]; +}; + +export type EncryptedStoredPasswordModelV007 = string; + +export type CurrentAccountIdModelV007 = string; + +type AccountId = string; +type NetworkId = string; + +export type AccountNamesModelV007 = { [key in AccountId]: string }; + +export type EstablishSitesModelV007 = { + [key in AccountId]: { + hostname: string; + chainId: string; + account: string; + name: string; + favicon: string | null; + establishedTime: string; + }[]; +}; + +export type AddressBookModelV007 = string; + +export type AccountTokenMetainfoModelV007 = { + [key in string]: { + main: boolean; + tokenId: string; + networkId: string; + display: boolean; + type: 'gno-native' | 'grc20' | 'ibc-native' | 'ibc-tokens'; + name: string; + symbol: string; + decimals: number; + description?: string; + websiteUrl?: string; + image: string; + denom?: string; + pkgPath?: string; + originChain?: string; + originDenom?: string; + originType?: string; + path?: string; + channel?: string; + port?: string; + }[]; +}; + +export type AccountGRC721CollectionsV007 = { + [key in AccountId]: { + [key in NetworkId]: { + tokenId: string; + networkId: string; + display: boolean; + type: 'grc721'; + packagePath: string; + name: string; + symbol: string; + image: string | null; + isTokenUri: boolean; + isMetadata: boolean; + }[]; + }; +}; + +export type AccountGRC721PinnedPackagesV007 = { + [key in AccountId]: { [key in NetworkId]: string[] }; +}; diff --git a/packages/adena-extension/src/migrates/storage-migrator.spec.ts b/packages/adena-extension/src/migrates/storage-migrator.spec.ts index 83668561e..540d751ea 100644 --- a/packages/adena-extension/src/migrates/storage-migrator.spec.ts +++ b/packages/adena-extension/src/migrates/storage-migrator.spec.ts @@ -1,5 +1,5 @@ -import { StorageMigrator } from './storage-migrator'; import { StorageModelV001 } from './migrations/v001/storage-model-v001'; +import { StorageMigrator } from './storage-migrator'; const mockStorageV001: StorageModelV001 = { version: 1, @@ -67,7 +67,7 @@ describe('StorageMigrator', () => { const migrated = await migrator.migrate(current); expect(migrated).not.toBeNull(); - expect(migrated?.version).toBe(6); + expect(migrated?.version).toBe(7); expect(migrated?.data).not.toBeNull(); expect(migrated?.data.NETWORKS).toHaveLength(3); expect(migrated?.data.CURRENT_CHAIN_ID).toBe(''); @@ -89,7 +89,7 @@ describe('StorageMigrator', () => { const migrated = await migrator.migrate(current); expect(migrated).not.toBeNull(); - expect(migrated?.version).toBe(6); + expect(migrated?.version).toBe(7); expect(migrated?.data).not.toBeNull(); expect(migrated?.data.SERIALIZED).not.toBe(''); expect(migrated?.data.ADDRESS_BOOK).toBe(''); diff --git a/packages/adena-extension/src/migrates/storage-migrator.ts b/packages/adena-extension/src/migrates/storage-migrator.ts index 3e95a628c..c69819d06 100644 --- a/packages/adena-extension/src/migrates/storage-migrator.ts +++ b/packages/adena-extension/src/migrates/storage-migrator.ts @@ -1,16 +1,18 @@ import { StorageModel } from '@common/storage'; -import { Migration, Migrator } from './migrator'; -import { StorageModelV002 } from './migrations/v002/storage-model-v002'; import { StorageModelDataV001, StorageModelV001 } from './migrations/v001/storage-model-v001'; import { StorageMigration002 } from './migrations/v002/storage-migration-v002'; -import { StorageModelV003 } from './migrations/v003/storage-model-v003'; +import { StorageModelV002 } from './migrations/v002/storage-model-v002'; import { StorageMigration003 } from './migrations/v003/storage-migration-v003'; +import { StorageModelV003 } from './migrations/v003/storage-model-v003'; import { StorageMigration004 } from './migrations/v004/storage-migration-v004'; import { StorageModelV004 } from './migrations/v004/storage-model-v004'; -import { StorageModelV005 } from './migrations/v005/storage-model-v005'; import { StorageMigration005 } from './migrations/v005/storage-migration-v005'; -import { StorageModelV006 } from './migrations/v006/storage-model-v006'; +import { StorageModelV005 } from './migrations/v005/storage-model-v005'; import { StorageMigration006 } from './migrations/v006/storage-migration-v006'; +import { StorageModelV006 } from './migrations/v006/storage-model-v006'; +import { StorageMigration007 } from './migrations/v007/storage-migration-v007'; +import { StorageModelV007 } from './migrations/v007/storage-model-v007'; +import { Migration, Migrator } from './migrator'; const LegacyStorageKeys = [ 'NETWORKS', @@ -25,7 +27,7 @@ const LegacyStorageKeys = [ 'ACCOUNT_TOKEN_METAINFOS', ]; -export type StorageModelLatest = StorageModelV006; +export type StorageModelLatest = StorageModelV007; const defaultData: StorageModelDataV001 = { ACCOUNT_NAMES: {}, @@ -47,6 +49,7 @@ interface Storage { export class StorageMigrator implements Migrator { private static StorageKey = 'ADENA_DATA'; + constructor( private migrations: Migration[], private storage: Storage, @@ -76,6 +79,7 @@ export class StorageMigrator implements Migrator { async deserialize( data: string | undefined, ): Promise< + | StorageModelV007 | StorageModelV006 | StorageModelV005 | StorageModelV004 @@ -95,6 +99,7 @@ export class StorageMigrator implements Migrator { } async getCurrent(): Promise< + | StorageModelV007 | StorageModelV006 | StorageModelV005 | StorageModelV004 @@ -113,7 +118,7 @@ export class StorageMigrator implements Migrator { }; } - async migrate(current: StorageModel): Promise { + async migrate(current: StorageModel): Promise { let latest = current; try { const currentVersion = current.version || 1; @@ -152,6 +157,7 @@ export class StorageMigrator implements Migrator { // eslint-disable-next-line @typescript-eslint/no-explicit-any json: any, ): Promise< + | StorageModelV007 | StorageModelV006 | StorageModelV005 | StorageModelV004 @@ -159,6 +165,9 @@ export class StorageMigrator implements Migrator { | StorageModelV002 | StorageModelV001 > { + if (json?.version === 7) { + return json as StorageModelV007; + } if (json?.version === 6) { return json as StorageModelV006; } @@ -208,6 +217,7 @@ export class StorageMigrator implements Migrator { new StorageMigration004(), new StorageMigration005(), new StorageMigration006(), + new StorageMigration007(), ]; } } diff --git a/packages/adena-extension/src/pages/popup/wallet/deposit/index.tsx b/packages/adena-extension/src/pages/popup/wallet/deposit/index.tsx index ae3a09e4c..17e83d7a4 100644 --- a/packages/adena-extension/src/pages/popup/wallet/deposit/index.tsx +++ b/packages/adena-extension/src/pages/popup/wallet/deposit/index.tsx @@ -63,7 +63,7 @@ export const Deposit = (): JSX.Element => { return ( - {`Deposit ${params?.tokenMetainfo?.symbol || ''}`} + {`Deposit ${params?.token.symbol || ''}`} diff --git a/packages/adena-extension/src/pages/popup/wallet/history/index.tsx b/packages/adena-extension/src/pages/popup/wallet/history/index.tsx index 02662590f..1eca60844 100644 --- a/packages/adena-extension/src/pages/popup/wallet/history/index.tsx +++ b/packages/adena-extension/src/pages/popup/wallet/history/index.tsx @@ -1,18 +1,19 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; +import { HISTORY_FETCH_INTERVAL_TIME } from '@common/constants/interval.constant'; import { TransactionHistory } from '@components/molecules'; -import { RoutePath } from '@types'; -import { TransactionHistoryMapper } from '@repositories/transaction/mapper/transaction-history-mapper'; -import useScrollHistory from '@hooks/use-scroll-history'; -import { fonts } from '@styles/theme'; -import mixins from '@styles/mixins'; +import { useGetGRC721TokenUri } from '@hooks/nft/use-get-grc721-token-uri'; import useAppNavigate from '@hooks/use-app-navigate'; -import { useTransactionHistory } from '@hooks/wallet/transaction-history/use-transaction-history'; import { useCurrentAccount } from '@hooks/use-current-account'; -import { HISTORY_FETCH_INTERVAL_TIME } from '@common/constants/interval.constant'; import { useNetwork } from '@hooks/use-network'; +import useScrollHistory from '@hooks/use-scroll-history'; +import { useTransactionHistory } from '@hooks/wallet/transaction-history/use-transaction-history'; import { useTransactionHistoryPage } from '@hooks/wallet/transaction-history/use-transaction-history-page'; +import { TransactionHistoryMapper } from '@repositories/transaction/mapper/transaction-history-mapper'; +import mixins from '@styles/mixins'; +import { fonts } from '@styles/theme'; +import { RoutePath } from '@types'; const StyledHistoryLayout = styled.div` ${mixins.flex({ align: 'normal', justify: 'normal' })}; @@ -112,6 +113,7 @@ const HistoryContainer: React.FC = () => { diff --git a/packages/adena-extension/src/pages/popup/wallet/manage-nft/index.tsx b/packages/adena-extension/src/pages/popup/wallet/manage-nft/index.tsx new file mode 100644 index 000000000..7b74c1615 --- /dev/null +++ b/packages/adena-extension/src/pages/popup/wallet/manage-nft/index.tsx @@ -0,0 +1,85 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import ManageCollections from '@components/pages/manage-nft/manage-collections/manage-collections'; +import { ManageTokenLayout } from '@components/pages/manage-token-layout'; +import { useNFTCollectionHandler } from '@hooks/nft/use-collection-handler'; +import { useGetGRC721Balance } from '@hooks/nft/use-get-grc721-balance'; +import { useGetGRC721Collections } from '@hooks/nft/use-get-grc721-collections'; +import { useGetGRC721TokenUri } from '@hooks/nft/use-get-grc721-token-uri'; +import useAppNavigate from '@hooks/use-app-navigate'; +import { ManageGRC721Info } from '@types'; + +const ManageNFTContainer: React.FC = () => { + const { goBack } = useAppNavigate(); + const [searchKeyword, setSearchKeyword] = useState(''); + const [isClose, setIsClose] = useState(false); + const { data: collections, refetch: refetchCollections } = useGetGRC721Collections({ + refetchOnMount: true, + }); + const { showCollection, hideCollection } = useNFTCollectionHandler(); + + useEffect(() => { + if (isClose) { + goBack(); + } + }, [isClose]); + + const filteredCollections: ManageGRC721Info[] = useMemo(() => { + if (!collections) { + return []; + } + + const comparedKeyword = searchKeyword.toLowerCase(); + const filteredCollections = collections + .filter((collection) => { + if (comparedKeyword === '') return true; + if (collection.name.toLowerCase().includes(comparedKeyword)) return true; + if (collection.symbol.toLowerCase().includes(comparedKeyword)) return true; + return false; + }) + .map((collection) => { + return { + ...collection, + type: 'grc721' as const, + balance: '0', + logo: collection.isTokenUri ? collection.packagePath : '', + }; + }); + return filteredCollections; + }, [searchKeyword, collections]); + + const onChangeKeyword = useCallback((keyword: string) => { + setSearchKeyword(keyword); + }, []); + + const onToggleActiveItem = useCallback( + (packagePath: string, activated: boolean) => { + if (activated) { + showCollection(packagePath).then(() => refetchCollections()); + } else { + hideCollection(packagePath).then(() => refetchCollections()); + } + }, + [showCollection, hideCollection], + ); + + const onClickClose = useCallback(() => { + setIsClose(true); + }, []); + + return ( + + + + ); +}; + +export default ManageNFTContainer; diff --git a/packages/adena-extension/src/pages/popup/wallet/manage-token/index.tsx b/packages/adena-extension/src/pages/popup/wallet/manage-token/index.tsx index cf0849f99..01cfd47c4 100644 --- a/packages/adena-extension/src/pages/popup/wallet/manage-token/index.tsx +++ b/packages/adena-extension/src/pages/popup/wallet/manage-token/index.tsx @@ -1,13 +1,13 @@ -import React, { useCallback, useEffect, useState } from 'react'; import BigNumber from 'bignumber.js'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import ManageTokenSearch from '@components/pages/manage-token/manage-token'; -import { useCurrentAccount } from '@hooks/use-current-account'; -import { useTokenBalance } from '@hooks/use-token-balance'; -import { RoutePath } from '@types'; import UnknownTokenIcon from '@assets/common-unknown-token.svg'; import { ManageTokenLayout } from '@components/pages/manage-token-layout'; +import ManageTokenSearch from '@components/pages/manage-token/manage-token'; import useAppNavigate from '@hooks/use-app-navigate'; +import { useCurrentAccount } from '@hooks/use-current-account'; +import { useTokenBalance } from '@hooks/use-token-balance'; +import { ManageTokenInfo, RoutePath } from '@types'; const ManageTokenSearchContainer: React.FC = () => { const { navigate, goBack } = useAppNavigate(); @@ -22,30 +22,28 @@ const ManageTokenSearchContainer: React.FC = () => { } }, [isClose]); - const filterTokens = useCallback( - (keyword: string) => { - const comparedKeyword = keyword.toLowerCase(); - const filteredTokens = currentBalances - .filter((token) => { - if (comparedKeyword === '') return true; - if (token.name.toLowerCase().includes(comparedKeyword)) return true; - if (token.symbol.toLowerCase().includes(comparedKeyword)) return true; - return false; - }) - .map((metainfo) => { - return { - ...metainfo, - balanceAmount: { - value: BigNumber(metainfo.amount.value).toFormat(), - denom: metainfo.amount.denom, - }, - logo: metainfo.image || `${UnknownTokenIcon}`, - }; - }); - return filteredTokens; - }, - [currentBalances], - ); + const filteredTokens: ManageTokenInfo[] = useMemo(() => { + const comparedKeyword = searchKeyword.toLowerCase(); + const filteredTokens = currentBalances + .filter((token) => { + if (comparedKeyword === '') return true; + if (token.name.toLowerCase().includes(comparedKeyword)) return true; + if (token.symbol.toLowerCase().includes(comparedKeyword)) return true; + return false; + }) + .map((metainfo) => { + return { + ...metainfo, + type: 'token' as const, + balance: { + value: BigNumber(metainfo.amount.value).toFormat(), + denom: metainfo.amount.denom, + }, + logo: metainfo.image || `${UnknownTokenIcon}`, + }; + }); + return filteredTokens; + }, [searchKeyword, currentBalances]); const moveTokenAddedPage = useCallback(() => { navigate(RoutePath.ManageTokenAdded); @@ -76,7 +74,7 @@ const ManageTokenSearchContainer: React.FC = () => { { + const { params, navigate, goBack } = useAppNavigate(); + const grc721Token = params.collectionAsset; + + const addressBookInput = useAddressBookInput(); + const { currentAccount } = useCurrentAccount(); + const { getHistoryData, setHistoryData } = useHistoryData(); + const { currentNetwork } = useNetwork(); + const { memorizedTransferInfo, clear: clearMemorizedTransferInfo } = useTransferInfo(); + const [memo, setMemo] = useState(memorizedTransferInfo?.memo || ''); + + const memoError = useMemo(() => { + const size = calculateByteSize(memo); + if (size < MEMO_MAX_BYTES) { + return null; + } + + return new TransactionValidationError('MEMO_TOO_LARGE_ERROR'); + }, [memo]); + + const onChangeMemo = useCallback((memo: string) => { + setMemo(memo); + }, []); + + const saveHistoryData = (): void => { + if (!grc721Token) { + return; + } + setHistoryData({ + grc721Token, + addressInput: { + selected: addressBookInput.selected, + selectedAddressBook: addressBookInput.selectedAddressBook, + address: addressBookInput.address, + }, + }); + }; + + const isNext = useMemo(() => { + if (addressBookInput.resultAddress === '') { + return false; + } + if (memoError !== null) { + return false; + } + return true; + }, [addressBookInput, memoError]); + + const onClickCancel = useCallback(() => { + goBack(); + }, []); + + const onClickNext = useCallback(async () => { + if (!isNext) { + return; + } + if (!grc721Token) { + return; + } + const validAddress = + addressBookInput.validateAddressBookInput() && + (await addressBookInput.validateEqualAddress()); + + if (validAddress) { + saveHistoryData(); + navigate(RoutePath.NftTransferSummary, { + state: { + grc721Token, + toAddress: addressBookInput.resultAddress, + networkFee: { + value: BigNumber(DEFAULT_NETWORK_FEE) + .shiftedBy(GNOT_TOKEN.decimals * -1) + .toString(), + denom: GNOT_TOKEN.symbol, + }, + memo, + }, + }); + } + }, [addressBookInput, isNext]); + + useEffect(() => { + if (currentAccount) { + addressBookInput.updateAddressBook(); + clearMemorizedTransferInfo(); + } + }, [currentAccount, currentNetwork.chainId]); + + useEffect(() => { + const historyData = getHistoryData(); + if (historyData) { + addressBookInput.setSelected(historyData.addressInput.selected); + if (historyData.addressInput.selectedAddressBook) { + addressBookInput.setSelectedAddressBook(historyData.addressInput.selectedAddressBook); + } + if (historyData.addressInput.address) { + addressBookInput.setAddress(historyData.addressInput.address); + } + } + }, [getHistoryData()]); + + return ( + + ); +}; + +export default NFTTransferInputContainer; diff --git a/packages/adena-extension/src/pages/popup/wallet/nft-transfer-summary/index.tsx b/packages/adena-extension/src/pages/popup/wallet/nft-transfer-summary/index.tsx new file mode 100644 index 000000000..e0cfa15f1 --- /dev/null +++ b/packages/adena-extension/src/pages/popup/wallet/nft-transfer-summary/index.tsx @@ -0,0 +1,166 @@ +import { isLedgerAccount } from 'adena-module'; +import BigNumber from 'bignumber.js'; +import React, { useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; + +import { DEFAULT_GAS_WANTED } from '@common/constants/tx.constant'; +import NFTTransferSummary from '@components/pages/nft-transfer-summary/nft-transfer-summary/nft-transfer-summary'; +import { useGetGRC721TokenUri } from '@hooks/nft/use-get-grc721-token-uri'; +import useAppNavigate from '@hooks/use-app-navigate'; +import { useAdenaContext, useWalletContext } from '@hooks/use-context'; +import { useCurrentAccount } from '@hooks/use-current-account'; +import { useNetwork } from '@hooks/use-network'; +import { useTransferInfo } from '@hooks/use-transfer-info'; +import { TransactionMessage } from '@services/index'; +import mixins from '@styles/mixins'; +import { GRC721Model, RoutePath } from '@types'; + +const NFTTransferSummaryLayout = styled.div` + ${mixins.flex({ align: 'normal', justify: 'normal' })}; + width: 100%; + height: auto; + padding: 24px 20px 116px; +`; + +const NFTTransferSummaryContainer: React.FC = () => { + const normalNavigate = useNavigate(); + const { navigate, goBack, params } = useAppNavigate(); + const summaryInfo = params; + const { wallet, gnoProvider } = useWalletContext(); + const { transactionService } = useAdenaContext(); + const { currentAccount, currentAddress } = useCurrentAccount(); + const { currentNetwork } = useNetwork(); + const { memorizedTransferInfo, setMemorizedTransferInfo } = useTransferInfo(); + const [isSent, setIsSent] = useState(false); + const [isErrorNetworkFee, setIsErrorNetworkFee] = useState(false); + + const makeGRC721TransferMessage = useCallback( + (grc721Token: GRC721Model, fromAddress: string, toAddress: string) => { + return TransactionMessage.createMessageOfVmCall({ + caller: fromAddress, + send: '', + pkgPath: grc721Token.packagePath, + func: 'TransferFrom', + args: [fromAddress, toAddress, grc721Token.tokenId], + }); + }, + [], + ); + + const createDocument = useCallback(async () => { + if (!currentNetwork || !currentAccount || !currentAddress) { + return null; + } + const { grc721Token, toAddress, networkFee, memo } = summaryInfo; + const message = makeGRC721TransferMessage(grc721Token, currentAddress, toAddress); + const networkFeeAmount = BigNumber(networkFee.value).shiftedBy(6).toNumber(); + const document = await transactionService.createDocument( + currentAccount, + currentNetwork.networkId, + [message], + DEFAULT_GAS_WANTED, + networkFeeAmount, + memo, + ); + return document; + }, [summaryInfo, currentAccount]); + + const createTransaction = useCallback(async () => { + const document = await createDocument(); + if (!currentNetwork || !currentAccount || !document || !wallet) { + return null; + } + + const walletInstance = wallet.clone(); + walletInstance.currentAccountId = currentAccount.id; + const { signed } = await transactionService.createTransaction(walletInstance, document); + return transactionService.sendTransaction(walletInstance, currentAccount, signed).catch((e) => { + console.error(e); + return null; + }); + }, [summaryInfo, currentAccount, currentNetwork]); + + const hasNetworkFee = useCallback(async () => { + if (!gnoProvider || !currentAddress) { + return false; + } + + const currentBalance = await gnoProvider.getBalance(currentAddress, 'ugnot'); + const networkFee = summaryInfo.networkFee.value; + return BigNumber(currentBalance).shiftedBy(-6).isGreaterThanOrEqualTo(networkFee); + }, [gnoProvider, currentAddress, summaryInfo]); + + const transfer = useCallback(async () => { + if (isSent || !currentAccount) { + return false; + } + + const isNetworkFee = await hasNetworkFee(); + if (!isNetworkFee) { + setIsErrorNetworkFee(true); + return false; + } + + setIsSent(true); + if (isLedgerAccount(currentAccount)) { + return transferByLedger(); + } + return transferByCommon(); + }, [summaryInfo, currentAccount, isSent, hasNetworkFee]); + + const transferByCommon = useCallback(async () => { + try { + createTransaction(); + navigate(RoutePath.History); + } catch (e) { + if (!(e instanceof Error)) { + return false; + } + } + setIsSent(false); + return false; + }, [summaryInfo, currentAccount, isSent, hasNetworkFee]); + + const transferByLedger = useCallback(async () => { + const document = await createDocument(); + if (document) { + navigate(RoutePath.TransferLedgerLoading, { state: { document } }); + } + return true; + }, [summaryInfo, currentAccount, isSent, hasNetworkFee]); + + const onClickBack = useCallback(() => { + if (memorizedTransferInfo) { + setMemorizedTransferInfo({ + ...memorizedTransferInfo, + memo: summaryInfo.memo, + toAddress: summaryInfo.toAddress, + }); + } + + goBack(); + }, [memorizedTransferInfo, summaryInfo]); + + const onClickCancel = useCallback(() => { + normalNavigate(-2); + }, [summaryInfo, navigate]); + + return ( + + + + ); +}; + +export default NFTTransferSummaryContainer; diff --git a/packages/adena-extension/src/pages/popup/wallet/nft/collection-asset.tsx b/packages/adena-extension/src/pages/popup/wallet/nft/collection-asset.tsx new file mode 100644 index 000000000..1c2bc5d44 --- /dev/null +++ b/packages/adena-extension/src/pages/popup/wallet/nft/collection-asset.tsx @@ -0,0 +1,152 @@ +import styled, { useTheme } from 'styled-components'; + +import { Button } from '@components/atoms'; +import NFTAssetImageCard from '@components/molecules/nft-asset-image-card/nft-asset-image-card'; +import NFTAssetHeader from '@components/pages/nft-asset/nft-asset-header/nft-asset-header'; +import NFTAssetMetadata from '@components/pages/nft-asset/nft-asset-metadata/nft-asset-metadata'; +import { useNFTCollectionHandler } from '@hooks/nft/use-collection-handler'; +import { useGetGRC721Collections } from '@hooks/nft/use-get-grc721-collections'; +import { useGetGRC721PinnedCollections } from '@hooks/nft/use-get-grc721-pinned-collections'; +import { useGetGRC721TokenMetadata } from '@hooks/nft/use-get-grc721-token-metadata'; +import { useGetGRC721TokenUri } from '@hooks/nft/use-get-grc721-token-uri'; +import useAppNavigate from '@hooks/use-app-navigate'; +import useLink from '@hooks/use-link'; +import mixins from '@styles/mixins'; +import { fonts, getTheme } from '@styles/theme'; +import { RoutePath } from '@types'; +import { useCallback, useMemo } from 'react'; + +const Wrapper = styled.main` + ${mixins.flex({ align: 'flex-start', justify: 'flex-start' })}; + width: 100%; + height: fit-content; + padding-top: 24px; + padding-bottom: 96px; + gap: 12px; + background-color: ${getTheme('neutral', '_8')}; + + .send-button { + flex-shrink: 0; + ${fonts.body1Bold} + } + + .empty-block { + display: flex; + flex-shrink: 0; + width: 100%; + height: 60px; + } +`; + +export const NftCollectionAsset = (): JSX.Element => { + const theme = useTheme(); + const { openScannerLink } = useLink(); + const { params, navigate, goBack } = useAppNavigate(); + const { pinCollection, unpinCollection, showCollection, hideCollection } = + useNFTCollectionHandler(); + const collectionAsset = params.collectionAsset; + + const { data: collections, refetch: refetchCollections } = useGetGRC721Collections({ + refetchOnMount: true, + }); + + const { data: pinnedCollections, refetch: refetchPinnedCollection } = + useGetGRC721PinnedCollections({ + refetchOnMount: true, + }); + + const title = useMemo(() => { + return `${collectionAsset.name} #${collectionAsset.tokenId}`; + }, [collectionAsset]); + + const pinnedCollection = useMemo(() => { + if (!pinnedCollections) { + return false; + } + + return !!pinnedCollections.find((packagePath) => packagePath === collectionAsset.packagePath); + }, [collectionAsset, pinnedCollections]); + + const visibleCollection = useMemo(() => { + if (!collections) { + return false; + } + + const currentCollection = collections.find( + (collection) => collection.packagePath === collectionAsset.packagePath, + ); + + if (!currentCollection) { + return false; + } + + return currentCollection.display; + }, [collectionAsset, collections]); + + const moveGnoscanCollection = useCallback(() => { + openScannerLink('/realms/details', { path: params.collectionAsset.packagePath }); + }, [openScannerLink]); + + const pinCollectionWithRefetch = useCallback(async () => { + await pinCollection(collectionAsset.packagePath); + await refetchPinnedCollection(); + }, [collectionAsset.packagePath, pinCollection]); + + const unpinCollectionWithRefetch = useCallback(async () => { + await unpinCollection(collectionAsset.packagePath); + await refetchPinnedCollection(); + }, [collectionAsset.packagePath, unpinCollection]); + + const showCollectionWithRefetch = useCallback(async () => { + await showCollection(collectionAsset.packagePath); + await refetchCollections(); + }, [collectionAsset.packagePath, showCollection]); + + const hideCollectionWithRefetch = useCallback(async () => { + await hideCollection(collectionAsset.packagePath); + await refetchCollections(); + }, [collectionAsset.packagePath, hideCollection]); + + const onClickSend = useCallback(() => { + navigate(RoutePath.NftTransferInput, { + state: { + collectionAsset, + }, + }); + }, [collectionAsset]); + + return ( + + + + + + + Send + + + + + + + ); +}; diff --git a/packages/adena-extension/src/pages/popup/wallet/nft/collection.tsx b/packages/adena-extension/src/pages/popup/wallet/nft/collection.tsx new file mode 100644 index 000000000..40e1a38e7 --- /dev/null +++ b/packages/adena-extension/src/pages/popup/wallet/nft/collection.tsx @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useCallback, useMemo } from 'react'; +import styled from 'styled-components'; + +import NFTCollectionAssets from '@components/pages/nft-collection/nft-collection-assets/nft-collection-assets'; +import NFTCollectionHeader from '@components/pages/nft-collection/nft-collection-header/nft-collection-header'; +import { useGetGRC721TokenUri } from '@hooks/nft/use-get-grc721-token-uri'; +import { useGetGRC721Tokens } from '@hooks/nft/use-get-grc721-tokens'; +import { useIsLoadingNFT } from '@hooks/nft/use-is-loading-nft'; +import useAppNavigate from '@hooks/use-app-navigate'; +import useLink from '@hooks/use-link'; +import mixins from '@styles/mixins'; +import { getTheme } from '@styles/theme'; +import { GRC721Model, RoutePath } from '@types'; + +const Wrapper = styled.main` + ${mixins.flex({ align: 'flex-start', justify: 'flex-start' })}; + width: 100%; + height: 100%; + padding-top: 24px; + gap: 12px; + background-color: ${getTheme('neutral', '_8')}; +`; + +export const NftCollection = (): JSX.Element => { + const { openScannerLink } = useLink(); + const { params, navigate, goBack } = useAppNavigate(); + + const { data: grc721Tokens, isFetched: isFetchedGRC721Tokens } = useGetGRC721Tokens( + params.collection, + { + refetchOnMount: true, + }, + ); + + const fetchingCount = useIsLoadingNFT(); + + const isFinishFetchedGRC721Tokens = useMemo(() => { + return fetchingCount === 0 && isFetchedGRC721Tokens; + }, [fetchingCount, isFetchedGRC721Tokens]); + + const title = useMemo(() => { + return params.collection.name; + }, [params.collection]); + + const moveBack = useCallback(() => { + goBack(); + }, [goBack]); + + const moveGnoscanCollection = useCallback(() => { + openScannerLink('/realms/details', { path: params.collection.packagePath }); + }, [openScannerLink]); + + const moveCollectionAssetPage = useCallback( + (grc721Token: GRC721Model) => { + navigate(RoutePath.NftCollectionAsset, { + state: { + collectionAsset: grc721Token, + }, + }); + }, + [navigate], + ); + + return ( + + + + + ); +}; diff --git a/packages/adena-extension/src/pages/popup/wallet/nft/index.tsx b/packages/adena-extension/src/pages/popup/wallet/nft/index.tsx index cb8da4138..5fc8a1119 100644 --- a/packages/adena-extension/src/pages/popup/wallet/nft/index.tsx +++ b/packages/adena-extension/src/pages/popup/wallet/nft/index.tsx @@ -1,45 +1,116 @@ -import React, { useState } from 'react'; -import styled, { useTheme } from 'styled-components'; +import styled from 'styled-components'; -import { Text } from '@components/atoms'; -import { LoadingNft } from '@components/molecules'; -import { getTheme } from '@styles/theme'; +import NFTCollections from '@components/pages/nft/nft-collections/nft-collections'; +import NFTHeader from '@components/pages/nft/nft-header/nft-header'; +import { useNFTCollectionHandler } from '@hooks/nft/use-collection-handler'; +import { useGetGRC721Balance } from '@hooks/nft/use-get-grc721-balance'; +import { useGetGRC721Collections } from '@hooks/nft/use-get-grc721-collections'; +import { useGetGRC721PinnedCollections } from '@hooks/nft/use-get-grc721-pinned-collections'; +import { useGetGRC721TokenUri } from '@hooks/nft/use-get-grc721-token-uri'; +import { useIsLoadingNFT } from '@hooks/nft/use-is-loading-nft'; +import useAppNavigate from '@hooks/use-app-navigate'; +import { useCurrentAccount } from '@hooks/use-current-account'; +import useLink from '@hooks/use-link'; import mixins from '@styles/mixins'; +import { getTheme } from '@styles/theme'; +import { GRC721CollectionModel, RoutePath } from '@types'; +import { useCallback, useMemo } from 'react'; const Wrapper = styled.main` ${mixins.flex({ align: 'flex-start', justify: 'flex-start' })}; width: 100%; - height: 100%; + height: auto; + flex-shrink: 0; padding-top: 24px; + padding-bottom: 96px; + gap: 12px; background-color: ${getTheme('neutral', '_8')}; - .desc { - position: absolute; - top: 210px; - left: 0px; - width: 100%; - text-align: center; - } `; export const Nft = (): JSX.Element => { - const theme = useTheme(); - const [state] = useState('FINISH'); - const [data] = useState([]); + const { currentAddress } = useCurrentAccount(); + const { navigate } = useAppNavigate(); + const { openScannerLink } = useLink(); + + const { data: collections, isFetched: isFetchedCollections } = useGetGRC721Collections({ + refetchOnMount: true, + }); + const { + data: pinnedCollections, + isFetched: isFetchedPinnedCollections, + refetch: refetchPinnedCollection, + } = useGetGRC721PinnedCollections({ + refetchOnMount: true, + }); + + const { pinCollection, unpinCollection } = useNFTCollectionHandler(); + + const fetchingCount = useIsLoadingNFT(); + + const isFinishFetchedCollections = useMemo(() => { + return fetchingCount === 0 && isFetchedCollections; + }, [fetchingCount, isFetchedCollections]); + + const pin = useCallback( + async (packagePath: string) => { + await pinCollection(packagePath); + await refetchPinnedCollection(); + }, + [pinCollection], + ); + + const unpin = useCallback( + async (packagePath: string) => { + await unpinCollection(packagePath); + await refetchPinnedCollection(); + }, + [unpinCollection], + ); + + const openGnoscan = useCallback(() => { + if (!currentAddress) { + return; + } + openScannerLink('/accounts/' + currentAddress); + }, [currentAddress, openScannerLink]); + + const moveDepositPage = useCallback(() => { + navigate(RoutePath.Deposit, { + state: { + token: { + symbol: 'NFT', + }, + type: 'token', + }, + }); + }, [navigate]); + + const moveCollectionPage = useCallback( + (collection: GRC721CollectionModel) => { + navigate(RoutePath.NftCollection, { state: { collection } }); + }, + [navigate], + ); + + const moveManageCollectionsPage = useCallback(() => { + navigate(RoutePath.ManageNft); + }, [navigate]); return ( - NFTs - {state === 'FINISH' ? ( - data.length > 0 ? ( - data.map(() => <>>) - ) : ( - - No NFTs to display - - ) - ) : ( - - )} + + ); }; diff --git a/packages/adena-extension/src/pages/popup/wallet/search/index.tsx b/packages/adena-extension/src/pages/popup/wallet/search/index.tsx index 635a11a87..649faf11e 100644 --- a/packages/adena-extension/src/pages/popup/wallet/search/index.tsx +++ b/packages/adena-extension/src/pages/popup/wallet/search/index.tsx @@ -1,23 +1,22 @@ -import React, { useState, useRef } from 'react'; -import styled from 'styled-components'; import BigNumber from 'bignumber.js'; +import React, { useRef, useState } from 'react'; +import styled from 'styled-components'; -import search from '@assets/search.svg'; import cancel from '@assets/cancel-dark.svg'; -import { Text, DefaultInput, Button } from '@components/atoms'; -import { TokenBalance } from '@components/molecules'; -import { RoutePath } from '@types'; -import { searchTextFilter } from '@common/utils/client-utils'; -import { ListBox, ListHierarchy } from '@components/atoms'; -import { useTokenBalance } from '@hooks/use-token-balance'; import UnknownTokenIcon from '@assets/common-unknown-token.svg'; +import search from '@assets/search.svg'; +import { searchTextFilter } from '@common/utils/client-utils'; +import { Button, DefaultInput, ListBox, ListHierarchy, Text } from '@components/atoms'; +import { TokenBalance } from '@components/molecules'; import useHistoryData from '@hooks/use-history-data'; +import { useTokenBalance } from '@hooks/use-token-balance'; +import { RoutePath } from '@types'; -import { TokenBalanceType } from '@types'; -import mixins from '@styles/mixins'; -import { getTheme } from '@styles/theme'; import useAppNavigate from '@hooks/use-app-navigate'; import useSessionState from '@hooks/use-session-state'; +import mixins from '@styles/mixins'; +import { getTheme } from '@styles/theme'; +import { TokenBalanceType } from '@types'; const Wrapper = styled.main` width: 100%; @@ -108,7 +107,7 @@ export const WalletSearch = (): JSX.Element => { navigate(RoutePath.Deposit, { state: { type: 'wallet', - tokenMetainfo: tokenBalance, + token: tokenBalance, }, }); }; diff --git a/packages/adena-extension/src/pages/popup/wallet/token-details/index.tsx b/packages/adena-extension/src/pages/popup/wallet/token-details/index.tsx index feb2a4114..b19c88efc 100644 --- a/packages/adena-extension/src/pages/popup/wallet/token-details/index.tsx +++ b/packages/adena-extension/src/pages/popup/wallet/token-details/index.tsx @@ -1,31 +1,30 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { isAirgapAccount } from 'adena-module'; import BigNumber from 'bignumber.js'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled, { useTheme } from 'styled-components'; -import { isAirgapAccount } from 'adena-module'; -import { Text, StaticMultiTooltip, LeftArrowBtn } from '@components/atoms'; -import { TransactionHistory, DoubleButton } from '@components/molecules'; import etc from '@assets/etc.svg'; -import { RoutePath } from '@types'; -import { getTheme } from '@styles/theme'; -import { useCurrentAccount } from '@hooks/use-current-account'; -import { TransactionHistoryMapper } from '@repositories/transaction/mapper/transaction-history-mapper'; -import { HighlightNumber } from '@components/atoms'; -import useScrollHistory from '@hooks/use-scroll-history'; import { isGRC20TokenModel } from '@common/validation/validation-token'; +import { HighlightNumber, LeftArrowBtn, StaticMultiTooltip, Text } from '@components/atoms'; +import { DoubleButton, TransactionHistory } from '@components/molecules'; +import { useCurrentAccount } from '@hooks/use-current-account'; import useHistoryData from '@hooks/use-history-data'; +import useScrollHistory from '@hooks/use-scroll-history'; +import { TransactionHistoryMapper } from '@repositories/transaction/mapper/transaction-history-mapper'; +import { getTheme } from '@styles/theme'; +import { RoutePath } from '@types'; -import LoadingTokenDetails from './loading-token-details'; -import mixins from '@styles/mixins'; +import { SCANNER_URL } from '@common/constants/resource.constant'; +import { makeQueryString } from '@common/utils/string-utils'; import useAppNavigate from '@hooks/use-app-navigate'; import useLink from '@hooks/use-link'; +import { useNetwork } from '@hooks/use-network'; import useSessionParams from '@hooks/use-session-state'; import { useTokenBalance } from '@hooks/use-token-balance'; import { useTokenTransactions } from '@hooks/wallet/token-details/use-token-transactions'; -import { useNetwork } from '@hooks/use-network'; -import { SCANNER_URL } from '@common/constants/resource.constant'; -import { makeQueryString } from '@common/utils/string-utils'; import { useTokenTransactionsPage } from '@hooks/wallet/token-details/use-token-transactions-page'; +import mixins from '@styles/mixins'; +import LoadingTokenDetails from './loading-token-details'; const Wrapper = styled.main` ${mixins.flex({ align: 'flex-start', justify: 'flex-start' })}; @@ -179,7 +178,7 @@ export const TokenDetails = (): JSX.Element => { if (!tokenBalance) { return; } - navigate(RoutePath.Deposit, { state: { type: 'token', tokenMetainfo: tokenBalance } }); + navigate(RoutePath.Deposit, { state: { type: 'token', token: tokenBalance } }); }; const SendButtonClick = (): void => { diff --git a/packages/adena-extension/src/pages/popup/wallet/transaction-detail/index.tsx b/packages/adena-extension/src/pages/popup/wallet/transaction-detail/index.tsx index cbe5651ee..438a703ea 100644 --- a/packages/adena-extension/src/pages/popup/wallet/transaction-detail/index.tsx +++ b/packages/adena-extension/src/pages/popup/wallet/transaction-detail/index.tsx @@ -1,22 +1,23 @@ -import React, { useCallback } from 'react'; +import { useCallback } from 'react'; import styled from 'styled-components'; -import useLink from '@hooks/use-link'; -import { Text, CopyIconButton, Button } from '@components/atoms'; -import { TokenBalance } from '@components/molecules'; -import { formatHash, getDateTimeText, getStatusStyle } from '@common/utils/client-utils'; -import IconShare from '@assets/icon-share'; -import { useTokenMetainfo } from '@hooks/use-token-metainfo'; -import ContractIcon from '@assets/contract.svg'; import AddPackageIcon from '@assets/addpkg.svg'; -import { useNetwork } from '@hooks/use-network'; -import { fonts, getTheme } from '@styles/theme'; -import mixins from '@styles/mixins'; -import useAppNavigate from '@hooks/use-app-navigate'; -import { RoutePath } from '@types'; import UnknownTokenIcon from '@assets/common-unknown-token.svg'; +import ContractIcon from '@assets/contract.svg'; +import IconShare from '@assets/icon-share'; import { SCANNER_URL } from '@common/constants/resource.constant'; +import { formatHash, getDateTimeText, getStatusStyle } from '@common/utils/client-utils'; import { makeQueryString } from '@common/utils/string-utils'; +import { Button, CopyIconButton, Text } from '@components/atoms'; +import { TokenBalance } from '@components/molecules'; +import { useGetGRC721TokenUri } from '@hooks/nft/use-get-grc721-token-uri'; +import useAppNavigate from '@hooks/use-app-navigate'; +import useLink from '@hooks/use-link'; +import { useNetwork } from '@hooks/use-network'; +import { useTokenMetainfo } from '@hooks/use-token-metainfo'; +import mixins from '@styles/mixins'; +import { fonts, getTheme } from '@styles/theme'; +import { RoutePath } from '@types'; interface DLProps { color?: string; @@ -28,8 +29,16 @@ export const TransactionDetail = (): JSX.Element => { const { currentNetwork, scannerParameters } = useNetwork(); const { goBack, params } = useAppNavigate(); const transactionItem = params.transactionInfo; + const tokenUriQuery = + transactionItem?.type === 'TRANSFER_GRC721' + ? useGetGRC721TokenUri(transactionItem.logo, '0') + : null; const getLogoImage = useCallback(() => { + if (transactionItem?.type === 'TRANSFER_GRC721') { + return tokenUriQuery?.data || `${UnknownTokenIcon}`; + } + if (transactionItem?.type === 'ADD_PACKAGE') { return `${AddPackageIcon}`; } diff --git a/packages/adena-extension/src/pages/popup/wallet/transfer-summary/index.tsx b/packages/adena-extension/src/pages/popup/wallet/transfer-summary/index.tsx index 56fe4709f..bfd512c5c 100644 --- a/packages/adena-extension/src/pages/popup/wallet/transfer-summary/index.tsx +++ b/packages/adena-extension/src/pages/popup/wallet/transfer-summary/index.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import UnknownTokenIcon from '@assets/common-unknown-token.svg'; +import { DEFAULT_GAS_WANTED } from '@common/constants/tx.constant'; import { isGRC20TokenModel, isNativeTokenModel } from '@common/validation/validation-token'; import TransferSummary from '@components/pages/transfer-summary/transfer-summary/transfer-summary'; import useAppNavigate from '@hooks/use-app-navigate'; @@ -80,7 +81,6 @@ const TransferSummaryContainer: React.FC = () => { return null; } const { tokenMetainfo, networkFee } = summaryInfo; - const GAS_WANTED = 1000000; const message = tokenMetainfo.type === 'gno-native' ? getNativeTransferMessage() : getGRC20TransferMessage(); const networkFeeAmount = BigNumber(networkFee.value).shiftedBy(6).toNumber(); @@ -88,7 +88,7 @@ const TransferSummaryContainer: React.FC = () => { currentAccount, currentNetwork.networkId, [message], - GAS_WANTED, + DEFAULT_GAS_WANTED, networkFeeAmount, summaryInfo.memo, ); @@ -140,10 +140,8 @@ const TransferSummaryContainer: React.FC = () => { const transferByCommon = useCallback(async () => { try { - const result = await createTransaction(); - if (result) { - navigate(RoutePath.History); - } + createTransaction(); + navigate(RoutePath.History); } catch (e) { if (!(e instanceof Error)) { return false; diff --git a/packages/adena-extension/src/repositories/common/mapper/token-query.mapper.ts b/packages/adena-extension/src/repositories/common/mapper/token-query.mapper.ts index 142155940..a035b89ff 100644 --- a/packages/adena-extension/src/repositories/common/mapper/token-query.mapper.ts +++ b/packages/adena-extension/src/repositories/common/mapper/token-query.mapper.ts @@ -1,4 +1,5 @@ -import { GRC20TokenModel } from '@types'; +import { parseGRC721FileContents } from '@common/utils/parse-utils'; +import { GRC20TokenModel, GRC721CollectionModel } from '@types'; export const GRC20_FUNCTIONS = [ 'TotalSupply', @@ -9,6 +10,37 @@ export const GRC20_FUNCTIONS = [ 'TransferFrom', ]; +export function mapGRC721CollectionModel( + networkId: string, + message: any, +): GRC721CollectionModel | null { + const packageInfo = message?.value?.package; + if (!packageInfo) { + return null; + } + const packagePath = packageInfo.path; + + for (const file of packageInfo.files) { + const tokenInfo = parseGRC721FileContents(file.body); + if (tokenInfo) { + return { + tokenId: packagePath, + networkId: networkId, + display: false, + type: 'grc721', + packagePath, + name: tokenInfo.name, + symbol: tokenInfo.symbol, + image: null, + isMetadata: tokenInfo.isMetadata, + isTokenUri: tokenInfo.isTokenUri, + }; + } + } + + return null; +} + export function mapGRC20TokenModel(networkId: string, message: any): GRC20TokenModel | null { const packageInfo = message?.value?.package; if (!packageInfo) { diff --git a/packages/adena-extension/src/repositories/common/response/app-info-response.ts b/packages/adena-extension/src/repositories/common/response/app-info-response.ts new file mode 100644 index 000000000..b4ae3f802 --- /dev/null +++ b/packages/adena-extension/src/repositories/common/response/app-info-response.ts @@ -0,0 +1,9 @@ +export interface AppInfoResponse { + symbol: string; + name: string; + description: string; + logo: string; + link: string; + display: boolean; + order: number; +} diff --git a/packages/adena-extension/src/repositories/common/response/index.ts b/packages/adena-extension/src/repositories/common/response/index.ts index bea82c7e4..bd5b237ae 100644 --- a/packages/adena-extension/src/repositories/common/response/index.ts +++ b/packages/adena-extension/src/repositories/common/response/index.ts @@ -1,3 +1,4 @@ +export * from './app-info-response'; export * from './faucet-response'; export * from './search-grc20-token-response'; export * from './token-asset-response'; diff --git a/packages/adena-extension/src/repositories/common/token.queries.ts b/packages/adena-extension/src/repositories/common/token.queries.ts index 59d7eb8a5..845109e04 100644 --- a/packages/adena-extension/src/repositories/common/token.queries.ts +++ b/packages/adena-extension/src/repositories/common/token.queries.ts @@ -24,3 +24,103 @@ export const makeAllRealmsQuery = (): string => ` } } `; + +export const makeGRC721TransferEventsQuery = (packagePath: string, address: string): string => ` +{ + transactions( + filter: { + success: true + events: { + type: "Transfer" + pkg_path: "${packagePath}" + attrs: [{ + key: "from" + value: "${address}" + }, { + key:"to" + value: "${address}" + }] + } + messages: [ + { + type_url: exec + } + ] + } + ascending: false + after: null + ) { + pageInfo { + last + hasNext + } + edges { + cursor + transaction { + hash + index + success + block_height + response { + events { + ...on GnoEvent { + type + pkg_path + func + attrs { + key + value + } + } + } + } + } + } + } +} +`; + +export const makeAllTransferEventsQueryBy = (address: string): string => ` +{ + transactions( + filter: { + success: true + events: { + type: "Transfer" + attrs: [{ + key: "to" + value: "${address}" + }] + } + } + ascending: false + after: null + ) { + pageInfo { + last + hasNext + } + edges { + cursor + transaction { + hash + index + success + block_height + response { + events { + ...on GnoEvent { + type + pkg_path + func + attrs { + key + value + } + } + } + } + } + } + } +}`; diff --git a/packages/adena-extension/src/repositories/common/token.ts b/packages/adena-extension/src/repositories/common/token.ts index 54cc9d619..615d0a742 100644 --- a/packages/adena-extension/src/repositories/common/token.ts +++ b/packages/adena-extension/src/repositories/common/token.ts @@ -11,19 +11,38 @@ import { import { GnoProvider } from '@common/provider/gno/gno-provider'; import { makeRPCRequest } from '@common/utils/fetch-utils'; -import { parseGRC20ByABCIRender, parseGRC20ByFileContents } from '@common/utils/parse-utils'; +import { + parseGRC20ByABCIRender, + parseGRC20ByFileContents, + parseGRC721FileContents, +} from '@common/utils/parse-utils'; import { GRC20TokenModel, + GRC721CollectionModel, + GRC721MetadataModel, + GRC721Model, IBCNativeTokenModel, IBCTokenModel, NativeTokenModel, NetworkMetainfo, TokenModel, } from '@types'; -import { mapGRC20TokenModel } from './mapper/token-query.mapper'; -import { makeAllRealmsQuery } from './token.queries'; - -type LocalValueType = 'ACCOUNT_TOKEN_METAINFOS'; +import BigNumber from 'bignumber.js'; +import { mapGRC20TokenModel, mapGRC721CollectionModel } from './mapper/token-query.mapper'; +import { AppInfoResponse } from './response'; +import { + makeAllRealmsQuery, + makeAllTransferEventsQueryBy, + makeGRC721TransferEventsQuery, +} from './token.queries'; +import { ITokenRepository } from './types'; + +enum LocalValueType { + AccountTokenMetainfos = 'ACCOUNT_TOKEN_METAINFOS', + AccountGRC721Collections = 'ACCOUNT_GRC721_COLLECTIONS', + AccountGRC721PinnedPackages = 'ACCOUNT_GRC721_PINNED_PACKAGES', + AccountTransferEventBlockHeight = 'ACCOUNT_TRANSFER_EVENT_BLOCK_HEIGHT', +} const DEFAULT_TOKEN_NETWORK_ID = 'DEFAULT'; @@ -43,17 +62,7 @@ const DEFAULT_TOKEN_METAINFOS: NativeTokenModel[] = [ }, ]; -export interface AppInfoResponse { - symbol: string; - name: string; - description: string; - logo: string; - link: string; - display: boolean; - order: number; -} - -export class TokenRepository { +export class TokenRepository implements ITokenRepository { private static GNO_TOKEN_RESOURCE_URI = 'https://raw.githubusercontent.com/onbloc/gno-token-resource/main'; @@ -126,7 +135,7 @@ export class TokenRepository { public getAccountTokenMetainfos = async (accountId: string): Promise => { const accountTokenMetainfos = await this.localStorage.getToObject<{ [key in string]: TokenModel[]; - }>('ACCOUNT_TOKEN_METAINFOS'); + }>(LocalValueType.AccountTokenMetainfos); return accountTokenMetainfos[accountId] ?? []; }; @@ -137,7 +146,7 @@ export class TokenRepository { ): Promise => { const accountTokenMetainfos = await this.localStorage.getToObject<{ [key in string]: TokenModel[]; - }>('ACCOUNT_TOKEN_METAINFOS'); + }>(LocalValueType.AccountTokenMetainfos); const isUnique = function (token0: TokenModel, token1: TokenModel): boolean { return token0.tokenId === token1.tokenId && token0.networkId === token1.networkId; @@ -152,26 +161,32 @@ export class TokenRepository { [accountId]: filteredTokenMetainfos, }; - await this.localStorage.setByObject('ACCOUNT_TOKEN_METAINFOS', changedAccountTokenMetainfos); + await this.localStorage.setByObject( + LocalValueType.AccountTokenMetainfos, + changedAccountTokenMetainfos, + ); return true; }; public deleteTokenMetainfos = async (accountId: string): Promise => { const accountTokenMetainfos = await this.localStorage.getToObject<{ [key in string]: TokenModel[]; - }>('ACCOUNT_TOKEN_METAINFOS'); + }>(LocalValueType.AccountTokenMetainfos); const changedAccountTokenMetainfos = { ...accountTokenMetainfos, [accountId]: [], }; - await this.localStorage.setByObject('ACCOUNT_TOKEN_METAINFOS', changedAccountTokenMetainfos); + await this.localStorage.setByObject( + LocalValueType.AccountTokenMetainfos, + changedAccountTokenMetainfos, + ); return true; }; public deleteAllTokenMetainfo = async (): Promise => { - await this.localStorage.setByObject('ACCOUNT_TOKEN_METAINFOS', {}); + await this.localStorage.setByObject(LocalValueType.AccountTokenMetainfos, {}); return true; }; @@ -253,6 +268,338 @@ export class TokenRepository { ); }; + public async fetchGRC721Collections(): Promise { + if (this.apiUrl) { + const tokens = await TokenRepository.postRPCRequest<{ + result: { + name: string; + symbol: string; + packagePath: string; + isTokenURI: boolean; + isTokenMeta: boolean; + }[]; + }>( + this.networkInstance, + this.apiUrl + '/gno', + makeRPCRequest({ + method: 'getGRC721Packages', + }), + ).then((data) => data?.result || []); + + return tokens.reduce((accumulated, current) => { + const exists = !!accumulated.find((item) => item.packagePath === current.packagePath); + if (!exists) { + accumulated.push({ + tokenId: current.packagePath, + networkId: this.networkId, + display: false, + type: 'grc721', + packagePath: current.packagePath, + name: current.name, + symbol: current.symbol, + image: null, + isMetadata: !!current.isTokenMeta, + isTokenUri: !!current.isTokenURI, + }); + } + return accumulated; + }, []); + } + + if (!this.queryUrl) { + return []; + } + + const allRealmsQuery = makeAllRealmsQuery(); + return TokenRepository.postGraphQuery(this.networkInstance, this.queryUrl, allRealmsQuery).then( + (result) => + result?.data?.transactions + ? result?.data?.transactions + .flatMap((tx: any) => tx.messages) + .map((message: any) => + mapGRC721CollectionModel(this.networkMetainfo?.networkId || '', message), + ) + : [], + ); + } + + public async fetchAllTransferPackagesBy(address: string): Promise { + if (!this.apiUrl || !this.queryUrl) { + return []; + } + + if (this.apiUrl) { + const packages = await TokenRepository.postRPCRequest<{ + result: string[]; + }>( + this.networkInstance, + this.apiUrl + '/gno', + makeRPCRequest({ + method: 'getUserTransferPackages', + params: [address], + }), + ) + .then((data) => data?.result || []) + .then((packages) => [...new Set(packages)]); + + return packages; + } + + const transferEventsQuery = makeAllTransferEventsQueryBy(address); + return TokenRepository.postGraphQuery( + this.networkInstance, + this.queryUrl, + transferEventsQuery, + ).then((result) => { + const edges = result?.data?.transactions?.edges; + if (!edges) { + return []; + } + + const packagePaths: string[] = edges + .flatMap((edge: any) => edge?.transaction?.response?.events || []) + .filter((event: any) => { + const eventType = event?.type; + const eventAttributes = event?.attrs || []; + const eventToAttribute = eventAttributes.find((attribute: any) => attribute.key === 'to'); + + if (!eventType || !eventToAttribute) { + return false; + } + + return true; + }) + .map((event: any) => event?.pkg_path || ''); + + return [...new Set(packagePaths)]; + }); + } + + public async fetchGRC721CollectionByPackagePath( + packagePath: string, + ): Promise { + if (!this.gnoProvider) { + throw new Error('Gno provider not initialized.'); + } + + const fileContents = await this.gnoProvider.getFileContent(packagePath).catch(() => null); + const fileNames = fileContents?.split('\n') || []; + + if (fileContents === null || fileNames.length === 0) { + throw new Error('Not available realm'); + } + + const fileTokenInfo = await this.fetchGRC721CollectionQueryFiles(packagePath, fileNames).catch( + () => null, + ); + if (fileTokenInfo) { + return fileTokenInfo; + } + + throw new Error('Realm is not GRC721'); + } + + public async fetchGRC721TokenUriBy(packagePath: string, tokenId: string): Promise { + if (!this.gnoProvider) { + throw new Error('Gno provider not initialized.'); + } + + const response = await this.gnoProvider.getValueByEvaluateExpression(packagePath, 'TokenURI', [ + tokenId, + ]); + + if (!response) { + throw new Error('not found token uri'); + } + + return response.replace(/"/g, ''); + } + + public async fetchGRC721TokenMetadataBy( + packagePath: string, + tokenId: string, + ): Promise { + if (!this.gnoProvider) { + throw new Error('Gno provider not initialized.'); + } + + const response = await this.gnoProvider.getValueByEvaluateExpression( + packagePath, + 'TokenMetadata', + [tokenId], + ); + + if (!response) { + throw new Error('not found token uri'); + } + + const jsonStr = response.replace(/\\"/g, '"'); + + const metadata: GRC721MetadataModel = JSON.parse(jsonStr); + return metadata; + } + + public async fetchGRC721BalanceBy(packagePath: string, address: string): Promise { + if (!this.gnoProvider) { + throw new Error('Gno provider not initialized.'); + } + + const response = await this.gnoProvider.getValueByEvaluateExpression(packagePath, 'BalanceOf', [ + address, + ]); + + if (!response || BigNumber(response).isNaN()) { + throw new Error('not found token uri'); + } + + return BigNumber(response).toNumber(); + } + + public async fetchGRC721TokensBy(packagePath: string, address: string): Promise { + if (!this.queryUrl) { + return []; + } + + const grc721TransferEventsQuery = makeGRC721TransferEventsQuery(packagePath, address); + const events: { + type: string; + pkg_path: string; + func: string; + attrs: { [key in string]: string }[]; + }[] = await TokenRepository.postGraphQuery( + this.networkInstance, + this.queryUrl, + grc721TransferEventsQuery, + ).then((result) => + result?.data?.transactions + ? result?.data?.transactions?.edges.flatMap((edge: any) => edge.transaction.response.events) + : [], + ); + + const receivedTokenIds: string[] = []; + const sendedTokenIds: string[] = []; + const tokens: GRC721Model[] = []; + + for (const event of events) { + if (event.pkg_path !== packagePath || event.type !== 'Transfer') { + continue; + } + + const tokenIdValue = event.attrs.find((attr) => attr.key === 'tid')?.value; + const toValue = event.attrs.find((attr) => attr.key === 'to')?.value; + const fromValue = event.attrs.find((attr) => attr.key === 'from')?.value; + + if (tokenIdValue === undefined || toValue === undefined || fromValue === undefined) { + continue; + } + + if (toValue !== address && fromValue !== address) { + continue; + } + + if (receivedTokenIds.includes(tokenIdValue) || sendedTokenIds.includes(tokenIdValue)) { + continue; + } + + const isSended = fromValue === address; + if (isSended) { + sendedTokenIds.push(tokenIdValue); + continue; + } + + receivedTokenIds.push(tokenIdValue); + tokens.push({ + tokenId: tokenIdValue, + networkId: this.networkId, + type: 'grc721', + packagePath, + name: '', + symbol: '', + isTokenUri: false, + isMetadata: false, + metadata: null, + }); + } + + return tokens; + } + + public async getAccountGRC721CollectionsBy( + accountId: string, + networkId: string, + ): Promise { + const accountGRC721CollectionsMap = await this.localStorage.getToObject<{ + [key in string]: { [key in string]: GRC721CollectionModel[] }; + }>(LocalValueType.AccountGRC721Collections); + + if (!accountGRC721CollectionsMap?.[accountId]?.[networkId]) { + return []; + } + + return accountGRC721CollectionsMap[accountId][networkId]; + } + + public async saveAccountGRC721CollectionsBy( + accountId: string, + networkId: string, + collections: GRC721CollectionModel[], + ): Promise { + const accountGRC721CollectionsMap = + (await this.localStorage.getToObject<{ + [key in string]: { [key in string]: GRC721CollectionModel[] }; + }>(LocalValueType.AccountGRC721Collections)) || {}; + + const currentAccountCollections = accountGRC721CollectionsMap?.[accountId] || {}; + + await this.localStorage.setByObject(LocalValueType.AccountGRC721Collections, { + ...accountGRC721CollectionsMap, + [accountId]: { + ...currentAccountCollections, + [networkId]: collections, + }, + }); + + return true; + } + + public async getAccountGRC721PinnedPackagesBy( + accountId: string, + networkId: string, + ): Promise { + const accountGRC721PinnedPackagesMap = await this.localStorage.getToObject<{ + [key in string]: { [key in string]: string[] }; + }>(LocalValueType.AccountGRC721PinnedPackages); + + if (!accountGRC721PinnedPackagesMap?.[accountId]?.[networkId]) { + return []; + } + + return accountGRC721PinnedPackagesMap[accountId][networkId]; + } + + public async saveAccountGRC721PinnedPackagesBy( + accountId: string, + networkId: string, + packagePaths: string[], + ): Promise { + const accountGRC721PinnedPackagesMap = + (await this.localStorage.getToObject<{ + [key in string]: { [key in string]: string[] }; + }>(LocalValueType.AccountGRC721PinnedPackages)) || {}; + + const currentAccountPinnedPackages = accountGRC721PinnedPackagesMap?.[accountId] || {}; + + await this.localStorage.setByObject(LocalValueType.AccountGRC721PinnedPackages, { + ...accountGRC721PinnedPackagesMap, + [accountId]: { + ...currentAccountPinnedPackages, + [networkId]: [...new Set(packagePaths)], + }, + }); + + return true; + } + private fetchNativeTokenAssets = async (): Promise => { const requestUri = TokenRepository.GNO_TOKEN_RESOURCE_URI + `/gno-native/${this.networkId}.json`; @@ -351,6 +698,42 @@ export class TokenRepository { return null; } + private async fetchGRC721CollectionQueryFiles( + packagePath: string, + fileNames: string[], + ): Promise { + if (!this.gnoProvider) { + throw new Error('Gno provider not initialized.'); + } + + for (const fileName of fileNames) { + const filePath = [packagePath, fileName].join('/'); + const contents = await this.gnoProvider.getFileContent(filePath).catch(() => null); + if (!contents) { + continue; + } + + const tokenInfo = parseGRC721FileContents(contents); + + if (tokenInfo) { + return { + tokenId: packagePath, + packagePath: packagePath, + networkId: this.networkId, + display: false, + type: 'grc721', + name: tokenInfo.name, + symbol: tokenInfo.symbol, + image: null, + isMetadata: tokenInfo.isMetadata, + isTokenUri: tokenInfo.isTokenUri, + }; + } + } + + return null; + } + private static postRPCRequest = ( axiosInstance: AxiosInstance, url: string, diff --git a/packages/adena-extension/src/repositories/common/types.ts b/packages/adena-extension/src/repositories/common/types.ts new file mode 100644 index 000000000..363f9bfc1 --- /dev/null +++ b/packages/adena-extension/src/repositories/common/types.ts @@ -0,0 +1,55 @@ +import { + GRC20TokenModel, + GRC721CollectionModel, + GRC721MetadataModel, + GRC721Model, + NetworkMetainfo, + TokenModel, +} from '@types'; +import { AppInfoResponse } from './response'; + +export interface ITokenRepository extends IGRC721TokenRepository { + supported: boolean; + apiUrl: string | null; + queryUrl: string | null; + + setNetworkMetainfo: (networkMetainfo: NetworkMetainfo) => void; + fetchTokenMetainfos: () => Promise; + fetchAppInfos: () => Promise; + fetchAllGRC20Tokens: () => Promise; + fetchAllTransferPackagesBy: (address: string, fromBlockHeight: number) => Promise; + fetchGRC20TokenByPackagePath: (packagePath: string) => Promise; + + getAccountTokenMetainfos: (accountId: string) => Promise; + updateTokenMetainfos: (accountId: string, tokenMetainfos: TokenModel[]) => Promise; + deleteTokenMetainfos: (accountId: string) => Promise; + deleteAllTokenMetainfo: () => Promise; +} + +export interface IGRC721TokenRepository { + fetchGRC721Collections: () => Promise; + fetchGRC721CollectionByPackagePath: (packagePath: string) => Promise; + fetchGRC721TokenUriBy: (packagePath: string, address: string) => Promise; + fetchGRC721TokenMetadataBy: ( + packagePath: string, + address: string, + ) => Promise; + fetchGRC721BalanceBy: (packagePath: string, address: string) => Promise; + fetchGRC721TokensBy: (packagePath: string, address: string) => Promise; + + getAccountGRC721CollectionsBy: ( + accountId: string, + networkId: string, + ) => Promise; + saveAccountGRC721CollectionsBy: ( + accountId: string, + networkId: string, + collections: GRC721CollectionModel[], + ) => Promise; + getAccountGRC721PinnedPackagesBy: (accountId: string, networkId: string) => Promise; + saveAccountGRC721PinnedPackagesBy: ( + accountId: string, + networkId: string, + packagePaths: string[], + ) => Promise; +} diff --git a/packages/adena-extension/src/repositories/transaction/mapper/transaction-history-mapper.ts b/packages/adena-extension/src/repositories/transaction/mapper/transaction-history-mapper.ts index 0f81be3ab..a44c7ae78 100644 --- a/packages/adena-extension/src/repositories/transaction/mapper/transaction-history-mapper.ts +++ b/packages/adena-extension/src/repositories/transaction/mapper/transaction-history-mapper.ts @@ -10,7 +10,7 @@ import { interface TransactionInfo { hash: string; logo: string; - type: 'TRANSFER' | 'ADD_PACKAGE' | 'CONTRACT_CALL' | 'MULTI_CONTRACT_CALL'; + type: 'TRANSFER' | 'TRANSFER_GRC721' | 'ADD_PACKAGE' | 'CONTRACT_CALL' | 'MULTI_CONTRACT_CALL'; typeName?: string; status: 'SUCCESS' | 'FAIL'; title: string; diff --git a/packages/adena-extension/src/repositories/transaction/mapper/transaction-history-query.mapper.ts b/packages/adena-extension/src/repositories/transaction/mapper/transaction-history-query.mapper.ts index 871d8bbbd..392cecd63 100644 --- a/packages/adena-extension/src/repositories/transaction/mapper/transaction-history-query.mapper.ts +++ b/packages/adena-extension/src/repositories/transaction/mapper/transaction-history-query.mapper.ts @@ -54,8 +54,11 @@ export function mapTransactionEdgeByAddress( // receive native token return mapReceivedTransactionByBankMsgSend(transaction); case 'exec': - // receive grc20 token - if (message.value.func === 'Transfer' && message.value.caller !== address) { + // receive grc20 or grc721 token + if ( + ['Transfer', 'TransferFrom'].includes(message.value.func) && + message.value.caller !== address + ) { return mapReceivedTransactionByMsgCall(transaction); } return mapVMTransaction(transaction); @@ -99,6 +102,34 @@ export function mapReceivedTransactionByMsgCall( tx: TransactionResponse, ): TransactionInfo { const firstMessage = getDefaultMessage(tx.messages); + if (firstMessage.value.func === 'TransferFrom' && tx.messages.length === 1) { + return { + hash: tx.hash, + height: tx.block_height, + logo: firstMessage.value.pkg_path || '', + type: 'TRANSFER_GRC721', + status: tx.success ? 'SUCCESS' : 'FAIL', + typeName: 'Receive', + title: 'Receive', + description: `From: ${formatAddress(firstMessage.value.caller || '')}`, + extraInfo: tx.messages.length > 1 ? `+${tx.messages.length - 1}` : '', + amount: { + value: firstMessage.value.args?.[2] || '0', + denom: firstMessage.value.pkg_path || '', + }, + to: formatAddress(firstMessage.value.args?.[1] || '', 4), + from: formatAddress(firstMessage.value.args?.[0] || '', 4), + originTo: firstMessage.value.caller || '', + originFrom: firstMessage.value.args?.[0] || '', + valueType: mapValueType(tx.success, true), + date: '', + networkFee: { + value: `${tx.gas_fee.amount || '0'}`, + denom: `${tx.gas_fee.denom}`, + }, + }; + } + return { hash: tx.hash, height: tx.block_height, @@ -217,12 +248,13 @@ export function mapVMTransaction( if (firstMessage.value.__typename === 'MsgCall') { const messageValue = firstMessage.value as MsgCallValue; const isTransfer = messageValue.func === 'Transfer'; - - const fromAddress = messageValue.caller || ''; - const toAddress = messageValue.args?.[0] || ''; - const sendAmount = messageValue.args?.[1] || '0'; + const isTransferGRC721 = messageValue.func === 'TransferFrom'; if (isTransfer) { + const fromAddress = messageValue.caller || ''; + const toAddress = messageValue.args?.[0] || ''; + const sendAmount = messageValue.args?.[1] || '0'; + return { hash: tx.hash, height: tx.block_height, @@ -249,6 +281,36 @@ export function mapVMTransaction( }; } + if (isTransferGRC721) { + const fromAddress = messageValue.args?.[0] || ''; + const toAddress = messageValue.args?.[1] || ''; + + return { + hash: tx.hash, + height: tx.block_height, + logo: '', + type: 'TRANSFER_GRC721', + status: tx.success ? 'SUCCESS' : 'FAIL', + typeName: 'Send', + title: 'Send', + description: `To: ${formatAddress(toAddress)}`, + amount: { + value: messageValue.args?.[2] || '0', + denom: messageValue.pkg_path || '', + }, + valueType: mapValueType(tx.success), + date: '', + from: formatAddress(fromAddress), + originFrom: fromAddress, + to: formatAddress(toAddress), + originTo: toAddress, + networkFee: { + value: tx.gas_fee.amount.toString(), + denom: tx.gas_fee.denom, + }, + }; + } + return { hash: tx.hash, height: tx.block_height, diff --git a/packages/adena-extension/src/repositories/transaction/transaction-history.queries.ts b/packages/adena-extension/src/repositories/transaction/transaction-history.queries.ts index 3b0eb044c..627d1b9bc 100644 --- a/packages/adena-extension/src/repositories/transaction/transaction-history.queries.ts +++ b/packages/adena-extension/src/repositories/transaction/transaction-history.queries.ts @@ -47,6 +47,15 @@ export const makeAccountTransactionsQuery = ( } } } + { + type_url: exec + vm_param: { + exec: { + func: "TransferFrom" + args: ["", "${address}"] + } + } + } ] } size: ${size} diff --git a/packages/adena-extension/src/router/popup/index.tsx b/packages/adena-extension/src/router/popup/index.tsx index 5cfe079ef..f1f391b2c 100644 --- a/packages/adena-extension/src/router/popup/index.tsx +++ b/packages/adena-extension/src/router/popup/index.tsx @@ -3,60 +3,65 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { RoutePath } from '../../types/router'; -import { Login } from '@pages/popup/certify/login'; -import { ForgotPassword } from '@pages/popup/certify/forgot-password'; -import { EnterSeedPhrase } from '@pages/popup/certify/enter-seed'; -import { Settings } from '@pages/popup/certify/settings'; +import { AboutAdena } from '@pages/popup/certify/about-adena'; +import AddAddress from '@pages/popup/certify/add-address'; +import AddressBook from '@pages/popup/certify/address-book'; +import ChangeNetwork from '@pages/popup/certify/change-network'; import { ChangePassword } from '@pages/popup/certify/change-password'; import { ConnectedApps } from '@pages/popup/certify/connected-apps'; -import AddressBook from '@pages/popup/certify/address-book'; -import AddAddress from '@pages/popup/certify/add-address'; -import { SecurityPrivacy } from '@pages/popup/certify/security-privacy'; -import { AboutAdena } from '@pages/popup/certify/about-adena'; +import { EnterSeedPhrase } from '@pages/popup/certify/enter-seed'; +import { ForgotPassword } from '@pages/popup/certify/forgot-password'; +import { Login } from '@pages/popup/certify/login'; import { RemoveAccount } from '@pages/popup/certify/remove-account'; import { ResetWallet } from '@pages/popup/certify/reset-wallet'; -import ChangeNetwork from '@pages/popup/certify/change-network'; +import { SecurityPrivacy } from '@pages/popup/certify/security-privacy'; +import { Settings } from '@pages/popup/certify/settings'; -import ApproveTransactionMain from '@pages/popup/wallet/approve-transaction-main'; +import AccountDetailsPage from '@pages/popup/wallet/account-details'; +import AddCustomNetworkPage from '@pages/popup/wallet/add-custom-network'; +import ApproveAddingNetworkPage from '@pages/popup/wallet/approve-adding-network'; +import ApproveChangingNetworkPage from '@pages/popup/wallet/approve-changing-network'; +import ApproveEstablish from '@pages/popup/wallet/approve-establish'; import { ApproveLogin } from '@pages/popup/wallet/approve-login'; -import { WalletSearch } from '@pages/popup/wallet/search'; +import ApproveSign from '@pages/popup/wallet/approve-sign'; +import ApproveSignLedgerLoading from '@pages/popup/wallet/approve-sign-ledger-loading'; +import ApproveSignTransaction from '@pages/popup/wallet/approve-sign-transaction'; +import ApproveSignTransactionLedgerLoading from '@pages/popup/wallet/approve-sign-transaction-ledger-loading'; +import ApproveTransactionLedgerLoading from '@pages/popup/wallet/approve-transaction-ledger-loading'; +import ApproveTransactionMain from '@pages/popup/wallet/approve-transaction-main'; +import BroadcastTransactionScreen from '@pages/popup/wallet/broadcast-transaction-screen'; import { Deposit } from '@pages/popup/wallet/deposit'; -import { TokenDetails } from '@pages/popup/wallet/token-details'; -import { WalletMain } from '@pages/popup/wallet/wallet-main'; -import { Nft } from '@pages/popup/wallet/nft'; -import { Staking } from '@pages/popup/wallet/staking'; +import EditCustomNetworkPage from '@pages/popup/wallet/edit-custom-network'; import { Explore } from '@pages/popup/wallet/explore'; import History from '@pages/popup/wallet/history'; -import { TransactionDetail } from '@pages/popup/wallet/transaction-detail'; -import ApproveEstablish from '@pages/popup/wallet/approve-establish'; -import ApproveSign from '@pages/popup/wallet/approve-sign'; -import TransferInput from '@pages/popup/wallet/transfer-input'; -import TransferSummary from '@pages/popup/wallet/transfer-summary'; import ManageToken from '@pages/popup/wallet/manage-token'; import ManageTokenAdded from '@pages/popup/wallet/manage-token-added'; +import { Nft } from '@pages/popup/wallet/nft'; +import { WalletSearch } from '@pages/popup/wallet/search'; +import { Staking } from '@pages/popup/wallet/staking'; +import { TokenDetails } from '@pages/popup/wallet/token-details'; +import { TransactionDetail } from '@pages/popup/wallet/transaction-detail'; +import TransferInput from '@pages/popup/wallet/transfer-input'; import TransferLedgerLoading from '@pages/popup/wallet/transfer-ledger-loading'; import TransferLedgerReject from '@pages/popup/wallet/transfer-ledger-reject'; -import ApproveTransactionLedgerLoading from '@pages/popup/wallet/approve-transaction-ledger-loading'; -import ApproveSignLedgerLoading from '@pages/popup/wallet/approve-sign-ledger-loading'; -import AddCustomNetworkPage from '@pages/popup/wallet/add-custom-network'; -import EditCustomNetworkPage from '@pages/popup/wallet/edit-custom-network'; -import ApproveChangingNetworkPage from '@pages/popup/wallet/approve-changing-network'; -import ApproveAddingNetworkPage from '@pages/popup/wallet/approve-adding-network'; -import AccountDetailsPage from '@pages/popup/wallet/account-details'; -import ApproveSignTransaction from '@pages/popup/wallet/approve-sign-transaction'; -import ApproveSignTransactionLedgerLoading from '@pages/popup/wallet/approve-sign-transaction-ledger-loading'; -import BroadcastTransactionScreen from '@pages/popup/wallet/broadcast-transaction-screen'; +import TransferSummary from '@pages/popup/wallet/transfer-summary'; +import { WalletMain } from '@pages/popup/wallet/wallet-main'; import { ErrorContainer } from '@components/molecules'; -import { Header } from './header'; -import { Navigation } from './navigation'; -import LoadingMain from './loading-main'; +import { useNetwork } from '@hooks/use-network'; import { CreatePassword } from '@pages/popup/certify/create-password'; import { LaunchAdena } from '@pages/popup/certify/launch-adena'; import ApproveSignFailedScreen from '@pages/popup/wallet/approve-sign-failed-screen'; +import ManageNFT from '@pages/popup/wallet/manage-nft'; +import NFTTransferInput from '@pages/popup/wallet/nft-transfer-input'; +import NFTTransferSummary from '@pages/popup/wallet/nft-transfer-summary'; +import { NftCollection } from '@pages/popup/wallet/nft/collection'; +import { NftCollectionAsset } from '@pages/popup/wallet/nft/collection-asset'; +import { Header } from './header'; +import LoadingMain from './loading-main'; +import { Navigation } from './navigation'; import ToastContainer from './toast-container'; -import { useNetwork } from '@hooks/use-network'; export const PopupRouter = (): JSX.Element => { const { failedNetwork } = useNetwork(); @@ -81,17 +86,22 @@ export const PopupRouter = (): JSX.Element => { } /> } /> + } /> + } /> } /> } /> } /> } /> } /> + } /> } /> } /> } /> } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/packages/adena-extension/src/router/popup/navigation/index.tsx b/packages/adena-extension/src/router/popup/navigation/index.tsx index a746e84cc..e39ff0989 100644 --- a/packages/adena-extension/src/router/popup/navigation/index.tsx +++ b/packages/adena-extension/src/router/popup/navigation/index.tsx @@ -1,14 +1,14 @@ -import React, { useState } from 'react'; -import styled from 'styled-components'; +import { useCallback, useMemo } from 'react'; import { useMatch, useNavigate } from 'react-router-dom'; -import { useRecoilState } from 'recoil'; +import styled from 'styled-components'; -import { RoutePath } from '@types'; import { Icon, IconName } from '@components/atoms'; -import { WalletState } from '@states'; +import { useWalletContext } from '@hooks/use-context'; import { useNetwork } from '@hooks/use-network'; -import { getTheme } from '@styles/theme'; import mixins from '@styles/mixins'; +import { getTheme } from '@styles/theme'; +import { RoutePath } from '@types'; +import React from 'react'; const Wrapper = styled.nav` width: 100%; @@ -39,68 +39,93 @@ const Wrapper = styled.nav` export const Navigation = (): JSX.Element => { const navigate = useNavigate(); - const [loading] = useState(false); - const wallet = useMatch(RoutePath.Wallet); - const explore = useMatch(RoutePath.Explore); - const nft = useMatch(RoutePath.Nft); - const history = useMatch(RoutePath.History); - const tokenDetails = useMatch(RoutePath.TokenDetails); - const [walletState] = useRecoilState(WalletState.state); + const matchedWallet = useMatch(RoutePath.Wallet); + const matchedExplore = useMatch(RoutePath.Explore); + const matchedNft = useMatch(RoutePath.Nft + '/*'); + const matchedHistory = useMatch(RoutePath.History); + const matchedTokenDetails = useMatch(RoutePath.TokenDetails); const { failedNetwork } = useNetwork(); - const navItems = [ - { - iconName: 'iconWallet', - currAddress: wallet || tokenDetails, - routingAddress: RoutePath.Wallet, - }, - { - iconName: 'iconGallery', - currAddress: nft, - routingAddress: RoutePath.Nft, - }, - { - iconName: 'iconSearch', - currAddress: explore, - routingAddress: RoutePath.Explore, - }, - { - iconName: 'iconClock', - currAddress: history, - routingAddress: RoutePath.History, - }, - ]; + const { walletStatus } = useWalletContext(); + + const isActiveWallet = walletStatus === 'FINISH'; - const loadingComplete = walletState === 'FINISH'; + const navigationItems = useMemo( + () => [ + { + iconName: 'iconWallet', + active: !!matchedWallet || !!matchedTokenDetails, + routingAddress: RoutePath.Wallet, + }, + { + iconName: 'iconGallery', + active: !!matchedNft, + routingAddress: RoutePath.Nft, + }, + { + iconName: 'iconSearch', + active: !!matchedExplore, + routingAddress: RoutePath.Explore, + }, + { + iconName: 'iconClock', + active: !!matchedHistory, + routingAddress: RoutePath.History, + }, + ], + [matchedWallet, matchedExplore, matchedNft, matchedHistory, matchedTokenDetails], + ); + + const visibleNavigation = useMemo(() => { + if (!isActiveWallet) { + return false; + } - const isRender = (): boolean => { - if (wallet || tokenDetails || nft || explore || history) { - return loadingComplete || failedNetwork === false; + if (failedNetwork || failedNetwork === undefined) { + return false; } - return false; - }; + + return ( + !!matchedWallet || + !!matchedExplore || + !!matchedNft || + !!matchedHistory || + !!matchedTokenDetails + ); + }, [ + matchedWallet, + matchedExplore, + matchedNft, + matchedHistory, + matchedTokenDetails, + isActiveWallet, + failedNetwork, + ]); + + const onClickNavigationItem = useCallback( + (item: { iconName: string; active: boolean; routingAddress: RoutePath }) => { + if (!isActiveWallet) { + return; + } + + navigate(item.routingAddress, { replace: true }); + }, + [isActiveWallet], + ); + + if (!visibleNavigation) { + return ; + } return ( - <> - {isRender() && ( - - {navItems.map((item, idx) => ( - - - walletState === 'FINISH' ? navigate(item.routingAddress, { replace: true }) : null - } - disabled={walletState !== 'FINISH'} - > - - - - ))} - - )} - > + + {navigationItems.map((item, idx) => ( + + onClickNavigationItem(item)} disabled={!isActiveWallet}> + + + + ))} + ); }; diff --git a/packages/adena-extension/src/services/resource/token.ts b/packages/adena-extension/src/services/resource/token.ts index e777e3b04..f4b7f68c0 100644 --- a/packages/adena-extension/src/services/resource/token.ts +++ b/packages/adena-extension/src/services/resource/token.ts @@ -1,15 +1,24 @@ import { parseReamPathItemsByPath } from '@common/utils/parse-utils'; import { isGRC20TokenModel, isNativeTokenModel } from '@common/validation/validation-token'; -import { AppInfoResponse, TokenRepository } from '@repositories/common'; +import { AppInfoResponse } from '@repositories/common/response'; +import { ITokenRepository } from '@repositories/common/types'; -import { AccountTokenBalance, GRC20TokenModel, NetworkMetainfo, TokenModel } from '@types'; +import { + AccountTokenBalance, + GRC20TokenModel, + GRC721CollectionModel, + GRC721MetadataModel, + GRC721Model, + NetworkMetainfo, + TokenModel, +} from '@types'; export class TokenService { - private tokenRepository: TokenRepository; + private tokenRepository: ITokenRepository; private tokenMetaInfos: TokenModel[]; - constructor(tokenRepository: TokenRepository) { + constructor(tokenRepository: ITokenRepository) { this.tokenRepository = tokenRepository; this.tokenMetaInfos = []; } @@ -53,6 +62,11 @@ export class TokenService { return this.tokenRepository.fetchGRC20TokenByPackagePath(tokenPath).catch(() => null); } + public async fetchAllTransferPackagesBy(address: string): Promise { + const transferPackages = await this.tokenRepository.fetchAllTransferPackagesBy(address, 1); + return transferPackages; + } + public async getAppInfos(): Promise { const response = await this.tokenRepository.fetchAppInfos(); return response; @@ -165,6 +179,67 @@ export class TokenService { return true; } + public async fetchGRC721Collections(): Promise { + return this.tokenRepository.fetchGRC721Collections(); + } + + public async fetchGRC721Collection(packagePath: string): Promise { + return this.tokenRepository.fetchGRC721CollectionByPackagePath(packagePath); + } + + public async fetchGRC721TokenUri(packagePath: string, tokenId: string): Promise { + return this.tokenRepository.fetchGRC721TokenUriBy(packagePath, tokenId); + } + + public async fetchGRC721TokenMetadata( + packagePath: string, + tokenId: string, + ): Promise { + return this.tokenRepository.fetchGRC721TokenMetadataBy(packagePath, tokenId); + } + + public async fetchGRC721Balance(packagePath: string, address: string): Promise { + return this.tokenRepository.fetchGRC721BalanceBy(packagePath, address); + } + + public async fetchGRC721Tokens(packagePath: string, address: string): Promise { + return this.tokenRepository.fetchGRC721TokensBy(packagePath, address); + } + + public async getAccountGRC721Collections( + accountId: string, + networkId: string, + ): Promise { + return this.tokenRepository.getAccountGRC721CollectionsBy(accountId, networkId); + } + + public async saveAccountGRC721Collections( + accountId: string, + networkId: string, + collections: GRC721CollectionModel[], + ): Promise { + return this.tokenRepository.saveAccountGRC721CollectionsBy(accountId, networkId, collections); + } + + public async getAccountGRC721PinnedPackages( + accountId: string, + networkId: string, + ): Promise { + return this.tokenRepository.getAccountGRC721PinnedPackagesBy(accountId, networkId); + } + + public async saveAccountGRC721PinnedPackages( + accountId: string, + networkId: string, + packagePaths: string[], + ): Promise { + return this.tokenRepository.saveAccountGRC721PinnedPackagesBy( + accountId, + networkId, + packagePaths, + ); + } + public clear = async (): Promise => { await this.tokenRepository.deleteAllTokenMetainfo(); return true; diff --git a/packages/adena-extension/src/styles/theme.ts b/packages/adena-extension/src/styles/theme.ts index a54adce63..e5c30c38c 100644 --- a/packages/adena-extension/src/styles/theme.ts +++ b/packages/adena-extension/src/styles/theme.ts @@ -353,6 +353,11 @@ export const fonts: FontsKeyType = { font-size: 11px; line-height: 18px; `, + light1Bold: css` + font-weight: 700; + font-size: 10px; + line-height: 18px; + `, light1Reg: css` font-weight: 400; font-size: 10.5px; @@ -394,6 +399,7 @@ export type FontsType = | 'title1' | 'captionBold' | 'captionReg' + | 'light1Bold' | 'light1Reg' | 'bold13' | 'light13' diff --git a/packages/adena-extension/src/types/router.ts b/packages/adena-extension/src/types/router.ts index b2aa583a9..39d963da0 100644 --- a/packages/adena-extension/src/types/router.ts +++ b/packages/adena-extension/src/types/router.ts @@ -1,6 +1,13 @@ import { InjectionMessage } from '@inject/message'; import { AddressBookItem } from '@repositories/wallet'; -import { CreateAccountState, TokenBalanceType, TokenModel, TransactionInfo } from '@types'; +import { + CreateAccountState, + GRC721CollectionModel, + GRC721Model, + TokenBalanceType, + TokenModel, + TransactionInfo, +} from '@types'; import { Document } from 'adena-module'; export const REGISTER_PATH = 'register.html' as const; @@ -10,6 +17,8 @@ export enum RoutePath { Home = '/', Login = '/login', Nft = '/nft', + NftCollection = '/nft/collection', + NftCollectionAsset = '/nft/collection/assets', Staking = '/staking', Explore = '/explore', History = '/history', @@ -39,9 +48,12 @@ export enum RoutePath { ApproveAddingNetwork = '/approve/wallet/network/add', AccountDetails = '/wallet/accounts/:accountId', ManageToken = '/wallet/manage-token', + ManageNft = '/wallet/manage-nft', ManageTokenAdded = '/wallet/manage-token/added', + NftTransferInput = '/wallet/nft-transfer-input', TransferInput = '/wallet/transfer-input', TransferSummary = '/wallet/transfer-summary', + NftTransferSummary = '/wallet/nft-transfer-summary', TransferLedgerLoading = '/wallet/transfer-ledger/loading', TransferLedgerReject = '/wallet/transfer-ledger/reject', BroadcastTransaction = '/wallet/broadcast-transaction', @@ -83,6 +95,12 @@ export type RouteParams = { [RoutePath.Home]: null; [RoutePath.Login]: null; [RoutePath.Nft]: null; + [RoutePath.NftCollection]: { + collection: GRC721CollectionModel; + }; + [RoutePath.NftCollectionAsset]: { + collectionAsset: GRC721Model; + }; [RoutePath.Staking]: null; [RoutePath.Explore]: null; [RoutePath.History]: null; @@ -104,7 +122,9 @@ export type RouteParams = { }; [RoutePath.Deposit]: { type: 'token' | 'wallet'; - tokenMetainfo: TokenBalanceType; + token: { + symbol: string; + }; }; [RoutePath.Send]: null; [RoutePath.TokenDetails]: { @@ -133,6 +153,10 @@ export type RouteParams = { [RoutePath.AccountDetails]: null; [RoutePath.ManageToken]: null; [RoutePath.ManageTokenAdded]: null; + [RoutePath.ManageNft]: null; + [RoutePath.NftTransferInput]: { + collectionAsset: GRC721Model; + }; [RoutePath.TransferInput]: { tokenBalance: TokenBalanceType; isTokenSearch?: boolean; @@ -151,6 +175,15 @@ export type RouteParams = { }; memo: string; }; + [RoutePath.NftTransferSummary]: { + grc721Token: GRC721Model; + toAddress: string; + networkFee: { + value: string; + denom: string; + }; + memo: string; + }; [RoutePath.TransferLedgerLoading]: { document: Document; }; diff --git a/packages/adena-extension/src/types/token.ts b/packages/adena-extension/src/types/token.ts index 7a3b19c5e..f13a94ac1 100644 --- a/packages/adena-extension/src/types/token.ts +++ b/packages/adena-extension/src/types/token.ts @@ -39,17 +39,26 @@ export interface IBCTokenModel extends TokenModel { export interface ManageTokenInfo { tokenId: string; + type: 'token'; logo: string; - symbol: string; name: string; display?: boolean; main?: boolean; - balanceAmount: { + balance: { value: string; denom: string; }; } +export interface ManageGRC721Info { + tokenId: string; + packagePath: string; + type: 'grc721'; + isTokenUri: boolean; + name: string; + display?: boolean; +} + export interface TokenInfo { tokenId: string; name: string; @@ -103,3 +112,44 @@ export interface MainToken { denom: string; }; } + +export interface GRC721CollectionModel { + tokenId: string; + networkId: string; + display: boolean; + type: 'grc721'; + packagePath: string; + name: string; + symbol: string; + image: string | null; + isTokenUri: boolean; + isMetadata: boolean; +} + +export interface GRC721Model { + tokenId: string; + networkId: string; + type: 'grc721'; + packagePath: string; + name: string; + symbol: string; + isTokenUri: boolean; + isMetadata: boolean; + metadata: GRC721MetadataModel | null; +} + +export interface GRC721MetadataModel { + name: string; + image: string; + imageData: string; + externalUrl: string; + description: string; + backgroundColor: string; + animationUrl: string; + youtubeUrl: string; + attributes: { + displayType: string; + traitType: string; + value: string; + }[]; +} diff --git a/packages/adena-extension/src/types/tx-history.ts b/packages/adena-extension/src/types/tx-history.ts index e8bb39eec..d43cc6db2 100644 --- a/packages/adena-extension/src/types/tx-history.ts +++ b/packages/adena-extension/src/types/tx-history.ts @@ -8,7 +8,7 @@ export interface TransactionInfo { hash: string; height?: number; logo: string; - type: 'TRANSFER' | 'ADD_PACKAGE' | 'CONTRACT_CALL' | 'MULTI_CONTRACT_CALL'; + type: 'TRANSFER' | 'TRANSFER_GRC721' | 'ADD_PACKAGE' | 'CONTRACT_CALL' | 'MULTI_CONTRACT_CALL'; typeName?: string; status: 'SUCCESS' | 'FAIL'; title: string;