diff --git a/app/ResolveRoute.js b/app/ResolveRoute.js
index fd9ec96..f2a11d4 100644
--- a/app/ResolveRoute.js
+++ b/app/ResolveRoute.js
@@ -1,14 +1,15 @@
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-tokens|nft-collections|filled-orders|permissions|created|password|witness|settings)\/??(?:&?[^=&]*=[^=&]*)*$/,
UserProfile3: /^\/(@[\w\.\d-]+)\/[\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-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.]+)\/?($|\?)/,
+ NFTMarket: /^\/nft\/([\w\d\.]+)\/?($|\?)/,
UserJson: /^\/(@[\w\.\d-]+)(\.json)$/,
UserNameJson: /^.*(?=(\.json))/
};
@@ -89,5 +90,12 @@ export default function resolveRoute(path)
if (match) {
return {page: 'ConvertAssetsPage', 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..0d6f3e7 100644
--- a/app/RootRoute.js
+++ b/app/RootRoute.js
@@ -33,6 +33,8 @@ export default {
cb(null, [require('@pages/UserProfile')]);
} else if (route.page === 'ConvertAssetsPage') {
cb(null, [require('@pages/ConvertAssetsPage')]);
+ } else if (route.page === 'NFTMarketPage') {
+ cb(null, [require('@pages/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..f6c7ffb 100644
--- a/app/components/all.scss
+++ b/app/components/all.scss
@@ -39,6 +39,7 @@
@import "./elements/market/MarketPair";
@import "./elements/market/OrderForm";
@import "./elements/market/TickerPriceStat";
+@import "./elements/nft/NFTSmallIcon";
@import "./elements/workers/WorkerRequestVoting";
// dialogs
@@ -67,6 +68,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/NFTTokenTransfer";
+@import "./modules/nft/NFTTokenDetails";
// pages
@import "./pages/Exchanges";
diff --git a/app/components/cards/TransferHistoryRow.jsx b/app/components/cards/TransferHistoryRow.jsx
index 302ab55..5f6e9a1 100644
--- a/app/components/cards/TransferHistoryRow.jsx
+++ b/app/components/cards/TransferHistoryRow.jsx
@@ -31,6 +31,7 @@ class TransferHistoryRow extends React.Component {
let link = null, linkTitle = null, linkExternal = false
let code_key = "";
let description_end = "";
+ let link_end = null
let target_hint = "";
let data_memo = data.memo;
@@ -312,6 +313,40 @@ class TransferHistoryRow extends React.Component {
} else {
code_key = JSON.stringify({type, ...data}, null, 2);
}
+ } else if (type === 'nft_transfer') {
+ const { nft_tokens } = this.props
+ 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')
+ }
+ 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
+ if (meta.image) {
+ link_end = {tokenTitle}
+ }
+ } catch (err) {
+ console.error(err)
+ }
+ }
+ if (!tokenTitle) {
+ tokenTitle = '#' + data.token_id
+ }
+ description_end += ' ' + (!link_end ? tokenTitle : '')
}
else {
@@ -332,6 +367,7 @@ class TransferHistoryRow extends React.Component {
{linkTitle || link} :
{linkTitle || link})}
{description_end}
+ {link_end}
{target_hint && [{target_hint}]}
@@ -358,6 +394,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/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/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/modules/Header.jsx b/app/components/modules/Header.jsx
index 12bfd41..585a4ba 100644
--- a/app/components/modules/Header.jsx
+++ b/app/components/modules/Header.jsx
@@ -91,23 +91,25 @@ 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"){
+ 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.page === 'ConvertAssetsPage') {
page_title = tt('g.convert_assets')
+ } 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..3432068
--- /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 (
+
+ )}}
+ )
+ }
+}
+
+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 (
+
+ )}}
+ )
+ }
+}
+
+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..36199a7
--- /dev/null
+++ b/app/components/modules/nft/NFTCollections.jsx
@@ -0,0 +1,208 @@
+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 =
+ }
+
+ const { showCreate, showIssue, issueName, issueNum } = this.state
+
+ return ()
+ }
+}
+
+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/NFTTokenDetails.jsx b/app/components/modules/nft/NFTTokenDetails.jsx
new file mode 100644
index 0000000..1437b55
--- /dev/null
+++ b/app/components/modules/nft/NFTTokenDetails.jsx
@@ -0,0 +1,150 @@
+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 { api } from 'golos-lib-js'
+import { Asset } from 'golos-lib-js/lib/utils'
+
+import Expandable from 'app/components/elements/Expandable'
+import LoadingIndicator from 'app/components/elements/LoadingIndicator'
+import { getAssetMeta } from 'app/utils/market/utils'
+import transaction from 'app/redux/Transaction'
+
+class NFTTokenDetails extends Component {
+ state = {
+ }
+
+ onBurnClick = async (e) => {
+ const { nft_tokens, tokenIdx, currentUser } = this.props
+ const token = nft_tokens.toJS().data[tokenIdx]
+ const { token_id } = token
+ await this.props.burnToken(token_id, currentUser, () => {
+ this.props.fetchState()
+ this.props.onClose()
+ }, (err) => {
+ if (!err || err.toString() === 'Canceled') return
+ console.error(err)
+ alert(err.toString())
+ })
+ }
+
+ onTransferClick = (e) => {
+ const { tokenIdx } = this.props
+ this.props.onClose()
+ this.props.showTransfer(e, tokenIdx)
+ }
+
+ async componentDidMount() {
+ const { nft_tokens, tokenIdx, } = this.props
+ const token = nft_tokens.toJS().data[tokenIdx]
+ const { token_id } = token
+ const ops = await api.getNftTokenOps({
+ token_ids: [ token_id ],
+ from: 0,
+ limit: 500
+ })
+ this.setState({
+ ops: ops[token_id]
+ })
+ }
+
+ render() {
+ const { nft_tokens, nft_assets, tokenIdx, onClose, } = this.props
+
+ const token = nft_tokens.toJS().data[tokenIdx]
+
+ const assets = nft_assets.toJS()
+
+ 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
+
+ let last_price
+ const last_buy_price = Asset('1.005 GOLOS')// Asset(token.last_buy_price)
+ if (last_buy_price.amount > 0) {
+ const asset = assets[last_buy_price.symbol]
+ let imageUrl
+ if (asset) {
+ imageUrl = getAssetMeta(asset).image_url
+ }
+ last_price =
+ {imageUrl && }
+ {last_buy_price.amountFloat}
+
+ }
+
+ const dataStr = JSON.stringify(data, null, 2)
+
+ const { ops } = this.state
+
+ let title = data.title || ''
+ if (title.length > 18) {
+ title = title.substr(0, 18) + '...'
+ }
+
+ let name = token.name
+ if (name.length > 38) {
+ name = name.substr(0, 38) + '...'
+ }
+
+ return
+
+
+
+
+
+ {title}
+
+
{name}
+
+ {data.description || ''}
+
+
+
+ {dataStr}
+
+
+
+ {ops ? ops.map(op => {
+ return {JSON.stringify(op)}
+ }) : < LoadingIndicator type='circle' />}
+
+
+ {last_price}
+
+
+
+
+
+
+
+
+ }
+}
+
+export default connect(
+ // mapStateToProps
+ (state, ownProps) => {
+ return { ...ownProps,
+ nft_tokens: state.global.get('nft_tokens'),
+ nft_assets: state.global.get('nft_assets')
+ }
+ },
+
+ dispatch => ({
+ })
+)(NFTTokenDetails)
diff --git a/app/components/modules/nft/NFTTokenDetails.scss b/app/components/modules/nft/NFTTokenDetails.scss
new file mode 100644
index 0000000..c4e11e8
--- /dev/null
+++ b/app/components/modules/nft/NFTTokenDetails.scss
@@ -0,0 +1,47 @@
+.NFTTokenDetails {
+ .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;
+ }
+ .button:hover {
+ background-color: #016aad !important;
+ }
+ .button.hollow {
+ border: 0px;
+ }
+ .button.hollow:hover {
+ background-color: transparent !important;
+ }
+}
diff --git a/app/components/modules/nft/NFTTokenTransfer.jsx b/app/components/modules/nft/NFTTokenTransfer.jsx
new file mode 100644
index 0000000..336291b
--- /dev/null
+++ b/app/components/modules/nft/NFTTokenTransfer.jsx
@@ -0,0 +1,187 @@
+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 })
+ }
+
+ _onSubmit = async (values) => {
+ this.setSubmitting(true)
+ this.setState({
+ errorMessage: ''
+ })
+
+ const { nft_tokens, tokenIdx, currentUser, onClose, } = this.props
+ const token = nft_tokens.toJS().data[tokenIdx]
+ const { token_id } = token
+
+ const username = currentUser.get('username')
+
+ await this.props.transferToken(token_id, values.to, currentUser, () => {
+ this.props.fetchState()
+ this.props.onClose()
+ this.setSubmitting(false)
+ }, (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() {
+ const { nft_tokens, tokenIdx, onClose, } = this.props
+
+ const token = nft_tokens.toJS().data[tokenIdx]
+
+ 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 (
+
+ )}}
+
+ }
+}
+
+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..a46ee79
--- /dev/null
+++ b/app/components/modules/nft/NFTTokens.jsx
@@ -0,0 +1,303 @@
+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 { 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 NFTTokenDetails from 'app/components/modules/nft/NFTTokenDetails'
+import NFTTokenTransfer from 'app/components/modules/nft/NFTTokenTransfer'
+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 NFTTokens extends Component {
+ state = {}
+
+ constructor() {
+ super()
+ }
+
+ componentDidMount() {
+ if (!this.props.nft_tokens) {
+ 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,
+ })
+ }
+
+ burnIt = async (e, tokenIdx) => {
+ e.preventDefault()
+ const { nft_tokens, currentUser } = this.props
+ const token = nft_tokens.toJS().data[tokenIdx]
+ 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())
+ })
+ }
+
+ showDetails = (e, tokenIdx) => {
+ e.preventDefault()
+ this.setState({
+ showDetails: true,
+ tokenIdx,
+ })
+ }
+
+ hideDetails = () => {
+ this.setState({
+ showDetails: false,
+ })
+ }
+
+ onClick = (e, tokenIdx) => {
+ e.preventDefault()
+ for (let node = e.target; node && node != e.currentTarget; node = node.parentNode) {
+ if (node.onclick) {
+ return
+ }
+ }
+ this.showDetails(e, tokenIdx)
+ }
+
+ sortOrder = (e, sort, sortReversed) => {
+ e.preventDefault()
+ this.sort = sort
+ this.sortReversed = sortReversed
+ this.props.fetchNFTTokens(this.props.account, 0, this.sort, this.sortReversed)
+ }
+
+ render() {
+ const { currentUser, account, isMyAccount, nft_tokens, nft_assets, fetchState } = this.props
+ const accountName = account.get('name')
+
+ 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) {
+ 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]
+ 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
+
+ let last_price
+ const last_buy_price = Asset(token.last_buy_price)
+ if (last_buy_price.amount > 0) {
+ const asset = assets[last_buy_price.symbol]
+ let imageUrl
+ if (asset) {
+ imageUrl = getAssetMeta(asset).image_url
+ }
+ last_price =
+ {imageUrl && }
+ {last_buy_price.amountFloat}
+
+ }
+
+ const kebabItems = [
+ { link: '#', onClick: e => {
+ this.burnIt(e, i)
+ }, value: tt('g.burn') },
+ { link: '#', onClick: e => {
+ this.showDetails(e, i)
+ }, value: tt('g.more_hint') },
+ ]
+
+ if (last_price) {
+ kebabItems.unshift({ link: '#', onClick: e => {
+ this.showTransfer(e, i)
+ }, value: tt('g.transfer') })
+ }
+
+ let buttons
+ if (last_price) {
+ buttons =
+ {last_price}
+
+
+
+
+
+ } else {
+ buttons =
+
+
+
+
+
+
+ }
+
+ items.push( this.onClick(e, i)}>
+
+
+
)
+ }
+ }
+
+ const { showTransfer, showDetails, 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}
+
+
+
+
+
+
+
+
+ this.showTransfer(e, tokenIdx)}
+ burnToken={this.props.burnToken}
+ onClose={this.hideDetails}
+ tokenIdx={tokenIdx}
+ fetchState={fetchState}
+ />
+
+
)
+ }
+}
+
+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 => ({
+ fetchState: () => {
+ const pathname = window.location.pathname
+ dispatch({type: 'FETCH_STATE', payload: {pathname}})
+ },
+ fetchNFTTokens: (account, start_token_id, sort, sortReversed) => {
+ if (!account) return
+ dispatch(g.actions.fetchNftTokens({ account: account.get('username'), start_token_id, sort, reverse_sort: sortReversed }))
+ },
+ 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
+ }))
+ }
+ })
+)(NFTTokens)
diff --git a/app/components/modules/nft/NFTTokens.scss b/app/components/modules/nft/NFTTokens.scss
new file mode 100644
index 0000000..fa68a85
--- /dev/null
+++ b/app/components/modules/nft/NFTTokens.scss
@@ -0,0 +1,49 @@
+.NFTTokens {
+ .Token {
+ 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;
+
+ .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;
+ }
+ .button.hollow {
+ border: 0px;
+ }
+ .button.hollow:hover {
+ background-color: transparent;
+ }
+ }
+ .Token:hover {
+ background-color: rgba(208,208,208,0.45);
+ border: 1px solid rgba(128,128,128,0.45);
+ }
+}
diff --git a/app/components/pages/NFTMarketPage.jsx b/app/components/pages/NFTMarketPage.jsx
new file mode 100644
index 0000000..ba810b8
--- /dev/null
+++ b/app/components/pages/NFTMarketPage.jsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import { connect, } from 'react-redux'
+import tt from 'counterpart'
+
+class NFTMarketPage extends React.Component {
+ render() {
+ const { currentAccount, routeParams } = this.props
+ }
+}
+
+module.exports = {
+ path: '/nft(/:sym)',
+ component: connect(
+ (state, ownProps) => {
+ const currentUser = state.user.getIn(['current'])
+ const currentAccount = currentUser && state.global.getIn(['accounts', currentUser.get('username')])
+
+ return {
+ currentAccount,
+ }
+ },
+ dispatch => ({
+ })
+ )(NFTMarketPage),
+}
diff --git a/app/components/pages/UserProfile.jsx b/app/components/pages/UserProfile.jsx
index 14c99c1..23d5db3 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';
@@ -246,8 +248,15 @@ export default class UserProfile extends React.Component {
;
- }
- else if( section === 'curation-rewards' ) {
+ } else if( section === 'nft-collections' ) {
+ tab_content =
+
+
+ } else if( section === 'nft-tokens' ) {
+ tab_content =
+
+
+ } else if( section === 'curation-rewards' ) {
rewardsClass = 'active';
tab_content =
{tt('g.assets')}
+
{isMyAccount ?
{tt('navigation.market2')}
: null}
diff --git a/app/locales/en.json b/app/locales/en.json
index d0d9054..ce2dfd5 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,9 @@
"reset_password": "Password reset",
"invites": "Invite checks",
"assets": "UIA assets",
+ "nft": "NFT",
+ "nft_tokens": "NFT-tokens",
+ "nft_collections": "NFT-collections",
"help_wallet": "Wallet functions",
"phone": "phone",
"post": "Post",
@@ -312,7 +316,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 +516,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 +730,52 @@
"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"
+ },
"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 +940,12 @@
"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"
},
"savingswithdrawhistory_jsx": {
"cancel_this_withdraw_request": "Cancel this withdraw request?",
@@ -918,6 +984,9 @@
"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_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..1a355c0 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,9 @@
"reset_password": "Сброс пароля",
"invites": "Инвайт-чеки",
"assets": "Активы UIA",
+ "nft": "NFT",
+ "nft_tokens": "Токены NFT",
+ "nft_collections": "Коллекции NFT",
"help_wallet": "Функции кошелька",
"phone": "телефон",
"post": "Пост",
@@ -427,6 +441,7 @@
"donate_for_post": "отблагодарил вас за пост",
"you_donate_for_post": "Вы отблагодарили",
"for_the_post": "за пост",
+ "for_the_comment": "за комментарий",
"night_mode": "Ночной режим",
"social_network": "Социальные сети",
"about_project": "Голос Кошелёк — децентрализованный сервис, работающий на базе блокчейна Golos",
@@ -501,6 +516,9 @@
"author_rewards_by": "Автор награжден",
"donates_from": "Донаты, отправленные",
"donates_to": "Донаты, полученные",
+ "nft_tokens": "NFT-токены",
+ "nft_collections": "NFT-коллекции, созданные",
+ "nft_market": "Биржа NFT",
"replies_to": "Ответы на",
"comments_by": "Комментарии"
},
@@ -914,7 +932,12 @@
"vote": "голос",
"some_action": "действие",
"with_negrep": " при отрицательной репутации",
- "with_unlimit": " вне суточного лимита"
+ "with_unlimit": " вне суточного лимита",
+ "gifted": "подарил",
+ "you_gifted0": "К",
+ "you_gifted": " отправлен подарок",
+ "nft_token": "NFT-токен",
+ "burnt": "Сожжен"
},
"user_profile": {
"unknown_account": "Неизвестный аккаунт",
@@ -1053,6 +1076,52 @@
"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": "По имени коллекции"
+ },
"invites_jsx": {
"create_invite": "Создание чека",
"create_invite_info": "Чеки (инвайт-коды) — инструмент для передачи токенов другим людям вне блокчейна. Использовать чек можно двумя способами: перевести его баланс на аккаунт (форма для этого ниже) или зарегистрировать с его помощью новый аккаунт.",
diff --git a/app/redux/FetchDataSaga.js b/app/redux/FetchDataSaga.js
index 6c48c16..40c2e77 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,7 @@ 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'
export function* fetchDataWatches () {
yield fork(watchLocationChange);
@@ -21,6 +23,7 @@ export function* fetchDataWatches () {
yield fork(watchFetchExchangeRates);
yield fork(watchFetchVestingDelegations);
yield fork(watchFetchUiaBalances);
+ yield fork(watchFetchNftTokens);
}
export function* watchGetContent() {
@@ -124,6 +127,67 @@ 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 {
+ const noImgColls = {}
+ for (let i = 0; i < state.nft_collections.length; ++i) {
+ const nco = state.nft_collections[i]
+
+ nco.image = parseNFTImage(nco.json_metadata)
+ 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 = state.nft_collections[idx]
+ nco.image = parseNFTImage(token.json_metadata)
+ }
+
+ 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 = {}
+ if (syms.size) {
+ const nft_assets = yield call([api, api.getAssets], '', [...syms])
+ for (const a of nft_assets) {
+ const supply = Asset(a.supply)
+ state.nft_assets[supply.symbol] = a
+ }
+ }
+ } 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
@@ -143,11 +207,14 @@ export function* fetchState(location_change_action) {
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'*/, 'nft_token', 'nft_transfer', 'nft_sell', 'nft_buy']})
account.transfer_history = []
account.other_history = []
- state.cprops = yield call([api, api.getChainPropertiesAsync])
+ state.cprops = yield call([api, api.getChainPropertiesAsync])
+
+ const nft_token_ids = new Set()
+
history.forEach(operation => {
switch (operation[1].op[0]) {
case 'donate':
@@ -174,11 +241,34 @@ export function* fetchState(location_change_action) {
//case 'subscription_prepaid_return':
state.accounts[uname].transfer_history.push(operation)
break
+ 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].transfer_history.push(operation)
+ break
default:
state.accounts[uname].other_history.push(operation)
}
})
+
+ 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
}
}
@@ -387,3 +477,54 @@ 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,
+ start_token_id,
+ sort: sort || 'by_last_update',
+ reverse_sort: !!reverse_sort,
+ 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) || NFTImageStub()
+
+ const price = Asset(no.last_buy_price)
+ syms.add(price.symbol)
+ }
+
+ nft_assets = {}
+ 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
+ }
+ }
+ } 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)
+ }
+}
diff --git a/app/redux/GlobalReducer.js b/app/redux/GlobalReducer.js
index ce156bd..4f48838 100644
--- a/app/redux/GlobalReducer.js
+++ b/app/redux/GlobalReducer.js
@@ -52,7 +52,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 +171,36 @@ 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
+ if (!start_token_id) {
+ new_state = new_state.set('nft_tokens', fromJS({
+ data: nft_tokens,
+ next_from: next_from
+ }))
+ } else {
+ new_state = new_state.update('nft_tokens', tokens => {
+ tokens = tokens.update('data', data => {
+ for (const token of nft_tokens) {
+ data = data.push(fromJS(token))
+ }
+ return data
+ })
+ tokens = tokens.set('next_from', next_from)
+ return tokens
+ })
+ }
+ if (nft_assets)
+ new_state = new_state.set('nft_assets', fromJS(nft_assets))
+ 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..d5fb6bc
--- /dev/null
+++ b/app/utils/NFTUtils.js
@@ -0,0 +1,12 @@
+
+export function parseNFTImage(json_metadata) {
+ if (json_metadata) {
+ const meta = JSON.parse(json_metadata)
+ if (meta) return meta.image
+ }
+ return null
+}
+
+export function NFTImageStub() {
+ return require('app/assets/images/nft.png')
+}
diff --git a/package.json b/package.json
index 4aa7f8c..e49362d 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.59",
"history": "^2.0.0-rc2",
"immutable": "^3.8.2",
"intl": "^1.2.5",
diff --git a/yarn.lock b/yarn.lock
index ddbc36f..76cc403 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.32.2"
+ resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.32.2.tgz#172fb5949ef468f93b4be7841af6ab1f21992db7"
+ integrity sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==
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.59:
+ version "0.9.59"
+ resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.59.tgz#cc7affe590d35cd2241dcb39473edaea00025dc8"
+ integrity sha512-K1cp5kCCpagkOuMC0lu7d3E4KW41ujZh2dk9Q7pDQiOHoaCLzAQFeBEErdDlVh6sTr4pP7OVd5F7Y0kRVKBzGQ==
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"