diff --git a/app/ResolveRoute.js b/app/ResolveRoute.js index fd9ec96..d057967 100644 --- a/app/ResolveRoute.js +++ b/app/ResolveRoute.js @@ -1,14 +1,18 @@ export const routeRegex = { UserProfile1: /^\/(@[\w\.\d-]+)\/?$/, - UserProfile2: /^\/(@[\w\.\d-]+)\/(transfers|assets|create-asset|invites|curation-rewards|author-rewards|donates-from|donates-to|filled-orders|permissions|created|password|witness|settings)\/??(?:&?[^=&]*=[^=&]*)*$/, + UserProfile2: /^\/(@[\w\.\d-]+)\/(transfers|assets|create-asset|invites|curation-rewards|author-rewards|donates-from|donates-to|nft-history|nft-tokens|nft-collections|filled-orders|permissions|created|password|witness|settings)\/??(?:&?[^=&]*=[^=&]*)*$/, UserProfile3: /^\/(@[\w\.\d-]+)\/[\w\.\d-]+/, + UserNFTEndPoints: /^\/(@[\w\.\d-]+)\/nft-tokens\/([\w\d.-]+)\/?$/, UserAssetEndPoints: /^\/(@[\w\.\d-]+)\/assets\/([\w\d.-]+)\/(update|transfer)$/, - UserEndPoints: /^(transfers|assets|create-asset|invites|curation-rewards|author-rewards|donates-from|donates-to|filled-orders|permissions|created|password|witness|settings)$/, + UserEndPoints: /^(transfers|assets|create-asset|invites|curation-rewards|author-rewards|donates-from|donates-to|nft-history|nft-tokens|nft-collections|filled-orders|permissions|created|password|witness|settings)$/, WorkerSort: /^\/workers\/([\w\d\-]+)\/?($|\?)/, WorkerSearchByAuthor: /^\/workers\/([\w\d\-]+)\/(\@[\w\d.-]+)\/?($|\?)/, WorkerRequest: /^\/workers\/([\w\d\-]+)\/(\@[\w\d.-]+)\/([\w\d-]+)\/?($|\?)/, MarketPair: /^\/market\/([\w\d\.]+)\/([\w\d.]+)\/?($|\?)/, ConvertPair: /^\/convert\/([\w\d\.]+)\/([\w\d.]+)\/?($|\?)/, + NFTCollection: /^\/nft-collections\/([\w\d\.]+)\/?($|\?)/, + NFTToken: /^\/nft-tokens\/([\w\d\.]+)\/?($|\?)/, + NFTMarket: /^\/nft\/([\w\d\.]+)\/?($|\?)/, UserJson: /^\/(@[\w\.\d-]+)(\.json)$/, UserNameJson: /^.*(?=(\.json))/ }; @@ -72,6 +76,10 @@ export default function resolveRoute(path) if (match) { return {page: 'Workers', params: match.slice(1)}; } + match = path.match(routeRegex.UserNFTEndPoints) + if (match) { + return {page: 'UserProfile', params: [match[1], 'nft-tokens', match[2]]} + } match = path.match(routeRegex.UserAssetEndPoints); if (match) { return {page: 'UserProfile', params: [match[1], 'assets', match[2], match[3]]}; @@ -89,5 +97,20 @@ export default function resolveRoute(path) if (match) { return {page: 'ConvertAssetsPage', params: match.slice(1)} } + match = path.match(routeRegex.NFTCollection) + if (match) { + return {page: 'NFTCollectionPage', params: match.slice(1)} + } + match = path.match(routeRegex.NFTToken) + if (match) { + return {page: 'NFTTokenPage', params: match.slice(1)} + } + if (path === '/nft') { + return {page: 'NFTMarketPage'} + } + match = path.match(routeRegex.NFTMarket) + if (match) { + return {page: 'NFTMarketPage', params: match.slice(1)} + } return {page: 'NotFound'}; } diff --git a/app/RootRoute.js b/app/RootRoute.js index bd2cad2..77d8721 100644 --- a/app/RootRoute.js +++ b/app/RootRoute.js @@ -33,6 +33,12 @@ export default { cb(null, [require('@pages/UserProfile')]); } else if (route.page === 'ConvertAssetsPage') { cb(null, [require('@pages/ConvertAssetsPage')]); + } else if (route.page === 'NFTCollectionPage') { + cb(null, [require('@pages/nft/NFTCollectionPage')]); + } else if (route.page === 'NFTTokenPage') { + cb(null, [require('@pages/nft/NFTTokenPage')]); + } else if (route.page === 'NFTMarketPage') { + cb(null, [require('@pages/nft/NFTMarketPage')]); } else if (route.page === 'Market') { cb(null, [require('@pages/MarketLoader')]); } else if (route.page === 'Rating') { diff --git a/app/assets/images/nft.png b/app/assets/images/nft.png new file mode 100644 index 0000000..db3321a Binary files /dev/null and b/app/assets/images/nft.png differ diff --git a/app/components/all.scss b/app/components/all.scss index d85eb42..fb72464 100644 --- a/app/components/all.scss +++ b/app/components/all.scss @@ -39,6 +39,9 @@ @import "./elements/market/MarketPair"; @import "./elements/market/OrderForm"; @import "./elements/market/TickerPriceStat"; +@import "./elements/nft/NFTSmallIcon"; +@import "./elements/nft/NFTMarketCollections"; +@import "./elements/nft/NFTTokenItem"; @import "./elements/workers/WorkerRequestVoting"; // dialogs @@ -67,6 +70,12 @@ @import "./modules/Powerdown.scss"; @import "./modules/QuickBuy.scss"; @import "./modules/Modals"; +@import "./modules/nft/NFTCollections"; +@import "./modules/nft/CreateNFTCollection"; +@import "./modules/nft/IssueNFTToken"; +@import "./modules/nft/NFTTokens"; +@import "./modules/nft/NFTTokenSell"; +@import "./modules/nft/NFTTokenTransfer"; // pages @import "./pages/Exchanges"; @@ -76,3 +85,6 @@ @import "./pages/Rating"; @import "./pages/UserProfile"; @import "./pages/Witnesses"; +@import "./pages/nft/NFTCollectionPage"; +@import "./pages/nft/NFTMarketPage"; +@import "./pages/nft/NFTTokenPage"; diff --git a/app/components/cards/TransferHistoryRow.jsx b/app/components/cards/TransferHistoryRow.jsx index 302ab55..2c368d7 100644 --- a/app/components/cards/TransferHistoryRow.jsx +++ b/app/components/cards/TransferHistoryRow.jsx @@ -29,11 +29,38 @@ class TransferHistoryRow extends React.Component { /* all transfers involve up to 2 accounts, context and 1 other. */ let description_start = ""; let link = null, linkTitle = null, linkExternal = false + let description_middle = '' + let link2 = null, linkTitle2 = null, linkExternal2 = false + let description_middle2 = '' + let link3 = null, linkTitle3 = null, linkExternal3 = false let code_key = ""; let description_end = ""; + let link4 = null, linkTitle4 = null, linkExternal4 = false let target_hint = ""; let data_memo = data.memo; + const getToken = (token_id) => { + const { nft_tokens } = this.props + let tokenLink + let tokenTitle + const token = nft_tokens && nft_tokens.toJS()[data.token_id] + if (token) { + try { + const meta = JSON.parse(token.json_metadata) + tokenTitle = meta.title + } catch (err) { + console.error(err) + } + } + if (!tokenTitle) { + tokenTitle = '#' + data.token_id + } + tokenLink = + {tokenTitle} + + return { tokenTitle, tokenLink } + } + if (/^transfer$|^transfer_to_savings$|^transfer_from_savings$/.test(type)) { const fromWhere = type === 'transfer_to_savings' ? tt('transferhistoryrow_jsx.to_savings') : @@ -312,12 +339,52 @@ class TransferHistoryRow extends React.Component { } else { code_key = JSON.stringify({type, ...data}, null, 2); } + } else if (type === 'nft_token') { + link = data.creator + description_middle = tt('transferhistoryrow_jsx.nft_issued') + tt('transferhistoryrow_jsx.nft_token') + ' ' + const { tokenTitle, tokenLink } = getToken(data.token_id) + link2 = tokenLink + if (!link2) { + description_middle += tokenTitle + } + linkExternal2 = true + if (data.creator !== data.to) { + description_middle2 += tt('transferhistoryrow_jsx.nft_issued_for') + link3 = data.to + } + description_end = ', ' + tt('transferhistoryrow_jsx.nft_issued_cost') + Asset(data.issue_cost).floatString + } else if (type === 'nft_transfer') { + if (this.props.context === data.from) { + if (data.to === 'null') { + description_end += tt('transferhistoryrow_jsx.burnt') + ' ' + description_end += tt('transferhistoryrow_jsx.nft_token') + } else { + description_start += tt('transferhistoryrow_jsx.you_gifted0') + ' ' + link = data.to + description_end += tt('transferhistoryrow_jsx.you_gifted') + ': ' + description_end += tt('transferhistoryrow_jsx.nft_token') + } + } else { + link = data.from + description_end += ' ' + tt('transferhistoryrow_jsx.gifted') + ' ' + description_end += tt('transferhistoryrow_jsx.nft_token') + } + const { tokenTitle, tokenLink } = getToken(data.token_id) + link4 = tokenLink + linkExternal4 = true + description_end += ' ' + (!link4 ? tokenTitle : '') } else { code_key = JSON.stringify({type, ...data}, null, 2); } + const wrapLink = (href, title, isExternal) => { + return (isExternal ? + {title || href} : + {title || href}) + } + return( @@ -328,10 +395,13 @@ class TransferHistoryRow extends React.Component { {description_start} {code_key && {code_key}} - {link && (linkExternal ? - {linkTitle || link} : - {linkTitle || link})} + {link && wrapLink(link, linkTitle, linkExternal)} + {description_middle} + {link2 && wrapLink(link2, linkTitle2, linkExternal2)} + {description_middle2} + {link3 && wrapLink(link3, linkTitle3, linkExternal3)} {description_end} + {link4 && wrapLink(link4, linkTitle4, linkExternal4)} {target_hint && [{target_hint}]} @@ -358,6 +428,7 @@ export default connect( username, curation_reward, author_reward, + nft_tokens: state.global.get('nft_token_map') } }, )(TransferHistoryRow) diff --git a/app/components/elements/DropdownMenu.jsx b/app/components/elements/DropdownMenu.jsx index 521bf75..2173600 100644 --- a/app/components/elements/DropdownMenu.jsx +++ b/app/components/elements/DropdownMenu.jsx @@ -68,7 +68,7 @@ export default class DropdownMenu extends React.Component { } render() { - const {el, items, selected, children, className, title, href, noArrow} = this.props; + const {el, items, selected, children, className, title, href, onClick, noArrow} = this.props; const hasDropdown = items.length > 0 let entry = children || @@ -76,7 +76,7 @@ export default class DropdownMenu extends React.Component { {hasDropdown && !noArrow && } - if(hasDropdown) entry = {entry} + if(hasDropdown) entry = { onClick(e); this.toggle(e) } : this.toggle}>{entry} const menu = ; const cls = 'DropdownMenu' + (this.state.shown ? ' show' : '') + (className ? ` ${className}` : '') diff --git a/app/components/elements/Expandable.jsx b/app/components/elements/Expandable.jsx index 9cd2089..222bb1f 100644 --- a/app/components/elements/Expandable.jsx +++ b/app/components/elements/Expandable.jsx @@ -13,9 +13,9 @@ class Expandable extends Component { }; render() { - const { title, } = this.props; + const { title, ...rest } = this.props; const { opened, } = this.state; - return (
+ return (
{title}
diff --git a/app/components/elements/Memo.jsx b/app/components/elements/Memo.jsx index cc2a380..db8d34e 100644 --- a/app/components/elements/Memo.jsx +++ b/app/components/elements/Memo.jsx @@ -4,10 +4,12 @@ import { connect, } from 'react-redux'; import tt from 'counterpart'; import { memo, } from 'golos-lib-js'; import { Link, } from 'react-router'; -import links from 'app/utils/Links'; + +import links from 'app/utils/Links' import shouldComponentUpdate from 'app/utils/shouldComponentUpdate' import { validate_account_name, } from 'app/utils/ChainValidation' -import user from 'app/redux/User'; +import user from 'app/redux/User' +import { blogsUrl } from 'app/utils/blogsUtils' class Memo extends React.Component { static propTypes = { @@ -36,6 +38,17 @@ class Memo extends React.Component { const sections = [] let idx = 0 if (!text || (typeof text.split !== 'function')) return + const isNftPost = text.startsWith('Post: ') + const isNftComment = !isNftPost && text.startsWith('Comment: ') + if (isNftPost || isNftComment) { + sections.push((isNftPost ? tt('g.for_the_post') : tt('g.for_the_comment')) + ': ') + const link = text.split(' ')[1] + let linkText = link + const truncateText = isNftPost ? 50 : 40 + if (linkText.length > truncateText) linkText = linkText.substr(0, truncateText) + '...' + sections.push({linkText} ) + return sections + } for (let section of text.split(' ')) { if (section.trim().length === 0) continue const matchUserName = section.match(/(^|\s)(@[a-z][-\.a-z\d]+[a-z\d])/i) diff --git a/app/components/elements/forms/AmountAssetField.jsx b/app/components/elements/forms/AmountAssetField.jsx new file mode 100644 index 0000000..4eb5782 --- /dev/null +++ b/app/components/elements/forms/AmountAssetField.jsx @@ -0,0 +1,50 @@ +import React from 'react' +import { Field, } from 'formik' +import { Asset, AssetEditor, } from 'golos-lib-js/lib/utils' + +class AmountAssetField extends React.Component { + static defaultProps = { + amountField: 'amount', + } + + onChange = (e) => { + const { amountField, values, setFieldValue, assets } = this.props + const value = e.target.value + const asset = assets[value] + if (asset) { + const { supply } = asset + const oldValue = values[amountField].asset + setFieldValue(amountField, AssetEditor(oldValue.amount, + supply.precision, supply.symbol)) + + if (!values.author) { // if not edit mode + if (asset.allow_override_transfer || value === 'GBG') { + if (values.tip_cost) + setFieldValue('tip_cost', false) + setFieldValue('disable_tip', true) + } else { + setFieldValue('disable_tip', false) + } + } + } + } + + render() { + const { name, assets, values, amountField } = this.props + + const options = [] + for (const [ sym, asset ] of Object.entries(assets)) { + options.push() + } + + const { asset } = values[amountField] + + return ( + {options} + ) + } +} + +export default AmountAssetField diff --git a/app/components/elements/forms/AmountField.jsx b/app/components/elements/forms/AmountField.jsx index 8aeda2f..f9b5ef2 100644 --- a/app/components/elements/forms/AmountField.jsx +++ b/app/components/elements/forms/AmountField.jsx @@ -3,26 +3,33 @@ import { Field, ErrorMessage, } from 'formik' import { AssetEditor } from 'golos-lib-js/lib/utils' class AmountField extends React.Component { + static defaultProps = { + name: 'amount', + } + _renderInput = ({ field, form }) => { - const { value, ...rest } = field + // TODO: is it right to pass all props to input + const { placeholder, name, ...rest } = this.props + const { value, } = field const { values, setFieldValue } = form - return this.onChange(e, values, setFieldValue)} /> } onChange = (e, values, setFieldValue) => { - const newAmount = values.amount.withChange(e.target.value) + const { name } = this.props + const newAmount = values[name].withChange(e.target.value) if (newAmount.hasChange && newAmount.asset.amount >= 0) { - setFieldValue('amount', newAmount) + setFieldValue(name, newAmount) } } render() { - const { placeholder, } = this.props - return ( + autoComplete='off' autoCorrect='off' spellCheck='false' {...rest}> {this._renderInput} ) } diff --git a/app/components/elements/nft/NFTMarketCollections.jsx b/app/components/elements/nft/NFTMarketCollections.jsx new file mode 100644 index 0000000..0da0f8c --- /dev/null +++ b/app/components/elements/nft/NFTMarketCollections.jsx @@ -0,0 +1,47 @@ +import React from 'react' +import tt from 'counterpart' + +import Icon from 'app/components/elements/Icon' +import NFTSmallIcon from 'app/components/elements/nft/NFTSmallIcon' +import PagedDropdownMenu from 'app/components/elements/PagedDropdownMenu' + +class NFTMarketCollections extends React.Component { + render() { + let { nft_market_collections, selected } = this.props + const nft_colls = nft_market_collections ? nft_market_collections.toJS() : [] + + const colls = [] + colls.push({ + key: '_all', + link: '/nft', + label: + + {tt('nft_market_page_jsx.all_collections')} + , + value: tt('nft_market_page_jsx.all_collections') + }) + for (const nft_coll of nft_colls) { + colls.push({ + key: nft_coll.name, + link: '/nft/' + nft_coll.name, + label: + + {nft_coll.name} + , + value: nft_coll.name + }) + } + + selected = selected || tt('nft_market_page_jsx.all_collections') + + return item} + selected={selected} + perPage={20}> + {selected} + + + } +} + +export default NFTMarketCollections diff --git a/app/components/elements/nft/NFTMarketCollections.scss b/app/components/elements/nft/NFTMarketCollections.scss new file mode 100644 index 0000000..7a0b01a --- /dev/null +++ b/app/components/elements/nft/NFTMarketCollections.scss @@ -0,0 +1,9 @@ +.NFTMarketCollections { + .NFTSmallIcon { + margin-top: 0.25rem; + margin-right: 0.25rem; + margin-bottom: 0.25rem; + width: 2rem; + height: 2rem; + } +} diff --git a/app/components/elements/nft/NFTSmallIcon.jsx b/app/components/elements/nft/NFTSmallIcon.jsx new file mode 100644 index 0000000..c3dba89 --- /dev/null +++ b/app/components/elements/nft/NFTSmallIcon.jsx @@ -0,0 +1,12 @@ +import React, { Component, } from 'react' + +class NFTSmallIcon extends Component { + render() { + const { image, ...rest } = this.props + + return + } +} + +export default NFTSmallIcon diff --git a/app/components/elements/nft/NFTSmallIcon.scss b/app/components/elements/nft/NFTSmallIcon.scss new file mode 100644 index 0000000..0ee5941 --- /dev/null +++ b/app/components/elements/nft/NFTSmallIcon.scss @@ -0,0 +1,11 @@ +.NFTSmallIcon { + background-size: cover; + background-repeat: no-repeat; + background-position: 50% 50%; + border-radius: 50%; + + width: 3rem; + height: 3rem; + display: inline-block; + vertical-align: top; +} diff --git a/app/components/elements/nft/NFTTokenItem.jsx b/app/components/elements/nft/NFTTokenItem.jsx new file mode 100644 index 0000000..c0bc6d5 --- /dev/null +++ b/app/components/elements/nft/NFTTokenItem.jsx @@ -0,0 +1,240 @@ +import React, {Component} from 'react' +import PropTypes from 'prop-types' +import {connect} from 'react-redux' +import { Link } from 'react-router' +import tt from 'counterpart' +import { Asset } from 'golos-lib-js/lib/utils' + +import DropdownMenu from 'app/components/elements/DropdownMenu' +import Icon from 'app/components/elements/Icon' +import g from 'app/redux/GlobalReducer' +import user from 'app/redux/User' +import transaction from 'app/redux/Transaction' +import { getAssetMeta } from 'app/utils/market/utils' + +class NFTTokenItem extends Component { + state = {} + + constructor() { + super() + } + + cancelOrder = async (e, tokenIdx) => { + e.preventDefault() + const { token, currentUser } = this.props + const { order } = token + await this.props.cancelOrder(order.order_id, currentUser, () => { + this.props.refetch() + }, (err) => { + if (!err || err.toString() === 'Canceled') return + console.error(err) + alert(err.toString()) + }) + } + + buyToken = async (e, tokenIdx) => { + e.preventDefault() + const { token, currentUser } = this.props + const { token_id, order, json_metadata } = token + let tokenTitle + try { + tokenTitle = JSON.parse(json_metadata).title.substring(0, 100) + } catch (err) { + console.error(err) + tokenTitle = '#' + token_id + } + const price = Asset(order.price).floatString + + if (!currentUser) { + this.props.login() + return + } + await this.props.buyToken(token_id, order.order_id, tokenTitle, price, currentUser, () => { + this.props.refetch() + }, (err) => { + if (!err || err.toString() === 'Canceled') return + console.error(err) + alert(err.toString()) + }) + } + + burnIt = async (e, tokenIdx) => { + e.preventDefault() + const { token, currentUser } = this.props + const { token_id } = token + await this.props.burnToken(token_id, currentUser, () => { + this.props.refetch() + }, (err) => { + if (!err || err.toString() === 'Canceled') return + console.error(err) + alert(err.toString()) + }) + } + + render() { + const { token, tokenIdx, currentUser, page, assets } = this.props + + const { json_metadata, image, selling } = token + + let data + if (json_metadata) { + data = JSON.parse(json_metadata) + } + data = data || {} // node allows to use '', null, object, or array + + let last_price + const price = token.selling ? Asset(token.order.price) : Asset(token.last_buy_price) + { + const asset = assets[price.symbol] + let imageUrl + if (asset) { + imageUrl = getAssetMeta(asset).image_url + } + last_price = + {imageUrl && {''}} + {price.amountFloat} + + } + + const link = '/nft-tokens/' + token.token_id + + const kebabItems = [ + { link, target: '_blank', value: tt('g.more_hint') }, + ] + + const isMy = currentUser && currentUser.get('username') === token.owner + + if (isMy && !selling) { + kebabItems.unshift({ link: '#', onClick: e => { + this.burnIt(e, tokenIdx) + }, value: tt('g.burn') }) + } + + if (last_price && !selling && isMy) { + kebabItems.unshift({ link: '#', onClick: e => { + this.props.showTransfer(e, tokenIdx) + }, value: tt('g.transfer') }) + } + + const isCollection = page === 'collection' + + let buttons + if (last_price) { + buttons =
+ {last_price} + {kebabItems.length > 1 ? + + : null} + {isMy && !selling && } + {isMy && selling && } + {!isMy && selling && } +
+ } else { + buttons =
+ {isCollection && !isMy && + {'@' + token.owner} + } +   + {isMy && } + {isMy && } + + + +
+ } + + return +
+ +
+
{data.title}
+ + + {token.name} + + + {buttons} +
+
+
+ } +} + +export default connect( + (state, ownProps) => { + const {locationBeforeTransitions: {pathname}} = state.routing; + let currentUser = ownProps.currentUser || state.user.getIn(['current']) + return { ...ownProps, currentUser } + }, + dispatch => ({ + burnToken: ( + token_id, currentUser, successCallback, errorCallback + ) => { + const username = currentUser.get('username') + const operation = { + from: username, + to: 'null', + token_id, + memo: '' + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'nft_transfer', + confirm: tt('g.are_you_sure'), + username, + operation, + successCallback, + errorCallback + })) + }, + cancelOrder: ( + order_id, currentUser, successCallback, errorCallback + ) => { + const username = currentUser.get('username') + const operation = { + owner: username, + order_id, + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'nft_cancel_order', + confirm: tt('g.are_you_sure'), + username, + operation, + successCallback, + errorCallback + })) + }, + buyToken: ( + token_id, order_id, tokenTitle, price, currentUser, successCallback, errorCallback + ) => { + const username = currentUser.get('username') + const operation = { + buyer: username, + name: '', + token_id, + order_id, + price: '0.000 GOLOS' + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'nft_buy', + confirm: tt('nft_tokens_jsx.buy_confirm') + tokenTitle + tt('nft_tokens_jsx.buy_confirm2') + price + '?', + username, + operation, + successCallback, + errorCallback + })) + }, + login: () => { + dispatch(user.actions.showLogin({ + loginDefault: { unclosable: false } + })); + }, + }) +)(NFTTokenItem) diff --git a/app/components/elements/nft/NFTTokenItem.scss b/app/components/elements/nft/NFTTokenItem.scss new file mode 100644 index 0000000..7c9b1d3 --- /dev/null +++ b/app/components/elements/nft/NFTTokenItem.scss @@ -0,0 +1,56 @@ +.NFTTokenItem { + display: inline-block; + border: 1px solid rgba(128,128,128,0.45); + border-radius: 5px; + margin-right: 1rem; + margin-bottom: 1em; + padding: 0.5rem; + cursor: pointer; + + &.collection { + background-color: rgb(226, 247, 223); + } + + .token-image { + width: 200px; + height: 200px; + object-fit: cover; + } + .price-icon { + width: 20px; + height: 20px; + margin-right: 0.25rem; + } + + .token-title { + margin-top: 0.25rem; + margin-bottom: 0.1rem; + } + .token-coll { + display: inline-block; + margin-bottom: 0.35rem; + } + + .button { + margin: 0px !important; + margin-right: 5px !important; + } + .button:hover { + background-color: #016aad !important; + } + .button.hollow { + border: 0px; + } + .button.hollow:hover { + background-color: transparent !important; + } +} + +.NFTTokenItem:hover { + background-color: rgba(208,208,208,0.45); + border: 1px solid rgba(128,128,128,0.45); + + &.collection { + background-color: rgb(206, 247, 203); + } +} diff --git a/app/components/modules/Header.jsx b/app/components/modules/Header.jsx index 12bfd41..42e8fd4 100644 --- a/app/components/modules/Header.jsx +++ b/app/components/modules/Header.jsx @@ -91,23 +91,31 @@ class Header extends React.Component { const name = acct_meta ? normalizeProfile(acct_meta.toJS()).name : null; const user_title = name ? `${name} (@${user_name})` : user_name; page_title = user_title; - if(route.params[1] === "curation-rewards"){ + if (route.params[1] === "curation-rewards"){ page_title = tt('header_jsx.curation_rewards_by') + " " + user_title; - } - if(route.params[1] === "author-rewards"){ + } else if (route.params[1] === "author-rewards"){ page_title = tt('header_jsx.author_rewards_by') + " " + user_title; - } - if(route.params[1] === "donates-from"){ + } else if (route.params[1] === "donates-from"){ page_title = tt('header_jsx.donates_from') + " " + user_title; - } - if(route.params[1] === "donates-to"){ + } else if (route.params[1] === "donates-to"){ page_title = tt('header_jsx.donates_to') + " " + user_title; - } - if(route.params[1] === "recent-replies"){ + } else if (route.params[1] === "recent-replies"){ page_title = tt('header_jsx.replies_to') + " " + user_title; + } else if (route.params[1] === "nft-tokens"){ + page_title = tt('header_jsx.nft_tokens') + " " + user_title + } else if (route.params[1] === "nft-collections"){ + page_title = tt('header_jsx.nft_collections') + " " + user_title + } else if (route.params[1] === "nft-history"){ + page_title = tt('g.nft_history') + " " + user_title } } else if (route.page === 'ConvertAssetsPage') { page_title = tt('g.convert_assets') + } else if (route.page === `NFTCollectionPage`){ + page_title = tt('header_jsx.nft_collection') + } else if (route.page === `NFTTokenPage`){ + page_title = tt('header_jsx.nft_token') + } else if (route.page === `NFTMarketPage`){ + page_title = tt('header_jsx.nft_market') } else { page_name = ''; //page_title = route.page.replace( /([a-z])([A-Z])/g, '$1 $2' ).toLowerCase(); } diff --git a/app/components/modules/nft/CreateNFTCollection.jsx b/app/components/modules/nft/CreateNFTCollection.jsx new file mode 100644 index 0000000..d953d1d --- /dev/null +++ b/app/components/modules/nft/CreateNFTCollection.jsx @@ -0,0 +1,398 @@ +import React, { Component, } from 'react' +import tt from 'counterpart' +import { connect, } from 'react-redux' +import CloseButton from 'react-foundation-components/lib/global/close-button' +import { Formik, Form, Field, ErrorMessage, } from 'formik' + +import Expandable from 'app/components/elements/Expandable' +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import transaction from 'app/redux/Transaction' + +const UINT32_MAX = '4294967295' + +class CreateNFTCollection extends Component { + state = { + collection: { + name: '', + title: '', + json_metadata: '{}', + max_token_count: UINT32_MAX, + infinity: true + } + } + + validate = (values) => { + const errors = {} + const { title, name } = values + if (!title.length) { + errors.title = tt('g.required') + } + if (name.length < 3) { + errors.name = tt('assets_jsx.symbol_too_short') + } else { + const parts = name.split('.') + if (parts[0] == 'GOLOS' || parts[0] == 'GBG' || parts[0] == 'GESTS') { + errors.name = tt('assets_jsx.top_symbol_not_your') + } else if (parts.length == 2 && parts[1].length < 3) { + errors.name = tt('assets_jsx.subsymbol_too_short') + } + } + let meta = values.json_metadata + try { + meta = JSON.parse(meta) + if (!meta || Array.isArray(meta)) throw new Error('JSON is array') + } catch (err) { + console.error('json_metadata', err) + //errors.json_metadata = tt('create_nft_collection_jsx.json_wrong') + meta = null + } + if (meta) { + const noFields = [] + if (!('title' in meta)) noFields.push('title') + if (values.image && !('image' in meta)) noFields.push('image') + if (values.description && !('description' in meta)) noFields.push('description') + if (noFields.length) { + errors.json_metadata = tt('create_nft_collection_jsx.json_no_fields') + errors.json_metadata += noFields.join(', ') + '. ' + } + } + ++this.validationTime + return errors + } + + setSubmitting = (submitting) => { + this.setState({ submitting }) + } + + _onSubmit = async (values) => { + this.setSubmitting(true) + this.setState({ + errorMessage: '' + }) + const { currentUser } = this.props + const username = currentUser.get('username') + await this.props.createCollection(values.name, values.json_metadata, values.max_token_count, currentUser, () => { + this.props.fetchState() + this.props.onClose() + this.setSubmitting(false) + }, (err) => { + console.error(err) + this.setSubmitting(false) + this.setState({ + errorMessage: err.toString() + }) + }) + } + + _renderSubmittingIndicator = () => { + const { submitting } = this.state + + return submitting ? + + : null + } + + onNameChange = (e, values, setFieldValue) => { + let newName = '' + let hasDot + for (let i = 0; i < e.target.value.length; ++i) { + const c = e.target.value[i] + if ((c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && c !== '.') { + continue + } + if (c == '.') { + if (i < 3 || hasDot) { + continue + } + hasDot = true + } + newName += c.toUpperCase() + } + setFieldValue('name', newName) + } + + updateJSONMetadata = (values, setFieldValue, currentVals, force = false) => { + let { json_metadata } = values + try { + json_metadata = JSON.parse(json_metadata) + if (json_metadata === null || Array.isArray(json_metadata)) { + json_metadata = {} + } + } catch (err) { + console.error('updateJSONMetadata', err) + if (!force) { + return + } + json_metadata = {} + } + + const title = currentVals.title || values.title + json_metadata.title = title || '' + + const description = currentVals.description || values.description + if (description) { + json_metadata.description = description + } + + const image = currentVals.image || values.image + if (image) { + json_metadata.image = image + } + + setFieldValue('json_metadata', JSON.stringify(json_metadata, null, 2)) + } + + onTitleChange = (e, values, setFieldValue) => { + const title = e.target.value + setFieldValue('title', title) + this.updateJSONMetadata(values, setFieldValue, { title }) + } + + onDescriptionChange = (e, values, setFieldValue) => { + const description = e.target.value + setFieldValue('description', description) + this.updateJSONMetadata(values, setFieldValue, { description }) + } + + onImageChange = (e, values, setFieldValue) => { + const image = e.target.value + setFieldValue('image', image) + this.updateJSONMetadata(values, setFieldValue, { image }) + } + + onImageBlur = (e) => { + e.preventDefault() + this.setState({ + showImage: true + }) + } + + onMaxTokenCountChange = (e, setFieldValue) => { + let maxTokenCount = '' + for (let i = 0; i < e.target.value.length; ++i) { + const c = e.target.value[i] + if (c < '0' || c > '9') { + continue + } + maxTokenCount += c + } + if (maxTokenCount === UINT32_MAX) { + setFieldValue('infinity', true) + } + setFieldValue('max_token_count', maxTokenCount) + } + + onInfinityChange = (e, values, setFieldValue) => { + if (!values.infinity) { + setFieldValue('max_token_count', UINT32_MAX) + setFieldValue('infinity', !values.infinity) + } else { + setFieldValue('max_token_count', '') + setFieldValue('infinity', !values.infinity) + } + } + + restoreJson = (e, values, setFieldValue) => { + e.preventDefault() + this.updateJSONMetadata(values, setFieldValue, {}, true) + } + + fixJson = (e, values, setFieldValue) => { + e.preventDefault() + this.updateJSONMetadata(values, setFieldValue, {}) + } + + onCancelMouseDown = (e) => { + e.preventDefault() + this.setState({ + cancelMouseDown: true + }) + } + + onCancelMouseUp = (e) => { + e.preventDefault() + if (this.state.cancelMouseDown) { + this.props.onClose() + this.setState({ + cancelMouseDown: false + }) + } + } + + onMouseUp = (e) => { + e.preventDefault() + if (this.state.cancelMouseDown) { + this.props.onClose() + } + } + + render() { + const { onClose, } = this.props; + const { submitting, showImage, errorMessage, hideErrors } = this.state + + return (
+ +

+ {tt('create_nft_collection_jsx.title')} +

+ + {({ + handleSubmit, isValid, values, errors, touched, setFieldValue, handleChange, + }) => { + return ( +
+
+
+ {tt('create_nft_collection_jsx.name') + '*'} +
+ this.onNameChange(e, values, setFieldValue)} /> +
+
+
+ {tt('create_nft_collection_jsx.coll_title') + '*'} +
+ this.onTitleChange(e, values, setFieldValue)} /> +
+ {!errors.name && } +
+ {!hideErrors && } +
+
+ {tt('create_nft_collection_jsx.coll_descr')} + {' '} + {tt('create_nft_collection_jsx.not_required')} +
+
+
+
+ this.onDescriptionChange(e, values, setFieldValue)} /> +
+ +
+
+
+ {tt('create_nft_collection_jsx.image')} + {' '} + {tt('create_nft_collection_jsx.not_required')} +
+
+
+
+ this.onImageChange(e, values, setFieldValue)} /> + +
+ +
+
+
+ +
+
+ +
+
+
+
+
+
+ + {(touched.json_metadata && errors.json_metadata) ? + (errors.json_metadata === tt('create_nft_collection_jsx.json_wrong') ? + this.restoreJson(e, values, setFieldValue)}>{tt('create_nft_collection_jsx.restore_json')} : + this.fixJson(e, values, setFieldValue)}>{tt('create_nft_collection_jsx.json_fix')}) + : null} +
+
+
+ {tt('create_nft_collection_jsx.token_count')} +
+
+
+
+ this.onMaxTokenCountChange(e, setFieldValue)} + /> +
+
+
+
+ +
+
+
+ {(errorMessage && errorMessage !== 'Canceled') ?
+
+
{errorMessage}
+
+
: null} +
+
+ + + {this._renderSubmittingIndicator()} +
+
+
+ )}}
+
) + } +} + +export default connect( + // mapStateToProps + (state, ownProps) => { + const {locationBeforeTransitions: {pathname}} = state.routing; + let currentUser = ownProps.currentUser || state.user.getIn(['current']) + if (!currentUser) { + const currentUserNameFromRoute = pathname.split(`/`)[1].substring(1); + currentUser = Map({username: currentUserNameFromRoute}); + } + const currentAccount = currentUser && state.global.getIn(['accounts', currentUser.get('username')]); + return { ...ownProps, currentUser, currentAccount, }; + }, + + dispatch => ({ + createCollection: ( + name, json_metadata, max_token_count, currentUser, successCallback, errorCallback + ) => { + const username = currentUser.get('username') + let json = JSON.parse(json_metadata) + json = JSON.stringify(json) + const operation = { + creator: username, + name, + json_metadata: json, + max_token_count: parseInt(max_token_count) + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'nft_collection', + username, + operation, + successCallback, + errorCallback + })) + } + }) +)(CreateNFTCollection) diff --git a/app/components/modules/nft/CreateNFTCollection.scss b/app/components/modules/nft/CreateNFTCollection.scss new file mode 100644 index 0000000..3db9e28 --- /dev/null +++ b/app/components/modules/nft/CreateNFTCollection.scss @@ -0,0 +1,34 @@ +.CreateNFTCollection { + .column { + padding-left: 0rem; + padding-right: 0rem; + } + .padding-left { + padding-left: 0.75rem; + } + .image-preview { + max-width: 150px; + height: 40px; + margin-left: 0.75rem; + border: none; + } + .json_metadata { + min-width: 100%; + min-height: 120px; + font-family: monospace; + } + .json-error { + margin-bottom: 0px; + } + .Expandable { + margin-bottom: 0px; + padding-bottom: 0px; + width: 100%; + .Expander { + margin-bottom: 0px; + h5 { + font-size: 1rem; + } + } + } +} diff --git a/app/components/modules/nft/IssueNFTToken.jsx b/app/components/modules/nft/IssueNFTToken.jsx new file mode 100644 index 0000000..df971b6 --- /dev/null +++ b/app/components/modules/nft/IssueNFTToken.jsx @@ -0,0 +1,361 @@ +import React, { Component, } from 'react' +import tt from 'counterpart' +import { connect, } from 'react-redux' +import CloseButton from 'react-foundation-components/lib/global/close-button' +import { Formik, Form, Field, ErrorMessage, } from 'formik' +import { Asset, validateAccountName } from 'golos-lib-js/lib/utils' + +import Expandable from 'app/components/elements/Expandable' +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import transaction from 'app/redux/Transaction' + +class IssueNFTToken extends Component { + state = { + token: { + name: '', + to: '', + title: '', + json_metadata: '{}', + } + } + + initTo = (currentUser) => { + if (!currentUser) return + const username = currentUser.get('username') + this.setState({ + token: { + ...this.state.token, + to: username + } + }) + } + + componentDidMount() { + this.initTo(this.props.currentUser) + } + + componentDidUpdate(prevProps) { + const { currentUser } = this.props + if (currentUser && !prevProps.currentUser) { + this.initTo(currentUser) + } + } + + validate = (values) => { + const errors = {} + const { title, to } = values + if (!title.length) { + errors.title = tt('g.required') + } + const accNameError = validateAccountName(values.to) + if (accNameError.error) { + errors.to = tt('account_name.' + accNameError.error) + } + let meta = values.json_metadata + try { + meta = JSON.parse(meta) + if (Array.isArray(meta)) throw new Error('JSON is array') + } catch (err) { + console.error('json_metadata', err) + errors.json_metadata = tt('create_nft_collection_jsx.json_wrong') + meta = null + } + if (meta) { + const noFields = [] + if (!('title' in meta)) noFields.push('title') + if (values.image && !('image' in meta)) noFields.push('image') + if (values.description && !('description' in meta)) noFields.push('description') + if (noFields.length) { + errors.json_metadata = tt('create_nft_collection_jsx.json_no_fields') + errors.json_metadata += noFields.join(', ') + '. ' + } + } + return errors + } + + setSubmitting = (submitting) => { + this.setState({ submitting }) + } + + _onSubmit = async (values) => { + this.setSubmitting(true) + this.setState({ + errorMessage: '' + }) + const { currentUser, issueName } = this.props + const username = currentUser.get('username') + await this.props.issueToken(issueName, values.to, values.json_metadata, currentUser, () => { + this.props.fetchState() + this.props.onClose() + this.setSubmitting(false) + }, (err) => { + console.error(err) + this.setSubmitting(false) + this.setState({ + errorMessage: err.toString() + }) + }) + } + + _renderSubmittingIndicator = () => { + const { submitting } = this.state + + return submitting ? + + : null + } + + updateJSONMetadata = (values, setFieldValue, currentVals, force = false) => { + let { json_metadata } = values + try { + json_metadata = JSON.parse(json_metadata) + if (Array.isArray(json_metadata)) { + json_metadata = {} + } + } catch (err) { + console.error('updateJSONMetadata', err) + if (!force) { + return + } + json_metadata = {} + } + + const title = currentVals.title || values.title + json_metadata.title = title || '' + + const description = currentVals.description || values.description + if (description) { + json_metadata.description = description + } + + const image = currentVals.image || values.image + if (image) { + json_metadata.image = image + } + + setFieldValue('json_metadata', JSON.stringify(json_metadata, null, 2)) + } + + onTitleChange = (e, values, setFieldValue) => { + const title = e.target.value + setFieldValue('title', title) + this.updateJSONMetadata(values, setFieldValue, { title }) + } + + onDescriptionChange = (e, values, setFieldValue) => { + const description = e.target.value + setFieldValue('description', description) + this.updateJSONMetadata(values, setFieldValue, { description }) + } + + onImageChange = (e, values, setFieldValue) => { + const image = e.target.value + setFieldValue('image', image) + this.updateJSONMetadata(values, setFieldValue, { image }) + } + + onImageBlur = (e) => { + e.preventDefault() + this.setState({ + showImage: true + }) + } + + restoreJson = (e, values, setFieldValue) => { + e.preventDefault() + this.updateJSONMetadata(values, setFieldValue, {}, true) + } + + fixJson = (e, values, setFieldValue) => { + e.preventDefault() + this.updateJSONMetadata(values, setFieldValue, {}) + } + + onCancelMouseDown = (e) => { + e.preventDefault() + this.setState({ + cancelMouseDown: true + }) + } + + onCancelMouseUp = (e) => { + e.preventDefault() + if (this.state.cancelMouseDown) { + this.props.onClose() + this.setState({ + cancelMouseDown: false + }) + } + } + + onMouseUp = (e) => { + e.preventDefault() + if (this.state.cancelMouseDown) { + this.props.onClose() + } + } + + render() { + const { issueName, issueNum, cprops, onClose, } = this.props; + const { submitting, showImage, errorMessage } = this.state + + return (
+ +

+ {tt('issue_nft_token_jsx.title') + ' (' + issueName + ', #' + issueNum + ')'} +

+ + {({ + handleSubmit, isValid, values, errors, touched, setFieldValue, handleChange, + }) => { + let cost = null + if (cprops) { + let issueCost = Asset(cprops.get('nft_issue_cost')) + const multiplier = Math.floor(values.json_metadata.length / 1024) + issueCost = issueCost.plus(issueCost.mul(multiplier)) + cost =
+ {tt('issue_nft_token_jsx.issue_cost')}  + {issueCost.floatString} + . +
+ } + + return ( +
+
+
+ {tt('create_nft_collection_jsx.coll_title') + '*'} +
+ this.onTitleChange(e, values, setFieldValue)} /> +
+ +
+
+
+ {tt('assets_jsx.transfer_new_owner')} +
+
+
+
+ +
+ +
+
+
+ {tt('create_nft_collection_jsx.coll_descr')} + {' '} + {tt('create_nft_collection_jsx.not_required')} +
+
+
+
+ this.onDescriptionChange(e, values, setFieldValue)} /> +
+ +
+
+
+ {tt('create_nft_collection_jsx.image')} + {' '} + {tt('create_nft_collection_jsx.not_required')} +
+
+
+
+ this.onImageChange(e, values, setFieldValue)} /> + +
+ +
+
+
+ +
+
+ +
+
+
+
+
+
+ + {(touched.json_metadata && errors.json_metadata) ? + (errors.json_metadata === tt('create_nft_collection_jsx.json_wrong') ? + this.restoreJson(e, values, setFieldValue)}>{tt('create_nft_collection_jsx.restore_json')} : + this.fixJson(e, values, setFieldValue)}>{tt('create_nft_collection_jsx.json_fix')}) + : null} +
+
+ {cost} + {(errorMessage && errorMessage !== 'Canceled') ?
+
+
{errorMessage}
+
+
: null} +
+
+ + + {this._renderSubmittingIndicator()} +
+
+
+ )}}
+
) + } +} + +export default connect( + // mapStateToProps + (state, ownProps) => { + const {locationBeforeTransitions: {pathname}} = state.routing; + let currentUser = ownProps.currentUser || state.user.getIn(['current']) + if (!currentUser) { + const currentUserNameFromRoute = pathname.split(`/`)[1].substring(1); + currentUser = Map({username: currentUserNameFromRoute}); + } + const currentAccount = currentUser && state.global.getIn(['accounts', currentUser.get('username')]); + const cprops = state.global.get('cprops') + return { ...ownProps, currentUser, currentAccount, cprops, } + }, + + dispatch => ({ + issueToken: ( + name, to, json_metadata, currentUser, successCallback, errorCallback + ) => { + const username = currentUser.get('username') + let json = JSON.parse(json_metadata) + json = JSON.stringify(json) + const operation = { + creator: username, + name, + to, + json_metadata: json, + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'nft_issue', + username, + operation, + successCallback, + errorCallback + })) + } + }) +)(IssueNFTToken) diff --git a/app/components/modules/nft/IssueNFTToken.scss b/app/components/modules/nft/IssueNFTToken.scss new file mode 100644 index 0000000..ff33515 --- /dev/null +++ b/app/components/modules/nft/IssueNFTToken.scss @@ -0,0 +1,31 @@ +.IssueNFTToken { + .column { + padding-left: 0rem; + padding-right: 0rem; + } + .image-preview { + max-width: 150px; + height: 40px; + margin-left: 0.75rem; + border: none; + } + .json_metadata { + min-width: 100%; + min-height: 120px; + font-family: monospace; + } + .json-error { + margin-bottom: 0px; + } + .Expandable { + margin-bottom: 0px; + padding-bottom: 0px; + width: 100%; + .Expander { + margin-bottom: 0px; + h5 { + font-size: 1rem; + } + } + } +} diff --git a/app/components/modules/nft/NFTCollections.jsx b/app/components/modules/nft/NFTCollections.jsx new file mode 100644 index 0000000..bdcedbe --- /dev/null +++ b/app/components/modules/nft/NFTCollections.jsx @@ -0,0 +1,210 @@ +import React, {Component} from 'react' +import PropTypes from 'prop-types' +import {connect} from 'react-redux'; +import { Link } from 'react-router'; +import tt from 'counterpart'; +import { Asset } from 'golos-lib-js/lib/utils'; +import Reveal from 'react-foundation-components/lib/global/reveal'; + +import Icon from 'app/components/elements/Icon'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import NFTSmallIcon from 'app/components/elements/nft/NFTSmallIcon' +import CreateNFTCollection from 'app/components/modules/nft/CreateNFTCollection' +import IssueNFTToken from 'app/components/modules/nft/IssueNFTToken' +import g from 'app/redux/GlobalReducer' +import user from 'app/redux/User' +import transaction from 'app/redux/Transaction' +import { getAssetMeta } from 'app/utils/market/utils' + +class NFTCollections extends Component { + state = {} + + constructor() { + super() + } + + showCreate = (e) => { + e.preventDefault() + this.setState({ + showCreate: true, + }) + } + + hideCreate = () => { + this.setState({ + showCreate: false, + }) + } + + showIssue = (e) => { + e.preventDefault() + this.setState({ + showIssue: true, + }) + } + + hideIssue = () => { + this.setState({ + showIssue: false, + }) + } + + render() { + const { account, isMyAccount, nft_collections, nft_assets, fetchState } = this.props + const accountName = account.get('name') + + const collections = nft_collections ? nft_collections.toJS() : null + const assets = nft_assets ? nft_assets.toJS() : null + + let items + if (!collections) { + items = + } else if (!collections.length) { + if (isMyAccount) { + items = {tt('nft_collections_jsx.not_yet')} + } else { + items = {tt('nft_collections_jsx.not_yet2') + accountName + tt('nft_collections_jsx.not_yet3')} + } + } else { + items = [] + let count = 0 + for (const collection of collections) { + const { name, token_count, json_metadata, image, market_volume, last_buy_price } = collection + + let data + if (json_metadata) { + data = JSON.parse(json_metadata) + } + data = data || {} // node allows to use '', null, object, or array + + const issueIt = (e) => { + e.preventDefault() + this.setState({ + showIssue: true, + issueName: name, + issueNum: token_count + 1 + }) + } + + const deleteIt = async (e) => { + e.preventDefault() + + await this.props.deleteCollection(name, accountName, () => { + this.props.fetchState() + }, (err) => { + console.error(err) + }) + } + + const price = Asset(last_buy_price) + const asset = assets[price.symbol] + let imageUrl + if (asset) { + imageUrl = getAssetMeta(asset).image_url + } + + items.push( + + + + + + {name} + + + + {tt('nft_tokens_jsx.token_count', { count: token_count })} + {isMyAccount ? : null} + + +
+ {tt('rating_jsx.volume') + ' ' + parseFloat(market_volume).toFixed(3)} +
+
+ {imageUrl && {''}} + {price.amountFloat} +
+ + + {isMyAccount ? : null} + + ) + + ++count + } + + items = + {items} +
+ } + + const { showCreate, showIssue, issueName, issueNum } = this.state + + return (
+
+
+

{tt('g.nft_collections')}

+ {isMyAccount && + {tt('nft_collections_jsx.create')} + } +
+
+
+
+ {items} +
+
+ + + + + + + + +
) + } +} + +export default connect( + (state, ownProps) => { + return {...ownProps, + nft_collections: state.global.get('nft_collections'), + nft_assets: state.global.get('nft_assets') + } + }, + dispatch => ({ + fetchState: () => { + const pathname = window.location.pathname + dispatch({type: 'FETCH_STATE', payload: {pathname}}) + }, + deleteCollection: ( + name, username, successCallback, errorCallback + ) => { + const operation = { + creator: username, + name, + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'nft_collection_delete', + username, + operation, + successCallback, + errorCallback + })) + } + }) +)(NFTCollections) diff --git a/app/components/modules/nft/NFTCollections.scss b/app/components/modules/nft/NFTCollections.scss new file mode 100644 index 0000000..0074e6c --- /dev/null +++ b/app/components/modules/nft/NFTCollections.scss @@ -0,0 +1,20 @@ +.NFTCollections { + .market-stats { + font-size: 90%; + } + .price-icon { + width: 20px; + height: 20px; + margin-right: 0.25rem; + } + .price-val { + display: inline-block; + vertical-align: middle; + } + + .button { + margin-left: 0.5rem; + margin-right: 0rem; + margin-bottom: 0rem; + } +} diff --git a/app/components/modules/nft/NFTHistory.jsx b/app/components/modules/nft/NFTHistory.jsx new file mode 100644 index 0000000..d89483e --- /dev/null +++ b/app/components/modules/nft/NFTHistory.jsx @@ -0,0 +1,85 @@ +/* eslint react/prop-types: 0 */ +import React from 'react'; +import {connect} from 'react-redux' +import tt from 'counterpart' + +import TransferHistoryRow from 'app/components/cards/TransferHistoryRow' + +class NFTHistory extends React.Component { + state = { historyIndex: 0 } + + shouldComponentUpdate(nextProps, nextState) { + if (!this.props.account.nft_history) return true; + if (!nextProps.account.nft_history) return true; + return ( + nextProps.account.nft_history.length !== this.props.account.nft_history.length || + nextState.historyIndex !== this.state.historyIndex); + } + + _setHistoryPage(back) { + const newIndex = this.state.historyIndex + (back ? 10 : -10); + this.setState({historyIndex: Math.max(0, newIndex)}); + } + + render() { + const {state: {historyIndex}} = this + const {account, incoming} = this.props; + + const nft_history = account.nft_history || []; + + /// nft log + let idx = 0 + let nftLog = nft_history.map((item, index) => { + return ; + }).filter(el => !!el); + let currentIndex = -1; + const nftLength = nftLog.length; + const limitedIndex = Math.min(historyIndex, nftLength - 10); + nftLog = nftLog.reverse().filter(() => { + currentIndex++; + return currentIndex >= limitedIndex && currentIndex < limitedIndex + 10; + }); + + const navButtons = ( + + ); + + return (
+
+
+

{tt('g.nft_history')}

+ {navButtons} + + + {nftLog} + +
+ {navButtons} +
+
+
); + } +} + +export default connect( + // mapStateToProps + (state, ownProps) => { + return { + state, + ...ownProps + } + } +)(NFTHistory) diff --git a/app/components/modules/nft/NFTTokenSell.jsx b/app/components/modules/nft/NFTTokenSell.jsx new file mode 100644 index 0000000..9fd9e6b --- /dev/null +++ b/app/components/modules/nft/NFTTokenSell.jsx @@ -0,0 +1,215 @@ +import React, { Component, } from 'react' +import tt from 'counterpart' +import { connect, } from 'react-redux' +import CloseButton from 'react-foundation-components/lib/global/close-button' +import { Formik, Form, Field, ErrorMessage, } from 'formik' +import { Asset, AssetEditor } from 'golos-lib-js/lib/utils' + +import AmountField from 'app/components/elements/forms/AmountField' +import AmountAssetField from 'app/components/elements/forms/AmountAssetField' +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import NFTSmallIcon from 'app/components/elements/nft/NFTSmallIcon' +import transaction from 'app/redux/Transaction' +import { generateOrderID } from 'app/utils/market/utils' + +class NFTTokenSell extends Component { + state = { + order: { + price: AssetEditor('0.000 GOLOS') + } + } + + validate = (values) => { + const errors = {} + const { price } = values + if (values.price.asset.eq(0)) { + errors.price = tt('nft_token_sell_jsx.fill_price') + } + return errors + } + + setSubmitting = (submitting) => { + this.setState({ submitting }) + } + + getToken = () => { + const { nft_tokens, tokenIdx } = this.props + if (tokenIdx !== undefined) { + return nft_tokens.toJS().data[tokenIdx] + } + return this.props.token + } + + _onSubmit = async (values) => { + this.setSubmitting(true) + this.setState({ + errorMessage: '' + }) + + const { currentUser, onClose, } = this.props + const token = this.getToken() + const { token_id } = token + + const username = currentUser.get('username') + + await this.props.sellToken(token_id, values.price, currentUser, () => { + this.props.onClose() + this.setSubmitting(false) + this.doNotRender = true + this.props.refetch() + }, (err) => { + console.error(err) + this.setSubmitting(false) + this.setState({ + errorMessage: err.toString() + }) + }) + } + + onCancelMouseDown = (e) => { + e.preventDefault() + this.setState({ + cancelMouseDown: true + }) + } + + onCancelMouseUp = (e) => { + e.preventDefault() + if (this.state.cancelMouseDown) { + this.props.onClose() + this.setState({ + cancelMouseDown: false + }) + } + } + + onMouseUp = (e) => { + e.preventDefault() + if (this.state.cancelMouseDown) { + this.props.onClose() + } + } + + _renderSubmittingIndicator = () => { + const { submitting } = this.state + + return submitting ? + + : null + } + + render() { + if (this.doNotRender) { + return + } + + const { onClose, } = this.props + + const token = this.getToken() + + const { json_metadata, image } = token + + let data + if (json_metadata) { + data = JSON.parse(json_metadata) + } + data = data || {} // node allows to use '', null, object, or array + + const { errorMessage, submitting } = this.state + + const assets = {} + assets['GOLOS'] = { supply: Asset(0, 3, 'GOLOS') } + assets['GBG'] = { supply: Asset(0, 3, 'GBG') } + for (const asset of this.props.assets) { + asset.supply = asset.supply.symbol ? asset.supply : Asset(asset.supply) + assets[asset.supply.symbol] = asset + } + + return
+ +

{tt('g.sell')}

+
+ + {data.title} +
+ + {({ + handleSubmit, isValid, values, errors, touched, setFieldValue, handleChange, + }) => { + return ( +
+
+ {tt('g.price')} +
+
+
+
+ + + + +
+ +
+
+ {(errorMessage && errorMessage !== 'Canceled') ?
+
+
{errorMessage}
+
+
: null} +
+
+ + + {this._renderSubmittingIndicator()} +
+
+
+ )}}
+
+ } +} + +export default connect( + // mapStateToProps + (state, ownProps) => { + return { ...ownProps, + nft_tokens: state.global.get('nft_tokens'), + assets: state.global.get('assets') + } + }, + + dispatch => ({ + sellToken: ( + token_id, price, currentUser, successCallback, errorCallback + ) => { + const username = currentUser.get('username') + const operation = { + seller: username, + token_id, + buyer: '', + order_id: generateOrderID(), + price: price.asset.toString() + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'nft_sell', + username, + operation, + successCallback, + errorCallback + })) + } + }) +)(NFTTokenSell) diff --git a/app/components/modules/nft/NFTTokenSell.scss b/app/components/modules/nft/NFTTokenSell.scss new file mode 100644 index 0000000..cfe7ea3 --- /dev/null +++ b/app/components/modules/nft/NFTTokenSell.scss @@ -0,0 +1,9 @@ +.NFTTokenSell { + .column { + padding-left: 0rem; + padding-right: 0rem; + } + .error { + margin-bottom: 0px; + } +} diff --git a/app/components/modules/nft/NFTTokenTransfer.jsx b/app/components/modules/nft/NFTTokenTransfer.jsx new file mode 100644 index 0000000..6a3a150 --- /dev/null +++ b/app/components/modules/nft/NFTTokenTransfer.jsx @@ -0,0 +1,200 @@ +import React, { Component, } from 'react' +import tt from 'counterpart' +import { connect, } from 'react-redux' +import CloseButton from 'react-foundation-components/lib/global/close-button' +import { Formik, Form, Field, ErrorMessage, } from 'formik' +import { validateAccountName } from 'golos-lib-js/lib/utils' + +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import NFTSmallIcon from 'app/components/elements/nft/NFTSmallIcon' +import transaction from 'app/redux/Transaction' + +class NFTTokenTransfer extends Component { + state = { + token: { + to: '' + } + } + + validate = (values) => { + const errors = {} + const { to } = values + const accNameError = validateAccountName(values.to) + if (accNameError.error) { + errors.to = tt('account_name.' + accNameError.error) + } + return errors + } + + setSubmitting = (submitting) => { + this.setState({ submitting }) + } + + getToken = () => { + const { nft_tokens, tokenIdx } = this.props + if (tokenIdx !== undefined) { + return nft_tokens.toJS().data[tokenIdx] + } + return this.props.token + } + + _onSubmit = async (values) => { + this.setSubmitting(true) + this.setState({ + errorMessage: '' + }) + + const { currentUser, onClose, } = this.props + const token = this.getToken() + const { token_id } = token + + const username = currentUser.get('username') + + await this.props.transferToken(token_id, values.to, currentUser, () => { + this.props.onClose() + this.setSubmitting(false) + this.doNotRender = true + this.props.refetch() + }, (err) => { + console.error(err) + this.setSubmitting(false) + this.setState({ + errorMessage: err.toString() + }) + }) + } + + onCancelMouseDown = (e) => { + e.preventDefault() + this.setState({ + cancelMouseDown: true + }) + } + + onCancelMouseUp = (e) => { + e.preventDefault() + if (this.state.cancelMouseDown) { + this.props.onClose() + this.setState({ + cancelMouseDown: false + }) + } + } + + onMouseUp = (e) => { + e.preventDefault() + if (this.state.cancelMouseDown) { + this.props.onClose() + } + } + + _renderSubmittingIndicator = () => { + const { submitting } = this.state + + return submitting ? + + : null + } + + render() { + if (this.doNotRender) { + return + } + + const { onClose, } = this.props + + const token = this.getToken() + + const { json_metadata, image } = token + + let data + if (json_metadata) { + data = JSON.parse(json_metadata) + } + data = data || {} // node allows to use '', null, object, or array + + const { errorMessage, submitting } = this.state + + return
+ +

{tt('g.transfer')}

+
+ + {data.title} +
+ + {({ + handleSubmit, isValid, values, errors, touched, setFieldValue, handleChange, + }) => { + return ( +
+
+ {tt('assets_jsx.transfer_new_owner')} +
+
+
+
+ +
+ +
+
+ {(errorMessage && errorMessage !== 'Canceled') ?
+
+
{errorMessage}
+
+
: null} +
+
+ + + {this._renderSubmittingIndicator()} +
+
+
+ )}}
+
+ } +} + +export default connect( + // mapStateToProps + (state, ownProps) => { + return { ...ownProps, + nft_tokens: state.global.get('nft_tokens'), + } + }, + + dispatch => ({ + transferToken: ( + token_id, to, currentUser, successCallback, errorCallback + ) => { + const username = currentUser.get('username') + const operation = { + from: username, + to, + token_id, + memo: '' + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'nft_transfer', + username, + operation, + successCallback, + errorCallback + })) + } + }) +)(NFTTokenTransfer) diff --git a/app/components/modules/nft/NFTTokenTransfer.scss b/app/components/modules/nft/NFTTokenTransfer.scss new file mode 100644 index 0000000..f371653 --- /dev/null +++ b/app/components/modules/nft/NFTTokenTransfer.scss @@ -0,0 +1,9 @@ +.NFTTokenTransfer { + .column { + padding-left: 0rem; + padding-right: 0rem; + } + .error { + margin-bottom: 0px; + } +} diff --git a/app/components/modules/nft/NFTTokens.jsx b/app/components/modules/nft/NFTTokens.jsx new file mode 100644 index 0000000..0186c7c --- /dev/null +++ b/app/components/modules/nft/NFTTokens.jsx @@ -0,0 +1,195 @@ +import React, {Component} from 'react' +import PropTypes from 'prop-types' +import {connect} from 'react-redux' +import { Link } from 'react-router' +import tt from 'counterpart' +import { Map } from 'immutable' +import Reveal from 'react-foundation-components/lib/global/reveal' + +import DropdownMenu from 'app/components/elements/DropdownMenu' +import Icon from 'app/components/elements/Icon' +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import NFTTokenItem from 'app/components/elements/nft/NFTTokenItem' +import NFTTokenTransfer from 'app/components/modules/nft/NFTTokenTransfer' +import NFTTokenSell from 'app/components/modules/nft/NFTTokenSell' +import g from 'app/redux/GlobalReducer' + +class NFTTokens extends Component { + state = {} + + constructor() { + super() + } + + componentDidMount() { + if (!this.props.nft_tokens) { + this.refetch() + } + } + + refetch = () => { + this.props.fetchNFTTokens(this.props.account, 0, this.sort, this.sortReversed) + } + + showTransfer = (e, tokenIdx) => { + e.preventDefault() + this.setState({ + showTransfer: true, + tokenIdx, + }) + } + + hideTransfer = () => { + this.setState({ + showTransfer: false, + }) + } + + showSell = (e, tokenIdx) => { + e.preventDefault() + this.setState({ + showSell: true, + tokenIdx, + }) + } + + hideSell = () => { + this.setState({ + showSell: false, + }) + } + + sortOrder = (e, sort, sortReversed) => { + e.preventDefault() + this.sort = sort + this.sortReversed = sortReversed + this.refetch() + } + + render() { + const { currentUser, account, isMyAccount, nft_tokens, nft_assets, } = this.props + const accountName = account.get('name') + + const tokens = nft_tokens ? nft_tokens.toJS().data : null + const assets = nft_assets ? nft_assets.toJS() : {} + + const next_from = nft_tokens && nft_tokens.get('next_from') + + let items = [] + if (!tokens) { + items = + } else if (!tokens.length) { + if (isMyAccount) { + items = {tt('nft_tokens_jsx.not_yet')} + } else { + items = {tt('nft_tokens_jsx.not_yet2') + accountName + tt('nft_tokens_jsx.not_yet3')} + } + } else { + for (let i = 0; i < tokens.length; ++i) { + const token = tokens[i] + items.push() + } + } + + const { showTransfer, showSell, tokenIdx } = this.state + + const sortItems = [ + { link: '#', onClick: e => { + this.sortOrder(e, 'by_last_update', false) + }, value: tt('nft_tokens_jsx.sort_new') }, + { link: '#', onClick: e => { + this.sortOrder(e, 'by_last_update', true) + }, value: tt('nft_tokens_jsx.sort_old') }, + { link: '#', onClick: e => { + this.sortOrder(e, 'by_last_price', false) + }, value: tt('nft_tokens_jsx.sort_price') }, + { link: '#', onClick: e => { + this.sortOrder(e, 'by_name', false) + }, value: tt('nft_tokens_jsx.sort_name') }, + ] + + let currentSort + if (this.sort === 'by_last_price') { + currentSort = tt('nft_tokens_jsx.sort_price') + } else if (this.sort === 'by_name') { + currentSort = tt('nft_tokens_jsx.sort_name') + } else { + if (this.sortReversed) { + currentSort = tt('nft_tokens_jsx.sort_old') + } else { + currentSort = tt('nft_tokens_jsx.sort_new') + } + } + + return (
+
+
+

{tt('g.nft_tokens')}

+ + {tt('g.buy')} + +    + + + {currentSort} + + + +
+
+
+
+ {items} + {next_from ?
+
+
: null} +
+
+ + + + + + + + +
) + } +} + +export default connect( + (state, ownProps) => { + const {locationBeforeTransitions: {pathname}} = state.routing; + let currentUser = ownProps.currentUser || state.user.getIn(['current']) + if (!currentUser) { + const currentUserNameFromRoute = pathname.split(`/`)[1].substring(1); + currentUser = Map({username: currentUserNameFromRoute}); + } + return {...ownProps, currentUser, + nft_tokens: state.global.get('nft_tokens'), + nft_assets: state.global.get('nft_assets') + } + }, + dispatch => ({ + fetchNFTTokens: (account, start_token_id, sort, sortReversed) => { + if (!account) return + dispatch(g.actions.fetchNftTokens({ account: account.get('name'), start_token_id, sort, reverse_sort: sortReversed })) + }, + }) +)(NFTTokens) diff --git a/app/components/modules/nft/NFTTokens.scss b/app/components/modules/nft/NFTTokens.scss new file mode 100644 index 0000000..6e85ea3 --- /dev/null +++ b/app/components/modules/nft/NFTTokens.scss @@ -0,0 +1,2 @@ +.NFTTokens { +} diff --git a/app/components/pages/UserProfile.jsx b/app/components/pages/UserProfile.jsx index 14c99c1..8924f49 100644 --- a/app/components/pages/UserProfile.jsx +++ b/app/components/pages/UserProfile.jsx @@ -14,6 +14,8 @@ import CreateAsset from 'app/components/modules/uia/CreateAsset'; import Assets from 'app/components/modules/uia/Assets'; import UpdateAsset from 'app/components/modules/uia/UpdateAsset'; import TransferAsset from 'app/components/modules/uia/TransferAsset'; +import NFTCollections from 'app/components/modules/nft/NFTCollections' +import NFTTokens from 'app/components/modules/nft/NFTTokens' import Invites from 'app/components/elements/Invites'; import PasswordReset from 'app/components/elements/PasswordReset'; import UserWallet from 'app/components/modules/UserWallet'; @@ -24,6 +26,7 @@ import DonatesTo from 'app/components/modules/DonatesTo'; import CurationRewards from 'app/components/modules/CurationRewards'; import AuthorRewards from 'app/components/modules/AuthorRewards'; import FilledOrders from 'app/components/modules/FilledOrders' +import NFTHistory from 'app/components/modules/nft/NFTHistory' import LoadingIndicator from 'app/components/elements/LoadingIndicator'; import { authUrl, } from 'app/utils/AuthApiClient' import { getGameLevel } from 'app/utils/GameUtils' @@ -213,7 +216,7 @@ export default class UserProfile extends React.Component { // const global_status = this.props.global.get('status'); - let rewardsClass = '', walletClass = '', permissionsClass = ''; + let rewardsClass = '', walletClass = '', permissionsClass = '', nftClass = '' if (!section || section === 'transfers') { // transfers, check if url has query params const { location: { query } } = this.props; @@ -246,8 +249,17 @@ export default class UserProfile extends React.Component {
; - } - else if( section === 'curation-rewards' ) { + } else if( section === 'nft-collections' ) { + nftClass = 'active' + tab_content =
+ +
+ } else if( section === 'nft-tokens' ) { + nftClass = 'active' + tab_content =
+ +
+ } else if( section === 'curation-rewards' ) { rewardsClass = 'active'; tab_content = } + else if( section === 'nft-history' ) { + nftClass = 'active' + tab_content = + } else if( section === 'donates-from' ) { rewardsClass = 'active'; tab_content = {tt('g.assets')} +
+ } + > + + {tt('g.nft')} + + + +
{isMyAccount ? {tt('navigation.market2')} : null} diff --git a/app/components/pages/nft/NFTCollectionPage.jsx b/app/components/pages/nft/NFTCollectionPage.jsx new file mode 100644 index 0000000..dfded4d --- /dev/null +++ b/app/components/pages/nft/NFTCollectionPage.jsx @@ -0,0 +1,272 @@ +import React, { Component, } from 'react' +import tt from 'counterpart' +import { connect, } from 'react-redux' +import { Link } from 'react-router' +import Reveal from 'react-foundation-components/lib/global/reveal' + +import IssueNFTToken from 'app/components/modules/nft/IssueNFTToken' +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import NFTTokenItem from 'app/components/elements/nft/NFTTokenItem' +import NFTTokenTransfer from 'app/components/modules/nft/NFTTokenTransfer' +import NFTTokenSell from 'app/components/modules/nft/NFTTokenSell' +import NotFound from 'app/components/pages/NotFound' +import g from 'app/redux/GlobalReducer' +import transaction from 'app/redux/Transaction' + +class NFTCollectionPage extends Component { + state = { + } + + componentDidMount() { + this.refetch() + } + + componentDidUpdate() { + if (!this.props.nft_tokens) + this.refetch() + } + + refetch = () => { + this.props.fetchNftCollectionTokens(this.props.routeParams.name, + 0, this.sort, this.sortReversed) + } + + showIssue = (e) => { + e.preventDefault() + const { nft_collection, } = this.props + + const coll = nft_collection.toJS() + + const { name, token_count } = coll + this.setState({ + showIssue: true, + issueName: name, + issueNum: token_count + 1 + }) + } + + hideIssue = () => { + this.setState({ + showIssue: false, + }) + } + + deleteIt = async (e) => { + e.preventDefault() + + const { currentUser, nft_collection } = this.props + const coll = nft_collection.toJS() + + await this.props.deleteCollection(coll.name, currentUser, () => { + this.refetch() + }, (err) => { + console.error(err) + }) + } + + showTransfer = (e, tokenIdx) => { + e.preventDefault() + this.setState({ + showTransfer: true, + tokenIdx, + }) + } + + hideTransfer = () => { + this.setState({ + showTransfer: false, + }) + } + + showSell = (e, tokenIdx) => { + e.preventDefault() + this.setState({ + showSell: true, + tokenIdx, + }) + } + + hideSell = () => { + this.setState({ + showSell: false, + }) + } + + onBurnClick = async (e) => { + const { nft_token, currentUser, } = this.props + const token = nft_token.toJS() + const { token_id } = token + await this.props.burnToken(token_id, currentUser, () => { + this.refetch() + }, (err) => { + if (!err || err.toString() === 'Canceled') return + console.error(err) + alert(err.toString()) + }) + } + + render() { + const { nft_collection, nft_collection_loaded, nft_tokens, nft_assets, currentUser } = this.props + + if (!nft_collection_loaded) { + return
+
+ +
+
+ } + + const coll = nft_collection.toJS() + + if (!coll.name) { + return + } + + const tokens = nft_tokens ? nft_tokens.toJS().data : null + const assets = nft_assets ? nft_assets.toJS() : null + + const next_from = nft_tokens && nft_tokens.get('next_from') + + let items = [] + if (!tokens) { + items = + } else if (!tokens.length) { + items = {tt('nft_collection_page_jsx.not_yet')} + } else { + for (let i = 0; i < tokens.length; ++i) { + const token = tokens[i] + items.push() + } + } + + const { showIssue, issueName, issueNum, showTransfer, showSell, tokenIdx } = this.state + + const isMy = currentUser && currentUser.get('username') === coll.creator + + const { json_metadata, token_count } = coll + + let data + if (json_metadata) { + data = JSON.parse(json_metadata) + } + data = data || {} // node allows to use '', null, object, or array + + return
+
+

{data.title || coll.name}

+ +
+ + + {tt('nft_collection_page_jsx.owner_is')} + + + @{coll.creator} + {isMy ? + + + : null} +
+ +
+ {items} +
+
+ + + + + + + + + + + + +
+ } +} + +module.exports = { + path: '/nft-collections(/:name)', + component: connect( + // mapStateToProps + (state, ownProps) => { + const currentUser = state.user.getIn(['current']) + + return { ...ownProps, + currentUser, + nft_collection: state.global.get('nft_collection'), + nft_collection_loaded: state.global.get('nft_collection_loaded'), + nft_tokens: state.global.get('nft_tokens'), + nft_assets: state.global.get('nft_assets') + } + }, + + dispatch => ({ + fetchNftCollectionTokens: (collectionName, start_token_id, sort, sortReversed) => { + dispatch(g.actions.fetchNftCollectionTokens({ collectionName, start_token_id, sort, reverse_sort: sortReversed })) + }, + deleteCollection: ( + name, currentUser, successCallback, errorCallback + ) => { + const username = currentUser.get('username') + const operation = { + creator: username, + name, + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'nft_collection_delete', + username, + operation, + successCallback, + errorCallback + })) + }, + burnToken: ( + token_id, currentUser, successCallback, errorCallback + ) => { + const username = currentUser.get('username') + const operation = { + from: username, + to: 'null', + token_id, + memo: '' + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'nft_transfer', + confirm: tt('g.are_you_sure'), + username, + operation, + successCallback, + errorCallback + })) + } + }) + )(NFTCollectionPage) +} + diff --git a/app/components/pages/nft/NFTCollectionPage.scss b/app/components/pages/nft/NFTCollectionPage.scss new file mode 100644 index 0000000..83d7d55 --- /dev/null +++ b/app/components/pages/nft/NFTCollectionPage.scss @@ -0,0 +1,8 @@ +.NFTCollectionPage { + padding: 1rem; + flex: 1; + + .button { + margin-bottom: 0px; + } +} diff --git a/app/components/pages/nft/NFTMarketPage.jsx b/app/components/pages/nft/NFTMarketPage.jsx new file mode 100644 index 0000000..fc168aa --- /dev/null +++ b/app/components/pages/nft/NFTMarketPage.jsx @@ -0,0 +1,80 @@ +import React from 'react' +import { connect, } from 'react-redux' +import tt from 'counterpart' + +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import NFTTokenItem from 'app/components/elements/nft/NFTTokenItem' +import g from 'app/redux/GlobalReducer' + +class NFTMarketPage extends React.Component { + componentDidMount() { + setTimeout(() => { + this.refetch() + }, 500) + } + + refetch() { + const { currentUser } = this.props + const username = currentUser && currentUser.get('username') + this.props.fetchNftMarket(username, '', 0, this.sort, this.sortReversed) + } + + render() { + const { currentAccount, nft_orders, own_nft_orders, nft_assets, routeParams } = this.props + + const { name } = routeParams + + let content + const orders = nft_orders ? nft_orders.toJS().data : null + const own_orders = own_nft_orders ? own_nft_orders.toJS().data : null + const assets = nft_assets ? nft_assets.toJS() : {} + + if (!orders || !own_orders) { + content = + } else { + let items = [] + if (!orders.length) { + items = tt('nft_market_page_jsx.no_orders') + } else { + for (let i = 0; i < orders.length; ++i) { + const order = orders[i] + items.push() + } + } + } + + return
+
+

{tt('header_jsx.nft_market')}

+ {content} +
+
+ } +} + +module.exports = { + path: '/nft(/:name)', + component: connect( + (state, ownProps) => { + const currentUser = state.user.getIn(['current']) + const currentAccount = currentUser && state.global.getIn(['accounts', currentUser.get('username')]) + + const nft_orders = state.global.get('nft_orders') + const own_nft_orders = state.global.get('own_nft_orders') + + return { + currentAccount, + nft_orders, + own_nft_orders, + nft_assets: state.global.get('nft_assets') + } + }, + dispatch => ({ + fetchNftMarket: (account, collectionName, start_order_id, sort, reverse_sort) => { + dispatch(g.actions.fetchNftMarket({ account, collectionName, start_order_id, sort, reverse_sort })) + }, + }) + )(NFTMarketPage), +} diff --git a/app/components/pages/nft/NFTMarketPage.scss b/app/components/pages/nft/NFTMarketPage.scss new file mode 100644 index 0000000..7be7f25 --- /dev/null +++ b/app/components/pages/nft/NFTMarketPage.scss @@ -0,0 +1,4 @@ +.NFTMarketPage { + padding: 1rem; + flex: 1; +} diff --git a/app/components/pages/nft/NFTTokenPage.jsx b/app/components/pages/nft/NFTTokenPage.jsx new file mode 100644 index 0000000..bef2a8d --- /dev/null +++ b/app/components/pages/nft/NFTTokenPage.jsx @@ -0,0 +1,429 @@ +import React, { Component, } from 'react' +import tt from 'counterpart' +import { connect, } from 'react-redux' +import { Link } from 'react-router' +import { api } from 'golos-lib-js' +import { Asset } from 'golos-lib-js/lib/utils' +import Reveal from 'react-foundation-components/lib/global/reveal' + +import Expandable from 'app/components/elements/Expandable' +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import TimeAgoWrapper from 'app/components/elements/TimeAgoWrapper' +import NFTTokenTransfer from 'app/components/modules/nft/NFTTokenTransfer' +import NFTTokenSell from 'app/components/modules/nft/NFTTokenSell' +import NotFound from 'app/components/pages/NotFound' +import { getAssetMeta } from 'app/utils/market/utils' +import transaction from 'app/redux/Transaction' + +class NFTTokenPage extends Component { + state = { + } + + cancelOrder = async (e) => { + e.preventDefault() + const { nft_token, currentUser, } = this.props + const token = nft_token.toJS() + const { order } = token + await this.props.cancelOrder(order.order_id, currentUser, () => { + this.props.fetchState() + }, (err) => { + if (!err || err.toString() === 'Canceled') return + console.error(err) + alert(err.toString()) + }) + } + + buyToken = async (e) => { + e.preventDefault() + const { nft_token, currentUser, } = this.props + const token = nft_token.toJS() + const { token_id, order, json_metadata } = token + let tokenTitle + try { + tokenTitle = JSON.parse(json_metadata).title.substring(0, 100) + } catch (err) { + console.error(err) + tokenTitle = '#' + token_id + } + const price = Asset(order.price).floatString + + await this.props.buyToken(token_id, order.order_id, tokenTitle, price, currentUser, () => { + this.props.fetchState() + }, (err) => { + if (!err || err.toString() === 'Canceled') return + console.error(err) + alert(err.toString()) + }) + } + + onBurnClick = async (e) => { + const { nft_token, currentUser, } = this.props + const token = nft_token.toJS() + const { token_id } = token + await this.props.burnToken(token_id, currentUser, () => { + this.props.fetchState() + }, (err) => { + if (!err || err.toString() === 'Canceled') return + console.error(err) + alert(err.toString()) + }) + } + + async loadOps() { + const { nft_token, } = this.props + const token = nft_token.toJS() + const { token_id } = token + const ops = await api.getNftTokenOps({ + token_ids: [ token_id ], + from: 0, + limit: 500 + }) + this.setState({ + ops: ops[token_id] + }) + } + + async componentDidMount() { + if (this.props.nft_token) { + await this.loadOps() + } + } + + async componentDidUpdate(prevProps) { + if (this.props.nft_token && !prevProps.nft_token) { + await this.loadOps() + } + } + + showTransfer = (e) => { + e.preventDefault() + this.setState({ + showTransfer: true, + }) + } + + hideTransfer = () => { + this.setState({ + showTransfer: false, + }) + } + + showSell = (e) => { + e.preventDefault() + this.setState({ + showSell: true, + }) + } + + hideSell = () => { + this.setState({ + showSell: false, + }) + } + + _renderOp = (trx, i) => { + const [ opType, op ] = trx.op + + const accLink = (acc) => { + let title = acc + if (title.length > 10) { + title = title.substr(0, 7) + '...' + } + return {title} + } + + let content + if (opType === 'nft_token') { + content =
+ {accLink(op.creator)} + {tt('nft_token_page_jsx.issued')} + {op.creator !== op.to ? + {tt('nft_token_page_jsx.issued_for') + ' '} + {accLink(op.to)} + : null} +
+ } else if (opType === 'nft_transfer') { + content =
+ {accLink(op.from)} + {tt('nft_token_page_jsx.transfered') + ' '} + {accLink(op.to)} +
+ } else if (opType === 'nft_sell') { + const price = Asset(op.price) + if (op.buyer) { + content =
+ {accLink(op.seller)} + {tt('nft_token_page_jsx.selled2')} + {accLink(op.buyer)} + {!price.eq(0) ? (tt('nft_token_page_jsx.selled2m') + price.floatString) : ''} +
+ } else { + content =
+ {accLink(op.seller)} + {tt('nft_token_page_jsx.selled')} + {price.floatString} +
+ } + } else if (opType === 'nft_buy') { + const price = Asset(op.price) + if (!op.name) { + content =
+ {accLink(op.buyer)} + {tt('nft_token_page_jsx.bought')} + {!price.eq(0) ? (tt('nft_token_page_jsx.selled2m') + price.floatString) : ''} +
+ } + } + return + + {content} + + } + + _renderOps = (ops) => { + if (!ops) { + return + } + + const rows = [] + for (let i = 0; i < ops.length; ++i) { + const trx = ops[i] + rows.push(this._renderOp(trx, i)) + } + return + {rows} +
+ } + + render() { + if (!this.props.nft_token_loaded) { + return + } + + const { nft_token, nft_assets, currentUser } = this.props + + const token = nft_token.toJS() + + if (!token.name) { + return + } + + if (token.burnt) { + return
+
+
{tt('nft_token_page_jsx.burnt')}
+
{tt('nft_token_page_jsx.burnt2')}
+
{tt('nft_token_page_jsx.burnt3')}
+
+
+ } + + const assets = nft_assets.toJS() + + const { json_metadata, image, selling } = token + + let data + if (json_metadata) { + data = JSON.parse(json_metadata) + } + data = data || {} // node allows to use '', null, object, or array + + let last_price + const price = token.selling ? Asset(token.order.price) : Asset(token.last_buy_price) + if (price.amount > 0) { + const asset = assets[price.symbol] + let imageUrl + if (asset) { + imageUrl = getAssetMeta(asset).image_url + } + last_price = + {imageUrl && {''}} + {(token.selling ? (' ' + tt('nft_tokens_jsx.selling_for')) : '') + price.floatString} + + } + + const dataStr = JSON.stringify(data, null, 2) + + const { ops } = this.state + + let title = data.title || '' + if (title.length > 45) { + title = title.substr(0, 45) + '...' + } + + let name = token.name + if (name.length > 38) { + name = name.substr(0, 38) + '...' + } + + const description = data.description || '' + + const isMy = currentUser && currentUser.get('username') === token.owner + + return
+
+
+
+ + + +
+
+

+ {title} +

+
+ + + {name} + + +
+ {tt('nft_token_page_jsx.owner_is')} + + {'@' + token.owner} + +
+
+ {description ?
+ {description} +
: null} + +
+                                {dataStr}
+                            
+
+ + {this._renderOps(ops)} + + {!description ?
+
: null} + {isMy ?
+ {last_price} + + {selling && } + {!selling && } + {!selling && } + {!selling && } +
:
+ {last_price} + {selling && } + +
} +
+
+
+ + + + + + + + +
+ } +} + +module.exports = { + path: '/nft-tokens(/:id)', + component: connect( + // mapStateToProps + (state, ownProps) => { + const currentUser = state.user.getIn(['current']) + + return { ...ownProps, + currentUser, + nft_token: state.global.get('nft_token'), + nft_token_loaded: state.global.get('nft_token_loaded'), + nft_assets: state.global.get('nft_assets') + } + }, + + dispatch => ({ + fetchState: () => { + const pathname = window.location.pathname + dispatch({type: 'FETCH_STATE', payload: {pathname}}) + }, + burnToken: ( + token_id, currentUser, successCallback, errorCallback + ) => { + const username = currentUser.get('username') + const operation = { + from: username, + to: 'null', + token_id, + memo: '' + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'nft_transfer', + confirm: tt('g.are_you_sure'), + username, + operation, + successCallback, + errorCallback + })) + }, + cancelOrder: ( + order_id, currentUser, successCallback, errorCallback + ) => { + const username = currentUser.get('username') + const operation = { + owner: username, + order_id, + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'nft_cancel_order', + confirm: tt('g.are_you_sure'), + username, + operation, + successCallback, + errorCallback + })) + }, + buyToken: ( + token_id, order_id, tokenTitle, price, currentUser, successCallback, errorCallback + ) => { + const username = currentUser.get('username') + const operation = { + buyer: username, + name: '', + token_id, + order_id, + price: '0.000 GOLOS' + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'nft_buy', + confirm: tt('nft_tokens_jsx.buy_confirm') + tokenTitle + tt('nft_tokens_jsx.buy_confirm2') + price + '?', + username, + operation, + successCallback, + errorCallback + })) + } + }) + )(NFTTokenPage) +} diff --git a/app/components/pages/nft/NFTTokenPage.scss b/app/components/pages/nft/NFTTokenPage.scss new file mode 100644 index 0000000..fe81472 --- /dev/null +++ b/app/components/pages/nft/NFTTokenPage.scss @@ -0,0 +1,47 @@ +.NFTTokenPage { + padding: 1rem; + flex: 1; + + .container { + display: flex; + } + .buttons { + align-self: flex-end; + display: flex; + width: 100%; + } + + img { + max-width: 320px; + max-height: 320px; + } + + .Expandable { + margin-bottom: 0.5rem; + padding-bottom: 0px; + width: 100%; + .Expander { + margin-bottom: 0rem; + h5 { + font-size: 1rem; + } + } + } + + .price-icon { + width: 20px; + height: 20px; + margin-right: 0.25rem; + } + + .button { + margin: 0px !important; + margin-left: 5px !important; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + .button-center { + margin-top: -4px !important; + margin-left: 10px !important; + } +} diff --git a/app/locales/en.json b/app/locales/en.json index d0d9054..2a482ad 100644 --- a/app/locales/en.json +++ b/app/locales/en.json @@ -58,6 +58,7 @@ "bid": "Bid", "blog": "Blog", "browse": "Browse", + "burn": "Burn", "buy": "Buy", "quick_buy": "Quick Buy", "buy_VESTING_TOKEN": "Buy %(VESTING_TOKEN)s", @@ -133,6 +134,10 @@ "reset_password": "Password reset", "invites": "Invite checks", "assets": "UIA assets", + "nft": "NFT", + "nft_tokens": "NFT-tokens", + "nft_collections": "NFT-collections", + "nft_history": "NFT history", "help_wallet": "Wallet functions", "phone": "phone", "post": "Post", @@ -312,7 +317,8 @@ "hide_content": "Information was not found or was hidden for violating the rules.", "donate_for_post": "thanked you for the post", "you_donate_for_post": "You thanked", - "for_the_post": "for the post", + "for_the_post": "for the post", + "for_the_comment": "for the comment", "night_mode": "Night Mode", "social_network": "Social network", "about_project": "Golos Wallet - is a decentralized service based on the GOLOS blockchain" @@ -511,6 +517,16 @@ "other": "%(count)s posts" } }, + "account_name": { + "account_name_should_be_shorter": "Account name should be shorter.", + "account_name_should_be_longer": "Account name should be longer.", + "account_name_should_not_be_empty": "Account name should not be empty.", + "each_account_segment_should_start_with_a_letter": "Each account name segment should start with a letter.", + "each_account_segment_should_have_only_letters_digits_or_dashes": "Each account name segment should have only letters, digits or dashes.", + "each_account_segment_should_have_only_one_dash_in_a_row": "Each account name segment should have only one dash in a row.", + "each_account_segment_should_end_with_a_letter_or_digit": "Each account name segment should end with a letter or digit.", + "each_account_segment_should_be_longer": "Each account name segment should be longer." + }, "authorrewards_jsx": { "estimated_author_rewards_last_week": "Estimated author rewards last week", "author_rewards_history": "Author Rewards History" @@ -715,6 +731,84 @@ "no_way_error": "If you set at leas one of these fields, you should also set at least 1 withdrawal method or Details field.", "no_to_error": "If you set withdraw via transfer with memo, please also set, to which account to transfer." }, + "nft_collections_jsx": { + "not_yet": "You haven't any own NFT-token collections.", + "not_yet2": "", + "not_yet3": " haven't any own NFT-token collections.", + "create": "Create collection", + "name_exists": "Collection with such name is already created by another author", + "tokens_exist": "Cannot delete collection which has tokens", + "volume_hint": "Market volume for all time", + "price_hint": "Last buy price" + }, + "create_nft_collection_jsx": { + "title": "Create NFT collection", + "name": "Token name", + "coll_title": "Title", + "coll_descr": "Description", + "image": "Image URL", + "json_metadata": "JSON metadata", + "json_wrong": "Wrong JSON. ", + "not_required": "(not required)", + "token_count": "Max token count", + "infinity": "Infinity", + "create": "Create", + "restore_json": "Restore default", + "json_no_fields": "In JSON missing fields: ", + "json_fix": "Add missing fields" + }, + "issue_nft_token_jsx": { + "title": "Issue NFT", + "issue_cost": "The issue cost is", + "max_token_count": "Max token count in collection is already reached." + }, + "nft_tokens_jsx": { + "not_yet": "You have no NFT-tokens.", + "not_yet2": "", + "not_yet3": " has no NFT-tokens.", + "token_count": { + "zero": "0 tokens", + "one": "1 token", + "other": "%(count)s tokens" + }, + "sort": "Sorting", + "sort_new": "News first", + "sort_old": "Old first", + "sort_price": "By price", + "sort_name": "By collection name", + "cancel": "Cancel", + "cancel_hint": "Cancel selling", + "selling_for": "Selling for ", + "buy": "Buy", + "buy2": "Buy for ", + "buy_confirm": "Are you sure you want to buy ", + "buy_confirm2": " for ", + "your_token": "Your token" + }, + "nft_collection_page_jsx": { + "owner_is": "Owner of collection is ", + "not_yet": "No tokens yet in this collection." + }, + "nft_token_page_jsx": { + "issued": " issued token", + "issued_for": " for", + "transfered": " sent token for", + "selled": " placed selling order for ", + "selled2": " selled token to ", + "selled2m": " for ", + "bought": " bought token ", + "owner_is": "Owner is ", + "burnt": "This NFT-токен was existing sometime.", + "burnt2": "But it was burnt.", + "burnt3": "And all that reminds us of him is a small handful of ashes on our server room..." + }, + "nft_token_sell_jsx": { + "fill_price": "Fill price please" + }, + "nft_market_page_jsx": { + "no_orders": "No NFT tokens are selling on the market now.", + "no_own_orders": "You are not selling any NFT token yet." + }, "invites_jsx": { "create_invite": "Create new invite check", "create_invite_info": "Cheques (invite codes) are a universal tool for transferring of GOLOS tokens to other people outside the blockchain. There are two ways to redeem the code: transfer its balance to your account or register a new account using it.", @@ -879,7 +973,15 @@ "vote": "vote", "some_action": "action", "with_negrep": " with negative reputation", - "with_unlimit": " out of daily limit" + "with_unlimit": " out of daily limit", + "gifted": "gifted", + "you_gifted0": "Sent a gift for", + "you_gifted": "", + "nft_token": "NFT-token", + "burnt": "Burnt", + "nft_issued": " issued ", + "nft_issued_for": " for ", + "nft_issued_cost": "issue cost is " }, "savingswithdrawhistory_jsx": { "cancel_this_withdraw_request": "Cancel this withdraw request?", @@ -918,6 +1020,11 @@ "author_rewards_by": "Author rewards by", "donates_from": "Donates from", "donates_to": "Donates to", + "nft_tokens": "NFT-tokens", + "nft_collections": "NFT-collections, created by", + "nft_token": "NFT-token", + "nft_collection": "NFT-collection", + "nft_market": "NFT-market", "replies_to": "Replies to", "comments_by": "Comments by" }, diff --git a/app/locales/ru-RU.json b/app/locales/ru-RU.json index d4a29b1..d49521f 100644 --- a/app/locales/ru-RU.json +++ b/app/locales/ru-RU.json @@ -12,6 +12,16 @@ "TIP_TOKEN": "TIP-баланс", "CLAIM_TOKEN": "Накопительный баланс" }, + "account_name": { + "account_name_should_be_shorter": "Имя аккаунта должно быть короче.", + "account_name_should_be_longer": "Имя аккаунта должно быть длиннее.", + "account_name_should_not_be_empty": "Имя аккаунта не должно быть пустым.", + "each_account_segment_should_start_with_a_letter": "Каждый сегмент имени аккаунта должен начинаться с буквы.", + "each_account_segment_should_have_only_letters_digits_or_dashes": "Сегмент имени аккаунта может содержать только буквы, цифры и дефисы.", + "each_account_segment_should_have_only_one_dash_in_a_row": "Каждый сегмент имени аккаунта может содержать только один дефис.", + "each_account_segment_should_end_with_a_letter_or_digit": "Каждый сегмент имени аккаунта должен заканчиваться буквой или цифрой.", + "each_account_segment_should_be_longer": "Сегмент имени аккаунта должен быть длиннее." + }, "authorrewards_jsx": { "estimated_author_rewards_last_week": "Оценочные авторские вознаграждения за неделю", "author_rewards_history": "История авторских наград" @@ -164,6 +174,7 @@ "blog": "Блог", "browse": "Посмотреть", "basic": "Основные", + "burn": "Сжечь", "buy": "Купить", "quick_buy": "Быстрая покупка", "buy_VESTING_TOKEN": "Купить %(VESTING_TOKEN2)s", @@ -242,6 +253,10 @@ "reset_password": "Сброс пароля", "invites": "Инвайт-чеки", "assets": "Активы UIA", + "nft": "NFT", + "nft_tokens": "Токены NFT", + "nft_collections": "Коллекции NFT", + "nft_history": "История NFT", "help_wallet": "Функции кошелька", "phone": "телефон", "post": "Пост", @@ -427,6 +442,7 @@ "donate_for_post": "отблагодарил вас за пост", "you_donate_for_post": "Вы отблагодарили", "for_the_post": "за пост", + "for_the_comment": "за комментарий", "night_mode": "Ночной режим", "social_network": "Социальные сети", "about_project": "Голос Кошелёк — децентрализованный сервис, работающий на базе блокчейна Golos", @@ -501,6 +517,11 @@ "author_rewards_by": "Автор награжден", "donates_from": "Донаты, отправленные", "donates_to": "Донаты, полученные", + "nft_tokens": "NFT-токены", + "nft_collections": "NFT-коллекции, созданные", + "nft_token": "NFT-токен", + "nft_collection": "NFT-коллекция", + "nft_market": "Биржа NFT", "replies_to": "Ответы на", "comments_by": "Комментарии" }, @@ -914,7 +935,15 @@ "vote": "голос", "some_action": "действие", "with_negrep": " при отрицательной репутации", - "with_unlimit": " вне суточного лимита" + "with_unlimit": " вне суточного лимита", + "gifted": "подарил", + "you_gifted0": "К", + "you_gifted": " отправлен подарок", + "nft_token": "NFT-токен", + "burnt": "Сожжен", + "nft_issued": " выпустил ", + "nft_issued_for": " для ", + "nft_issued_cost": "за выпуск списано " }, "user_profile": { "unknown_account": "Неизвестный аккаунт", @@ -1053,6 +1082,84 @@ "no_way_error": "Если вы заполнили одно из полей, то вы должны указать хотя бы один способ вывода или заполнить Дополнительно.", "no_to_error": "Если вы указали вывод через перевод с заметкой, то укажите, куда переводить." }, + "nft_collections_jsx": { + "not_yet": "У вас нет своих собственных коллекций NFT-токенов.", + "not_yet2": "У ", + "not_yet3": " нет своих собственных коллекций NFT-токенов.", + "create": "Создать коллекцию", + "name_exists": "Коллекция с таким именем уже создана другим автором", + "tokens_exist": "Нельзя удалить коллекцию, в которой уже есть токены", + "volume_hint": "Объем торгов за все время", + "price_hint": "Последняя цена покупки" + }, + "create_nft_collection_jsx": { + "title": "Создать коллекцию NFT", + "name": "Имя токена", + "coll_title": "Название", + "coll_descr": "Описание", + "image": "Ссылка на изображение", + "json_metadata": "JSON-метаданные", + "json_wrong": "Некорректный JSON. ", + "not_required": "(не обязательно)", + "token_count": "Количество токенов", + "infinity": "Бесконечное", + "create": "Создать", + "restore_json": "Вернуть по умолчанию", + "json_no_fields": "В JSON не хватает полей: ", + "json_fix": "Добавить недостающее" + }, + "issue_nft_token_jsx": { + "title": "Выпустить NFT", + "issue_cost": "Будет списана плата за выпуск -", + "max_token_count": "Достигнуто максимальное кол-во токенов в коллекции." + }, + "nft_tokens_jsx": { + "not_yet": "У вас пока нет NFT-токенов.", + "not_yet2": "У ", + "not_yet3": " пока нет NFT-токенов.", + "token_count": { + "zero": "0 токенов", + "one": "1 токен", + "other": "%(count)s токенов" + }, + "sort": "Сортировка", + "sort_new": "Сначала новые", + "sort_old": "Сначала старые", + "sort_price": "По цене", + "sort_name": "По имени коллекции", + "cancel": "Отменить", + "cancel_hint": "Отменить продажу", + "selling_for": "Продается за ", + "buy": "Купить", + "buy2": "Купить за ", + "buy_confirm": "Вы уверены, что хотите купить ", + "buy_confirm2": " за ", + "your_token": "Ваш токен" + }, + "nft_collection_page_jsx": { + "owner_is": "Владелец коллекции - ", + "not_yet": "В этой коллекции еще нет токенов." + }, + "nft_token_page_jsx": { + "issued": " выпустил токен", + "issued_for": " для", + "transfered": " отправил токен", + "selled": " выставил на продажу токен за ", + "selled2": " продал токен ", + "selled2m": " за ", + "bought": " купил токен", + "owner_is": "Владелец - ", + "burnt": "Этот NFT-токен существовал когда-то.", + "burnt2": "Но он был сожжен.", + "burnt3": "И все, что о нем напоминает, это маленькая горстка пепла в нашей серверной..." + }, + "nft_token_sell_jsx": { + "fill_price": "Укажите цену" + }, + "nft_market_page_jsx": { + "no_orders": "На бирже сейчас не продается ни один NFT-токен.", + "no_own_orders": "Вы пока не продаете NFT-токенов." + }, "invites_jsx": { "create_invite": "Создание чека", "create_invite_info": "Чеки (инвайт-коды) — инструмент для передачи токенов другим людям вне блокчейна. Использовать чек можно двумя способами: перевести его баланс на аккаунт (форма для этого ниже) или зарегистрировать с его помощью новый аккаунт.", diff --git a/app/redux/FetchDataSaga.js b/app/redux/FetchDataSaga.js index 6c48c16..7ce70bd 100644 --- a/app/redux/FetchDataSaga.js +++ b/app/redux/FetchDataSaga.js @@ -1,6 +1,7 @@ import { call, put, select, fork, cancelled, takeLatest, takeEvery } from 'redux-saga/effects'; import cookie from "react-cookie"; import {config, api} from 'golos-lib-js'; +import { Asset } from 'golos-lib-js/lib/utils' import { getPinnedPosts, getMutedInNew } from 'app/utils/NormalizeProfile'; import { getBlockings, listBlockings } from 'app/redux/BlockingSaga' @@ -11,6 +12,45 @@ import constants from './constants'; import { reveseTag, getFilterTags } from 'app/utils/tags'; import { CATEGORIES, SELECT_TAGS_KEY, DEBT_TOKEN_SHORT, LIQUID_TICKER } from 'app/client_config'; import { getAllPairs } from 'app/utils/market/utils' +import { parseNFTImage, NFTImageStub } from 'app/utils/NFTUtils' + +function* fillNftCollectionImages(nft_collections) { + const noImgColls = {} + for (let i = 0; i < nft_collections.length; ++i) { + const nco = nft_collections[i] + + nco.image = parseNFTImage(nco.json_metadata, false) + if (!nco.image) { + noImgColls[nco.name] = i + } + } + + const noImgKeys = Object.keys(noImgColls) + + const tokens = (yield call([api, api.getNftTokensAsync], { + select_collections: noImgKeys, + collection_limit: 1, + limit: 100, + collections: false, + orders: false, + })) + + for (const token of tokens) { + const idx = noImgColls[token.name] + const nco = nft_collections[idx] + nco.image = parseNFTImage(token.json_metadata) + } +} + +function* loadNftAssets(nft_assets, syms) { + if (syms.size) { + const assets = yield call([api, api.getAssets], '', [...syms]) + for (const a of assets) { + const supply = Asset(a.supply) + nft_assets[supply.symbol] = a + } + } +} export function* fetchDataWatches () { yield fork(watchLocationChange); @@ -21,6 +61,9 @@ export function* fetchDataWatches () { yield fork(watchFetchExchangeRates); yield fork(watchFetchVestingDelegations); yield fork(watchFetchUiaBalances); + yield fork(watchFetchNftTokens) + yield fork(watchFetchNftCollectionTokens) + yield fork(watchFetchNftMarket) } export function* watchGetContent() { @@ -124,6 +167,37 @@ export function* fetchState(location_change_action) { state.cprops = yield call([api, api.getChainPropertiesAsync]) break + case 'nft-collections': + state.nft_collections = (yield call([api, api.getNftCollectionsAsync], { + creator: uname, + limit: 100, + sort: 'by_created' + })) + + try { + yield fillNftCollectionImages(state.nft_collections) + + const syms = new Set() + + for (const nco of state.nft_collections) { + nco.image = nco.image || NFTImageStub() + + const price = Asset(nco.last_buy_price) + syms.add(price.symbol) + } + + state.nft_assets = {} + yield loadNftAssets(state.nft_assets, syms) + } catch (err) { + console.error(err) + } + + state.cprops = yield call([api, api.getChainPropertiesAsync]) + break + + case 'nft-tokens': + break + case 'invites': state.cprops = yield call([api, api.getChainPropertiesAsync]) break @@ -141,13 +215,53 @@ export function* fetchState(location_change_action) { case 'witness': state.witnesses[uname] = yield call([api, api.getWitnessByAccountAsync], uname) break + case 'nft-history': + const nftHistory = yield call([api, api.getAccountHistoryAsync], uname, -1, 1000, {select_ops: ['nft_token', 'nft_transfer', 'nft_sell', 'nft_buy']}) + account.nft_history = [] + + state.cprops = yield call([api, api.getChainPropertiesAsync]) + + const nft_token_ids = new Set() + + nftHistory.forEach(operation => { + switch (operation[1].op[0]) { + case 'nft_token': + case 'nft_transfer': + case 'nft_sell': + case 'nft_buy': + const { token_id } = operation[1].op[1] + if (token_id !== 0) nft_token_ids.add(token_id) + state.accounts[uname].nft_history.push(operation) + break + + default: + break + } + }) + + try { + if (nft_token_ids.size) { + state.nft_token_map = {} + const nft_tokens = (yield call([api, api.getNftTokensAsync], { + select_token_ids: [...nft_token_ids], + state: 'any' + })) + for (const nft of nft_tokens) { + state.nft_token_map[nft.token_id] = nft + } + } + } catch (err) { + console.error(err) + } + break case 'transfers': default: - const history = yield call([api, api.getAccountHistoryAsync], uname, -1, 1000, {select_ops: ['donate', 'transfer', 'author_reward', 'curation_reward', 'transfer_to_tip', 'transfer_from_tip', 'transfer_to_vesting', 'withdraw_vesting', 'asset_issue', 'invite', 'transfer_to_savings', 'transfer_from_savings', 'convert_sbd_debt', 'convert', 'fill_convert_request', 'interest', 'worker_reward', 'account_freeze', 'unwanted_cost', 'unlimit_cost', 'subscription_payment'/*, 'subscription_prepaid_return'*/]}) + const history = yield call([api, api.getAccountHistoryAsync], uname, -1, 1000, {select_ops: ['donate', 'transfer', 'author_reward', 'curation_reward', 'transfer_to_tip', 'transfer_from_tip', 'transfer_to_vesting', 'withdraw_vesting', 'asset_issue', 'invite', 'transfer_to_savings', 'transfer_from_savings', 'convert_sbd_debt', 'convert', 'fill_convert_request', 'interest', 'worker_reward', 'account_freeze', 'unwanted_cost', 'unlimit_cost', 'subscription_payment'/*, 'subscription_prepaid_return'*/, ]}) account.transfer_history = [] account.other_history = [] - state.cprops = yield call([api, api.getChainPropertiesAsync]) + state.cprops = yield call([api, api.getChainPropertiesAsync]) + history.forEach(operation => { switch (operation[1].op[0]) { case 'donate': @@ -183,6 +297,36 @@ export function* fetchState(location_change_action) { } } + } else if (parts[0] === 'nft-tokens') { + if (parts[1]) { + state.nft_token = (yield call([api, api.getNftTokensAsync], { + select_token_ids: [parts[1]], + state: 'any' + })) + state.nft_token = state.nft_token[0] + state.nft_token_loaded = true + + try { + const syms = new Set() + + if (state.nft_token) { + state.nft_token.image = parseNFTImage(state.nft_token.json_metadata) + + if (state.nft_token.order) { + const price = Asset(state.nft_token.order.price) + syms.add(price.symbol) + } + + const price = Asset(state.nft_token.last_buy_price) + syms.add(price.symbol) + } + + state.nft_assets = {} + yield loadNftAssets(state.nft_assets, syms) + } catch (err) { + console.error(err) + } + } } else if (parts[0] === 'witnesses' || parts[0] === '~witnesses') { state.witnesses = {}; @@ -387,3 +531,183 @@ export function* fetchUiaBalances({ payload: { account } }) { console.error('fetchUiaBalances', err) } } + +export function* watchFetchNftTokens() { + yield takeLatest('global/FETCH_NFT_TOKENS', fetchNftTokens) +} + +export function* fetchNftTokens({ payload: { account, start_token_id, sort, reverse_sort } }) { + try { + const limit = 20 + + const nft_tokens = yield call([api, api.getNftTokensAsync], { + owner: account, + collections: false, + start_token_id, + limit: limit + 1, + sort: sort || 'by_last_update', + selling_sorting: sort == 'by_last_price' ? 'sort_up_by_price': 'nothing', + reverse_sort: !!reverse_sort + }) + + let next_from + if (nft_tokens.length > limit) { + next_from = nft_tokens.pop().token_id + } + + let nft_assets + + try { + const syms = new Set() + + for (const no of nft_tokens) { + no.image = parseNFTImage(no.json_metadata) + + if (no.order) { + const price = Asset(no.order.price) + syms.add(price.symbol) + } + + const price = Asset(no.last_buy_price) + syms.add(price.symbol) + } + + nft_assets = {} + yield loadNftAssets(nft_assets, syms) + } catch (err) { + console.error(err) + } + + yield put(GlobalReducer.actions.receiveNftTokens({nft_tokens, start_token_id, next_from, nft_assets})) + } catch (err) { + console.error('fetchNftTokens', err) + } +} + +export function* watchFetchNftCollectionTokens() { + yield takeLatest('global/FETCH_NFT_COLLECTION_TOKENS', fetchNftCollectionTokens) +} + +export function* fetchNftCollectionTokens({ payload: { collectionName, start_token_id, sort, reverse_sort } }) { + try { + let nft_coll + + if (!start_token_id) { + nft_coll = yield call([api, api.getNftCollectionsAsync], { + select_names: [collectionName], + limit: 1 + }) + nft_coll = nft_coll[0] + } + + const limit = 20 + + const nft_tokens = yield call([api, api.getNftTokensAsync], { + select_collections: [collectionName], + start_token_id, + collections: false, + sort: sort || 'by_last_update', + reverse_sort: !!reverse_sort, + selling_sorting: 'sort_up_by_price', + limit: limit + 1 + }) + + let next_from + if (nft_tokens.length > limit) { + next_from = nft_tokens.pop().token_id + } + + let nft_assets + + try { + const syms = new Set() + + for (const no of nft_tokens) { + no.image = parseNFTImage(no.json_metadata) + + if (no.order) { + const price = Asset(no.order.price) + syms.add(price.symbol) + } + + const price = Asset(no.last_buy_price) + syms.add(price.symbol) + } + + nft_assets = {} + yield loadNftAssets(nft_assets, syms) + } catch (err) { + console.error(err) + } + + yield put(GlobalReducer.actions.receiveNftCollectionTokens({nft_coll, nft_tokens, start_token_id, next_from, nft_assets})) + } catch (err) { + console.error('fetchNftCollectionTokens', err) + } +} + +export function* watchFetchNftMarket() { + yield takeLatest('global/FETCH_NFT_MARKET', fetchNftMarket) +} + +export function* fetchNftMarket({ payload: { account, collectionName, start_order_id, sort, reverse_sort } }) { + try { + const limit = 20 + + const nft_orders = yield call([api, api.getNftOrdersAsync], { + filter_owners: [account], + select_collections: collectionName ? [collectionName] : undefined, + start_order_id, + type: 'selling', + sort: sort || 'by_price', + reverse_sort: !!reverse_sort, + limit: limit + 1 + }) + + let next_from + if (nft_orders.length > limit) { + next_from = nft_orders.pop().order_id + } + + const nft_own_orders = yield call([api, api.getNftOrdersAsync], { + owner: account, + select_collections: collectionName ? [collectionName] : undefined, + start_order_id, + type: 'selling', + sort: sort || 'by_price', + reverse_sort: !!reverse_sort, + limit: limit + 1 + }) + + let nft_assets + + try { + const syms = new Set() + + for (const no of nft_orders) { + alert(JSON.stringify(no)) + no.token.image = parseNFTImage(no.token.json_metadata) + + const price = Asset(no.price) + syms.add(price.symbol) + } + + for (const no of nft_own_orders) { + alert(JSON.stringify(no)) + no.token.image = parseNFTImage(no.token.json_metadata) + + const price = Asset(no.price) + syms.add(price.symbol) + } + + nft_assets = {} + yield loadNftAssets(nft_assets, syms) + } catch (err) { + console.error(err) + } + + yield put(GlobalReducer.actions.receiveNftMarket({nft_orders, nft_own_orders, start_order_id, next_from, nft_assets})) + } catch (err) { + console.error('fetchNftMarket', err) + } +} \ No newline at end of file diff --git a/app/redux/GlobalReducer.js b/app/redux/GlobalReducer.js index ce156bd..ae8a45c 100644 --- a/app/redux/GlobalReducer.js +++ b/app/redux/GlobalReducer.js @@ -6,6 +6,51 @@ import { fromJSGreedy } from 'app/utils/StateFunctions'; const emptyContentMap = Map(emptyContent); +const upsertNftAssets = (state, nft_assets, start_token_id) => { + if (!start_token_id) { + state = state.set('nft_assets', fromJS(nft_assets)) + } else { + state = state.update('nft_assets', data => { + data = data.merge(nft_assets) + return data + }) + } + return state +} + +const upsertPagedItems = (state, key, items, start_item_id, next_from) => { + if (!start_item_id) { + state = state.set(key, fromJS({ + data: items, + next_from + })) + } else { + state = state.update(key, stateItems => { + stateItems = stateItems.update('data', data => { + for (const item of items) { + data = data.push(fromJS(item)) + } + return data + }) + stateItems = stateItems.set('next_from', next_from) + return stateItems + }) + } + return state +} + +const upsertNftTokens = (state, nft_tokens, start_token_id, next_from) => { + return upsertPagedItems(state, 'nft_tokens', nft_tokens, start_token_id, next_from) +} + +const upsertNftOrders = (state, nft_orders, start_order_id, next_from) => { + return upsertPagedItems(state, 'nft_orders', nft_orders, start_order_id, next_from) +} + +const upsertOwnNftOrders = (state, own_nft_orders, start_order_id, next_from) => { + return upsertPagedItems(state, 'own_nft_orders', own_nft_orders, start_order_id, next_from) +} + export default createModule({ name: 'global', initialState: Map({ @@ -52,7 +97,15 @@ export default createModule({ ); } } - let res = state.mergeDeep(payload) + let res = state + if (res.has('nft_collections')) + res = res.delete('nft_collections') + res = res.mergeDeep(payload) + if (!payload.has('nft_tokens')) { + if (!window.location.pathname.endsWith('/nft-tokens')) { + res = res.delete('nft_tokens') + } + } return res }, }, @@ -163,6 +216,56 @@ export default createModule({ return state.set('assets', fromJS(assets)) }, }, + { + action: 'FETCH_NFT_TOKENS', + reducer: state => state, + }, + { + action: 'RECEIVE_NFT_TOKENS', + reducer: (state, { payload: { nft_tokens, start_token_id, next_from, nft_assets } }) => { + let new_state = state + new_state = upsertNftTokens(new_state, nft_tokens, start_token_id, next_from) + if (nft_assets) { + new_state = upsertNftAssets(new_state, nft_assets, start_token_id) + } + return new_state + }, + }, + { + action: 'FETCH_NFT_COLLECTION_TOKENS', + reducer: state => state, + }, + { + action: 'RECEIVE_NFT_COLLECTION_TOKENS', + reducer: (state, { payload: { nft_coll, nft_tokens, start_token_id, next_from, nft_assets } }) => { + let new_state = state + if (nft_coll) { + new_state = new_state.set('nft_collection', fromJS(nft_coll)) + new_state = new_state.set('nft_collection_loaded', true) + } + new_state = upsertNftTokens(new_state, nft_tokens, start_token_id, next_from) + if (nft_assets) { + new_state = upsertNftAssets(new_state, nft_assets, start_token_id) + } + return new_state + }, + }, + { + action: 'FETCH_NFT_MARKET', + reducer: state => state, + }, + { + action: 'RECEIVE_NFT_MARKET', + reducer: (state, { payload: { nft_orders, own_nft_orders, start_order_id, next_from, nft_assets } }) => { + let new_state = state + new_state = upsertNftOrders(new_state, nft_orders, start_order_id, next_from) + new_state = upsertOwnNftOrders(new_state, own_nft_orders) + if (nft_assets) { + new_state = upsertNftAssets(new_state, nft_assets, start_order_id) + } + return new_state + }, + }, { action: 'LINK_REPLY', reducer: (state, { payload: op }) => { diff --git a/app/redux/Transaction_Error.js b/app/redux/Transaction_Error.js index 0ea51cd..e05e861 100644 --- a/app/redux/Transaction_Error.js +++ b/app/redux/Transaction_Error.js @@ -8,6 +8,8 @@ export default function transactionErrorReducer( let errorStr = error.toString(); let errorKey = 'Transaction broadcast error.'; + let handled = false + for (const [type] of operations) { switch (type) { case 'vote': @@ -42,6 +44,21 @@ export default function transactionErrorReducer( errorKey = errorStr = tt('invites_jsx.claim_wrong_secret_fatal'); } break; + case 'nft_collection': + if (errorStr.includes('Object already exist')) { + errorKey = errorStr = tt('nft_collections_jsx.name_exists') + handled = true + } + break; + case 'nft_issue': + if (errorStr.includes('Account does not have sufficient funds')) { + errorKey = errorStr = tt('transfer_jsx.insufficient_funds') + handled = true + } else if (errorStr.includes('Cannot issue more tokens')) { + errorKey = errorStr = tt('issue_nft_token_jsx.max_token_count') + handled = true + } + break; case 'withdraw_vesting': if ( errorStr.includes( @@ -64,7 +81,6 @@ export default function transactionErrorReducer( break; } - let handled = false if (errorStr.includes('You are blocked by user')) { errorKey = errorStr = tt('chain_errors.user_blocked_user') handled = true diff --git a/app/utils/NFTUtils.js b/app/utils/NFTUtils.js new file mode 100644 index 0000000..bd761da --- /dev/null +++ b/app/utils/NFTUtils.js @@ -0,0 +1,13 @@ + +export function NFTImageStub() { + return require('app/assets/images/nft.png') +} + +export function parseNFTImage(json_metadata, useStub = true) { + if (json_metadata) { + const meta = JSON.parse(json_metadata) + if (meta) return meta.image + } + if (!useStub) return null + return NFTImageStub() +} diff --git a/package.json b/package.json index 4aa7f8c..84e59a3 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "foundation-sites": "^6.4.3", "fs-extra": "^10.0.1", "git-rev-sync": "^3.0.2", - "golos-lib-js": "^0.9.53", + "golos-lib-js": "^0.9.60", "history": "^2.0.0-rc2", "immutable": "^3.8.2", "intl": "^1.2.5", diff --git a/yarn.lock b/yarn.lock index ddbc36f..3b85059 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2185,14 +2185,15 @@ assert@^1.4.1: util "0.10.3" assert@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/assert/-/assert-2.0.0.tgz#95fc1c616d48713510680f2eaf2d10dd22e02d32" - integrity sha512-se5Cd+js9dXJnu6Ag2JFc00t+HmHOen+8Q+L7O9zI0PqQXr20uk2J0XQqMxZEeo5U50o8Nvmmx7dZrl+Ufr35A== + version "2.1.0" + resolved "https://registry.yarnpkg.com/assert/-/assert-2.1.0.tgz#6d92a238d05dc02e7427c881fb8be81c8448b2dd" + integrity sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw== dependencies: - es6-object-assign "^1.1.0" - is-nan "^1.2.1" - object-is "^1.0.1" - util "^0.12.0" + call-bind "^1.0.2" + is-nan "^1.3.2" + object-is "^1.1.5" + object.assign "^4.1.4" + util "^0.12.5" assertion-error@^1.1.0: version "1.1.0" @@ -2970,9 +2971,9 @@ core-js@^2.4.0, core-js@^2.5.0: integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== core-js@^3.17.3: - version "3.30.0" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.30.0.tgz#64ac6f83bc7a49fd42807327051701d4b1478dea" - integrity sha512-hQotSSARoNh1mYPi9O2YaWeiq/cEB95kOrFb4NCrO4RIFt1qqNpKsaE+vy/L3oiqvND5cThqXzUU3r9F7Efztg== + version "3.33.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.33.0.tgz#70366dbf737134761edb017990cf5ce6c6369c40" + integrity sha512-HoZr92+ZjFEKar5HS6MC776gYslNOKHt75mEBKWKnPeFDpZ6nH5OeF3S6HFT1mUAUZKrzkez05VboaX8myjSuw== core-js@^3.19.1, core-js@^3.6.0, core-js@^3.8.3: version "3.25.0" @@ -3605,11 +3606,6 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -es6-object-assign@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/es6-object-assign/-/es6-object-assign-1.1.0.tgz#c2c3582656247c39ea107cb1e6652b6f9f24523c" - integrity sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw== - escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -4204,10 +4200,10 @@ globule@^1.0.0: lodash "^4.17.21" minimatch "~3.0.2" -golos-lib-js@^0.9.53: - version "0.9.53" - resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.53.tgz#8469e0cfb5b0183bb0b161279ceb059907e5251e" - integrity sha512-ipjFLKOXhhI31JuQzYsgZtZbFJQem15PRwU0OG+ocnQtUHB1CsU02GGJV0la3gL+K2KvHZdmKqd8XzGx8jgA4g== +golos-lib-js@^0.9.60: + version "0.9.60" + resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.60.tgz#abf7e88499954312c817ebc38532e8e8d0451cbd" + integrity sha512-9UlH8OLTZj70godojecYTHIJ/6X+YW80zDVTYEq4qGDjZFlIAyczqu4UHlwZIxOtlZoyFMFtxMonWEXkw3nnPg== dependencies: abort-controller "^3.0.0" assert "^2.0.0" @@ -4849,7 +4845,7 @@ is-lambda@^1.0.1: resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ== -is-nan@^1.2.1: +is-nan@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w== @@ -6073,7 +6069,7 @@ object-inspect@^1.12.2: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g== -object-is@^1.0.1: +object-is@^1.0.1, object-is@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" integrity sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw== @@ -7242,11 +7238,16 @@ selfsigned@^2.1.1: dependencies: node-forge "^1" -"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^5.7.0: +"semver@2 || 3 || 4 || 5", semver@^5.6.0, semver@^5.7.0: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== +semver@^5.5.0: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" @@ -8148,7 +8149,7 @@ util@0.10.3: safe-buffer "^5.1.2" which-typed-array "^1.1.2" -util@^0.12.0: +util@^0.12.5: version "0.12.5" resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== @@ -8531,9 +8532,9 @@ ws@^5.2.0: async-limiter "~1.0.0" ws@^8.2.3: - version "8.13.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" - integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== + version "8.14.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f" + integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== ws@^8.4.2: version "8.11.0"