diff --git a/app/ResolveRoute.js b/app/ResolveRoute.js index fd9ec96..baed8c3 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|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|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-market\/([\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-market') { + 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/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/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 ( +
+
+
+ {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..1115534 --- /dev/null +++ b/app/components/modules/nft/IssueNFTToken.jsx @@ -0,0 +1,358 @@ +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 + + let cost = null + if (cprops) { + cost =
+ {tt('issue_nft_token_jsx.issue_cost')}  + {Asset(cprops.get('nft_issue_cost')).floatString} + . +
+ } + + return (
+ +

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

+ + {({ + handleSubmit, isValid, values, errors, touched, setFieldValue, handleChange, + }) => { + 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..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 = + {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/NFTTokenDetails.jsx b/app/components/modules/nft/NFTTokenDetails.jsx new file mode 100644 index 0000000..35ae71f --- /dev/null +++ b/app/components/modules/nft/NFTTokenDetails.jsx @@ -0,0 +1,109 @@ +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 { Asset } from 'golos-lib-js/lib/utils' + +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()[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) + } + + render() { + const { nft_tokens, nft_assets, tokenIdx, onClose, } = this.props + + const token = nft_tokens.toJS()[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} + + } + + return
+
+
+ + + +
+
+ +

+ {data.title} +

+ {token.name} + {data.description ?
+ {data.description} +
: null} +
+ {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..5ed64ea --- /dev/null +++ b/app/components/modules/nft/NFTTokenDetails.scss @@ -0,0 +1,35 @@ +.NFTTokenDetails { + .container { + display: flex; + } + .buttons { + align-self: flex-end; + display: flex; + width: 100%; + } + + img { + max-width: 320px; + max-height: 320px; + } + + .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..629030c --- /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()[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()[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 ( +
+
+ {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..62ec1c9 --- /dev/null +++ b/app/components/modules/nft/NFTTokens.jsx @@ -0,0 +1,244 @@ +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() + } + + 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()[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) + } + + render() { + const { currentUser, account, isMyAccount, nft_tokens, nft_assets, fetchState } = this.props + const accountName = account.get('name') + + const tokens = nft_tokens ? nft_tokens.toJS() : null + const assets = nft_assets ? nft_assets.toJS() : null + + 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)}> + +
+
{data.title}
+ {token.name} + {buttons} +
+
) + } + } + + const { showTransfer, showDetails, tokenIdx } = this.state + + return (
+
+
+

{tt('g.nft_tokens')}

+ + {tt('g.buy')} + +
+
+
+
+ {items} +
+
+ + + + + + + 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}}) + }, + 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..83d598b --- /dev/null +++ b/app/components/modules/nft/NFTTokens.scss @@ -0,0 +1,48 @@ +.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; + } + .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..06d0a87 --- /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-market(/: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..282516d 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' ) { + tab_content =
+ +
+ } else if( section === 'curation-rewards' ) { rewardsClass = 'active'; tab_content = {tt('g.assets')} +
+ } + > + + {tt('g.nft')} + + + +
{isMyAccount ? {tt('navigation.market2')} : null} diff --git a/app/locales/en.json b/app/locales/en.json index d0d9054..929f09d 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", @@ -511,6 +515,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 +729,47 @@ "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" + } + }, "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.", @@ -918,6 +973,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..de4809b 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": "Пост", @@ -501,6 +515,9 @@ "author_rewards_by": "Автор награжден", "donates_from": "Донаты, отправленные", "donates_to": "Донаты, полученные", + "nft_tokens": "NFT-токены", + "nft_collections": "NFT-коллекции, созданные", + "nft_market": "Биржа NFT", "replies_to": "Ответы на", "comments_by": "Комментарии" }, @@ -1053,6 +1070,47 @@ "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 токенов" + } + }, "invites_jsx": { "create_invite": "Создание чека", "create_invite_info": "Чеки (инвайт-коды) — инструмент для передачи токенов другим людям вне блокчейна. Использовать чек можно двумя способами: перевести его баланс на аккаунт (форма для этого ниже) или зарегистрировать с его помощью новый аккаунт.", diff --git a/app/redux/FetchDataSaga.js b/app/redux/FetchDataSaga.js index 6c48c16..43bd1ad 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); @@ -124,6 +126,92 @@ 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': + state.nft_tokens = (yield call([api, api.getNftTokensAsync], { + owner: uname + })) + + try { + const syms = new Set() + + for (const no of state.nft_tokens) { + no.image = parseNFTImage(no.json_metadata) || NFTImageStub() + + const price = Asset(no.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) + } + break + case 'invites': state.cprops = yield call([api, api.getChainPropertiesAsync]) break diff --git a/app/redux/GlobalReducer.js b/app/redux/GlobalReducer.js index ce156bd..76c49dd 100644 --- a/app/redux/GlobalReducer.js +++ b/app/redux/GlobalReducer.js @@ -52,7 +52,12 @@ export default createModule({ ); } } - let res = state.mergeDeep(payload) + let res = state + if (res.has('nft_collections')) + res = res.delete('nft_collections') + if (res.has('nft_tokens')) + res = res.delete('nft_tokens') + res = res.mergeDeep(payload) return res }, }, 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..95faadd 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.56", "history": "^2.0.0-rc2", "immutable": "^3.8.2", "intl": "^1.2.5", diff --git a/yarn.lock b/yarn.lock index ddbc36f..ecd2f83 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.56: + version "0.9.56" + resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.56.tgz#3dfe8c0658fba2f50976ef49103ddc3f34109c19" + integrity sha512-h9ay0q2AuHiYL8aFXsCGoEFe6ojHt67FHMv8W6oWbqayl44JlRuuEysfE1MZQiiLwzBDFOO1SNMAtv5sE0bRcg== 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.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.1.tgz#4b9586b4f70f9e6534c7bb1d3dc0baa8b8cf01e0" + integrity sha512-4OOseMUq8AzRBI/7SLMUwO+FEDnguetSk7KMb1sHwvF2w2Wv5Hoj0nlifx8vtGsftE/jWHojPy8sMMzYLJ2G/A== ws@^8.4.2: version "8.11.0"